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
14 changed files with 86 additions and 16 deletions
Showing only changes of commit 048b4c6ff1 - Show all commits
+2
View File
@@ -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()
+2
View File
@@ -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()
+2
View File
@@ -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()
+2
View File
@@ -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 -2
View File
@@ -23,7 +23,7 @@
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<div class="button-row center" style="justify-content: center;" @keydown="handleButtonKeydown">
<div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
@@ -60,7 +60,6 @@ import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
import { handleButtonKeydown } from '@/utils/keynav'
const status = reactive({
show: false,
+2
View File
@@ -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 -2
View File
@@ -1,7 +1,6 @@
<script setup>
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import { handleButtonKeydown } from '@/utils/keynav'
const props = defineProps({
dialog: Object,
@@ -82,7 +81,7 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions" @keydown="handleButtonKeydown">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
+1 -2
View File
@@ -2,7 +2,7 @@
<div class="message-container">
<div class="message-content">
<h2>🔒 Access Denied</h2>
<div class="button-row" @keydown="handleButtonKeydown">
<div class="button-row">
<button class="btn-secondary" @click="goBack">Back</button>
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
</div>
@@ -12,7 +12,6 @@
<script setup>
import { goBack } from '@/utils/helpers'
import { handleButtonKeydown } from '@/utils/keynav'
defineEmits(['reload'])
</script>
+1 -2
View File
@@ -4,7 +4,7 @@
<h1>📱 Add Another Device</h1>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
</header>
<div class="button-row" style="margin-top:1rem;" @keydown="handleButtonKeydown">
<div class="button-row" style="margin-top:1rem;">
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</div>
@@ -22,7 +22,6 @@
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { handleButtonKeydown } from '@/utils/keynav'
const authStore = useAuthStore()
const userName = ref(null)
+1 -2
View File
@@ -25,7 +25,7 @@
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row" @keydown="handleButtonKeydown">
<div class="button-row">
<button
type="button"
class="btn-secondary"
@@ -62,7 +62,6 @@ import { computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
import { goBack } from '@/utils/helpers'
import { handleButtonKeydown } from '@/utils/keynav'
defineProps({
initializing: {
+2 -2
View File
@@ -35,7 +35,7 @@
<script setup>
import { computed, ref } from 'vue'
import { handleButtonKeydown, getDirection } from '@/utils/keynav'
import { getDirection } from '@/utils/keynav'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -69,7 +69,7 @@ const handleActionsKeydown = (event) => {
inputRef.value?.focus()
return
}
handleButtonKeydown(event)
// Left/right navigation is handled globally by keynav
}
function handleCancel() {
+1 -2
View File
@@ -57,7 +57,7 @@
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;" @keydown="handleButtonKeydown">
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-secondary"
@@ -91,7 +91,6 @@ import { getSettings } from '@/utils/settings'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
import { useAuthStore } from '@/stores/auth'
import { handleButtonKeydown } from '@/utils/keynav'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
+2 -2
View File
@@ -18,7 +18,7 @@
<div class="section-body center">
<!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view">
<div class="button-row center" ref="buttonRow" @keydown="handleButtonKeydown">
<div class="button-row center" ref="buttonRow">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
@@ -60,7 +60,7 @@ import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { handleButtonKeydown, focusDialogButton } from '@/utils/keynav'
import { focusDialogButton } from '@/utils/keynav'
const props = defineProps({
mode: {
+66
View File
@@ -12,6 +12,11 @@
* - 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
@@ -406,3 +411,64 @@ export const installKeyboardNav = (container, options = {}) => {
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
}