-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: add mobile swipe gestures for sidebar navigation #1679
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
42tg
wants to merge
1
commit into
pingdotgg:main
Choose a base branch
from
42tg:feat/mobile-swipe-gestures
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+418
−6
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| resolvePullGestureState, | ||
| resolveSwipeGestureState, | ||
| SCROLL_CANCEL_PX, | ||
| SWIPE_AXIS_RATIO, | ||
| SWIPE_LOCK_PX, | ||
| } from "./sidebar.swipe.logic"; | ||
|
|
||
| describe("resolveSwipeGestureState", () => { | ||
| describe("terminal states pass through unchanged", () => { | ||
| it("returns swiping unchanged when already swiping", () => { | ||
| // Any delta — even one that would cancel — should not override a committed state | ||
| expect(resolveSwipeGestureState("swiping", { dx: 0, dy: SCROLL_CANCEL_PX + 1 })).toBe( | ||
| "swiping", | ||
| ); | ||
| }); | ||
|
|
||
| it("returns cancelled unchanged when already cancelled", () => { | ||
| // Any delta — even one that would lock-in — should not override a committed state | ||
| expect( | ||
| resolveSwipeGestureState("cancelled", { | ||
| dx: SWIPE_LOCK_PX + 1, | ||
| dy: 0, | ||
| }), | ||
| ).toBe("cancelled"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("scroll cancellation", () => { | ||
| it("cancels when vertical movement exceeds SCROLL_CANCEL_PX", () => { | ||
| expect(resolveSwipeGestureState("idle", { dx: 0, dy: SCROLL_CANCEL_PX + 1 })).toBe( | ||
| "cancelled", | ||
| ); | ||
| }); | ||
|
|
||
| it("does not cancel when vertical movement is exactly at SCROLL_CANCEL_PX", () => { | ||
| // Rule uses strict >, so equality stays idle | ||
| expect(resolveSwipeGestureState("idle", { dx: 0, dy: SCROLL_CANCEL_PX })).toBe("idle"); | ||
| }); | ||
|
|
||
| it("cancels even when horizontal movement would otherwise qualify for lock-in", () => { | ||
| // Vertical exceeds cancel threshold, so cancel wins regardless of horizontal | ||
| expect( | ||
| resolveSwipeGestureState("idle", { | ||
| dx: SWIPE_LOCK_PX + 1, | ||
| dy: SCROLL_CANCEL_PX + 1, | ||
| }), | ||
| ).toBe("cancelled"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("swipe lock-in", () => { | ||
| it("locks in when horizontal exceeds SWIPE_LOCK_PX and is more than 2x the vertical", () => { | ||
| const dy = 5; | ||
| const dx = dy * SWIPE_AXIS_RATIO + SWIPE_LOCK_PX; // satisfies both distance and ratio | ||
| expect(resolveSwipeGestureState("idle", { dx, dy })).toBe("swiping"); | ||
| }); | ||
|
|
||
| it("does not lock in when horizontal is exactly at SWIPE_LOCK_PX", () => { | ||
| // Rule uses strict >, so equality stays idle | ||
| expect(resolveSwipeGestureState("idle", { dx: SWIPE_LOCK_PX, dy: 0 })).toBe("idle"); | ||
| }); | ||
|
|
||
| it("does not lock in when horizontal is not 2x the vertical", () => { | ||
| // dy = SCROLL_CANCEL_PX → no cancel triggered (strict >) | ||
| // dx = SWIPE_LOCK_PX + 2 → exceeds distance threshold | ||
| // but SWIPE_LOCK_PX + 2 (22) is NOT > SCROLL_CANCEL_PX * SWIPE_AXIS_RATIO (24) → ratio fails | ||
| const dy = SCROLL_CANCEL_PX; | ||
| const dx = SWIPE_LOCK_PX + 2; | ||
| expect(resolveSwipeGestureState("idle", { dx, dy })).toBe("idle"); | ||
| }); | ||
|
|
||
| it("does not lock in when only the axis ratio is met but distance is insufficient", () => { | ||
| // dx=10 satisfies ratio (dy=4, 10 > 4*2=8) but dx(10) is NOT > SWIPE_LOCK_PX(20) | ||
| expect(resolveSwipeGestureState("idle", { dx: 10, dy: 4 })).toBe("idle"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("idle state", () => { | ||
| it("stays idle for small diagonal movement", () => { | ||
| // Both dx and dy are small — well under all thresholds | ||
| expect(resolveSwipeGestureState("idle", { dx: 5, dy: 3 })).toBe("idle"); | ||
| }); | ||
|
|
||
| it("stays idle at zero delta", () => { | ||
| expect(resolveSwipeGestureState("idle", { dx: 0, dy: 0 })).toBe("idle"); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("resolvePullGestureState", () => { | ||
| describe("terminal states pass through unchanged", () => { | ||
| it("returns pulling unchanged when already pulling", () => { | ||
| expect(resolvePullGestureState("pulling", { dx: SWIPE_LOCK_PX + 1, dy: 0 })).toBe("pulling"); | ||
| }); | ||
|
|
||
| it("returns cancelled unchanged when already cancelled", () => { | ||
| expect(resolvePullGestureState("cancelled", { dx: 0, dy: 100 })).toBe("cancelled"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("cancellation", () => { | ||
| it("cancels when horizontal movement exceeds SWIPE_LOCK_PX", () => { | ||
| expect(resolvePullGestureState("idle", { dx: SWIPE_LOCK_PX + 1, dy: 30 })).toBe("cancelled"); | ||
| }); | ||
|
|
||
| it("cancels when vertical movement is upward", () => { | ||
| expect(resolvePullGestureState("idle", { dx: 0, dy: -1 })).toBe("cancelled"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("pull lock-in", () => { | ||
| it("locks in when downward exceeds threshold and dominates horizontal", () => { | ||
| expect(resolvePullGestureState("idle", { dx: 2, dy: 20 })).toBe("pulling"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("idle state", () => { | ||
| it("stays idle for small movement", () => { | ||
| expect(resolvePullGestureState("idle", { dx: 2, dy: 5 })).toBe("idle"); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| export const SWIPE_THRESHOLD = 80; | ||
| // Minimum horizontal movement before we consider it a swipe intent | ||
| export const SWIPE_LOCK_PX = 20; | ||
| // Horizontal movement must be this many times greater than vertical to count as a swipe | ||
| export const SWIPE_AXIS_RATIO = 2; | ||
| // If vertical movement exceeds this before horizontal lock-in, treat as a scroll and cancel | ||
| export const SCROLL_CANCEL_PX = 12; | ||
|
|
||
| export type SwipeGestureState = "idle" | "swiping" | "cancelled"; | ||
|
|
||
| /** | ||
| * Pure state transition function for swipe gesture detection. | ||
| * Given the current gesture state and cumulative delta from touch start, | ||
| * returns the next state. | ||
| * | ||
| * Rules (evaluated in order): | ||
| * 1. Already committed (swiping or cancelled) → state is terminal, return as-is | ||
| * 2. Vertical movement exceeds SCROLL_CANCEL_PX → treat as scroll, cancel | ||
| * 3. Horizontal movement exceeds SWIPE_LOCK_PX and is 2× the vertical → lock in as swipe | ||
| * 4. Otherwise → remain idle | ||
| */ | ||
| export function resolveSwipeGestureState( | ||
| current: SwipeGestureState, | ||
| delta: { dx: number; dy: number }, | ||
| ): SwipeGestureState { | ||
| if (current !== "idle") return current; | ||
|
|
||
| if (Math.abs(delta.dy) > SCROLL_CANCEL_PX) return "cancelled"; | ||
|
|
||
| if ( | ||
| Math.abs(delta.dx) > SWIPE_LOCK_PX && | ||
| Math.abs(delta.dx) > Math.abs(delta.dy) * SWIPE_AXIS_RATIO | ||
| ) { | ||
| return "swiping"; | ||
| } | ||
|
|
||
| return "idle"; | ||
| } | ||
|
|
||
| // --- Pull-to-reveal (vertical) gesture --- | ||
|
|
||
| export const PULL_THRESHOLD = 64; | ||
| // Minimum downward movement before locking in as a pull gesture | ||
| const PULL_LOCK_PY = 16; | ||
| // Vertical movement must be this many times greater than horizontal | ||
| const PULL_AXIS_RATIO = 1.5; | ||
|
|
||
| export type PullGestureState = "idle" | "pulling" | "cancelled"; | ||
|
|
||
| /** | ||
| * Pure state transition for pull-down gesture detection. | ||
| * Mirrors resolveSwipeGestureState but for the vertical axis. | ||
| * | ||
| * Rules: | ||
| * 1. Already committed → terminal, return as-is | ||
| * 2. Horizontal movement too large → treat as swipe, cancel | ||
| * 3. Upward movement → cancel | ||
| * 4. Downward movement exceeds lock-in and dominates horizontal → lock in as pull | ||
| * 5. Otherwise → remain idle | ||
| */ | ||
| export function resolvePullGestureState( | ||
| current: PullGestureState, | ||
| delta: { dx: number; dy: number }, | ||
| ): PullGestureState { | ||
| if (current !== "idle") return current; | ||
| if (Math.abs(delta.dx) > SWIPE_LOCK_PX) return "cancelled"; | ||
| if (delta.dy < 0) return "cancelled"; | ||
| if (delta.dy > PULL_LOCK_PY && delta.dy > Math.abs(delta.dx) * PULL_AXIS_RATIO) { | ||
| return "pulling"; | ||
| } | ||
| return "idle"; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull gesture exports unused in production code
Low Severity
resolvePullGestureState,PULL_THRESHOLD, andPullGestureStateare exported but never imported by any production code — only the test file references them. ThePullToRevealcomponent mentioned in the PR description does not exist in the codebase, making this dead code that adds maintenance surface with no consumers.