From afae977b493da0cf7e3ab2bc791dd38ffa3f6db6 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 17:58:56 +0200 Subject: [PATCH] Make the mobile nav sheet swipe-to-close instead of fighting pull-to-refresh Reported directly: the only way to close the bottom sheet was tapping the scrim, and the instinctive swipe-it-down gesture fell straight through to the browser's own pull-to-refresh instead - nothing was telling the browser this app's own code owned that gesture. touch-action: none on the scrim and the (now real, not just visual) grabber handle stops the browser's default touch handling from kicking in there at all; overscroll-behavior: contain on the sheet itself stops a drag that hits its own scroll boundary (the library grid can run taller than the sheet's 75vh cap) from chaining to the page underneath. The grabber's small 36x4px pill now sits inside a much bigger invisible touch target that actually drags the sheet (pointerdown/move/up, 1:1 tracking, snap back under 90px or close past it) - scoped to the grabber alone, not the whole sheet, so the library grid's own taps/scroll keep working untouched. Co-Authored-By: Claude Sonnet 5 --- README.md | 3 ++ client/src/shell/Shell.css | 29 ++++++++++++++++++- client/src/shell/Shell.html | 9 +++++- client/src/shell/Shell.ts | 36 +++++++++++++++++++++++- docs/architecture.md | 55 +++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e2493f2..1e9f553 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,9 @@ Then put it behind your existing reverse proxy like everything else movie-poster tiles — circular artist tiles, square album tiles, a real hero header, and a mobile "now playing" full-screen player — see docs/architecture.md +- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag + handle, not just a decorative grabber) instead of fighting the + browser's own pull-to-refresh gesture — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/client/src/shell/Shell.css b/client/src/shell/Shell.css index e2ae3d1..09a2301 100644 --- a/client/src/shell/Shell.css +++ b/client/src/shell/Shell.css @@ -121,6 +121,12 @@ opacity: 0; pointer-events: none; transition: opacity .2s; + /* A touch starting here has no scrollable content of its own to + "contain" (overscroll-behavior wouldn't apply) - this is what + stops the browser reading a drag over the dimmed backdrop as a + page-level gesture (pull-to-refresh) instead of just dismissing + the sheet via the existing tap-to-close below. */ + touch-action: none; } .mobileNavScrim.open { opacity: 1; pointer-events: auto; } @@ -133,6 +139,12 @@ z-index: 21; max-height: 75vh; overflow-y: auto; + /* Stops a drag that hits the top of the sheet's OWN scroll (its + library grid can be taller than 75vh) from chaining past this + element to the page underneath and surfacing as pull-to-refresh - + complements touch-action:none on the dedicated grabber handle + below, which instead covers a drag that never scrolls anything. */ + overscroll-behavior: contain; background: var(--bg-header); border-radius: 16px 16px 0 0; padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px)); @@ -143,12 +155,27 @@ .mobileNavSheet.open { transform: translateY(0); } +/* Dragging (see Shell.ts) suspends the transition above for 1:1 + tracking under the finger, then this class restores it for the + snap-back/close animation - same "instant while dragging, animated + on release" split views/player/Player.css's own seek bar uses. */ +.mobileNavSheet.dragging { transition: none; } + +/* Bigger drag/touch target than the pill it contains (36x4px is far + below a real touch-target minimum) - see Shell.html/Shell.ts. */ +.sheetGrabberHandle { + display: flex; + justify-content: center; + padding: 14px 0 10px; + touch-action: none; + cursor: grab; +} + .sheetGrabber { width: 36px; height: 4px; border-radius: 99px; background: var(--muted); - margin: 10px auto 6px; } /* Accueil and the (conditional) Listes de lecture row both use this - diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 4d2d356..df0fa01 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -32,7 +32,14 @@
-
+ +
Accueil
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index cc06086..8d83edb 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -105,7 +105,7 @@ export async function renderShell(appEl: HTMLElement): Promise { // ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ---- const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement; - const navSheet = appEl.querySelector('#mobileNavSheet')!; + const navSheet = appEl.querySelector('#mobileNavSheet') as HTMLElement; const navScrim = appEl.querySelector('#mobileNavScrim')!; const closeMobileNav = () => { navSheet.classList.remove('open'); @@ -127,5 +127,39 @@ export async function renderShell(appEl: HTMLElement): Promise { }); window.addEventListener('hashchange', closeMobileNav); + // Drag-to-dismiss on the grabber handle only - not the whole sheet, + // which would fight the library grid's own scroll/tap targets (see + // docs/architecture.md's "Mobile navigation, take two"). Reported + // directly: without this, a scrim tap was the ONLY way to close the + // sheet, and the instinctive swipe-it-down gesture instead fell + // through to the browser's own pull-to-refresh (see Shell.css's + // touch-action/overscroll-behavior on the scrim/sheet themselves - + // this is the other half, giving that swipe something to actually do). + const CLOSE_DRAG_THRESHOLD_PX = 90; + const grabberHandle = appEl.querySelector('#sheetGrabberHandle') as HTMLElement; + let dragStartY: number | null = null; + let dragDeltaY = 0; + + grabberHandle.addEventListener('pointerdown', (e) => { + dragStartY = e.clientY; + dragDeltaY = 0; + grabberHandle.setPointerCapture(e.pointerId); + navSheet.classList.add('dragging'); + }); + grabberHandle.addEventListener('pointermove', (e) => { + if (dragStartY === null) return; + dragDeltaY = Math.max(0, e.clientY - dragStartY); // only follows a downward drag - closing is the only thing this gesture does + navSheet.style.transform = `translateY(${dragDeltaY}px)`; + }); + function endDrag() { + if (dragStartY === null) return; + dragStartY = null; + navSheet.classList.remove('dragging'); + navSheet.style.transform = ''; // hands back to the .open class's own rule - either translateY(0) (snap back) or the closeMobileNav() call below immediately overrides it anyway + if (dragDeltaY > CLOSE_DRAG_THRESHOLD_PX) closeMobileNav(); + } + grabberHandle.addEventListener('pointerup', endDrag); + grabberHandle.addEventListener('pointercancel', endDrag); + return appEl.querySelector('#mainContent') as HTMLElement; } diff --git a/docs/architecture.md b/docs/architecture.md index ca01269..59e9d43 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3775,6 +3775,61 @@ genuinely narrow viewport. **Not verified**: any of this against a real Emby server's actual artist/album images and names, or on a real phone. +## Mobile navigation, take two: swipe-to-close was fighting pull-to-refresh + +Reported directly, against a real phone: the bottom sheet (see "Mobile +navigation" above) had exactly one way to close it - tap the dimmed +scrim - since the grabber was purely decorative. Swiping the sheet +down, the instinctive gesture every real bottom sheet supports, fell +through to the browser's own default touch handling instead: with +nothing telling it otherwise, a downward drag starting on the scrim or +the sheet reads to the browser as "the user wants to pull the page +down," which on a phone is the built-in pull-to-refresh gesture - not +a bug in this app's own logic, but a real gap in it (nothing was +`preventDefault`-ing or claiming that gesture for the sheet instead). + +**Two independent parts, not one fix**: containing the browser's own +gesture, and giving the swipe something to actually do. + +- `touch-action: none` on `.mobileNavScrim` and the new + `.sheetGrabberHandle` - tells the browser not to apply *any* of its + own default touch behavior (panning, pull-to-refresh) to a touch + starting on either, since this app's own pointer handlers own that + gesture now. `overscroll-behavior: contain` on `.mobileNavSheet` + itself covers the other half of the same problem: its own library + grid can be taller than the sheet's `75vh` cap, so a drag that hits + *that* scroll boundary needs to stop there too, not chain past the + sheet to the page underneath. +- **The grabber is now a real drag handle**, not just a visual hint. + `#sheetGrabberHandle` wraps the small visible pill in a much bigger + invisible touch target (a real touch target, not a 36x4px sliver) + and drives the sheet with the same pointerdown/move/up(/cancel) + pattern `views/player/Player.css`'s own seek bar already uses: + 1:1 tracking under the finger while dragging (`.dragging` suspends + the CSS transition for this), then either snapping back open (a + drag under 90px) or calling the exact same `closeMobileNav()` the + scrim tap already used (past it) - one close path, two ways to + reach it, not a second parallel implementation. Deliberately scoped + to the grabber alone, not the whole sheet: the sheet's own content + (the library grid, the logout row) needs normal taps and its own + scroll to keep working, which a whole-sheet drag-to-close would + fight. + +Verified: full `typecheck`/`build` (client only). The CSS properties +(`touch-action`/`overscroll-behavior`) confirmed applied via +`getComputedStyle` against the real compiled `app.css`. The drag logic +itself confirmed with dispatched `PointerEvent`s carrying real +`clientY`/`pointerId` values against the real handlers copied from +`Shell.ts`: a 40px drag snaps back (stays open, `transform` cleared), +a 120px drag closes, and the live `transform` mid-drag tracks the +pointer 1:1 (confirmed at an intermediate point, not just the start/end +state) - not just asserted from reading the threshold constant. +Existing behavior re-confirmed unchanged in the same pass: scrim-tap- +to-close still closes. **Not verified**: actual touch input (not a +mouse or dispatched `PointerEvent`) on a real phone - no device +reachable from this sandbox, same standing gap as this project's other +touch-specific work (the video/music seek bars' own drag handling). + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's -- 2.45.2