Implement keyboard navigation using arrow keys in the whole application. #2
@@ -3,7 +3,9 @@ import '@/assets/style.css'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import AdminApp from './AdminApp.vue'
|
import AdminApp from './AdminApp.vue'
|
||||||
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
const app = createApp(AdminApp)
|
const app = createApp(AdminApp)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.mount('#admin-app')
|
app.mount('#admin-app')
|
||||||
|
initKeyboardNavigation()
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import '@/assets/style.css'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
initKeyboardNavigation()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import RestrictedApi from './RestrictedApi.vue'
|
import RestrictedApi from './RestrictedApi.vue'
|
||||||
import '@/assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
createApp(RestrictedApi).mount('#app')
|
createApp(RestrictedApi).mount('#app')
|
||||||
|
initKeyboardNavigation()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './RestrictedForward.vue'
|
import App from './RestrictedForward.vue'
|
||||||
import '@/assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
initKeyboardNavigation()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<section class="section-block" v-else-if="!canRegister">
|
<section class="section-block" v-else-if="!canRegister">
|
||||||
<div class="section-body center">
|
<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>
|
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,6 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import passkey from '@/utils/passkey'
|
import passkey from '@/utils/passkey'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
|
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||||
import { handleButtonKeydown } from '@/utils/keynav'
|
|
||||||
|
|
||||||
const status = reactive({
|
const status = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import ResetApp from './ResetApp.vue'
|
import ResetApp from './ResetApp.vue'
|
||||||
import '@/assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||||
|
|
||||||
createApp(ResetApp).mount('#app')
|
createApp(ResetApp).mount('#app')
|
||||||
|
initKeyboardNavigation()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import NameEditForm from '@/components/NameEditForm.vue'
|
import NameEditForm from '@/components/NameEditForm.vue'
|
||||||
import { handleButtonKeydown } from '@/utils/keynav'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dialog: Object,
|
dialog: Object,
|
||||||
@@ -82,7 +81,7 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
|
|||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<h2>🔒 Access Denied</h2>
|
<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-secondary" @click="goBack">Back</button>
|
||||||
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
import { handleButtonKeydown } from '@/utils/keynav'
|
|
||||||
|
|
||||||
defineEmits(['reload'])
|
defineEmits(['reload'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<h1>📱 Add Another Device</h1>
|
<h1>📱 Add Another Device</h1>
|
||||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||||
</header>
|
</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="showModal = true" class="btn-primary">Generate Registration Link</button>
|
||||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||||
import { handleButtonKeydown } from '@/utils/keynav'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userName = ref(null)
|
const userName = ref(null)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<section class="section-block">
|
<section class="section-block">
|
||||||
<div class="section-body host-actions">
|
<div class="section-body host-actions">
|
||||||
<div class="button-row" @keydown="handleButtonKeydown">
|
<div class="button-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -62,7 +62,6 @@ import { computed } from 'vue'
|
|||||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
import { handleButtonKeydown } from '@/utils/keynav'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
initializing: {
|
initializing: {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { handleButtonKeydown, getDirection } from '@/utils/keynav'
|
import { getDirection } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -69,7 +69,7 @@ const handleActionsKeydown = (event) => {
|
|||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleButtonKeydown(event)
|
// Left/right navigation is handled globally by keynav
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -91,7 +91,6 @@ import { getSettings } from '@/utils/settings'
|
|||||||
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
|
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
|
||||||
import { solvePoW } from '@/utils/pow'
|
import { solvePoW } from '@/utils/pow'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { handleButtonKeydown } from '@/utils/keynav'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: { type: String, default: 'Help Another Device Sign In' },
|
title: { type: String, default: 'Help Another Device Sign In' },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<!-- Local passkey authentication view -->
|
<!-- Local passkey authentication view -->
|
||||||
<div v-if="authView === 'local'" class="auth-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"
|
<slot name="actions"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:can-authenticate="canAuthenticate"
|
:can-authenticate="canAuthenticate"
|
||||||
@@ -60,7 +60,7 @@ import passkey from '@/utils/passkey'
|
|||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||||
import { handleButtonKeydown, focusDialogButton } from '@/utils/keynav'
|
import { focusDialogButton } from '@/utils/keynav'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
* - data-nav-group: Marks a navigation group container
|
* - data-nav-group: Marks a navigation group container
|
||||||
* - data-nav-primary: Marks the preferred element to focus when entering a group
|
* - 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])')
|
* - 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
|
// Direction mapping from key events
|
||||||
@@ -406,3 +411,64 @@ export const installKeyboardNav = (container, options = {}) => {
|
|||||||
|
|
||||||
return () => container.removeEventListener('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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user