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>

View file

@ -250,6 +250,7 @@
<button
type="submit"
disabled={loading}
aria-disabled={loading}
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
style:background-color={primaryColor + '60'}
style:border-color={primaryColor}

View file

@ -561,6 +561,7 @@
<button
type="submit"
disabled={loading || !twoFactorCode}
aria-disabled={loading || !twoFactorCode}
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
style:background-color={primaryColor + '60'}
style:border-color={primaryColor}
@ -636,6 +637,7 @@
type="button"
onclick={handlePasskeySignIn}
disabled={loading || showSuccess}
aria-disabled={loading || showSuccess}
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity bg-transparent hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
style:border-color={primaryColor}
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
@ -726,6 +728,7 @@
class="bg-transparent border-none cursor-pointer font-medium text-sm p-0 text-left underline hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleResendVerification}
disabled={resendingVerification}
aria-disabled={resendingVerification}
style:color={primaryColor}
>
{resendingVerification ? t.resendingVerification : t.resendVerification}
@ -842,6 +845,7 @@
<button
type="submit"
disabled={loading || showSuccess || rateLimitCountdown > 0}
aria-disabled={loading || showSuccess || rateLimitCountdown > 0}
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
style:background-color={showSuccess ? '#22c55e' : primaryColor + '60'}
style:border-color={showSuccess ? '#22c55e' : primaryColor}
@ -892,6 +896,7 @@
type="button"
onclick={handleSendMagicLink}
disabled={sendingMagicLink || !email}
aria-disabled={sendingMagicLink || !email}
class="w-full bg-transparent border-none cursor-pointer font-medium text-sm p-3 text-center transition-opacity hover:opacity-70 disabled:opacity-40 disabled:cursor-not-allowed"
style:color={primaryColor}
>

View file

@ -374,6 +374,7 @@
type="button"
onclick={handleResendVerification}
disabled={resendingVerification}
aria-disabled={resendingVerification}
class="w-full flex items-center justify-center gap-2 h-11 rounded-lg font-medium transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed border-[1.5px]"
style:background-color="{primaryColor}40"
style:border-color={primaryColor}
@ -504,6 +505,7 @@
<button
type="submit"
disabled={loading}
aria-disabled={loading}
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
style:background-color="{primaryColor}60"
style:border-color={primaryColor}