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()
|
||||
|
||||
Reference in New Issue
Block a user