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

This commit is contained in:
2025-12-10 15:43:40 +00:00
parent 7f47f44039
commit ca73febe2f
23 changed files with 1785 additions and 134 deletions
+1 -20
View File
@@ -1,5 +1,4 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
@@ -10,25 +9,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>
@@ -125,4 +106,4 @@ watch(() => props.dialog.type, (newType) => {
.error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); }
</style>
</style>
+266 -12
View File
@@ -1,12 +1,19 @@
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
const props = defineProps({
selectedOrg: Object,
permissions: Array
permissions: Array,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart'])
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
// Template refs for navigation
const orgTitleRef = ref(null)
const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => {
@@ -26,15 +33,259 @@ function permissionDisplayName(id) {
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
}
// Handle org title header keynav
function handleTitleKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(orgTitleRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to permission matrix
const firstCheckbox = permMatrixRef.value?.querySelector('input[type="checkbox"]')
if (firstCheckbox) {
firstCheckbox.focus()
} else {
// No matrix, go to roles grid
focusFirstRoleElement()
}
}
}
// Handle permission matrix grid navigation
function handleMatrixKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
if (target.tagName !== 'INPUT') return
event.preventDefault()
const checkboxes = Array.from(permMatrixRef.value.querySelectorAll('input[type="checkbox"]'))
const currentIndex = checkboxes.indexOf(target)
if (currentIndex === -1) return
// Calculate grid dimensions
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
let newIndex = currentIndex
if (direction === 'left' && currentCol > 0) {
newIndex = currentIndex - 1
} else if (direction === 'right' && currentCol < cols - 1) {
newIndex = currentIndex + 1
} else if (direction === 'up' && currentRow > 0) {
newIndex = currentIndex - cols
} else if (direction === 'down' && currentRow < rows - 1) {
newIndex = currentIndex + cols
} else if (direction === 'up' && currentRow === 0) {
// Navigate up to title
const titleButton = orgTitleRef.value?.querySelector('button')
if (titleButton) titleButton.focus()
return
} else if (direction === 'down' && currentRow === rows - 1) {
// Navigate down to roles grid
focusFirstRoleElement()
return
}
if (newIndex !== currentIndex && checkboxes[newIndex]) {
checkboxes[newIndex].focus()
}
}
// Handle navigation within user list
function handleUserListKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
if (!target.classList.contains('user-chip')) return
const list = target.closest('.user-list')
if (!list) return
const items = Array.from(list.querySelectorAll('.user-chip'))
const currentIndex = items.indexOf(target)
if (currentIndex === -1) return
// For vertical navigation within the list
if (direction === 'up' && currentIndex > 0) {
event.preventDefault()
items[currentIndex - 1].focus()
return
} else if (direction === 'down' && currentIndex < items.length - 1) {
event.preventDefault()
items[currentIndex + 1].focus()
return
}
// Handle boundary navigation
if (direction === 'up' && currentIndex === 0) {
event.preventDefault()
// Go to role header buttons
const roleColumn = list.closest('.role-column')
const headerButton = roleColumn?.querySelector('.role-header button')
if (headerButton) headerButton.focus()
return
}
if (direction === 'down' && currentIndex === items.length - 1) {
// At bottom - nothing below
return
}
// Handle left/right to navigate between role columns
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const roleColumns = Array.from(rolesGridRef.value?.querySelectorAll('.role-column') || [])
const currentColumn = list.closest('.role-column')
const colIndex = roleColumns.indexOf(currentColumn)
let targetColIndex = direction === 'left' ? colIndex - 1 : colIndex + 1
if (targetColIndex >= 0 && targetColIndex < roleColumns.length) {
const targetColumn = roleColumns[targetColIndex]
const targetUsers = targetColumn.querySelectorAll('.user-chip')
const targetIndex = Math.min(currentIndex, targetUsers.length - 1)
if (targetUsers[targetIndex]) {
targetUsers[targetIndex].focus()
} else {
// No users in target column, focus the add user button
const addBtn = targetColumn.querySelector('.plus-btn')
if (addBtn) addBtn.focus()
}
} else if (direction === 'left' && colIndex === 0) {
// At leftmost column, go up to matrix
const lastCheckbox = permMatrixRef.value?.querySelector('input[type="checkbox"]:last-of-type')
if (lastCheckbox) lastCheckbox.focus()
}
}
}
// Handle role header button navigation
function handleRoleHeaderKeydown(event, roleIndex) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const roleColumns = Array.from(rolesGridRef.value?.querySelectorAll('.role-column') || [])
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const buttons = event.currentTarget.querySelectorAll('button:not([disabled])')
const btnIndex = Array.from(buttons).indexOf(event.target)
if (direction === 'left' && btnIndex > 0) {
buttons[btnIndex - 1].focus()
} else if (direction === 'right' && btnIndex < buttons.length - 1) {
buttons[btnIndex + 1].focus()
} else if (direction === 'left' && btnIndex === 0 && roleIndex > 0) {
// Move to previous column's header
const prevColumn = roleColumns[roleIndex - 1]
const prevButtons = prevColumn?.querySelectorAll('.role-header button')
if (prevButtons?.length) prevButtons[prevButtons.length - 1].focus()
} else if (direction === 'right' && btnIndex === buttons.length - 1 && roleIndex < roleColumns.length - 1) {
// Move to next column's header
const nextColumn = roleColumns[roleIndex + 1]
const nextButton = nextColumn?.querySelector('.role-header button')
if (nextButton) nextButton.focus()
}
} else if (direction === 'up') {
event.preventDefault()
// Go to permission matrix
const checkboxes = permMatrixRef.value?.querySelectorAll('input[type="checkbox"]')
if (checkboxes?.length) {
// Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus()
} else {
const titleButton = orgTitleRef.value?.querySelector('button')
if (titleButton) titleButton.focus()
}
} else if (direction === 'down') {
event.preventDefault()
// Go to first user in this column
const roleColumn = roleColumns[roleIndex]
const firstUser = roleColumn?.querySelector('.user-chip')
if (firstUser) {
firstUser.focus()
}
}
}
// Handle empty role section keynav
function handleEmptyRoleKeydown(event, roleIndex) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const roleColumns = Array.from(rolesGridRef.value?.querySelectorAll('.role-column') || [])
if (direction === 'up') {
event.preventDefault()
const roleColumn = roleColumns[roleIndex]
const headerButton = roleColumn?.querySelector('.role-header button')
if (headerButton) headerButton.focus()
} else if (direction === 'left' && roleIndex > 0) {
event.preventDefault()
const prevColumn = roleColumns[roleIndex - 1]
const prevEmpty = prevColumn?.querySelector('.empty-role button')
const prevUser = prevColumn?.querySelector('.user-chip:last-child')
if (prevEmpty) prevEmpty.focus()
else if (prevUser) prevUser.focus()
} else if (direction === 'right' && roleIndex < roleColumns.length - 1) {
event.preventDefault()
const nextColumn = roleColumns[roleIndex + 1]
const nextEmpty = nextColumn?.querySelector('.empty-role button')
const nextUser = nextColumn?.querySelector('.user-chip')
if (nextEmpty) nextEmpty.focus()
else if (nextUser) nextUser.focus()
}
}
// Helper to focus first element in roles grid
function focusFirstRoleElement() {
const firstRoleColumn = rolesGridRef.value?.querySelector('.role-column')
const firstButton = firstRoleColumn?.querySelector('.role-header button')
if (firstButton) firstButton.focus()
}
// Focus helper for external navigation
function focusFirstElement() {
const titleButton = orgTitleRef.value?.querySelector('button')
if (titleButton) titleButton.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<h2 class="org-title" :title="selectedOrg.uuid">
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
<div class="matrix-wrapper">
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
@@ -49,7 +300,7 @@ function toggleRolePermission(role, pid, checked) {
>
<span>{{ r.display_name }}</span>
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button"></div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button" tabindex="0" @keydown.enter="$emit('createRole', selectedOrg)"></div>
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
@@ -70,15 +321,15 @@ function toggleRolePermission(role, pid, checked) {
</div>
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
</div>
<div class="roles-grid">
<div class="roles-grid" ref="rolesGridRef">
<div
v-for="r in sortedRoles"
v-for="(r, roleIndex) in sortedRoles"
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<div class="role-header">
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
@@ -88,7 +339,7 @@ function toggleRolePermission(role, pid, checked) {
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<ul class="user-list" @keydown="handleUserListKeydown">
<li
v-for="u in r.users.slice().sort((a, b) => {
const nameA = a.display_name.toLowerCase()
@@ -100,9 +351,11 @@ function toggleRolePermission(role, pid, checked) {
})"
:key="u.uuid"
class="user-chip"
tabindex="0"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
@@ -110,7 +363,7 @@ function toggleRolePermission(role, pid, checked) {
</li>
</ul>
</template>
<div v-else class="empty-role">
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
<p class="empty-text muted">No members</p>
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
</div>
@@ -144,6 +397,7 @@ function toggleRolePermission(role, pid, checked) {
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
.empty-text { margin: 0; }
@@ -154,4 +408,4 @@ function toggleRolePermission(role, pid, checked) {
@media (max-width: 720px) {
.roles-grid { flex-direction: column; }
}
</style>
</style>
+224 -10
View File
@@ -1,14 +1,24 @@
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
permissionSummary: Object
permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null)
const orgTableRef = ref(null)
const permMatrixRef = ref(null)
const permActionsRef = ref(null)
const permTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
@@ -27,15 +37,219 @@ function getRoleNames(org) {
.map(r => r.display_name)
.join(', ')
}
// Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
const row = target.closest('tr')
if (!row) return
const tbody = row.closest('tbody')
if (!tbody) return
const rows = Array.from(tbody.querySelectorAll('tr'))
const currentIndex = rows.indexOf(row)
if (currentIndex === -1) return
// Handle left/right navigation within the row
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const focusables = Array.from(row.querySelectorAll('a, button:not([disabled])'))
const currentFocusIndex = focusables.indexOf(target)
if (currentFocusIndex === -1) return
if (direction === 'left' && currentFocusIndex > 0) {
focusables[currentFocusIndex - 1].focus()
} else if (direction === 'right' && currentFocusIndex < focusables.length - 1) {
focusables[currentFocusIndex + 1].focus()
}
return
}
// Handle up/down navigation between rows
let newIndex = currentIndex
if (direction === 'up' && currentIndex > 0) {
newIndex = currentIndex - 1
} else if (direction === 'down' && currentIndex < rows.length - 1) {
newIndex = currentIndex + 1
} else if (direction === 'up' && currentIndex === 0) {
// At top of table, navigate to actions above
event.preventDefault()
if (tableType === 'org') {
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
} else if (tableType === 'perm') {
focusPreferred(permActionsRef.value, { itemSelector: 'button' })
}
return
} else if (direction === 'down' && currentIndex === rows.length - 1) {
// At bottom of org table, navigate to permissions section
event.preventDefault()
if (tableType === 'org' && props.info.is_global_admin) {
// Navigate to permissions matrix or actions
if (permMatrixRef.value) {
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
if (firstCheckbox) firstCheckbox.focus()
else focusPreferred(permActionsRef.value, { itemSelector: 'button' })
}
}
return
}
if (newIndex !== currentIndex) {
event.preventDefault()
const newRow = rows[newIndex]
const focusable = newRow.querySelector('a, button:not([disabled])')
if (focusable) focusable.focus()
}
}
// Handle org actions button keynav
function handleOrgActionsKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(orgActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to org table
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
if (firstFocusable) firstFocusable.focus()
}
}
// Handle permission matrix grid navigation
function handleMatrixKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
if (target.tagName !== 'INPUT') return
event.preventDefault()
const checkboxes = Array.from(permMatrixRef.value.querySelectorAll('input[type="checkbox"]'))
const currentIndex = checkboxes.indexOf(target)
if (currentIndex === -1) return
// Calculate grid dimensions
const cols = sortedOrgs.value.length
const rows = sortedPermissions.value.length
if (cols === 0 || rows === 0) return
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
let newIndex = currentIndex
if (direction === 'left') {
if (currentCol > 0) {
// Move left within the same row
newIndex = currentIndex - 1
}
// At leftmost column, do nothing (no wrap)
} else if (direction === 'right') {
if (currentCol < cols - 1) {
// Move right within the same row
newIndex = currentIndex + 1
}
// At rightmost column, do nothing (no wrap)
} else if (direction === 'up') {
if (currentRow > 0) {
// Move up within the same column
newIndex = currentIndex - cols
} else {
// At top row, navigate up to org table
const lastRow = orgTableRef.value?.querySelector('tbody tr:last-child')
const focusable = lastRow?.querySelector('a, button:not([disabled])')
if (focusable) focusable.focus()
return
}
} else if (direction === 'down') {
if (currentRow < rows - 1) {
// Move down within the same column
newIndex = currentIndex + cols
} else {
// At bottom row, navigate down to permission actions
focusPreferred(permActionsRef.value, { itemSelector: 'button' })
return
}
}
if (newIndex !== currentIndex && checkboxes[newIndex]) {
checkboxes[newIndex].focus()
}
}
// Handle permission actions button keynav
function handlePermActionsKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(permActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to first column of last row in matrix
const checkboxes = permMatrixRef.value?.querySelectorAll('input[type="checkbox"]')
if (checkboxes?.length) {
const cols = sortedOrgs.value.length
const rows = sortedPermissions.value.length
// First checkbox of last row = (rows - 1) * cols
const lastRowFirstIndex = (rows - 1) * cols
if (checkboxes[lastRowFirstIndex]) {
checkboxes[lastRowFirstIndex].focus()
} else {
checkboxes[0].focus()
}
} else {
// No matrix, go to org table
const lastRow = orgTableRef.value?.querySelector('tbody tr:last-child')
const focusable = lastRow?.querySelector('a, button:not([disabled])')
if (focusable) focusable.focus()
}
} else if (direction === 'down') {
// Move to permissions table
const firstFocusable = permTableRef.value?.querySelector('tbody tr button:not([disabled])')
if (firstFocusable) firstFocusable.focus()
}
}
// Focus helper for external navigation
function focusFirstElement() {
if (props.info.is_global_admin) {
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
} else {
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
if (firstFocusable) firstFocusable.focus()
}
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="permissions-section">
<div class="permissions-section" ref="orgSection">
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
<div class="actions">
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table">
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
<thead>
<tr>
<th>Name</th>
@@ -62,7 +276,7 @@ function getRoleNames(org) {
<div v-if="info.is_global_admin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper">
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
@@ -98,10 +312,10 @@ function getRoleNames(org) {
</div>
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
</div>
<div class="actions">
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
</div>
<table class="org-table">
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
<thead>
<tr>
<th scope="col">Permission</th>
@@ -162,4 +376,4 @@ function getRoleNames(org) {
.perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); }
</style>
</style>
+125 -19
View File
@@ -1,27 +1,39 @@
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
selectedUser: Object,
userDetail: Object,
selectedOrg: Object,
loading: Boolean,
showRegModal: Boolean
showRegModal: Boolean,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
// Template refs for navigation
const userInfoRef = ref(null)
const regActionsRef = ref(null)
const credentialListRef = ref(null)
const sessionListRef = ref(null)
const backButtonRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => props.showRegModal)
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
}
@@ -70,26 +82,114 @@ async function handleTerminateSession(session) {
}
}
// Handle user info section keynav
function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to registration actions
focusPreferred(regActionsRef.value, { itemSelector: 'button' })
}
}
// Handle registration actions keynav
function handleRegActionsKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to user info edit button
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
} else if (direction === 'down') {
// Move to credential list
credentialListRef.value?.$el?.focus()
}
}
// Handle credential list navigate out
function handleCredentialNavigateOut(direction) {
if (hasActiveModal.value || props.navigationDisabled) return
if (direction === 'up') {
focusPreferred(regActionsRef.value, { itemSelector: 'button' })
} else if (direction === 'down') {
// Move to session list
focusAtIndex(sessionListRef.value?.$el, 0, { itemSelector: '.session-group' })
}
}
// Handle session list navigate out
function handleSessionNavigateOut(direction) {
if (hasActiveModal.value || props.navigationDisabled) return
if (direction === 'up') {
// Move to credential list
credentialListRef.value?.$el?.focus()
} else if (direction === 'down') {
// Move to back button
const backBtn = backButtonRef.value?.querySelector('button')
if (backBtn) backBtn.focus()
}
}
// Handle back button keynav
function handleBackButtonKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'up') {
// Move to session list
focusAtIndex(sessionListRef.value?.$el, -1, { itemSelector: '.session-group' })
}
}
// Focus helper for external navigation
function focusFirstElement() {
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
</div>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<div class="registration-actions">
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
<button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
@@ -106,27 +206,33 @@ async function handleTerminateSession(session) {
</div>
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="userDetail.credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
:navigation-disabled="hasActiveModal"
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@navigate-out="handleCredentialNavigateOut"
/>
</div>
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:empty-message="'This user has no active sessions.'"
:section-description="'View and manage the active sessions for this user.'"
@terminate="handleTerminateSession"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
/>
</template>
<div class="actions ancillary-actions">
<div class="actions ancillary-actions" ref="backButtonRef" @keydown="handleBackButtonKeydown">
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button>
</div>
<RegistrationLinkModal
+14 -1
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"],
@@ -446,6 +453,7 @@ th {
gap: 0.5rem;
align-items: stretch;
margin: 0 auto;
outline: none;
}
@media (max-width: 720px) {
@@ -483,6 +491,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;
@@ -562,7 +575,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;
+59 -3
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
// Props:
// entries: Array<{ label:string, href:string }>
@@ -11,17 +12,72 @@ const props = defineProps({
homeHref: { type: String, default: '/' }
})
const navRef = ref(null)
const crumbs = computed(() => {
const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : []
return [...base, ...props.entries]
})
// Find the index of the crumb matching current location
const currentIndex = computed(() => {
const currentHref = window.location.hash || window.location.pathname
for (let i = crumbs.value.length - 1; i >= 0; i--) {
const href = crumbs.value[i].href
if (href === currentHref || (href && currentHref.startsWith(href))) {
return i
}
}
return crumbs.value.length - 1 // Default to last crumb
})
function handleFocusIn(event) {
// When the nav receives focus, focus the current page's crumb
if (event.target === navRef.value) {
const links = navRef.value.querySelectorAll('a')
const targetIndex = Math.min(currentIndex.value, links.length - 1)
if (links[targetIndex]) {
links[targetIndex].focus()
}
}
}
function handleKeydown(event) {
const direction = getDirection(event)
if (!direction) return
if (direction === 'left' || direction === 'right') {
event.preventDefault()
navigateButtonRow(navRef.value, event.target, direction, { itemSelector: 'a' })
}
// Up/down are handled by parent component
}
// Expose method to focus the current crumb from parent
function focusCurrent() {
const links = navRef.value?.querySelectorAll('a')
if (links?.length) {
const targetIndex = Math.min(currentIndex.value, links.length - 1)
links[targetIndex]?.focus()
}
}
defineExpose({ focusCurrent })
</script>
<template>
<nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length">
<nav
ref="navRef"
class="breadcrumbs"
aria-label="Breadcrumb"
v-if="crumbs.length"
tabindex="0"
@focusin="handleFocusIn"
@keydown="handleKeydown"
>
<ol>
<li v-for="(c, idx) in crumbs" :key="idx">
<a :href="c.href">{{ c.label }}</a>
<a :href="c.href" tabindex="-1">{{ c.label }}</a>
<span v-if="idx < crumbs.length - 1" class="sep"> </span>
</li>
</ol>
+46 -7
View File
@@ -1,5 +1,5 @@
<template>
<div class="credential-list">
<div class="credential-list" tabindex="0" @focusin="handleListFocus" @keydown="handleListKeydown">
<div v-if="loading"><p>Loading credentials...</p></div>
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
<template v-else>
@@ -11,12 +11,12 @@
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
}]"
tabindex="0"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleCredentialFocus(credential.credential_uuid)"
@focusout="handleCredentialBlur($event)"
@keydown="handleDelete($event, credential)"
@keydown="handleItemKeydown($event, credential)"
>
<div class="item-top">
<div class="item-icon">
@@ -62,6 +62,7 @@
<script setup>
import { formatDate } from '@/utils/helpers'
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
const props = defineProps({
credentials: { type: Array, default: () => [] },
@@ -70,9 +71,10 @@ const props = defineProps({
allowDelete: { type: Boolean, default: false },
hoveredCredentialUuid: { type: String, default: null },
hoveredSessionCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
})
const emit = defineEmits(['delete', 'credentialHover'])
const emit = defineEmits(['delete', 'credentialHover', 'navigate-out'])
const handleCredentialFocus = (uuid) => {
emit('credentialHover', uuid)
@@ -93,10 +95,47 @@ const handleCardClick = (event) => {
}
const handleDelete = (event, credential) => {
const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') {
event.preventDefault()
handleDeleteKey(event, () => {
if (props.allowDelete && !credential.is_current_session) emit('delete', credential)
})
}
const handleListFocus = (event) => {
if (props.navigationDisabled) return
const list = event.currentTarget
// If focus came to the list container itself (not a child), focus first item
if (event.target === list) {
const firstItem = list.querySelector('.credential-item')
if (firstItem) {
firstItem.focus()
}
}
}
const handleListKeydown = (event) => {
if (props.navigationDisabled) return
// Escape emits navigate-out
handleEscape(event, (dir) => emit('navigate-out', dir))
}
const handleItemKeydown = (event, credential) => {
// Handle delete (always allowed even with modal)
handleDelete(event, credential)
if (event.defaultPrevented) return
if (props.navigationDisabled) return
// Arrow key navigation
const direction = getDirection(event)
if (direction) {
event.preventDefault()
const list = event.currentTarget.closest('.credential-list')
const result = navigateGrid(list, event.currentTarget, direction, { itemSelector: '.credential-item' })
if (result === 'boundary') {
emit('navigate-out', direction)
}
}
}
+22 -3
View File
@@ -5,7 +5,7 @@
<p class="view-lede">{{ subheading }}</p>
</header>
<section class="section-block">
<section class="section-block" ref="userInfoSection">
<div class="section-body">
<UserBasicInfo
v-if="user"
@@ -25,7 +25,7 @@
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@@ -58,10 +58,11 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
import { goBack } from '@/utils/helpers'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
defineProps({
initializing: {
@@ -73,6 +74,10 @@ defineProps({
const authStore = useAuthStore()
const currentHost = window.location.host
// Template refs for navigation
const userInfoSection = ref(null)
const buttonRow = ref(null)
const user = computed(() => authStore.userInfo?.user || null)
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
@@ -105,6 +110,20 @@ const goToAuthSite = () => {
const logout = async () => {
await authStore.logout()
}
// Keyboard navigation for button row
const handleButtonRowKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
}
// Up does nothing (no elements above to navigate to)
// Down does nothing (no elements below to navigate to)
}
</script>
<style scoped>
+124 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="modal-overlay" @keydown.esc="$emit('close')" tabindex="-1">
<div class="modal-overlay" @keydown.esc="$emit('close')" @keydown="handleOverlayKeydown" tabindex="-1">
<div class="modal" role="dialog" aria-modal="true">
<slot />
</div>
@@ -7,7 +7,130 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
focusFallback: { type: [HTMLElement, Object], default: null },
// Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus
focusSiblingSelector: { type: String, default: '' }
})
defineEmits(['close'])
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
/**
* Try to restore focus to the original element, or find a suitable fallback.
* Called on unmount to restore focus when modal closes.
*/
const restoreFocus = () => {
const prev = previouslyFocusedElement.value
if (!prev) return
// Check if the original element still exists in DOM and is focusable
if (document.body.contains(prev) && !prev.disabled) {
prev.focus()
return
}
// Original element is gone (deleted) - try to find a sibling
if (props.focusSiblingSelector && props.focusIndex >= 0) {
// Find container that has items matching the selector
const containers = [
props.focusFallback?.$el || props.focusFallback,
prev.closest('[data-nav-group]'),
prev.parentElement?.closest('section'),
document.querySelector('.view-root')
].filter(Boolean)
for (const container of containers) {
if (!container) continue
const siblings = container.querySelectorAll(props.focusSiblingSelector)
if (siblings.length > 0) {
// Try to focus the next item, or the previous if we were at the end
const targetIndex = Math.min(props.focusIndex, siblings.length - 1)
const target = siblings[targetIndex]
if (target && !target.disabled) {
target.focus()
return
}
}
}
}
// Fall back to the provided fallback element
const fallback = props.focusFallback?.$el || props.focusFallback
if (fallback && document.body.contains(fallback)) {
const focusable = fallback.querySelector?.('button:not([disabled]), a, [tabindex="0"]') || fallback
if (focusable?.focus) {
focusable.focus()
return
}
}
}
const handleOverlayKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
// Check if we're in a modal-actions row
const target = event.target
const actionsRow = target.closest('.modal-actions')
if (actionsRow && (direction === 'left' || direction === 'right')) {
event.preventDefault()
navigateButtonRow(actionsRow, target, direction, { itemSelector: 'button' })
} else if (direction === 'up' && actionsRow) {
// From actions, try to go back to last input or focusable element in form
event.preventDefault()
const form = actionsRow.closest('form') || actionsRow.closest('.modal-form')
const inputs = form?.querySelectorAll('input, textarea, select, button:not(.modal-actions button)')
if (inputs && inputs.length > 0) {
inputs[inputs.length - 1].focus()
}
} else if (direction === 'down' && !actionsRow) {
// From an input, try to go to modal-actions
const form = target.closest('form') || target.closest('.modal-form')
if (form) {
event.preventDefault()
const actions = form.querySelector('.modal-actions')
if (actions) {
focusPreferred(actions, { primarySelector: '.btn-primary', itemSelector: 'button' })
}
}
}
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
nextTick(() => {
const modal = document.querySelector('.modal')
if (modal) {
// Mark primary button for keyboard navigation
const primaryBtn = modal.querySelector('.modal-actions .btn-primary')
if (primaryBtn) {
primaryBtn.setAttribute('data-nav-primary', '')
}
// Focus the most appropriate element
focusDialogDefault(modal)
}
})
})
onUnmounted(() => {
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
+13 -12
View File
@@ -12,7 +12,7 @@
/>
</label>
<div v-if="error" class="error small">{{ error }}</div>
<div class="modal-actions">
<div class="modal-actions" @keydown="handleActionsKeydown">
<button
type="button"
class="btn-secondary"
@@ -25,6 +25,7 @@
type="submit"
class="btn-primary"
:disabled="busy"
data-nav-primary
>
{{ submitText }}
</button>
@@ -33,7 +34,8 @@
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { getDirection } from '@/utils/keynav'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -60,16 +62,15 @@ const localValue = computed({
const resolvedInputId = computed(() => props.inputId || generatedId)
onMounted(() => {
if (!props.autoFocus) return
nextTick(() => {
if (props.autoSelect) {
inputRef.value?.select()
} else {
inputRef.value?.focus()
}
})
})
const handleActionsKeydown = (event) => {
const direction = getDirection(event)
if (direction === 'up') {
event.preventDefault()
inputRef.value?.focus()
return
}
// Left/right navigation is handled globally by keynav
}
function handleCancel() {
emit('cancel')
+131 -11
View File
@@ -2,13 +2,14 @@
<section class="view-root" data-view="profile">
<header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header>
<section class="section-block">
<section class="section-block" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at"
@@ -17,6 +18,7 @@
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
@keydown="handleUserInfoKeydown"
>
<div class="remote-auth-inline">
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words:</label>
@@ -36,33 +38,38 @@
<section class="section-block">
<div class="section-header">
<h2>Your Passkeys</h2>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login.</p>
<p><a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
</div>
<div class="section-body">
<CredentialList
ref="credentialList"
:credentials="authStore.userInfo?.credentials || []"
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
:navigation-disabled="hasActiveModal"
allow-delete
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@navigate-out="handleCredentialNavigateOut"
/>
<div class="button-row">
<button @click="addNewCredential" class="btn-primary">Register New</button>
<button @click="showRegLink = true" class="btn-secondary">Another Device</button>
<div class="button-row" ref="credentialButtons">
<button @click="addNewCredential" class="btn-primary" @keydown="handleCredentialButtonKeydown">Register New</button>
<button @click="showRegLink = true" class="btn-secondary" @keydown="handleCredentialButtonKeydown">Another Device</button>
</div>
</div>
</section>
<SessionList
ref="sessionList"
:sessions="sessions"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
@terminate="terminateSession"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/>
@@ -79,18 +86,19 @@
</Modal>
<section class="section-block">
<div class="button-row">
<div class="button-row" ref="logoutButtons">
<button
type="button"
class="btn-secondary"
@click="goBack"
@keydown="handleLogoutButtonKeydown"
>
Back
</button>
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading">Logout</button>
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
<template v-else>
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading">Logout</button>
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading">All</button>
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
</template>
</div>
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
@@ -119,6 +127,7 @@ import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from '@/utils/api'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
const authStore = useAuthStore()
const updateInterval = ref(null)
@@ -130,6 +139,16 @@ const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
const credentialList = ref(null)
const credentialButtons = ref(null)
const sessionList = ref(null)
const logoutButtons = ref(null)
const breadcrumbs = ref(null)
const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -165,6 +184,107 @@ const handlePairingError = (message) => {
}
}
// Helper to focus preferred button in a row (primary first, or first button)
const focusPreferredButton = (container) => {
focusPreferred(container, { primarySelector: '.btn-primary', itemSelector: 'button' })
}
// Navigation between components
const handleBreadcrumbKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
// Left/right handled internally by Breadcrumbs component
if (direction === 'down') {
event.preventDefault()
// Move to user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
}
// ArrowUp at the top does nothing
}
const handleUserInfoKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
const itemSelector = '.mini-btn, .pairing-input'
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
} else if (direction === 'up') {
// Move to breadcrumbs - focus current page crumb
breadcrumbs.value?.focusCurrent?.()
} else if (direction === 'down') {
// Move to credential list
credentialList.value?.$el?.focus()
}
}
const handleCredentialNavigateOut = (direction) => {
if (hasActiveModal.value) return // Block navigation when modal is open
if (direction === 'down' || direction === 'right') {
// Focus preferred button in credential section
focusPreferredButton(credentialButtons.value)
} else if (direction === 'up' || direction === 'left') {
// Focus user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
}
}
const handleCredentialButtonKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(credentialButtons.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move back to credential list
focusAtIndex(credentialList.value?.$el, 0, { itemSelector: '.credential-item' })
} else if (direction === 'down') {
// Move to session list
focusAtIndex(sessionList.value?.$el, 0, { itemSelector: '.session-group' })
}
}
const handleSessionNavigateOut = (direction) => {
if (hasActiveModal.value) return // Block navigation when modal is open
if (direction === 'up') {
// Focus preferred button in credential section
focusPreferredButton(credentialButtons.value)
} else if (direction === 'down') {
// Focus preferred button in logout section
focusPreferredButton(logoutButtons.value)
}
}
const handleLogoutButtonKeydown = (event) => {
if (hasActiveModal.value) return // Block navigation when modal is open
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(logoutButtons.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move back to session list - focus last group
focusAtIndex(sessionList.value?.$el, -1, { itemSelector: '.session-group' })
}
// ArrowDown at the bottom does nothing
}
const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="qr-display">
<div class="qr-section">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link" tabindex="0" @keydown.enter.prevent="copyLink">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<div v-if="showLink && url" class="link-text">{{ displayUrl }}</div>
</a>
@@ -5,7 +5,7 @@
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close"></button>
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1"></button>
</div>
<div class="device-link-section">
@@ -17,6 +17,7 @@
:url="linkUrl"
:show-link="true"
@copied="onCopied"
@keydown="handleQRKeydown"
/>
<p class="expiry-note" v-if="expiresAt">
@@ -24,7 +25,7 @@
</p>
</div>
<div class="reg-actions">
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
@@ -32,10 +33,11 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from '@/utils/api'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
const props = defineProps({
endpoint: { type: String, required: true },
@@ -46,6 +48,9 @@ const emit = defineEmits(['close', 'copied'])
const linkUrl = ref(null)
const expiresAt = ref(null)
const actionsRow = ref(null)
// Store the element that had focus before modal opened
const previouslyFocusedElement = ref(null)
async function generateLink() {
try {
@@ -53,6 +58,11 @@ async function generateLink() {
if (data.url) {
linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null
// Focus primary button (or first button if no primary) after content renders
await nextTick()
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
@@ -65,7 +75,37 @@ function onCopied() {
emit('copied')
}
const handleQRKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
// Navigation constrained within modal: QR link <-> Close button
if (direction === 'down' || direction === 'up') {
// Toggle between QR link and close button
actionsRow.value?.querySelector('button')?.focus()
}
// Left/right do nothing on QR code
}
const handleActionsKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
// Navigation constrained within modal: Close button <-> QR link
if (direction === 'up' || direction === 'down') {
// Toggle between close button and QR link
document.querySelector('.qr-link')?.focus()
}
// Left/right do nothing (only one button)
}
onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Hold backdrop before fetch to avoid gap if auth iframe shows
holdGlobalBackdrop()
generateLink()
@@ -74,6 +114,11 @@ onMounted(() => {
onUnmounted(() => {
// Release backdrop when modal closes
releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
prev.focus()
}
})
</script>
+11 -2
View File
@@ -18,7 +18,7 @@
<div class="section-body center">
<!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view">
<div class="button-row center">
<div class="button-row center" ref="buttonRow">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
@@ -55,11 +55,12 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
const props = defineProps({
mode: {
@@ -78,6 +79,7 @@ const settings = ref(null)
const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local' or 'remote'
const buttonRow = ref(null)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -255,6 +257,13 @@ function handleHeaderLinkClick(event) {
}
}
// Autofocus primary button when the view becomes ready
watch(initializing, (newVal) => {
if (!newVal) {
nextTick(() => focusDialogButton(buttonRow.value))
}
})
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
+84 -15
View File
@@ -7,7 +7,7 @@
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown.enter="handleGroupEnter($event, host)">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
@@ -22,12 +22,12 @@
'is-hovered': hoveredSession?.id === session.id,
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
}]"
tabindex="0"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleSessionFocus(session)"
@focusout="handleSessionBlur($event)"
@keydown="handleDelete($event, session)"
@keydown="handleItemKeydown($event, session)"
>
<div class="item-top">
<h4 class="item-title">{{ session.user_agent }}</h4>
@@ -66,6 +66,7 @@ import { computed, ref } from 'vue'
import { formatDate } from '@/utils/helpers'
import { useAuthStore } from '@/stores/auth'
import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
@@ -73,9 +74,10 @@ const props = defineProps({
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
})
const emit = defineEmits(['terminate', 'sessionHover'])
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
const authStore = useAuthStore()
@@ -104,19 +106,86 @@ const handleCardClick = (event) => {
}
}
const handleDelete = (event, session) => {
const apple = navigator.userAgent.includes('Mac OS')
if (event.key === 'Delete' || apple && event.key === 'Backspace') {
event.preventDefault()
if (!isTerminating(session.id)) emit('terminate', session)
}
}
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const handleGroupEnter = (event, host) => {
if (host && event.target === event.currentTarget) {
event.currentTarget.querySelector('a')?.click()
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
const sessionList = group.querySelector('.session-list')
const items = sessionList?.querySelectorAll('.session-item')
const allGroups = Array.from(document.querySelectorAll('.session-group'))
const groupIndex = allGroups.indexOf(group)
// Enter on group header opens link (always allowed)
if (event.key === 'Enter' && event.target === group) {
if (host) group.querySelector('a')?.click()
return
}
if (props.navigationDisabled) return
// Arrow keys to enter the grid from the group
const direction = getDirection(event)
if (['down', 'right'].includes(direction) && event.target === group) {
event.preventDefault()
items?.[0]?.focus()
return
}
// Up/Left from group navigates to previous group or out
if (['up', 'left'].includes(direction) && event.target === group) {
event.preventDefault()
if (groupIndex > 0) {
allGroups[groupIndex - 1].focus()
} else {
emit('navigate-out', 'up')
}
return
}
// Escape emits navigate-out
handleEscape(event, (dir) => emit('navigate-out', dir))
}
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
})
if (event.defaultPrevented) return
if (props.navigationDisabled) return
// Arrow key navigation
const direction = getDirection(event)
if (direction) {
event.preventDefault()
const group = event.currentTarget.closest('.session-group')
const sessionListEl = group.querySelector('.session-list')
const result = navigateGrid(sessionListEl, event.currentTarget, direction, { itemSelector: '.session-item' })
// Custom boundary handling for session list
if (result === 'boundary') {
if (direction === 'left' || direction === 'up') {
// At left/top edge, focus group
group?.focus()
} else if (direction === 'down' || direction === 'right') {
// Try to navigate to next group or emit navigate-out
const allGroups = Array.from(document.querySelectorAll('.session-group'))
const groupIndex = allGroups.indexOf(group)
if (groupIndex < allGroups.length - 1) {
allGroups[groupIndex + 1].focus()
} else {
emit('navigate-out', 'down')
}
}
}
}
// Escape focuses the group
if (event.key === 'Escape') {
event.preventDefault()
event.currentTarget.closest('.session-group')?.focus()
}
}
+479
View File
@@ -0,0 +1,479 @@
/**
* 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])')
*
* Automatic navigation:
* - Buttons/links inside .button-row or .modal-actions get automatic left/right arrow navigation
* - No need for explicit @keydown handlers on elements
* - Call initKeyboardNavigation() once at app startup to enable global navigation
*/
// Direction mapping from key events
const DIRECTION_MAP = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down'
}
// Input types that use left/right arrows for internal cursor movement
const TEXT_INPUT_TYPES = new Set([
'text', 'email', 'password', 'search', 'tel', 'url', 'number'
])
/**
* 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' && TEXT_INPUT_TYPES.has(target.type)) || 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)
}
// ============================================================================
// Global Automatic Keyboard Navigation
// ============================================================================
/**
* Selector for containers that should have automatic button row navigation
*/
const BUTTON_ROW_SELECTOR = '.button-row, .modal-actions, .actions, .role-actions, .ancillary-actions'
/**
* Global keydown handler for automatic button row navigation.
* Handles arrow key navigation for buttons/links within .button-row or .modal-actions containers.
* @param {KeyboardEvent} event
*/
const globalKeydownHandler = (event) => {
const direction = getDirection(event)
if (!direction) return
// Only handle buttons and links
const target = event.target
if (target.tagName !== 'BUTTON' && target.tagName !== 'A') return
// Find the button row container
const container = target.closest(BUTTON_ROW_SELECTOR)
if (!container) return
if (direction === 'left' || direction === 'right') {
event.preventDefault()
navigateButtonRow(container, target, direction, { itemSelector: 'button:not([disabled]), a' })
}
// Note: up/down navigation is intentionally not handled globally
// Components can add their own handlers for vertical navigation between groups
}
let globalNavInitialized = false
/**
* Initialize global keyboard navigation.
* Call this once at app startup to enable automatic arrow key navigation
* for buttons within .button-row and .modal-actions containers.
* Safe to call multiple times (only initializes once).
*/
export const initKeyboardNavigation = () => {
if (globalNavInitialized) return
if (typeof document === 'undefined') return // SSR safety
document.addEventListener('keydown', globalKeydownHandler)
globalNavInitialized = true
}
/**
* Cleanup global keyboard navigation (useful for testing).
*/
export const destroyKeyboardNavigation = () => {
if (!globalNavInitialized) return
if (typeof document === 'undefined') return
document.removeEventListener('keydown', globalKeydownHandler)
globalNavInitialized = false
}
+5 -11
View File
@@ -57,21 +57,15 @@ export async function register(resetToken = null, displayName = null, onstartreg
export async function authenticate() {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
try {
const res = await ws.receive_json()
console.log('Authentication options:', res)
// status field present means error
if (res.status) {
throw new Error(res.detail || `Authentication failed: ${res.status}`)
}
let res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
const authResponse = await startAuthentication(res)
ws.send_json(authResponse)
const result = await ws.receive_json()
if (result.status) {
throw new Error(result.detail || `Authentication failed: ${result.status}`)
}
return result
res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
return res
} catch (error) {
console.error('Authentication error:', error)
throw Error(error.name === "NotAllowedError" ? 'Passkey authentication cancelled' : error.message)