Refactored keyboard navigation to its own keynav.js. Implemented keyboard navigation on modal dialogs.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user