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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 14:22:25 +02:00
parent 201819280e
commit e5c63f65fb
8 changed files with 98 additions and 0 deletions

View file

@ -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<HTMLElement>;
// 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);
},
};
}
</script>
<svelte:window onkeydown={handleKeydown} />
@ -175,6 +214,7 @@
aria-modal="true"
aria-labelledby="auth-gate-title"
onclick={(e) => e.stopPropagation()}
use:trapFocus
>
<!-- Close button -->
<button

View file

@ -208,6 +208,7 @@
<button
type="submit"
disabled={!canSubmit}
aria-disabled={!canSubmit}
class="submit-button"
style:background-color={primaryColor + '60'}
style:border-color={primaryColor}

View file

@ -164,6 +164,44 @@
handleContinueAsGuest();
}
}
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 - skip close button (index 0)
const focusable = node.querySelectorAll(focusableSelectors) as NodeListOf<HTMLElement>;
if (focusable.length > 1) {
focusable[1].focus();
} else if (focusable.length > 0) {
focusable[0].focus();
}
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
},
};
}
</script>
<svelte:window onkeydown={handleKeydown} />
@ -186,6 +224,7 @@
class="modal-content"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
use:trapFocus
>
<!-- Close Button -->
<button type="button" class="close-button" onclick={handleContinueAsGuest} aria-label="Close">

View file

@ -242,6 +242,7 @@
class="pm-btn pm-btn-cancel"
onclick={cancelDelete}
disabled={loading}
aria-disabled={loading}
>
{t.cancelButton}
</button>
@ -250,6 +251,7 @@
class="pm-btn pm-btn-danger"
onclick={executeDelete}
disabled={loading}
aria-disabled={loading}
>
{#if loading}
<svg
@ -287,6 +289,7 @@
class="pm-btn pm-btn-cancel"
onclick={cancelRename}
disabled={loading}
aria-disabled={loading}
>
{t.cancelButton}
</button>
@ -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}
</button>
@ -363,6 +367,7 @@
class="pm-btn pm-btn-cancel"
onclick={cancelRegister}
disabled={loading}
aria-disabled={loading}
>
{t.cancelButton}
</button>
@ -371,6 +376,7 @@
class="pm-btn pm-btn-primary"
onclick={handleRegister}
disabled={loading}
aria-disabled={loading}
>
{#if loading}
<svg
@ -394,6 +400,7 @@
class="pm-btn pm-btn-register"
onclick={openRegisterForm}
disabled={loading}
aria-disabled={loading}
>
<svg
width="18"

View file

@ -202,6 +202,7 @@
class="refresh-button"
onclick={onRefresh}
disabled={loading}
aria-disabled={loading}
aria-label={t.refresh}
>
<svg
@ -289,6 +290,7 @@
class="revoke-button"
onclick={() => handleRevoke(session.id)}
disabled={revoking === session.id || revokingAll}
aria-disabled={revoking === session.id || revokingAll}
>
{#if revoking === session.id}
<span class="revoke-spinner"></span>
@ -307,6 +309,7 @@
class="revoke-all-button"
onclick={handleRevokeAll}
disabled={revokingAll}
aria-disabled={revokingAll}
>
{#if revokingAll}
<span class="revoke-spinner"></span>