Implement keyboard navigation using arrow keys in the whole application. (#2)

This commit is contained in:
LeoVasanko committed 2025-12-10 15:43:40 +00:00
1 parent 7f47f44039
commit ca73febe2f
23 files changed
+1785 -134

No files matched your search

+59 -3
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
// Props:
// entries: Array<{ label:string, href:string }>
@@ -11,17 +12,72 @@ const props = defineProps({
homeHref: { type: String, default: '/' }
})
const navRef = ref(null)
const crumbs = computed(() => {
const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : []
return [...base, ...props.entries]
})
// Find the index of the crumb matching current location
const currentIndex = computed(() => {
const currentHref = window.location.hash || window.location.pathname
for (let i = crumbs.value.length - 1; i >= 0; i--) {
const href = crumbs.value[i].href
if (href === currentHref || (href && currentHref.startsWith(href))) {
return i
}
}
return crumbs.value.length - 1 // Default to last crumb
})
function handleFocusIn(event) {
// When the nav receives focus, focus the current page's crumb
if (event.target === navRef.value) {
const links = navRef.value.querySelectorAll('a')
const targetIndex = Math.min(currentIndex.value, links.length - 1)
if (links[targetIndex]) {
links[targetIndex].focus()
}
}
}
function handleKeydown(event) {
const direction = getDirection(event)
if (!direction) return
if (direction === 'left' || direction === 'right') {
event.preventDefault()
navigateButtonRow(navRef.value, event.target, direction, { itemSelector: 'a' })
}
// Up/down are handled by parent component
}
// Expose method to focus the current crumb from parent
function focusCurrent() {
const links = navRef.value?.querySelectorAll('a')
if (links?.length) {
const targetIndex = Math.min(currentIndex.value, links.length - 1)
links[targetIndex]?.focus()
}
}
defineExpose({ focusCurrent })
</script>
<template>
<nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length">
<nav
ref="navRef"
class="breadcrumbs"
aria-label="Breadcrumb"
v-if="crumbs.length"
tabindex="0"
@focusin="handleFocusIn"
@keydown="handleKeydown"
>
<ol>
<li v-for="(c, idx) in crumbs" :key="idx">
<a :href="c.href">{{ c.label }}</a>
<a :href="c.href" tabindex="-1">{{ c.label }}</a>
<span v-if="idx < crumbs.length - 1" class="sep"> — </span>
</li>
</ol>
+46 -7
View File
@@ -1,5 +1,5 @@
<template>
<div class="credential-list">
<div class="credential-list" tabindex="0" @focusin="handleListFocus" @keydown="handleListKeydown">
<div v-if="loading"><p>Loading credentials...</p></div>
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
<template v-else>
@@ -11,12 +11,12 @@
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
}]"
tabindex="0"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleCredentialFocus(credential.credential_uuid)"
@focusout="handleCredentialBlur($event)"
@keydown="handleDelete($event, credential)"
@keydown="handleItemKeydown($event, credential)"
>
<div class="item-top">
<div class="item-icon">
@@ -62,6 +62,7 @@
<script setup>
import { formatDate } from '@/utils/helpers'
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
const props = defineProps({
credentials: { type: Array, default: () => [] },
@@ -70,9 +71,10 @@ const props = defineProps({
allowDelete: { type: Boolean, default: false },
hoveredCredentialUuid: { type: String, default: null },
hoveredSessionCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
})
const emit = defineEmits(['delete', 'credentialHover'])
const emit = defineEmits(['delete', 'credentialHover', 'navigate-out'])
const handleCredentialFocus = (uuid) => {
emit('credentialHover', uuid)
@@ -93,10 +95,47 @@ const handleCardClick = (event) => {
}
const handleDelete = (event, credential) => {
const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') {
event.preventDefault()
handleDeleteKey(event, () => {
if (props.allowDelete && !credential.is_current_session) emit('delete', credential)
})
}
const handleListFocus = (event) => {
if (props.navigationDisabled) return
const list = event.currentTarget
// If focus came to the list container itself (not a child), focus first item
if (event.target === list) {
const firstItem = list.querySelector('.credential-item')
if (firstItem) {
firstItem.focus()
}
}
}
const handleListKeydown = (event) => {
if (props.navigationDisabled) return
// Escape emits navigate-out
handleEscape(event, (dir) => emit('navigate-out', dir))
}
const handleItemKeydown = (event, credential) => {
// Handle delete (always allowed even with modal)
handleDelete(event, credential)
if (event.defaultPrevented) return
if (props.navigationDisabled) return
// Arrow key navigation
const direction = getDirection(event)
if (direction) {
event.preventDefault()
const list = event.currentTarget.closest('.credential-list')
const result = navigateGrid(list, event.currentTarget, direction, { itemSelector: '.credential-item' })
if (result === 'boundary') {
emit('navigate-out', direction)
}
}
}
+22 -3
View File
@@ -5,7 +5,7 @@
<p class="view-lede">{{ subheading }}</p>
</header>
<section class="section-block">
<section class="section-block" ref="userInfoSection">
<div class="section-body">
<UserBasicInfo
v-if="user"
@@ -25,7 +25,7 @@
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@@ -58,10 +58,11 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
import { goBack } from '@/utils/helpers'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
defineProps({
initializing: {
@@ -73,6 +74,10 @@ defineProps({
const authStore = useAuthStore()
const currentHost = window.location.host
// Template refs for navigation
const userInfoSection = ref(null)
const buttonRow = ref(null)
const user = computed(() => authStore.userInfo?.user || null)
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
@@ -105,6 +110,20 @@ const goToAuthSite = () => {
const logout = async () => {
await authStore.logout()
}
// Keyboard navigation for button row
const handleButtonRowKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
}
// Up does nothing (no elements above to navigate to)
// Down does nothing (no elements below to navigate to)
}
</script>
<style scoped>
+124 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="modal-overlay" @keydown.esc="$emit('close')" tabindex="-1">
<div class="modal-overlay" @keydown.esc="$emit('close')" @keydown="handleOverlayKeydown" tabindex="-1">
<div class="modal" role="dialog" aria-modal="true">
<slot />
</div>
@@ -7,7 +7,130 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
focusFallback: { type: [HTMLElement, Object], default: null },
// Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus
focusSiblingSelector: { type: String, default: '' }
})
defineEmits(['close'])
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
/**
* Try to restore focus to the original element, or find a suitable fallback.
* Called on unmount to restore focus when modal closes.
*/
const restoreFocus = () => {
const prev = previouslyFocusedElement.value
if (!prev) return
// Check if the original element still exists in DOM and is focusable
if (document.body.contains(prev) && !prev.disabled) {
prev.focus()
return
}
// Original element is gone (deleted) - try to find a sibling
if (props.focusSiblingSelector && props.focusIndex >= 0) {
// Find container that has items matching the selector
const containers = [
props.focusFallback?.$el || props.focusFallback,
prev.closest('[data-nav-group]'),
prev.parentElement?.closest('section'),
document.querySelector('.view-root')
].filter(Boolean)
for (const container of containers) {
if (!container) continue
const siblings = container.querySelectorAll(props.focusSiblingSelector)
if (siblings.length > 0) {
// Try to focus the next item, or the previous if we were at the end
const targetIndex = Math.min(props.focusIndex, siblings.length - 1)
const target = siblings[targetIndex]
if (target && !target.disabled) {
target.focus()
return
}
}
}
}
// Fall back to the provided fallback element
const fallback = props.focusFallback?.$el || props.focusFallback
if (fallback && document.body.contains(fallback)) {
const focusable = fallback.querySelector?.('button:not([disabled]), a, [tabindex="0"]') || fallback
if (focusable?.focus) {
focusable.focus()
return
}
}
}
const handleOverlayKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
// Check if we're in a modal-actions row
const target = event.target
const actionsRow = target.closest('.modal-actions')
if (actionsRow && (direction === 'left' || direction === 'right')) {
event.preventDefault()
navigateButtonRow(actionsRow, target, direction, { itemSelector: 'button' })
} else if (direction === 'up' && actionsRow) {
// From actions, try to go back to last input or focusable element in form
event.preventDefault()
const form = actionsRow.closest('form') || actionsRow.closest('.modal-form')
const inputs = form?.querySelectorAll('input, textarea, select, button:not(.modal-actions button)')
if (inputs && inputs.length > 0) {
inputs[inputs.length - 1].focus()
}
} else if (direction === 'down' && !actionsRow) {
// From an input, try to go to modal-actions
const form = target.closest('form') || target.closest('.modal-form')
if (form) {
event.preventDefault()
const actions = form.querySelector('.modal-actions')
if (actions) {
focusPreferred(actions, { primarySelector: '.btn-primary', itemSelector: 'button' })
}
}
}
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
nextTick(() => {
const modal = document.querySelector('.modal')
if (modal) {
// Mark primary button for keyboard navigation
const primaryBtn = modal.querySelector('.modal-actions .btn-primary')
if (primaryBtn) {
primaryBtn.setAttribute('data-nav-primary', '')
}
// Focus the most appropriate element
focusDialogDefault(modal)
}
})
})
onUnmounted(() => {
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
+13 -12
View File
@@ -12,7 +12,7 @@
/>
</label>
<div v-if="error" class="error small">{{ error }}</div>
<div class="modal-actions">
<div class="modal-actions" @keydown="handleActionsKeydown">
<button
type="button"
class="btn-secondary"
@@ -25,6 +25,7 @@
type="submit"
class="btn-primary"
:disabled="busy"
data-nav-primary
>
{{ submitText }}
</button>
@@ -33,7 +34,8 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { getDirection } from '@/utils/keynav'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -60,16 +62,15 @@ const localValue = computed({
const resolvedInputId = computed(() => props.inputId || generatedId)
onMounted(() => {
if (!props.autoFocus) return
nextTick(() => {
if (props.autoSelect) {
inputRef.value?.select()
} else {
inputRef.value?.focus()
}
})
})
const handleActionsKeydown = (event) => {
const direction = getDirection(event)
if (direction === 'up') {
event.preventDefault()
inputRef.value?.focus()
return
}
// Left/right navigation is handled globally by keynav
}
function handleCancel() {
emit('cancel')
+131 -11
View File
@@ -2,13 +2,14 @@
<section class="view-root" data-view="profile">
<header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header>
<section class="section-block">
<section class="section-block" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at"
@@ -17,6 +18,7 @@
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
@keydown="handleUserInfoKeydown"
>
<div class="remote-auth-inline">
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words:</label>
@@ -36,33 +38,38 @@
<section class="section-block">
<div class="section-header">
<h2>Your Passkeys</h2>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login.</p>
<p><a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
</div>
<div class="section-body">
<CredentialList
ref="credentialList"
:credentials="authStore.userInfo?.credentials || []"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
:navigation-disabled="hasActiveModal"
allow-delete
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@navigate-out="handleCredentialNavigateOut"
/>
<div class="button-row">
<button @click="addNewCredential" class="btn-primary">Register New</button>
<button @click="showRegLink = true" class="btn-secondary">Another Device</button>
<div class="button-row" ref="credentialButtons">
<button @click="addNewCredential" class="btn-primary" @keydown="handleCredentialButtonKeydown">Register New</button>
<button @click="showRegLink = true" class="btn-secondary" @keydown="handleCredentialButtonKeydown">Another Device</button>
</div>
</div>
</section>
<SessionList
ref="sessionList"
:sessions="sessions"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
@terminate="terminateSession"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/>
@@ -79,18 +86,19 @@
</Modal>
<section class="section-block">
<div class="button-row">
<div class="button-row" ref="logoutButtons">
<button
type="button"
class="btn-secondary"
@click="goBack"
@keydown="handleLogoutButtonKeydown"
>
Back
</button>
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading">Logout</button>
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
<template v-else>
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading">Logout</button>
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading">All</button>
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
</template>
</div>
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
@@ -119,6 +127,7 @@ import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from '@/utils/api'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
const authStore = useAuthStore()
const updateInterval = ref(null)
@@ -130,6 +139,16 @@ const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
const credentialList = ref(null)
const credentialButtons = ref(null)
const sessionList = ref(null)
const logoutButtons = ref(null)
const breadcrumbs = ref(null)
const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -165,6 +184,107 @@ const handlePairingError = (message) => {
}
}
// Helper to focus preferred button in a row (primary first, or first button)
const focusPreferredButton = (container) => {
focusPreferred(container, { primarySelector: '.btn-primary', itemSelector: 'button' })
}
// Navigation between components
const handleBreadcrumbKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
// Left/right handled internally by Breadcrumbs component
if (direction === 'down') {
event.preventDefault()
// Move to user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
}
// ArrowUp at the top does nothing
}
const handleUserInfoKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
const itemSelector = '.mini-btn, .pairing-input'
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
} else if (direction === 'up') {
// Move to breadcrumbs - focus current page crumb
breadcrumbs.value?.focusCurrent?.()
} else if (direction === 'down') {
// Move to credential list
credentialList.value?.$el?.focus()
}
}
const handleCredentialNavigateOut = (direction) => {
if (hasActiveModal.value) return // Block navigation when modal is open
if (direction === 'down' || direction === 'right') {
// Focus preferred button in credential section
focusPreferredButton(credentialButtons.value)
} else if (direction === 'up' || direction === 'left') {
// Focus user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
}
}
const handleCredentialButtonKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(credentialButtons.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move back to credential list
focusAtIndex(credentialList.value?.$el, 0, { itemSelector: '.credential-item' })
} else if (direction === 'down') {
// Move to session list
focusAtIndex(sessionList.value?.$el, 0, { itemSelector: '.session-group' })
}
}
const handleSessionNavigateOut = (direction) => {
if (hasActiveModal.value) return // Block navigation when modal is open
if (direction === 'up') {
// Focus preferred button in credential section
focusPreferredButton(credentialButtons.value)
} else if (direction === 'down') {
// Focus preferred button in logout section
focusPreferredButton(logoutButtons.value)
}
}
const handleLogoutButtonKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(logoutButtons.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move back to session list - focus last group
focusAtIndex(sessionList.value?.$el, -1, { itemSelector: '.session-group' })
}
// ArrowDown at the bottom does nothing
}
const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="qr-display">
<div class="qr-section">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link" tabindex="0" @keydown.enter.prevent="copyLink">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<div v-if="showLink && url" class="link-text">{{ displayUrl }}</div>
</a>
@@ -5,7 +5,7 @@
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close">✕</button>
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
</div>
<div class="device-link-section">
@@ -17,6 +17,7 @@
:url="linkUrl"
:show-link="true"
@copied="onCopied"
@keydown="handleQRKeydown"
/>
<p class="expiry-note" v-if="expiresAt">
@@ -24,7 +25,7 @@
</p>
</div>
<div class="reg-actions">
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
@@ -32,10 +33,11 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from '@/utils/api'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
const props = defineProps({
endpoint: { type: String, required: true },
@@ -46,6 +48,9 @@ const emit = defineEmits(['close', 'copied'])
const linkUrl = ref(null)
const expiresAt = ref(null)
const actionsRow = ref(null)
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
async function generateLink() {
try {
@@ -53,6 +58,11 @@ async function generateLink() {
if (data.url) {
linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null
// Focus primary button (or first button if no primary) after content renders
await nextTick()
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
@@ -65,7 +75,37 @@ function onCopied() {
emit('copied')
}
const handleQRKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
// Navigation constrained within modal: QR link <-> Close button
if (direction === 'down' || direction === 'up') {
// Toggle between QR link and close button
actionsRow.value?.querySelector('button')?.focus()
}
// Left/right do nothing on QR code
}
const handleActionsKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
// Navigation constrained within modal: Close button <-> QR link
if (direction === 'up' || direction === 'down') {
// Toggle between close button and QR link
document.querySelector('.qr-link')?.focus()
}
// Left/right do nothing (only one button)
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Hold backdrop before fetch to avoid gap if auth iframe shows
holdGlobalBackdrop()
generateLink()
@@ -74,6 +114,11 @@ onMounted(() => {
onUnmounted(() => {
// Release backdrop when modal closes
releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
prev.focus()
}
})
</script>
+11 -2
View File
@@ -18,7 +18,7 @@
<div class="section-body center">
<!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view">
<div class="button-row center">
<div class="button-row center" ref="buttonRow">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
@@ -55,11 +55,12 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
const props = defineProps({
mode: {
@@ -78,6 +79,7 @@ const settings = ref(null)
const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local' or 'remote'
const buttonRow = ref(null)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -255,6 +257,13 @@ function handleHeaderLinkClick(event) {
}
}
// Autofocus primary button when the view becomes ready
watch(initializing, (newVal) => {
if (!newVal) {
nextTick(() => focusDialogButton(buttonRow.value))
}
})
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
+84 -15
View File
@@ -7,7 +7,7 @@
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown.enter="handleGroupEnter($event, host)">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
@@ -22,12 +22,12 @@
'is-hovered': hoveredSession?.id === session.id,
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
}]"
tabindex="0"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleSessionFocus(session)"
@focusout="handleSessionBlur($event)"
@keydown="handleDelete($event, session)"
@keydown="handleItemKeydown($event, session)"
>
<div class="item-top">
<h4 class="item-title">{{ session.user_agent }}</h4>
@@ -66,6 +66,7 @@ import { computed, ref } from 'vue'
import { formatDate } from '@/utils/helpers'
import { useAuthStore } from '@/stores/auth'
import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
@@ -73,9 +74,10 @@ const props = defineProps({
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
})
const emit = defineEmits(['terminate', 'sessionHover'])
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
const authStore = useAuthStore()
@@ -104,19 +106,86 @@ const handleCardClick = (event) => {
}
}
const handleDelete = (event, session) => {
const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') {
event.preventDefault()
if (!isTerminating(session.id)) emit('terminate', session)
}
}
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const handleGroupEnter = (event, host) => {
if (host && event.target === event.currentTarget) {
event.currentTarget.querySelector('a')?.click()
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
const sessionList = group.querySelector('.session-list')
const items = sessionList?.querySelectorAll('.session-item')
const allGroups = Array.from(document.querySelectorAll('.session-group'))
const groupIndex = allGroups.indexOf(group)
// Enter on group header opens link (always allowed)
if (event.key === 'Enter' && event.target === group) {
if (host) group.querySelector('a')?.click()
return
}
if (props.navigationDisabled) return
// Arrow keys to enter the grid from the group
const direction = getDirection(event)
if (['down', 'right'].includes(direction) && event.target === group) {
event.preventDefault()
items?.[0]?.focus()
return
}
// Up/Left from group navigates to previous group or out
if (['up', 'left'].includes(direction) && event.target === group) {
event.preventDefault()
if (groupIndex > 0) {
allGroups[groupIndex - 1].focus()
} else {
emit('navigate-out', 'up')
}
return
}
// Escape emits navigate-out
handleEscape(event, (dir) => emit('navigate-out', dir))
}
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
})
if (event.defaultPrevented) return
if (props.navigationDisabled) return
// Arrow key navigation
const direction = getDirection(event)
if (direction) {
event.preventDefault()
const group = event.currentTarget.closest('.session-group')
const sessionListEl = group.querySelector('.session-list')
const result = navigateGrid(sessionListEl, event.currentTarget, direction, { itemSelector: '.session-item' })
// Custom boundary handling for session list
if (result === 'boundary') {
if (direction === 'left' || direction === 'up') {
// At left/top edge, focus group
group?.focus()
} else if (direction === 'down' || direction === 'right') {
// Try to navigate to next group or emit navigate-out
const allGroups = Array.from(document.querySelectorAll('.session-group'))
const groupIndex = allGroups.indexOf(group)
if (groupIndex < allGroups.length - 1) {
allGroups[groupIndex + 1].focus()
} else {
emit('navigate-out', 'down')
}
}
}
}
// Escape focuses the group
if (event.key === 'Escape') {
event.preventDefault()
event.currentTarget.closest('.session-group')?.focus()
}
}