Refactored keyboard navigation to its own keynav.js. Implemented keyboard navigation on modal dialogs.

This commit is contained in:
Leo Vasanko
2025-12-10 13:57:25 +00:00
parent fbce5b97db
commit 1fe14d5f5f
16 changed files with 631 additions and 330 deletions
+2 -1
View File
@@ -23,7 +23,7 @@
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<div class="button-row center" style="justify-content: center;">
<div class="button-row center" style="justify-content: center;" @keydown="handleButtonKeydown">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
@@ -60,6 +60,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
import { handleButtonKeydown } from '@/utils/keynav'
const status = reactive({
show: false,
+2 -20
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { handleButtonKeydown } from '@/utils/keynav'
const props = defineProps({
dialog: Object,
@@ -10,25 +10,7 @@ const props = defineProps({
const emit = defineEmits(['submitDialog', 'closeDialog'])
const nameInput = ref(null)
const displayNameInput = ref(null)
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
watch(() => props.dialog.type, (newType) => {
if (newType === 'org-create') {
nextTick(() => {
nameInput.value?.focus()
})
} else if (newType === 'perm-display' || newType === 'perm-create') {
nextTick(() => {
displayNameInput.value?.focus()
if (newType === 'perm-display') {
displayNameInput.value?.select()
}
})
}
})
</script>
<template>
@@ -100,7 +82,7 @@ watch(() => props.dialog.type, (newType) => {
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions" @keydown="handleButtonKeydown">
<button
type="button"
class="btn-secondary"
+7
View File
@@ -266,6 +266,13 @@ button:disabled {
filter: brightness(0.92);
}
/* Focus-visible outlines for buttons */
.btn-primary:focus-visible,
.btn-secondary:focus-visible,
.btn-danger:focus-visible {
outline: 1px solid var(-webkit-focus-ring-color);
}
input[type="text"],
input[type="search"],
input[type="email"],
+2 -1
View File
@@ -2,7 +2,7 @@
<div class="message-container">
<div class="message-content">
<h2>🔒 Access Denied</h2>
<div class="button-row">
<div class="button-row" @keydown="handleButtonKeydown">
<button class="btn-secondary" @click="goBack">Back</button>
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
</div>
@@ -12,6 +12,7 @@
<script setup>
import { goBack } from '@/utils/helpers'
import { handleButtonKeydown } from '@/utils/keynav'
defineEmits(['reload'])
</script>
+9 -80
View File
@@ -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: () => [] },
@@ -94,74 +95,9 @@ 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)
}
}
// 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) => {
@@ -181,10 +117,7 @@ const handleListKeydown = (event) => {
if (props.navigationDisabled) return
// Escape emits navigate-out
if (event.key === 'Escape') {
event.preventDefault()
emit('navigate-out', 'up')
}
handleEscape(event, (dir) => emit('navigate-out', dir))
}
const handleItemKeydown = (event, credential) => {
@@ -195,18 +128,14 @@ const handleItemKeydown = (event, credential) => {
if (props.navigationDisabled) return
// Arrow key navigation
const directionMap = {
'ArrowLeft': 'left',
'ArrowRight': 'right',
'ArrowUp': 'up',
'ArrowDown': 'down'
}
const direction = directionMap[event.key]
const direction = getDirection(event)
if (direction) {
event.preventDefault()
const list = event.currentTarget.closest('.credential-list')
navigateGrid(list, event.currentTarget, direction)
const result = navigateGrid(list, event.currentTarget, direction, { itemSelector: '.credential-item' })
if (result === 'boundary') {
emit('navigate-out', direction)
}
}
}
+2 -1
View File
@@ -4,7 +4,7 @@
<h1>📱 Add Another Device</h1>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
</header>
<div class="button-row" style="margin-top:1rem;">
<div class="button-row" style="margin-top:1rem;" @keydown="handleButtonKeydown">
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</div>
@@ -22,6 +22,7 @@
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { handleButtonKeydown } from '@/utils/keynav'
const authStore = useAuthStore()
const userName = ref(null)
+2 -1
View File
@@ -25,7 +25,7 @@
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row">
<div class="button-row" @keydown="handleButtonKeydown">
<button
type="button"
class="btn-secondary"
@@ -62,6 +62,7 @@ import { computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
import { goBack } from '@/utils/helpers'
import { handleButtonKeydown } from '@/utils/keynav'
defineProps({
initializing: {
+54 -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,60 @@
</template>
<script setup>
import { onMounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
defineEmits(['close'])
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(() => {
// 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)
}
})
})
</script>
<style scoped>
+11 -10
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 { handleButtonKeydown, 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 {
const handleActionsKeydown = (event) => {
const direction = getDirection(event)
if (direction === 'up') {
event.preventDefault()
inputRef.value?.focus()
return
}
handleButtonKeydown(event)
}
})
})
function handleCancel() {
emit('cancel')
+38 -87
View File
@@ -114,7 +114,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
@@ -128,6 +128,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)
@@ -154,11 +155,6 @@ watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userIn
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) })
@@ -191,70 +187,43 @@ 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()
focusPreferred(container, { primarySelector: '.btn-primary', itemSelector: 'button' })
}
// 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)
const direction = getDirection(event)
if (!direction) return
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()
const container = breadcrumbs.value?.$el
if (direction === 'left' || direction === 'right') {
navigateButtonRow(container, event.target, direction, { itemSelector: 'a' })
} else if (direction === 'down') {
// 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()
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
}
// 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)
const direction = getDirection(event)
if (!direction) return
if (currentIndex === -1) return // Not on a focusable element
event.preventDefault()
const itemSelector = '.mini-btn, .pairing-input'
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()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
} else if (direction === 'up') {
// 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()
focusAtIndex(breadcrumbs.value?.$el, 1, { itemSelector: 'a' })
} else if (direction === 'down') {
// Move to credential list
credentialList.value?.$el?.focus()
}
@@ -268,36 +237,26 @@ const handleCredentialNavigateOut = (direction) => {
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()
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 buttons = Array.from(credentialButtons.value?.querySelectorAll('button') || [])
const currentIndex = buttons.indexOf(event.target)
const direction = getDirection(event)
if (!direction) return
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()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(credentialButtons.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move back to credential list
credentialList.value?.$el?.querySelector('.credential-item')?.focus()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
focusAtIndex(credentialList.value?.$el, 0, { itemSelector: '.credential-item' })
} else if (direction === 'down') {
// Move to session list
sessionList.value?.$el?.querySelector('.session-group')?.focus()
focusAtIndex(sessionList.value?.$el, 0, { itemSelector: '.session-group' })
}
}
@@ -316,24 +275,16 @@ const handleSessionNavigateOut = (direction) => {
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)
const direction = getDirection(event)
if (!direction) return
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()
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
}
+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,7 @@ const emit = defineEmits(['close', 'copied'])
const linkUrl = ref(null)
const expiresAt = ref(null)
const actionsRow = ref(null)
async function generateLink() {
try {
@@ -53,6 +56,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,6 +73,34 @@ 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(() => {
// Hold backdrop before fetch to avoid gap if auth iframe shows
holdGlobalBackdrop()
+2 -1
View File
@@ -57,7 +57,7 @@
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;" @keydown="handleButtonKeydown">
<button
type="button"
class="btn-secondary"
@@ -91,6 +91,7 @@ import { getSettings } from '@/utils/settings'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
import { useAuthStore } from '@/stores/auth'
import { handleButtonKeydown } from '@/utils/keynav'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
+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" @keydown="handleButtonKeydown">
<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 { handleButtonKeydown, 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()
+17 -97
View File
@@ -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: () => [] },
@@ -105,81 +106,8 @@ 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]
// 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')
@@ -196,14 +124,15 @@ const handleGroupKeydown = (event, host) => {
if (props.navigationDisabled) return
// Arrow keys to enter the grid from the group
if (['ArrowDown', 'ArrowRight'].includes(event.key) && event.target === 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 (['ArrowUp', 'ArrowLeft'].includes(event.key) && event.target === group) {
if (['up', 'left'].includes(direction) && event.target === group) {
event.preventDefault()
if (groupIndex > 0) {
allGroups[groupIndex - 1].focus()
@@ -214,46 +143,37 @@ const handleGroupKeydown = (event, host) => {
}
// Escape emits navigate-out
if (event.key === 'Escape') {
event.preventDefault()
emit('navigate-out', 'up')
}
handleEscape(event, (dir) => emit('navigate-out', dir))
}
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()
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
return
}
})
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]
const direction = getDirection(event)
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)
const sessionListEl = group.querySelector('.session-list')
const result = navigateGrid(sessionListEl, event.currentTarget, direction, { itemSelector: '.session-item' })
// If at boundary, try to navigate to next/prev group or emit navigate-out
// 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 (direction === 'down' || direction === 'right') {
if (groupIndex < allGroups.length - 1) {
// Move to next group
allGroups[groupIndex + 1].focus()
} else {
emit('navigate-out', 'down')
+408
View File
@@ -0,0 +1,408 @@
/**
* Keyboard Navigation Module
*
* Provides reusable arrow key navigation for button groups and grids.
*
* Concepts:
* - Group: A container with focusable elements (buttons, links, items)
* - Button row: Left/right arrows navigate between buttons, up/down navigate to adjacent groups
* - Grid: A responsive grid of items; arrows follow the visual grid layout
*
* Data attributes for customization:
* - data-nav-group: Marks a navigation group container
* - data-nav-primary: Marks the preferred element to focus when entering a group
* - data-nav-items: CSS selector for focusable items within the group (default: 'button, a, [tabindex="0"], [tabindex="-1"]:not([disabled])')
*/
// Direction mapping from key events
const DIRECTION_MAP = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down'
}
/**
* Get the direction from a keyboard event.
* For text inputs with content, left/right arrows return null to preserve cursor movement.
* @param {KeyboardEvent} event
* @returns {string|null} 'left', 'right', 'up', 'down', or null
*/
export const getDirection = (event) => {
const direction = DIRECTION_MAP[event.key]
if (!direction) return null
// For text inputs, preserve left/right for cursor movement when there's content
const target = event.target
const isTextInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'
if (isTextInput && (direction === 'left' || direction === 'right')) {
// Only allow navigation when input is empty
if (target.value !== '') return null
}
return direction
}
/**
* Get focusable elements within a container
* @param {HTMLElement} container
* @param {string} selector - CSS selector for items (optional)
* @returns {HTMLElement[]}
*/
export const getFocusableItems = (container, selector = null) => {
if (!container) return []
const sel = selector || container.dataset?.navItems || 'button:not([disabled]), a, [tabindex="0"], [tabindex="-1"]:not([disabled])'
return Array.from(container.querySelectorAll(sel))
}
/**
* Get grid layout information for a container
* @param {HTMLElement} container
* @param {string} itemSelector - CSS selector for grid items
* @returns {{ items: HTMLElement[], cols: number } | null}
*/
export const getGridInfo = (container, itemSelector) => {
const items = getFocusableItems(container, itemSelector)
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) }
}
/**
* Navigate within a horizontal button row
* @param {HTMLElement} container - The container element
* @param {HTMLElement} current - Currently focused element
* @param {string} direction - 'left', 'right', 'up', or 'down'
* @param {Object} options
* @param {string} options.itemSelector - CSS selector for buttons
* @returns {'moved'|'boundary'|'none'} Result of navigation
*/
export const navigateButtonRow = (container, current, direction, options = {}) => {
const items = getFocusableItems(container, options.itemSelector)
if (items.length === 0) return 'none'
const currentIndex = items.indexOf(current)
if (currentIndex === -1) return 'none'
if (direction === 'left') {
if (currentIndex > 0) {
items[currentIndex - 1].focus()
return 'moved'
}
return 'boundary'
}
if (direction === 'right') {
if (currentIndex < items.length - 1) {
items[currentIndex + 1].focus()
return 'moved'
}
return 'boundary'
}
// Up/down are always boundaries for button rows
return 'boundary'
}
/**
* Navigate within a responsive grid
* @param {HTMLElement} container - The grid container
* @param {HTMLElement} current - Currently focused element
* @param {string} direction - 'left', 'right', 'up', or 'down'
* @param {Object} options
* @param {string} options.itemSelector - CSS selector for grid items
* @returns {'moved'|'boundary'|'none'} Result of navigation
*/
export const navigateGrid = (container, current, direction, options = {}) => {
const grid = getGridInfo(container, options.itemSelector)
if (!grid) return 'none'
const { items, cols } = grid
const currentIndex = items.indexOf(current)
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) return 'boundary'
newIndex = currentIndex - 1
break
case 'right':
if (currentIndex >= items.length - 1) return 'boundary'
newIndex = currentIndex + 1
break
case 'up':
if (row === 0) return 'boundary'
newIndex = currentIndex - cols
break
case 'down':
if (currentIndex + cols >= items.length) return 'boundary'
newIndex = currentIndex + cols
break
default:
return 'none'
}
if (newIndex !== currentIndex) {
items[newIndex].focus()
return 'moved'
}
return 'none'
}
/**
* Focus the preferred element in a group (primary or first focusable)
* @param {HTMLElement} container
* @param {Object} options
* @param {string} options.primarySelector - CSS selector for primary element
* @param {string} options.itemSelector - CSS selector for items
* @returns {HTMLElement|null} The focused element, or null if none found
*/
export const focusPreferred = (container, options = {}) => {
if (!container) return null
// First try data-nav-primary
const primary = container.querySelector('[data-nav-primary]') ||
(options.primarySelector && container.querySelector(options.primarySelector))
if (primary) {
primary.focus()
return primary
}
// Fall back to first focusable
const items = getFocusableItems(container, options.itemSelector)
if (items.length > 0) {
items[0].focus()
return items[0]
}
return null
}
/**
* Focus a specific item by index in a group
* @param {HTMLElement} container
* @param {number} index - Index of item to focus (negative counts from end)
* @param {Object} options
* @param {string} options.itemSelector - CSS selector for items
* @returns {HTMLElement|null} The focused element, or null if not found
*/
export const focusAtIndex = (container, index, options = {}) => {
if (!container) return null
const items = getFocusableItems(container, options.itemSelector)
if (items.length === 0) return null
// Support negative indices
const resolvedIndex = index < 0 ? items.length + index : index
if (resolvedIndex >= 0 && resolvedIndex < items.length) {
items[resolvedIndex].focus()
return items[resolvedIndex]
}
return null
}
/**
* Create a keydown handler for button row navigation
* @param {Object} options
* @param {() => HTMLElement} options.getContainer - Function returning the container element
* @param {string} options.itemSelector - CSS selector for buttons
* @param {(direction: string) => void} options.onBoundary - Called when navigation hits a boundary
* @param {() => boolean} options.isDisabled - Function returning whether navigation is disabled
* @returns {(event: KeyboardEvent) => void}
*/
export const createButtonRowHandler = (options) => {
const { getContainer, itemSelector, onBoundary, isDisabled } = options
return (event) => {
if (isDisabled?.()) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
const container = getContainer()
if (direction === 'up' || direction === 'down') {
// Vertical navigation always exits button rows
onBoundary?.(direction)
return
}
const result = navigateButtonRow(container, event.target, direction, { itemSelector })
if (result === 'boundary') {
onBoundary?.(direction)
}
}
}
/**
* Create a keydown handler for grid navigation
* @param {Object} options
* @param {() => HTMLElement} options.getContainer - Function returning the container element
* @param {string} options.itemSelector - CSS selector for grid items
* @param {(direction: string) => void} options.onBoundary - Called when navigation hits a boundary
* @param {() => boolean} options.isDisabled - Function returning whether navigation is disabled
* @returns {(event: KeyboardEvent) => void}
*/
export const createGridHandler = (options) => {
const { getContainer, itemSelector, onBoundary, isDisabled } = options
return (event) => {
if (isDisabled?.()) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
const container = getContainer()
const result = navigateGrid(container, event.target, direction, { itemSelector })
if (result === 'boundary') {
onBoundary?.(direction)
}
}
}
/**
* Handle escape key to navigate out of a component
* @param {KeyboardEvent} event
* @param {(direction: string) => void} onNavigateOut - Callback with direction
* @param {() => boolean} isDisabled - Function returning whether navigation is disabled
*/
export const handleEscape = (event, onNavigateOut, isDisabled) => {
if (isDisabled?.()) return false
if (event.key !== 'Escape') return false
event.preventDefault()
onNavigateOut?.('up')
return true
}
/**
* Handle delete/backspace key for item deletion
* @param {KeyboardEvent} event
* @param {() => void} onDelete - Callback to perform deletion
* @returns {boolean} Whether the key was handled
*/
export const handleDeleteKey = (event, onDelete) => {
const isMac = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || (isMac && event.key === 'Backspace')) {
event.preventDefault()
onDelete?.()
return true
}
return false
}
/**
* Focus the most appropriate button in a dialog/modal.
* Priority: .btn-primary > .btn-secondary > any button
* @param {HTMLElement} container - The dialog/modal container element
* @returns {HTMLElement|null} The focused element, or null if none found
*/
export const focusDialogButton = (container) => {
if (!container) return null
// Priority order for button selection
const selectors = [
'.btn-primary:not([disabled])',
'.btn-secondary:not([disabled])',
'button:not([disabled])'
]
for (const selector of selectors) {
const btn = container.querySelector(selector)
if (btn) {
btn.focus()
return btn
}
}
return null
}
/**
* Focus the most appropriate element in a dialog/modal.
* For dialogs with input fields (rename/edit forms): focuses first input and selects text
* For other dialogs: focuses primary button (or fallback)
* @param {HTMLElement} container - The dialog/modal container element
* @returns {HTMLElement|null} The focused element, or null if none found
*/
export const focusDialogDefault = (container) => {
if (!container) return null
// Check for input fields first (form dialogs like rename)
const input = container.querySelector('input:not([disabled]):not([type="hidden"]), textarea:not([disabled])')
if (input) {
input.focus()
// Select text for better UX in rename dialogs
if (typeof input.select === 'function') {
input.select()
}
return input
}
// Fall back to button focus for non-form dialogs
return focusDialogButton(container)
}
/**
* Standard keydown handler for button rows with left/right navigation.
* Can be used directly on buttons or on a container with event delegation.
* Automatically finds the .button-row or .modal-actions container.
* @param {KeyboardEvent} event - The keydown event
* @param {Object} options
* @param {(direction: string) => void} options.onBoundary - Called when navigation hits a boundary (up/down or edge)
*/
export const handleButtonKeydown = (event, options = {}) => {
const direction = getDirection(event)
if (!direction) return
// Find the button row container
const target = event.target
if (target.tagName !== 'BUTTON' && target.tagName !== 'A') return
const container = target.closest('.button-row, .modal-actions')
if (!container) return
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const result = navigateButtonRow(container, target, direction, { itemSelector: 'button, a' })
if (result === 'boundary') {
options.onBoundary?.(direction)
}
} else if (direction === 'up' || direction === 'down') {
// Vertical navigation exits button rows
options.onBoundary?.(direction)
}
}
/**
* Install keyboard navigation on a container element.
* Handles arrow key navigation for buttons within .button-row or .modal-actions.
* Uses event delegation so no need to add handlers to individual buttons.
* @param {HTMLElement} container - The container element to enable navigation on
* @param {Object} options
* @param {(direction: string) => void} options.onBoundary - Called when navigation hits a boundary
* @returns {() => void} Cleanup function to remove the event listener
*/
export const installKeyboardNav = (container, options = {}) => {
if (!container) return () => {}
const handler = (event) => handleButtonKeydown(event, options)
container.addEventListener('keydown', handler)
return () => container.removeEventListener('keydown', handler)
}