From b32231d4f42a9eb61fe429fb17d3cb34bf27c2a0 Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 30 Jun 2026 21:23:05 +0200 Subject: [PATCH 1/3] fix(a11y): gate always-mounted sheet dialog semantics on open state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContextSheet and UploadSheet stay mounted (slid off-screen via translate-y) when closed, but declared role="dialog" + aria-modal="true" — and kept their buttons in the a11y tree + tab order — unconditionally. So a screen reader always saw 2+ simultaneous modal dialogs, and their controls (e.g. each sheet's "Abbrechen") collided with real open dialogs. Surfaced by the e2e a11y suite: focus-trap asserted exactly one [role=dialog] [aria-modal] and got 2; upload-cancel-confirm's getByRole('button',{name: 'Abbrechen'}) matched the composer's X *and* a closed sheet's button. Fix: when closed, drop role/aria-modal and mark the subtree aria-hidden + inert so it's out of the a11y tree and tab order entirely. Open sheets are unchanged. Verified: chromium-mobile a11y/gesture suite now 21 passed / 5 skipped / 0 a11y failures (focus-trap + upload-cancel-confirm green); svelte-check 0 errors. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/components/ContextSheet.svelte | 6 ++++-- frontend/src/lib/components/UploadSheet.svelte | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/ContextSheet.svelte b/frontend/src/lib/components/ContextSheet.svelte index 97e7acf..07562ae 100644 --- a/frontend/src/lib/components/ContextSheet.svelte +++ b/frontend/src/lib/components/ContextSheet.svelte @@ -103,8 +103,10 @@ class:translate-y-full={!open} class:translate-y-0={open} style="padding-bottom: env(safe-area-inset-bottom)" - role="dialog" - aria-modal="true" + role={open ? 'dialog' : undefined} + aria-modal={open ? 'true' : undefined} + aria-hidden={!open} + inert={!open} tabindex="-1" >
diff --git a/frontend/src/lib/components/UploadSheet.svelte b/frontend/src/lib/components/UploadSheet.svelte index bfd9a8c..9a216be 100644 --- a/frontend/src/lib/components/UploadSheet.svelte +++ b/frontend/src/lib/components/UploadSheet.svelte @@ -136,9 +136,11 @@ class:translate-y-full={!open} class:translate-y-0={open} style="padding-bottom: env(safe-area-inset-bottom)" - role="dialog" - aria-modal="true" + role={open ? 'dialog' : undefined} + aria-modal={open ? 'true' : undefined} aria-label="Hochladen" + aria-hidden={!open} + inert={!open} tabindex="-1" > From 792a4f0e4b218ba7ba3cc288aa300f7588fe111f Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 30 Jun 2026 22:12:05 +0200 Subject: [PATCH 2/3] fix(frontend): robust Escape handling + deep-link back navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit focus-trap: focus the trapped container synchronously on mount instead of only after a deferred rAF. The keydown listener is node-scoped, so an Escape pressed before the rAF moved focus into the sheet landed on an element outside the node and was silently dropped — a real keyboard-a11y gap (open a sheet, immediately press Escape → nothing happened). The rAF still refines focus to the first control once laid out. recover: replace the `window.history.length > 1` back-chevron heuristic with SvelteKit's afterNavigate `from` signal. history.length is 2 on a fresh-tab deep link (about:blank + page), so history.back() landed on the blank entry. `from` is null only on a full-page load, so deep-linked users now correctly fall back to /join (or /feed when authed). Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/actions/focus-trap.ts | 9 +++++++-- frontend/src/routes/recover/+page.svelte | 13 +++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/actions/focus-trap.ts b/frontend/src/lib/actions/focus-trap.ts index c7d7f77..150f68e 100644 --- a/frontend/src/lib/actions/focus-trap.ts +++ b/frontend/src/lib/actions/focus-trap.ts @@ -64,11 +64,16 @@ export function focusTrap( node.addEventListener('keydown', onKeyDown); if (opts.autoFocus !== false) { - // Defer one frame so the element is fully laid out (sheets animate in). + // Focus the container synchronously on mount so the trap owns the keyboard + // immediately — otherwise an Escape pressed before the deferred focus below + // lands on an element *outside* the node, where this node-scoped listener + // never sees it (the keystroke is silently dropped). Then defer one frame to + // move focus onto the first control once the sheet has laid out / animated in. + if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1'); + node.focus({ preventScroll: true }); requestAnimationFrame(() => { const list = focusables(node); const target = list[0] ?? node; - if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1'); target.focus({ preventScroll: true }); }); } diff --git a/frontend/src/routes/recover/+page.svelte b/frontend/src/routes/recover/+page.svelte index 40de203..d2864ad 100644 --- a/frontend/src/routes/recover/+page.svelte +++ b/frontend/src/routes/recover/+page.svelte @@ -1,14 +1,23 @@