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

Merged
LeoVasanko merged 8 commits from keyboard-nav into main 2025-12-10 15:43:40 +00:00
7 changed files with 736 additions and 56 deletions
Showing only changes of commit ce783cbf57 - Show all commits
+50 -1
View File
@@ -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)
@@ -37,6 +38,15 @@ const renameDisplayValue = ref('')
const dialog = ref({ type: null, data: null, busy: false, error: '' })
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) {
@@ -397,6 +407,36 @@ 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: '' } }
// 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()
if (selectedUser.value) {
@@ -483,7 +523,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 +532,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 +546,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 +565,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 +581,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"
/>
+266 -12
View File
@@ -1,12 +1,19 @@
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
const props = defineProps({
selectedOrg: Object,
permissions: Array
permissions: Array,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart'])
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
// Template refs for navigation
const orgTitleRef = ref(null)
const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
const sortedRoles = computed(() => {
return [...props.selectedOrg.roles].sort((a, b) => {
@@ -26,15 +33,259 @@ function permissionDisplayName(id) {
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
}
// Handle org title header keynav
function handleTitleKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(orgTitleRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to permission matrix
const firstCheckbox = permMatrixRef.value?.querySelector('input[type="checkbox"]')
if (firstCheckbox) {
firstCheckbox.focus()
} else {
// No matrix, go to roles grid
focusFirstRoleElement()
}
}
}
// Handle permission matrix grid navigation
function handleMatrixKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
if (target.tagName !== 'INPUT') return
event.preventDefault()
const checkboxes = Array.from(permMatrixRef.value.querySelectorAll('input[type="checkbox"]'))
const currentIndex = checkboxes.indexOf(target)
if (currentIndex === -1) return
// Calculate grid dimensions
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
let newIndex = currentIndex
if (direction === 'left' && currentCol > 0) {
newIndex = currentIndex - 1
} else if (direction === 'right' && currentCol < cols - 1) {
newIndex = currentIndex + 1
} else if (direction === 'up' && currentRow > 0) {
newIndex = currentIndex - cols
} else if (direction === 'down' && currentRow < rows - 1) {
newIndex = currentIndex + cols
} else if (direction === 'up' && currentRow === 0) {
// Navigate up to title
const titleButton = orgTitleRef.value?.querySelector('button')
if (titleButton) titleButton.focus()
return
} else if (direction === 'down' && currentRow === rows - 1) {
// Navigate down to roles grid
focusFirstRoleElement()
return
}
if (newIndex !== currentIndex && checkboxes[newIndex]) {
checkboxes[newIndex].focus()
}
}
// Handle navigation within user list
function handleUserListKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
if (!target.classList.contains('user-chip')) return
const list = target.closest('.user-list')
if (!list) return
const items = Array.from(list.querySelectorAll('.user-chip'))
const currentIndex = items.indexOf(target)
if (currentIndex === -1) return
// For vertical navigation within the list
if (direction === 'up' && currentIndex > 0) {
event.preventDefault()
items[currentIndex - 1].focus()
return
} else if (direction === 'down' && currentIndex < items.length - 1) {
event.preventDefault()
items[currentIndex + 1].focus()
return
}
// Handle boundary navigation
if (direction === 'up' && currentIndex === 0) {
event.preventDefault()
// Go to role header buttons
const roleColumn = list.closest('.role-column')
const headerButton = roleColumn?.querySelector('.role-header button')
if (headerButton) headerButton.focus()
return
}
if (direction === 'down' && currentIndex === items.length - 1) {
// At bottom - nothing below
return
}
// Handle left/right to navigate between role columns
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const roleColumns = Array.from(rolesGridRef.value?.querySelectorAll('.role-column') || [])
const currentColumn = list.closest('.role-column')
const colIndex = roleColumns.indexOf(currentColumn)
let targetColIndex = direction === 'left' ? colIndex - 1 : colIndex + 1
if (targetColIndex >= 0 && targetColIndex < roleColumns.length) {
const targetColumn = roleColumns[targetColIndex]
const targetUsers = targetColumn.querySelectorAll('.user-chip')
const targetIndex = Math.min(currentIndex, targetUsers.length - 1)
if (targetUsers[targetIndex]) {
targetUsers[targetIndex].focus()
} else {
// No users in target column, focus the add user button
const addBtn = targetColumn.querySelector('.plus-btn')
if (addBtn) addBtn.focus()
}
} else if (direction === 'left' && colIndex === 0) {
// At leftmost column, go up to matrix
const lastCheckbox = permMatrixRef.value?.querySelector('input[type="checkbox"]:last-of-type')
if (lastCheckbox) lastCheckbox.focus()
}
}
}
// Handle role header button navigation
function handleRoleHeaderKeydown(event, roleIndex) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const roleColumns = Array.from(rolesGridRef.value?.querySelectorAll('.role-column') || [])
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const buttons = event.currentTarget.querySelectorAll('button:not([disabled])')
const btnIndex = Array.from(buttons).indexOf(event.target)
if (direction === 'left' && btnIndex > 0) {
buttons[btnIndex - 1].focus()
} else if (direction === 'right' && btnIndex < buttons.length - 1) {
buttons[btnIndex + 1].focus()
} else if (direction === 'left' && btnIndex === 0 && roleIndex > 0) {
// Move to previous column's header
const prevColumn = roleColumns[roleIndex - 1]
const prevButtons = prevColumn?.querySelectorAll('.role-header button')
if (prevButtons?.length) prevButtons[prevButtons.length - 1].focus()
} else if (direction === 'right' && btnIndex === buttons.length - 1 && roleIndex < roleColumns.length - 1) {
// Move to next column's header
const nextColumn = roleColumns[roleIndex + 1]
const nextButton = nextColumn?.querySelector('.role-header button')
if (nextButton) nextButton.focus()
}
} else if (direction === 'up') {
event.preventDefault()
// Go to permission matrix
const checkboxes = permMatrixRef.value?.querySelectorAll('input[type="checkbox"]')
if (checkboxes?.length) {
// Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus()
} else {
const titleButton = orgTitleRef.value?.querySelector('button')
if (titleButton) titleButton.focus()
}
} else if (direction === 'down') {
event.preventDefault()
// Go to first user in this column
const roleColumn = roleColumns[roleIndex]
const firstUser = roleColumn?.querySelector('.user-chip')
if (firstUser) {
firstUser.focus()
}
}
}
// Handle empty role section keynav
function handleEmptyRoleKeydown(event, roleIndex) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const roleColumns = Array.from(rolesGridRef.value?.querySelectorAll('.role-column') || [])
if (direction === 'up') {
event.preventDefault()
const roleColumn = roleColumns[roleIndex]
const headerButton = roleColumn?.querySelector('.role-header button')
if (headerButton) headerButton.focus()
} else if (direction === 'left' && roleIndex > 0) {
event.preventDefault()
const prevColumn = roleColumns[roleIndex - 1]
const prevEmpty = prevColumn?.querySelector('.empty-role button')
const prevUser = prevColumn?.querySelector('.user-chip:last-child')
if (prevEmpty) prevEmpty.focus()
else if (prevUser) prevUser.focus()
} else if (direction === 'right' && roleIndex < roleColumns.length - 1) {
event.preventDefault()
const nextColumn = roleColumns[roleIndex + 1]
const nextEmpty = nextColumn?.querySelector('.empty-role button')
const nextUser = nextColumn?.querySelector('.user-chip')
if (nextEmpty) nextEmpty.focus()
else if (nextUser) nextUser.focus()
}
}
// Helper to focus first element in roles grid
function focusFirstRoleElement() {
const firstRoleColumn = rolesGridRef.value?.querySelector('.role-column')
const firstButton = firstRoleColumn?.querySelector('.role-header button')
if (firstButton) firstButton.focus()
}
// Focus helper for external navigation
function focusFirstElement() {
const titleButton = orgTitleRef.value?.querySelector('button')
if (titleButton) titleButton.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<h2 class="org-title" :title="selectedOrg.uuid">
<h2 class="org-title" ref="orgTitleRef" @keydown="handleTitleKeydown" :title="selectedOrg.uuid">
<span class="org-name">{{ selectedOrg.display_name }}</span>
<button @click="$emit('updateOrg', selectedOrg)" class="icon-btn" aria-label="Rename organization" title="Rename organization"></button>
</h2>
<div class="matrix-wrapper">
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
@@ -49,7 +300,7 @@ function toggleRolePermission(role, pid, checked) {
>
<span>{{ r.display_name }}</span>
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button"></div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button" tabindex="0" @keydown.enter="$emit('createRole', selectedOrg)"></div>
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
@@ -70,15 +321,15 @@ function toggleRolePermission(role, pid, checked) {
</div>
<p class="matrix-hint muted">Toggle which permissions each role grants.</p>
</div>
<div class="roles-grid">
<div class="roles-grid" ref="rolesGridRef">
<div
v-for="r in sortedRoles"
v-for="(r, roleIndex) in sortedRoles"
:key="r.uuid"
class="role-column"
@dragover="$emit('onRoleDragOver', $event)"
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
>
<div class="role-header">
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
@@ -88,7 +339,7 @@ function toggleRolePermission(role, pid, checked) {
</div>
</div>
<template v-if="r.users.length > 0">
<ul class="user-list">
<ul class="user-list" @keydown="handleUserListKeydown">
<li
v-for="u in r.users.slice().sort((a, b) => {
const nameA = a.display_name.toLowerCase()
@@ -100,9 +351,11 @@ function toggleRolePermission(role, pid, checked) {
})"
:key="u.uuid"
class="user-chip"
tabindex="0"
draggable="true"
@dragstart="e => $emit('onUserDragStart', e, u, selectedOrg.uuid)"
@click="$emit('openUser', u)"
@keydown.enter="$emit('openUser', u)"
:title="u.uuid"
>
<span class="name">{{ u.display_name }}</span>
@@ -110,7 +363,7 @@ function toggleRolePermission(role, pid, checked) {
</li>
</ul>
</template>
<div v-else class="empty-role">
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
<p class="empty-text muted">No members</p>
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role"></button>
</div>
@@ -144,6 +397,7 @@ function toggleRolePermission(role, pid, checked) {
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
.empty-text { margin: 0; }
@@ -154,4 +408,4 @@ function toggleRolePermission(role, pid, checked) {
@media (max-width: 720px) {
.roles-grid { flex-direction: column; }
}
</style>
</style>
+224 -10
View File
@@ -1,14 +1,24 @@
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
permissionSummary: Object
permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null)
const orgTableRef = ref(null)
const permMatrixRef = ref(null)
const permActionsRef = ref(null)
const permTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.display_name.localeCompare(b.display_name)
@@ -27,15 +37,219 @@ function getRoleNames(org) {
.map(r => r.display_name)
.join(', ')
}
// Table navigation for both org and permissions tables
function handleTableKeydown(event, tableType) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
const row = target.closest('tr')
if (!row) return
const tbody = row.closest('tbody')
if (!tbody) return
const rows = Array.from(tbody.querySelectorAll('tr'))
const currentIndex = rows.indexOf(row)
if (currentIndex === -1) return
// Handle left/right navigation within the row
if (direction === 'left' || direction === 'right') {
event.preventDefault()
const focusables = Array.from(row.querySelectorAll('a, button:not([disabled])'))
const currentFocusIndex = focusables.indexOf(target)
if (currentFocusIndex === -1) return
if (direction === 'left' && currentFocusIndex > 0) {
focusables[currentFocusIndex - 1].focus()
} else if (direction === 'right' && currentFocusIndex < focusables.length - 1) {
focusables[currentFocusIndex + 1].focus()
}
return
}
// Handle up/down navigation between rows
let newIndex = currentIndex
if (direction === 'up' && currentIndex > 0) {
newIndex = currentIndex - 1
} else if (direction === 'down' && currentIndex < rows.length - 1) {
newIndex = currentIndex + 1
} else if (direction === 'up' && currentIndex === 0) {
// At top of table, navigate to actions above
event.preventDefault()
if (tableType === 'org') {
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
} else if (tableType === 'perm') {
focusPreferred(permActionsRef.value, { itemSelector: 'button' })
}
return
} else if (direction === 'down' && currentIndex === rows.length - 1) {
// At bottom of org table, navigate to permissions section
event.preventDefault()
if (tableType === 'org' && props.info.is_global_admin) {
// Navigate to permissions matrix or actions
if (permMatrixRef.value) {
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
if (firstCheckbox) firstCheckbox.focus()
else focusPreferred(permActionsRef.value, { itemSelector: 'button' })
}
}
return
}
if (newIndex !== currentIndex) {
event.preventDefault()
const newRow = rows[newIndex]
const focusable = newRow.querySelector('a, button:not([disabled])')
if (focusable) focusable.focus()
}
}
// Handle org actions button keynav
function handleOrgActionsKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(orgActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to org table
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
if (firstFocusable) firstFocusable.focus()
}
}
// Handle permission matrix grid navigation
function handleMatrixKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
const target = event.target
if (target.tagName !== 'INPUT') return
event.preventDefault()
const checkboxes = Array.from(permMatrixRef.value.querySelectorAll('input[type="checkbox"]'))
const currentIndex = checkboxes.indexOf(target)
if (currentIndex === -1) return
// Calculate grid dimensions
const cols = sortedOrgs.value.length
const rows = sortedPermissions.value.length
if (cols === 0 || rows === 0) return
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
let newIndex = currentIndex
if (direction === 'left') {
if (currentCol > 0) {
// Move left within the same row
newIndex = currentIndex - 1
}
// At leftmost column, do nothing (no wrap)
} else if (direction === 'right') {
if (currentCol < cols - 1) {
// Move right within the same row
newIndex = currentIndex + 1
}
// At rightmost column, do nothing (no wrap)
} else if (direction === 'up') {
if (currentRow > 0) {
// Move up within the same column
newIndex = currentIndex - cols
} else {
// At top row, navigate up to org table
const lastRow = orgTableRef.value?.querySelector('tbody tr:last-child')
const focusable = lastRow?.querySelector('a, button:not([disabled])')
if (focusable) focusable.focus()
return
}
} else if (direction === 'down') {
if (currentRow < rows - 1) {
// Move down within the same column
newIndex = currentIndex + cols
} else {
// At bottom row, navigate down to permission actions
focusPreferred(permActionsRef.value, { itemSelector: 'button' })
return
}
}
if (newIndex !== currentIndex && checkboxes[newIndex]) {
checkboxes[newIndex].focus()
}
}
// Handle permission actions button keynav
function handlePermActionsKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(permActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to first column of last row in matrix
const checkboxes = permMatrixRef.value?.querySelectorAll('input[type="checkbox"]')
if (checkboxes?.length) {
const cols = sortedOrgs.value.length
const rows = sortedPermissions.value.length
// First checkbox of last row = (rows - 1) * cols
const lastRowFirstIndex = (rows - 1) * cols
if (checkboxes[lastRowFirstIndex]) {
checkboxes[lastRowFirstIndex].focus()
} else {
checkboxes[0].focus()
}
} else {
// No matrix, go to org table
const lastRow = orgTableRef.value?.querySelector('tbody tr:last-child')
const focusable = lastRow?.querySelector('a, button:not([disabled])')
if (focusable) focusable.focus()
}
} else if (direction === 'down') {
// Move to permissions table
const firstFocusable = permTableRef.value?.querySelector('tbody tr button:not([disabled])')
if (firstFocusable) firstFocusable.focus()
}
}
// Focus helper for external navigation
function focusFirstElement() {
if (props.info.is_global_admin) {
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
} else {
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
if (firstFocusable) firstFocusable.focus()
}
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="permissions-section">
<div class="permissions-section" ref="orgSection">
<h2>{{ info.is_global_admin ? 'Organizations' : 'Your Organizations' }}</h2>
<div class="actions">
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
<button v-if="info.is_global_admin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table">
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
<thead>
<tr>
<th>Name</th>
@@ -62,7 +276,7 @@ function getRoleNames(org) {
<div v-if="info.is_global_admin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper">
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<div class="matrix-scroll">
<div
class="perm-matrix-grid"
@@ -98,10 +312,10 @@ function getRoleNames(org) {
</div>
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
</div>
<div class="actions">
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
<button v-if="info.is_global_admin" @click="$emit('openDialog', 'perm-create', { display_name: '', id: '' })">+ Create Permission</button>
</div>
<table class="org-table">
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
<thead>
<tr>
<th scope="col">Permission</th>
@@ -162,4 +376,4 @@ function getRoleNames(org) {
.perm-actions { text-align: center; }
.center { text-align: center; }
.muted { color: var(--color-text-muted); }
</style>
</style>
+125 -19
View File
@@ -1,27 +1,39 @@
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
selectedUser: Object,
userDetail: Object,
selectedOrg: Object,
loading: Boolean,
showRegModal: Boolean
showRegModal: Boolean,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail'])
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
// Template refs for navigation
const userInfoRef = ref(null)
const regActionsRef = ref(null)
const credentialListRef = ref(null)
const sessionListRef = ref(null)
const backButtonRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => props.showRegModal)
function onLinkCopied() {
authStore.showMessage('Link copied to clipboard!')
}
@@ -70,26 +82,114 @@ async function handleTerminateSession(session) {
}
}
// Handle user info section keynav
function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
} else if (direction === 'down') {
// Move to registration actions
focusPreferred(regActionsRef.value, { itemSelector: 'button' })
}
}
// Handle registration actions keynav
function handleRegActionsKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') {
// Move to user info edit button
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
} else if (direction === 'down') {
// Move to credential list
credentialListRef.value?.$el?.focus()
}
}
// Handle credential list navigate out
function handleCredentialNavigateOut(direction) {
if (hasActiveModal.value || props.navigationDisabled) return
if (direction === 'up') {
focusPreferred(regActionsRef.value, { itemSelector: 'button' })
} else if (direction === 'down') {
// Move to session list
focusAtIndex(sessionListRef.value?.$el, 0, { itemSelector: '.session-group' })
}
}
// Handle session list navigate out
function handleSessionNavigateOut(direction) {
if (hasActiveModal.value || props.navigationDisabled) return
if (direction === 'up') {
// Move to credential list
credentialListRef.value?.$el?.focus()
} else if (direction === 'down') {
// Move to back button
const backBtn = backButtonRef.value?.querySelector('button')
if (backBtn) backBtn.focus()
}
}
// Handle back button keynav
function handleBackButtonKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'up') {
// Move to session list
focusAtIndex(sessionListRef.value?.$el, -1, { itemSelector: '.session-group' })
}
}
// Focus helper for external navigation
function focusFirstElement() {
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="user-detail">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
<div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.display_name || selectedUser.display_name"
:visits="userDetail.visits"
:created-at="userDetail.created_at"
:last-seen="userDetail.last_seen"
:loading="loading"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
/>
</div>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error">
<div class="registration-actions">
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
<button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
@@ -106,27 +206,33 @@ async function handleTerminateSession(session) {
</div>
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="userDetail.credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
:navigation-disabled="hasActiveModal"
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@navigate-out="handleCredentialNavigateOut"
/>
</div>
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:empty-message="'This user has no active sessions.'"
:section-description="'View and manage the active sessions for this user.'"
@terminate="handleTerminateSession"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
/>
</template>
<div class="actions ancillary-actions">
<div class="actions ancillary-actions" ref="backButtonRef" @keydown="handleBackButtonKeydown">
<button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org"></button>
</div>
<RegistrationLinkModal
+59 -3
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
// Props:
// entries: Array<{ label:string, href:string }>
@@ -11,17 +12,72 @@ const props = defineProps({
homeHref: { type: String, default: '/' }
})
const navRef = ref(null)
const crumbs = computed(() => {
const base = props.showHome ? [{ label: '🏠', href: props.homeHref }] : []
return [...base, ...props.entries]
})
// Find the index of the crumb matching current location
const currentIndex = computed(() => {
const currentHref = window.location.hash || window.location.pathname
for (let i = crumbs.value.length - 1; i >= 0; i--) {
const href = crumbs.value[i].href
if (href === currentHref || (href && currentHref.startsWith(href))) {
return i
}
}
return crumbs.value.length - 1 // Default to last crumb
})
function handleFocusIn(event) {
// When the nav receives focus, focus the current page's crumb
if (event.target === navRef.value) {
const links = navRef.value.querySelectorAll('a')
const targetIndex = Math.min(currentIndex.value, links.length - 1)
if (links[targetIndex]) {
links[targetIndex].focus()
}
}
}
function handleKeydown(event) {
const direction = getDirection(event)
if (!direction) return
if (direction === 'left' || direction === 'right') {
event.preventDefault()
navigateButtonRow(navRef.value, event.target, direction, { itemSelector: 'a' })
}
// Up/down are handled by parent component
}
// Expose method to focus the current crumb from parent
function focusCurrent() {
const links = navRef.value?.querySelectorAll('a')
if (links?.length) {
const targetIndex = Math.min(currentIndex.value, links.length - 1)
links[targetIndex]?.focus()
}
}
defineExpose({ focusCurrent })
</script>
<template>
<nav class="breadcrumbs" aria-label="Breadcrumb" v-if="crumbs.length">
<nav
ref="navRef"
class="breadcrumbs"
aria-label="Breadcrumb"
v-if="crumbs.length"
tabindex="0"
@focusin="handleFocusIn"
@keydown="handleKeydown"
>
<ol>
<li v-for="(c, idx) in crumbs" :key="idx">
<a :href="c.href">{{ c.label }}</a>
<a :href="c.href" tabindex="-1">{{ c.label }}</a>
<span v-if="idx < crumbs.length - 1" class="sep"> </span>
</li>
</ol>
+6 -10
View File
@@ -38,8 +38,7 @@
<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
@@ -197,12 +196,9 @@ const handleBreadcrumbKeydown = (event) => {
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
const container = breadcrumbs.value?.$el
if (direction === 'left' || direction === 'right') {
navigateButtonRow(container, event.target, direction, { itemSelector: 'a' })
} else if (direction === 'down') {
// 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' })
}
@@ -221,8 +217,8 @@ const handleUserInfoKeydown = (event) => {
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
} else if (direction === 'up') {
// Move to breadcrumbs - always focus "Auth" (index 1)
focusAtIndex(breadcrumbs.value?.$el, 1, { itemSelector: 'a' })
// Move to breadcrumbs - focus current page crumb
breadcrumbs.value?.focusCurrent?.()
} else if (direction === 'down') {
// Move to credential list
credentialList.value?.$el?.focus()
+6 -1
View File
@@ -27,6 +27,11 @@ const DIRECTION_MAP = {
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.
@@ -39,7 +44,7 @@ export const getDirection = (event) => {
// For text inputs, preserve left/right for cursor movement when there's content
const target = event.target
const isTextInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'
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