From 24877596286eea3dec8a397b236aca68d3ba19d1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 10 Dec 2025 15:43:40 +0000 Subject: [PATCH] Implement keyboard navigation using arrow keys in the whole application. (#2) Reviewed-on: https://git.zi.fi/LeoVasanko/paskia/pulls/2 --- frontend/auth/admin/AdminApp.vue | 125 ++++- frontend/auth/admin/main.js | 2 + frontend/auth/main.js | 2 + frontend/auth/restricted/main.js | 2 + frontend/int/forward/main.js | 2 + frontend/int/reset/main.js | 2 + frontend/src/admin/AdminDialogs.vue | 21 +- frontend/src/admin/AdminOrgDetail.vue | 278 +++++++++- frontend/src/admin/AdminOverview.vue | 234 ++++++++- frontend/src/admin/AdminUserDetail.vue | 144 +++++- frontend/src/assets/style.css | 15 +- frontend/src/components/Breadcrumbs.vue | 62 ++- frontend/src/components/CredentialList.vue | 53 +- frontend/src/components/HostProfileView.vue | 25 +- frontend/src/components/Modal.vue | 125 ++++- frontend/src/components/NameEditForm.vue | 25 +- frontend/src/components/ProfileView.vue | 142 +++++- frontend/src/components/QRCodeDisplay.vue | 2 +- .../src/components/RegistrationLinkModal.vue | 51 +- frontend/src/components/RestrictedAuth.vue | 13 +- frontend/src/components/SessionList.vue | 99 +++- frontend/src/utils/keynav.js | 479 ++++++++++++++++++ frontend/src/utils/passkey.js | 16 +- 23 files changed, 1785 insertions(+), 134 deletions(-) create mode 100644 frontend/src/utils/keynav.js diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index ab436ca..1479d31 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -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() {

{{ pageHeading }}

- +
@@ -492,9 +602,11 @@ async function submitDialog() {
diff --git a/frontend/auth/admin/main.js b/frontend/auth/admin/main.js index c8848b8..f032833 100644 --- a/frontend/auth/admin/main.js +++ b/frontend/auth/admin/main.js @@ -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() diff --git a/frontend/auth/main.js b/frontend/auth/main.js index 231bb07..e9f238f 100644 --- a/frontend/auth/main.js +++ b/frontend/auth/main.js @@ -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() diff --git a/frontend/auth/restricted/main.js b/frontend/auth/restricted/main.js index ed6ba37..e1c87fa 100644 --- a/frontend/auth/restricted/main.js +++ b/frontend/auth/restricted/main.js @@ -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() diff --git a/frontend/int/forward/main.js b/frontend/int/forward/main.js index 49f5891..c300560 100644 --- a/frontend/int/forward/main.js +++ b/frontend/int/forward/main.js @@ -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() diff --git a/frontend/int/reset/main.js b/frontend/int/reset/main.js index 51f6e05..48a96ef 100644 --- a/frontend/int/reset/main.js +++ b/frontend/int/reset/main.js @@ -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() diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 17a079f..a03504d 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -1,5 +1,4 @@