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

Merged
LeoVasanko merged 8 commits from keyboard-nav into main 2025-12-10 15:43:40 +00:00
4 changed files with 460 additions and 22 deletions
Showing only changes of commit afc3c9a8f7 - Show all commits
+7 -1
View File
@@ -443,6 +443,7 @@ th {
gap: 0.5rem;
align-items: stretch;
margin: 0 auto;
outline: none;
}
@media (max-width: 720px) {
@@ -480,6 +481,11 @@ th {
.credential-item.is-linked-session,
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
.credential-item:focus,
.session-item:focus {
outline: 1px solid var(-webkit-focus-ring-color);
}
.item-top {
display: flex;
align-items: center;
@@ -559,7 +565,7 @@ th {
position: relative;
}
.session-group:focus-visible {
outline: var(-webkit-focus-ring-color) 1px solid;
outline: 1px solid var(-webkit-focus-ring-color);
}
.session-group-host {
display: flex;
+114 -4
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">
@@ -70,9 +70,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)
@@ -100,6 +101,115 @@ const handleDelete = (event, credential) => {
}
}
// Grid navigation helpers
const getGridInfo = (container) => {
const items = Array.from(container.querySelectorAll('.credential-item'))
if (items.length === 0) return null
// Calculate columns by checking which items share the same top position
const firstTop = items[0].getBoundingClientRect().top
let cols = 0
for (const item of items) {
if (Math.abs(item.getBoundingClientRect().top - firstTop) < 5) cols++
else break
}
return { items, cols: Math.max(1, cols) }
}
const navigateGrid = (container, currentItem, direction) => {
const grid = getGridInfo(container)
if (!grid) return
const { items, cols } = grid
const currentIndex = items.indexOf(currentItem)
if (currentIndex === -1) return
const row = Math.floor(currentIndex / cols)
const col = currentIndex % cols
let newIndex = currentIndex
switch (direction) {
case 'left':
if (col === 0) {
emit('navigate-out', 'left')
return
}
newIndex = currentIndex - 1
break
case 'right':
if (currentIndex >= items.length - 1) {
emit('navigate-out', 'right')
return
}
newIndex = currentIndex + 1
break
case 'up':
if (row === 0) {
emit('navigate-out', 'up')
return
}
newIndex = currentIndex - cols
break
case 'down':
if (currentIndex + cols >= items.length) {
emit('navigate-out', 'down')
return
}
newIndex = currentIndex + cols
break
}
if (newIndex !== currentIndex) {
items[newIndex].focus()
}
}
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
if (event.key === 'Escape') {
event.preventDefault()
emit('navigate-out', 'up')
}
}
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 directionMap = {
'ArrowLeft': 'left',
'ArrowRight': 'right',
'ArrowUp': 'up',
'ArrowDown': 'down'
}
const direction = directionMap[event.key]
if (direction) {
event.preventDefault()
const list = event.currentTarget.closest('.credential-list')
navigateGrid(list, event.currentTarget, direction)
}
}
const getCredentialAuthName = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid]
return info ? info.name : 'Unknown Authenticator'
+183 -10
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>
@@ -41,28 +43,34 @@
</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 +87,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>
@@ -105,7 +114,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
@@ -130,11 +139,26 @@ 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 || '' })
onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
// Autofocus the "Auth" breadcrumb (second link after home)
nextTick(() => {
const links = breadcrumbs.value?.$el?.querySelectorAll('a')
links?.[1]?.focus() // Index 1 is "Auth" (after home icon)
})
})
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
@@ -165,6 +189,155 @@ const handlePairingError = (message) => {
}
}
// Helper to focus preferred button in a row (primary first, or first button)
const focusPreferredButton = (container) => {
const primary = container?.querySelector('.btn-primary')
const first = container?.querySelector('button')
;(primary || first)?.focus()
}
// Navigation between components
const handleBreadcrumbKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const links = Array.from(breadcrumbs.value?.$el?.querySelectorAll('a') || [])
const currentIndex = links.indexOf(event.target)
if (event.key === 'ArrowRight') {
event.preventDefault()
if (currentIndex < links.length - 1) {
links[currentIndex + 1].focus()
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
if (currentIndex > 0) {
links[currentIndex - 1].focus()
}
} else if (event.key === 'ArrowDown') {
event.preventDefault()
// Move to user info section - always focus edit button first
const editBtn = userInfoSection.value?.querySelector('.mini-btn')
const codeInput = userInfoSection.value?.querySelector('.pairing-input')
;(editBtn || codeInput)?.focus()
}
// ArrowUp at the top does nothing
}
// Get focusable elements in user info section
const getUserInfoFocusables = () => {
const editBtn = userInfoSection.value?.querySelector('.mini-btn')
const codeInput = userInfoSection.value?.querySelector('.pairing-input')
return [editBtn, codeInput].filter(Boolean)
}
const handleUserInfoKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const focusables = getUserInfoFocusables()
const currentIndex = focusables.indexOf(event.target)
if (currentIndex === -1) return // Not on a focusable element
if (event.key === 'ArrowRight') {
event.preventDefault()
if (currentIndex < focusables.length - 1) {
focusables[currentIndex + 1].focus()
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
if (currentIndex > 0) {
focusables[currentIndex - 1].focus()
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
// Move to breadcrumbs - always focus "Auth" (index 1)
const links = breadcrumbs.value?.$el?.querySelectorAll('a')
links?.[1]?.focus()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
// 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
const editBtn = userInfoSection.value?.querySelector('.mini-btn')
const codeInput = userInfoSection.value?.querySelector('.pairing-input')
;(editBtn || codeInput)?.focus()
}
}
const handleCredentialButtonKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const buttons = Array.from(credentialButtons.value?.querySelectorAll('button') || [])
const currentIndex = buttons.indexOf(event.target)
if (event.key === 'ArrowRight') {
event.preventDefault()
if (currentIndex < buttons.length - 1) {
buttons[currentIndex + 1].focus()
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
if (currentIndex > 0) {
buttons[currentIndex - 1].focus()
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
// Move back to credential list
credentialList.value?.$el?.querySelector('.credential-item')?.focus()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
// Move to session list
sessionList.value?.$el?.querySelector('.session-group')?.focus()
}
}
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 buttons = Array.from(logoutButtons.value?.querySelectorAll('button') || [])
const currentIndex = buttons.indexOf(event.target)
if (event.key === 'ArrowRight') {
event.preventDefault()
if (currentIndex < buttons.length - 1) {
buttons[currentIndex + 1].focus()
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
if (currentIndex > 0) {
buttons[currentIndex - 1].focus()
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
// Move back to session list
const groups = sessionList.value?.$el?.querySelectorAll('.session-group')
groups?.[groups.length - 1]?.focus()
}
// ArrowDown at the bottom does nothing
}
const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
+156 -7
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>
@@ -73,9 +73,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()
@@ -114,9 +115,157 @@ const handleDelete = (event, session) => {
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const handleGroupEnter = (event, host) => {
if (host && event.target === event.currentTarget) {
event.currentTarget.querySelector('a')?.click()
// Grid navigation helpers
const getGridInfo = (container) => {
const items = Array.from(container.querySelectorAll('.session-item'))
if (items.length === 0) return null
// Calculate columns by checking which items share the same top position
const firstTop = items[0].getBoundingClientRect().top
let cols = 0
for (const item of items) {
if (Math.abs(item.getBoundingClientRect().top - firstTop) < 5) cols++
else break
}
return { items, cols: Math.max(1, cols) }
}
const navigateGrid = (container, currentItem, direction, group) => {
const grid = getGridInfo(container)
if (!grid) return 'none'
const { items, cols } = grid
const currentIndex = items.indexOf(currentItem)
if (currentIndex === -1) return 'none'
const row = Math.floor(currentIndex / cols)
const col = currentIndex % cols
let newIndex = currentIndex
switch (direction) {
case 'left':
if (col === 0) {
// At left edge, focus group
group?.focus()
return 'group'
}
newIndex = currentIndex - 1
break
case 'right':
if (currentIndex >= items.length - 1) {
return 'boundary'
}
newIndex = currentIndex + 1
break
case 'up':
if (row === 0) {
// At top row, focus group
group?.focus()
return 'group'
}
newIndex = currentIndex - cols
break
case 'down':
if (currentIndex + cols >= items.length) {
return 'boundary'
}
newIndex = currentIndex + cols
break
}
if (newIndex !== currentIndex) {
items[newIndex].focus()
return 'moved'
}
return 'none'
}
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
if (['ArrowDown', 'ArrowRight'].includes(event.key) && event.target === group) {
event.preventDefault()
items?.[0]?.focus()
return
}
// Up/Left from group navigates to previous group or out
if (['ArrowUp', 'ArrowLeft'].includes(event.key) && event.target === group) {
event.preventDefault()
if (groupIndex > 0) {
allGroups[groupIndex - 1].focus()
} else {
emit('navigate-out', 'up')
}
return
}
// Escape emits navigate-out
if (event.key === 'Escape') {
event.preventDefault()
emit('navigate-out', 'up')
}
}
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') {
event.preventDefault()
if (!isTerminating(session.id)) emit('terminate', session)
return
}
if (props.navigationDisabled) return
// Arrow key navigation
const directionMap = {
'ArrowLeft': 'left',
'ArrowRight': 'right',
'ArrowUp': 'up',
'ArrowDown': 'down'
}
const direction = directionMap[event.key]
if (direction) {
event.preventDefault()
const group = event.currentTarget.closest('.session-group')
const sessionList = group.querySelector('.session-list')
const result = navigateGrid(sessionList, event.currentTarget, direction, group)
// If at boundary, try to navigate to next/prev group or emit navigate-out
if (result === 'boundary') {
const allGroups = Array.from(document.querySelectorAll('.session-group'))
const groupIndex = allGroups.indexOf(group)
if (direction === 'down' || direction === 'right') {
if (groupIndex < allGroups.length - 1) {
// Move to next group
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()
}
}