Implement keyboard navigation using arrow keys in the whole application. #2
@@ -443,6 +443,7 @@ th {
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
margin: 0 auto;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -480,6 +481,11 @@ th {
|
||||
.credential-item.is-linked-session,
|
||||
.session-item.is-linked-credential { border-color: var(--color-accent); background-color: var(--color-surface-subtle); }
|
||||
|
||||
.credential-item:focus,
|
||||
.session-item:focus {
|
||||
outline: 1px solid var(-webkit-focus-ring-color);
|
||||
}
|
||||
|
||||
.item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -559,7 +565,7 @@ th {
|
||||
position: relative;
|
||||
}
|
||||
.session-group:focus-visible {
|
||||
outline: var(-webkit-focus-ring-color) 1px solid;
|
||||
outline: 1px solid var(-webkit-focus-ring-color);
|
||||
}
|
||||
.session-group-host {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="credential-list">
|
||||
<div class="credential-list" tabindex="0" @focusin="handleListFocus" @keydown="handleListKeydown">
|
||||
<div v-if="loading"><p>Loading credentials...</p></div>
|
||||
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
|
||||
<template v-else>
|
||||
@@ -11,12 +11,12 @@
|
||||
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
|
||||
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
|
||||
}]"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click.capture="handleCardClick"
|
||||
@focusin="handleCredentialFocus(credential.credential_uuid)"
|
||||
@focusout="handleCredentialBlur($event)"
|
||||
@keydown="handleDelete($event, credential)"
|
||||
@keydown="handleItemKeydown($event, credential)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<div class="item-icon">
|
||||
@@ -70,9 +70,10 @@ const props = defineProps({
|
||||
allowDelete: { type: Boolean, default: false },
|
||||
hoveredCredentialUuid: { type: String, default: null },
|
||||
hoveredSessionCredentialUuid: { type: String, default: null },
|
||||
navigationDisabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['delete', 'credentialHover'])
|
||||
const emit = defineEmits(['delete', 'credentialHover', 'navigate-out'])
|
||||
|
||||
const handleCredentialFocus = (uuid) => {
|
||||
emit('credentialHover', uuid)
|
||||
@@ -100,6 +101,115 @@ const handleDelete = (event, credential) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Grid navigation helpers
|
||||
const getGridInfo = (container) => {
|
||||
const items = Array.from(container.querySelectorAll('.credential-item'))
|
||||
if (items.length === 0) return null
|
||||
|
||||
// Calculate columns by checking which items share the same top position
|
||||
const firstTop = items[0].getBoundingClientRect().top
|
||||
let cols = 0
|
||||
for (const item of items) {
|
||||
if (Math.abs(item.getBoundingClientRect().top - firstTop) < 5) cols++
|
||||
else break
|
||||
}
|
||||
return { items, cols: Math.max(1, cols) }
|
||||
}
|
||||
|
||||
const navigateGrid = (container, currentItem, direction) => {
|
||||
const grid = getGridInfo(container)
|
||||
if (!grid) return
|
||||
|
||||
const { items, cols } = grid
|
||||
const currentIndex = items.indexOf(currentItem)
|
||||
if (currentIndex === -1) return
|
||||
|
||||
const row = Math.floor(currentIndex / cols)
|
||||
const col = currentIndex % cols
|
||||
|
||||
let newIndex = currentIndex
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
if (col === 0) {
|
||||
emit('navigate-out', 'left')
|
||||
return
|
||||
}
|
||||
newIndex = currentIndex - 1
|
||||
break
|
||||
case 'right':
|
||||
if (currentIndex >= items.length - 1) {
|
||||
emit('navigate-out', 'right')
|
||||
return
|
||||
}
|
||||
newIndex = currentIndex + 1
|
||||
break
|
||||
case 'up':
|
||||
if (row === 0) {
|
||||
emit('navigate-out', 'up')
|
||||
return
|
||||
}
|
||||
newIndex = currentIndex - cols
|
||||
break
|
||||
case 'down':
|
||||
if (currentIndex + cols >= items.length) {
|
||||
emit('navigate-out', 'down')
|
||||
return
|
||||
}
|
||||
newIndex = currentIndex + cols
|
||||
break
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
items[newIndex].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleListFocus = (event) => {
|
||||
if (props.navigationDisabled) return
|
||||
|
||||
const list = event.currentTarget
|
||||
// If focus came to the list container itself (not a child), focus first item
|
||||
if (event.target === list) {
|
||||
const firstItem = list.querySelector('.credential-item')
|
||||
if (firstItem) {
|
||||
firstItem.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleListKeydown = (event) => {
|
||||
if (props.navigationDisabled) return
|
||||
|
||||
// Escape emits navigate-out
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('navigate-out', 'up')
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemKeydown = (event, credential) => {
|
||||
// Handle delete (always allowed even with modal)
|
||||
handleDelete(event, credential)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (props.navigationDisabled) return
|
||||
|
||||
// Arrow key navigation
|
||||
const directionMap = {
|
||||
'ArrowLeft': 'left',
|
||||
'ArrowRight': 'right',
|
||||
'ArrowUp': 'up',
|
||||
'ArrowDown': 'down'
|
||||
}
|
||||
|
||||
const direction = directionMap[event.key]
|
||||
if (direction) {
|
||||
event.preventDefault()
|
||||
const list = event.currentTarget.closest('.credential-list')
|
||||
navigateGrid(list, event.currentTarget, direction)
|
||||
}
|
||||
}
|
||||
|
||||
const getCredentialAuthName = (credential) => {
|
||||
const info = props.aaguidInfo?.[credential.aaguid]
|
||||
return info ? info.name : 'Unknown Authenticator'
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<section class="view-root" data-view="profile">
|
||||
<header class="view-header">
|
||||
<h1>User Profile</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<section class="section-block" ref="userInfoSection">
|
||||
<UserBasicInfo
|
||||
v-if="authStore.userInfo?.user"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.user.user_name"
|
||||
:visits="authStore.userInfo.user.visits || 0"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
@@ -17,6 +18,7 @@
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
<div class="remote-auth-inline">
|
||||
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words:</label>
|
||||
@@ -41,28 +43,34 @@
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
ref="credentialList"
|
||||
:credentials="authStore.userInfo?.credentials || []"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
allow-delete
|
||||
@delete="handleDelete"
|
||||
@credential-hover="hoveredCredentialUuid = $event"
|
||||
@navigate-out="handleCredentialNavigateOut"
|
||||
/>
|
||||
<div class="button-row">
|
||||
<button @click="addNewCredential" class="btn-primary">Register New</button>
|
||||
<button @click="showRegLink = true" class="btn-secondary">Another Device</button>
|
||||
<div class="button-row" ref="credentialButtons">
|
||||
<button @click="addNewCredential" class="btn-primary" @keydown="handleCredentialButtonKeydown">Register New</button>
|
||||
<button @click="showRegLink = true" class="btn-secondary" @keydown="handleCredentialButtonKeydown">Another Device</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SessionList
|
||||
ref="sessionList"
|
||||
:sessions="sessions"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@terminate="terminateSession"
|
||||
@session-hover="hoveredSession = $event"
|
||||
@navigate-out="handleSessionNavigateOut"
|
||||
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
||||
/>
|
||||
|
||||
@@ -79,18 +87,19 @@
|
||||
</Modal>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="button-row">
|
||||
<div class="button-row" ref="logoutButtons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="goBack"
|
||||
@keydown="handleLogoutButtonKeydown"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading">Logout</button>
|
||||
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
|
||||
<template v-else>
|
||||
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading">Logout</button>
|
||||
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading">All</button>
|
||||
<button @click="logout" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">Logout</button>
|
||||
<button @click="logoutEverywhere" class="btn-danger" :disabled="authStore.isLoading" @keydown="handleLogoutButtonKeydown">All</button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||
@@ -105,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
@@ -130,11 +139,26 @@ const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showDeviceInfo = ref(false)
|
||||
const pairingEntry = ref(null)
|
||||
const credentialList = ref(null)
|
||||
const credentialButtons = ref(null)
|
||||
const sessionList = ref(null)
|
||||
const logoutButtons = ref(null)
|
||||
const breadcrumbs = ref(null)
|
||||
const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
||||
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
// Autofocus the "Auth" breadcrumb (second link after home)
|
||||
nextTick(() => {
|
||||
const links = breadcrumbs.value?.$el?.querySelectorAll('a')
|
||||
links?.[1]?.focus() // Index 1 is "Auth" (after home icon)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||
@@ -165,6 +189,155 @@ const handlePairingError = (message) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to focus preferred button in a row (primary first, or first button)
|
||||
const focusPreferredButton = (container) => {
|
||||
const primary = container?.querySelector('.btn-primary')
|
||||
const first = container?.querySelector('button')
|
||||
;(primary || first)?.focus()
|
||||
}
|
||||
|
||||
// Navigation between components
|
||||
const handleBreadcrumbKeydown = (event) => {
|
||||
if (hasActiveModal.value) return // Block navigation when modal is open
|
||||
|
||||
const links = Array.from(breadcrumbs.value?.$el?.querySelectorAll('a') || [])
|
||||
const currentIndex = links.indexOf(event.target)
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
if (currentIndex < links.length - 1) {
|
||||
links[currentIndex + 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
links[currentIndex - 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
// Move to user info section - always focus edit button first
|
||||
const editBtn = userInfoSection.value?.querySelector('.mini-btn')
|
||||
const codeInput = userInfoSection.value?.querySelector('.pairing-input')
|
||||
;(editBtn || codeInput)?.focus()
|
||||
}
|
||||
// ArrowUp at the top does nothing
|
||||
}
|
||||
|
||||
// Get focusable elements in user info section
|
||||
const getUserInfoFocusables = () => {
|
||||
const editBtn = userInfoSection.value?.querySelector('.mini-btn')
|
||||
const codeInput = userInfoSection.value?.querySelector('.pairing-input')
|
||||
return [editBtn, codeInput].filter(Boolean)
|
||||
}
|
||||
|
||||
const handleUserInfoKeydown = (event) => {
|
||||
if (hasActiveModal.value) return // Block navigation when modal is open
|
||||
|
||||
const focusables = getUserInfoFocusables()
|
||||
const currentIndex = focusables.indexOf(event.target)
|
||||
|
||||
if (currentIndex === -1) return // Not on a focusable element
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
if (currentIndex < focusables.length - 1) {
|
||||
focusables[currentIndex + 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
focusables[currentIndex - 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Move to breadcrumbs - always focus "Auth" (index 1)
|
||||
const links = breadcrumbs.value?.$el?.querySelectorAll('a')
|
||||
links?.[1]?.focus()
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
// Move to credential list
|
||||
credentialList.value?.$el?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCredentialNavigateOut = (direction) => {
|
||||
if (hasActiveModal.value) return // Block navigation when modal is open
|
||||
|
||||
if (direction === 'down' || direction === 'right') {
|
||||
// Focus preferred button in credential section
|
||||
focusPreferredButton(credentialButtons.value)
|
||||
} else if (direction === 'up' || direction === 'left') {
|
||||
// Focus user info section - always focus edit button first
|
||||
const editBtn = userInfoSection.value?.querySelector('.mini-btn')
|
||||
const codeInput = userInfoSection.value?.querySelector('.pairing-input')
|
||||
;(editBtn || codeInput)?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCredentialButtonKeydown = (event) => {
|
||||
if (hasActiveModal.value) return // Block navigation when modal is open
|
||||
|
||||
const buttons = Array.from(credentialButtons.value?.querySelectorAll('button') || [])
|
||||
const currentIndex = buttons.indexOf(event.target)
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
if (currentIndex < buttons.length - 1) {
|
||||
buttons[currentIndex + 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
buttons[currentIndex - 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Move back to credential list
|
||||
credentialList.value?.$el?.querySelector('.credential-item')?.focus()
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
// Move to session list
|
||||
sessionList.value?.$el?.querySelector('.session-group')?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSessionNavigateOut = (direction) => {
|
||||
if (hasActiveModal.value) return // Block navigation when modal is open
|
||||
|
||||
if (direction === 'up') {
|
||||
// Focus preferred button in credential section
|
||||
focusPreferredButton(credentialButtons.value)
|
||||
} else if (direction === 'down') {
|
||||
// Focus preferred button in logout section
|
||||
focusPreferredButton(logoutButtons.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoutButtonKeydown = (event) => {
|
||||
if (hasActiveModal.value) return // Block navigation when modal is open
|
||||
|
||||
const buttons = Array.from(logoutButtons.value?.querySelectorAll('button') || [])
|
||||
const currentIndex = buttons.indexOf(event.target)
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
if (currentIndex < buttons.length - 1) {
|
||||
buttons[currentIndex + 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
buttons[currentIndex - 1].focus()
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Move back to session list
|
||||
const groups = sessionList.value?.$el?.querySelectorAll('.session-group')
|
||||
groups?.[groups.length - 1]?.focus()
|
||||
}
|
||||
// ArrowDown at the bottom does nothing
|
||||
}
|
||||
|
||||
const handleDelete = async (credential) => {
|
||||
const credentialId = credential?.credential_uuid
|
||||
if (!credentialId) return
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="section-body">
|
||||
<div>
|
||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown.enter="handleGroupEnter($event, host)">
|
||||
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
|
||||
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||
<span class="session-group-icon">🌐</span>
|
||||
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
|
||||
@@ -22,12 +22,12 @@
|
||||
'is-hovered': hoveredSession?.id === session.id,
|
||||
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
|
||||
}]"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click.capture="handleCardClick"
|
||||
@focusin="handleSessionFocus(session)"
|
||||
@focusout="handleSessionBlur($event)"
|
||||
@keydown="handleDelete($event, session)"
|
||||
@keydown="handleItemKeydown($event, session)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<h4 class="item-title">{{ session.user_agent }}</h4>
|
||||
@@ -73,9 +73,10 @@ const props = defineProps({
|
||||
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||
terminatingSessions: { type: Object, default: () => ({}) },
|
||||
hoveredCredentialUuid: { type: String, default: null },
|
||||
navigationDisabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['terminate', 'sessionHover'])
|
||||
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -114,9 +115,157 @@ const handleDelete = (event, session) => {
|
||||
|
||||
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
||||
|
||||
const handleGroupEnter = (event, host) => {
|
||||
if (host && event.target === event.currentTarget) {
|
||||
event.currentTarget.querySelector('a')?.click()
|
||||
// Grid navigation helpers
|
||||
const getGridInfo = (container) => {
|
||||
const items = Array.from(container.querySelectorAll('.session-item'))
|
||||
if (items.length === 0) return null
|
||||
|
||||
// Calculate columns by checking which items share the same top position
|
||||
const firstTop = items[0].getBoundingClientRect().top
|
||||
let cols = 0
|
||||
for (const item of items) {
|
||||
if (Math.abs(item.getBoundingClientRect().top - firstTop) < 5) cols++
|
||||
else break
|
||||
}
|
||||
return { items, cols: Math.max(1, cols) }
|
||||
}
|
||||
|
||||
const navigateGrid = (container, currentItem, direction, group) => {
|
||||
const grid = getGridInfo(container)
|
||||
if (!grid) return 'none'
|
||||
|
||||
const { items, cols } = grid
|
||||
const currentIndex = items.indexOf(currentItem)
|
||||
if (currentIndex === -1) return 'none'
|
||||
|
||||
const row = Math.floor(currentIndex / cols)
|
||||
const col = currentIndex % cols
|
||||
|
||||
let newIndex = currentIndex
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
if (col === 0) {
|
||||
// At left edge, focus group
|
||||
group?.focus()
|
||||
return 'group'
|
||||
}
|
||||
newIndex = currentIndex - 1
|
||||
break
|
||||
case 'right':
|
||||
if (currentIndex >= items.length - 1) {
|
||||
return 'boundary'
|
||||
}
|
||||
newIndex = currentIndex + 1
|
||||
break
|
||||
case 'up':
|
||||
if (row === 0) {
|
||||
// At top row, focus group
|
||||
group?.focus()
|
||||
return 'group'
|
||||
}
|
||||
newIndex = currentIndex - cols
|
||||
break
|
||||
case 'down':
|
||||
if (currentIndex + cols >= items.length) {
|
||||
return 'boundary'
|
||||
}
|
||||
newIndex = currentIndex + cols
|
||||
break
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
items[newIndex].focus()
|
||||
return 'moved'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const handleGroupKeydown = (event, host) => {
|
||||
const group = event.currentTarget
|
||||
const sessionList = group.querySelector('.session-list')
|
||||
const items = sessionList?.querySelectorAll('.session-item')
|
||||
const allGroups = Array.from(document.querySelectorAll('.session-group'))
|
||||
const groupIndex = allGroups.indexOf(group)
|
||||
|
||||
// Enter on group header opens link (always allowed)
|
||||
if (event.key === 'Enter' && event.target === group) {
|
||||
if (host) group.querySelector('a')?.click()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.navigationDisabled) return
|
||||
|
||||
// Arrow keys to enter the grid from the group
|
||||
if (['ArrowDown', 'ArrowRight'].includes(event.key) && event.target === group) {
|
||||
event.preventDefault()
|
||||
items?.[0]?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Up/Left from group navigates to previous group or out
|
||||
if (['ArrowUp', 'ArrowLeft'].includes(event.key) && event.target === group) {
|
||||
event.preventDefault()
|
||||
if (groupIndex > 0) {
|
||||
allGroups[groupIndex - 1].focus()
|
||||
} else {
|
||||
emit('navigate-out', 'up')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Escape emits navigate-out
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('navigate-out', 'up')
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemKeydown = (event, session) => {
|
||||
// Handle delete (always allowed even with modal)
|
||||
const apple = navigator.userAgent.includes('Mac OS')
|
||||
if (event.key === 'Delete' || apple && event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
if (!isTerminating(session.id)) emit('terminate', session)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.navigationDisabled) return
|
||||
|
||||
// Arrow key navigation
|
||||
const directionMap = {
|
||||
'ArrowLeft': 'left',
|
||||
'ArrowRight': 'right',
|
||||
'ArrowUp': 'up',
|
||||
'ArrowDown': 'down'
|
||||
}
|
||||
|
||||
const direction = directionMap[event.key]
|
||||
if (direction) {
|
||||
event.preventDefault()
|
||||
const group = event.currentTarget.closest('.session-group')
|
||||
const sessionList = group.querySelector('.session-list')
|
||||
const result = navigateGrid(sessionList, event.currentTarget, direction, group)
|
||||
|
||||
// If at boundary, try to navigate to next/prev group or emit navigate-out
|
||||
if (result === 'boundary') {
|
||||
const allGroups = Array.from(document.querySelectorAll('.session-group'))
|
||||
const groupIndex = allGroups.indexOf(group)
|
||||
|
||||
if (direction === 'down' || direction === 'right') {
|
||||
if (groupIndex < allGroups.length - 1) {
|
||||
// Move to next group
|
||||
allGroups[groupIndex + 1].focus()
|
||||
} else {
|
||||
emit('navigate-out', 'down')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escape focuses the group
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.currentTarget.closest('.session-group')?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user