Replace every blocking alert() with a light toast, fix a mobile grid bug #12

Merged
kyuno merged 2 commits from feature/toast-notifications into master 2026-09-16 18:33:02 +02:00
5 changed files with 129 additions and 3 deletions
Showing only changes of commit acb45c8a1a - Show all commits

View file

@ -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
- [x] Every blocking `alert()` (host handoff, every "couldn't start/join
a party" error) replaced with a light, auto-dismissing toast — see
docs/architecture.md

View file

@ -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 -

View file

@ -32,7 +32,14 @@
<div class="mobileNavScrim" id="mobileNavScrim"></div>
<div class="mobileNavSheet" id="mobileNavSheet">
<div class="sheetGrabber"></div>
<!-- Bigger invisible drag target than the visible pill itself (see
.sheetGrabberHandle) - touch-action:none so this app's own
pointer handlers (Shell.ts) drive the drag instead of the
browser's default touch handling, which otherwise reads a
downward drag here as pull-to-refresh (the sheet sits right at
the top of the page's own scroll position, same conflict a
plain scrim tap-only close used to leave unsolved). -->
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
<div class="navSectionLabel">Bibliothèques</div>
<div class="sheetGrid" id="mobileLibGrid"></div>

View file

@ -105,7 +105,7 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
// ---- 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<HTMLElement> {
});
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;
}

View file

@ -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).
## Toast notifications, replacing every blocking `alert()`
Asked for directly, prompted by the host-handoff message specifically