From e5c63f65fb8a975af541326dd66da650f30e98d9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 31 Mar 2026 14:22:25 +0200 Subject: [PATCH] fix(auth-ui): add focus traps to modals + aria-disabled on all buttons - Add focus trap (Tab/Shift+Tab cycling) to AuthGateModal and GuestWelcomeModal with auto-focus on primary action - Add aria-disabled to all disabled buttons across 8 components for proper screen reader announcements Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/AuthGateModal.svelte | 40 +++++++++++++++++++ .../src/components/ChangePassword.svelte | 1 + .../src/components/GuestWelcomeModal.svelte | 39 ++++++++++++++++++ .../src/components/PasskeyManager.svelte | 7 ++++ .../src/components/SessionManager.svelte | 3 ++ .../src/pages/ForgotPasswordPage.svelte | 1 + .../shared-auth-ui/src/pages/LoginPage.svelte | 5 +++ .../src/pages/RegisterPage.svelte | 2 + 8 files changed, 98 insertions(+) diff --git a/packages/shared-auth-ui/src/components/AuthGateModal.svelte b/packages/shared-auth-ui/src/components/AuthGateModal.svelte index 845f92743..d6855cf23 100644 --- a/packages/shared-auth-ui/src/components/AuthGateModal.svelte +++ b/packages/shared-auth-ui/src/components/AuthGateModal.svelte @@ -159,6 +159,45 @@ onClose(); } } + + function trapFocus(node: HTMLElement) { + const focusableSelectors = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + function handleKeydown(e: KeyboardEvent) { + if (e.key !== 'Tab') return; + + const focusable = Array.from(node.querySelectorAll(focusableSelectors)) as HTMLElement[]; + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + node.addEventListener('keydown', handleKeydown); + // Auto-focus the primary (login) button + const focusable = node.querySelectorAll(focusableSelectors) as NodeListOf; + // Skip the close button (index 0), focus the login button (index 1) + if (focusable.length > 1) { + focusable[1].focus(); + } else if (focusable.length > 0) { + focusable[0].focus(); + } + + return { + destroy() { + node.removeEventListener('keydown', handleKeydown); + }, + }; + } @@ -175,6 +214,7 @@ aria-modal="true" aria-labelledby="auth-gate-title" onclick={(e) => e.stopPropagation()} + use:trapFocus > @@ -250,6 +251,7 @@ class="pm-btn pm-btn-danger" onclick={executeDelete} disabled={loading} + aria-disabled={loading} > {#if loading} {t.cancelButton} @@ -295,6 +298,7 @@ class="pm-btn pm-btn-primary" onclick={() => saveRename(passkey.id)} disabled={loading || !editName.trim()} + aria-disabled={loading || !editName.trim()} > {t.saveButton} @@ -363,6 +367,7 @@ class="pm-btn pm-btn-cancel" onclick={cancelRegister} disabled={loading} + aria-disabled={loading} > {t.cancelButton} @@ -371,6 +376,7 @@ class="pm-btn pm-btn-primary" onclick={handleRegister} disabled={loading} + aria-disabled={loading} > {#if loading} handleRevoke(session.id)} disabled={revoking === session.id || revokingAll} + aria-disabled={revoking === session.id || revokingAll} > {#if revoking === session.id} @@ -307,6 +309,7 @@ class="revoke-all-button" onclick={handleRevokeAll} disabled={revokingAll} + aria-disabled={revokingAll} > {#if revokingAll} diff --git a/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte b/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte index 38b0cea5c..8a08f780f 100644 --- a/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte +++ b/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte @@ -250,6 +250,7 @@