Implement keyboard navigation using arrow keys in the whole application. (#2)
This commit is contained in:
@@ -13,6 +13,7 @@ import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -35,8 +36,18 @@ const renameIdValue = ref('')
|
||||
const editingPermDisplay = ref(null)
|
||||
const renameDisplayValue = ref('')
|
||||
const dialog = ref({ type: null, data: null, busy: false, error: '' })
|
||||
const dialogPreviousFocus = ref(null) // Track element that had focus before dialog opened
|
||||
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
|
||||
|
||||
// Template refs for navigation
|
||||
const breadcrumbsRef = ref(null)
|
||||
const adminOverviewRef = ref(null)
|
||||
const adminOrgDetailRef = ref(null)
|
||||
const adminUserDetailRef = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
||||
|
||||
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||
|
||||
function handleGlobalClick(e) {
|
||||
@@ -394,8 +405,107 @@ async function toggleOrgPermission(org, permId, checked) {
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } }
|
||||
function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } }
|
||||
function openDialog(type, data) {
|
||||
const focused = document.activeElement
|
||||
dialogPreviousFocus.value = focused
|
||||
|
||||
// For delete operations, store sibling info to help restore focus after deletion
|
||||
if (type === 'confirm' && focused) {
|
||||
const row = focused.closest('tr')
|
||||
if (row) {
|
||||
const tbody = row.closest('tbody')
|
||||
if (tbody) {
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'))
|
||||
const idx = rows.indexOf(row)
|
||||
// Store context to find next/prev row after deletion
|
||||
dialog.value.focusContext = {
|
||||
tbody,
|
||||
index: idx,
|
||||
total: rows.length,
|
||||
selector: 'button:not([disabled]), a'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.value = { ...dialog.value, type, data, busy: false, error: '' }
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
const prev = dialogPreviousFocus.value
|
||||
const context = dialog.value.focusContext
|
||||
dialog.value = { type: null, data: null, busy: false, error: '' }
|
||||
// Restore focus after dialog closes
|
||||
restoreFocusAfterDialog(prev, context)
|
||||
dialogPreviousFocus.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore focus to the previously focused element, or find a sibling if deleted.
|
||||
*/
|
||||
function restoreFocusAfterDialog(prev, context) {
|
||||
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
|
||||
}
|
||||
|
||||
// Element was deleted - try to find a sibling using stored context
|
||||
if (context?.tbody && context.selector) {
|
||||
const rows = Array.from(context.tbody.querySelectorAll('tr'))
|
||||
if (rows.length > 0) {
|
||||
// Try the same index (next row moved up) or the last row
|
||||
const targetIdx = Math.min(context.index, rows.length - 1)
|
||||
const targetRow = rows[targetIdx]
|
||||
const focusable = targetRow?.querySelector(context.selector)
|
||||
if (focusable) {
|
||||
focusable.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find any focusable element in the admin panels
|
||||
const container = document.querySelector('.admin-panels')
|
||||
if (!container) return
|
||||
|
||||
const focusable = container.querySelector('button:not([disabled]), a, input:not([disabled]), [tabindex="0"]')
|
||||
if (focusable) {
|
||||
focusable.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation handlers
|
||||
function handleBreadcrumbKeydown(event) {
|
||||
if (hasActiveModal.value) return
|
||||
|
||||
const direction = getDirection(event)
|
||||
if (!direction) return
|
||||
|
||||
// Left/right handled internally by Breadcrumbs component
|
||||
if (direction === 'down') {
|
||||
event.preventDefault()
|
||||
// Move to admin panel content
|
||||
if (adminOverviewRef.value) {
|
||||
adminOverviewRef.value.focusFirstElement?.()
|
||||
} else if (adminOrgDetailRef.value) {
|
||||
adminOrgDetailRef.value.focusFirstElement?.()
|
||||
} else if (adminUserDetailRef.value) {
|
||||
adminUserDetailRef.value.focusFirstElement?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePanelNavigateOut(direction) {
|
||||
if (hasActiveModal.value) return
|
||||
|
||||
if (direction === 'up') {
|
||||
// Focus breadcrumbs - focus the current page's crumb
|
||||
breadcrumbsRef.value?.focusCurrent?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUserDetail() {
|
||||
await loadOrgs()
|
||||
@@ -483,7 +593,7 @@ async function submitDialog() {
|
||||
<section v-else-if="authenticated && (info?.is_global_admin || info?.is_org_admin)" class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
</header>
|
||||
|
||||
<section class="section-block admin-section">
|
||||
@@ -492,9 +602,11 @@ async function submitDialog() {
|
||||
<div v-else class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
||||
ref="adminOverviewRef"
|
||||
:info="info"
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@open-org="openOrg"
|
||||
@@ -504,15 +616,18 @@ async function submitDialog() {
|
||||
@open-dialog="openDialog"
|
||||
@delete-permission="deletePermission"
|
||||
@rename-permission-display="renamePermissionDisplay"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
<AdminUserDetail
|
||||
v-else-if="selectedUser"
|
||||
ref="adminUserDetailRef"
|
||||
:selected-user="selectedUser"
|
||||
:user-detail="userDetail"
|
||||
:selected-org="selectedOrg"
|
||||
:loading="loading"
|
||||
:show-reg-modal="showRegModal"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@generate-user-registration-link="generateUserRegistrationLink"
|
||||
@go-overview="goOverview"
|
||||
@open-org="openOrg"
|
||||
@@ -520,11 +635,14 @@ async function submitDialog() {
|
||||
@refresh-user-detail="refreshUserDetail"
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
ref="adminOrgDetailRef"
|
||||
:selected-org="selectedOrg"
|
||||
:permissions="permissions"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@update-org="updateOrg"
|
||||
@create-role="createRole"
|
||||
@update-role="updateRole"
|
||||
@@ -533,6 +651,7 @@ async function submitDialog() {
|
||||
@open-user="openUser"
|
||||
@toggle-role-permission="toggleRolePermission"
|
||||
@on-role-drag-over="onRoleDragOver"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,9 @@ import '@/assets/style.css'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import AdminApp from './AdminApp.vue'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
const app = createApp(AdminApp)
|
||||
app.use(createPinia())
|
||||
app.mount('#admin-app')
|
||||
initKeyboardNavigation()
|
||||
|
||||
@@ -3,9 +3,11 @@ import '@/assets/style.css'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
app.mount('#app')
|
||||
initKeyboardNavigation()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import RestrictedApi from './RestrictedApi.vue'
|
||||
import '@/assets/style.css'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
createApp(RestrictedApi).mount('#app')
|
||||
initKeyboardNavigation()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './RestrictedForward.vue'
|
||||
import '@/assets/style.css'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
initKeyboardNavigation()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import ResetApp from './ResetApp.vue'
|
||||
import '@/assets/style.css'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
createApp(ResetApp).mount('#app')
|
||||
initKeyboardNavigation()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user