archy/neode-ui/src/composables/useControllerNav.ts
archipelago 92d221bbf1
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m42s
fix(ui): show the controller focus ring only during actual arrow/gamepad navigation
The orange "gamepad selection" lift+glow was plain CSS :focus styling on
[data-controller-container] — any tap on a tabindex card lit it up (worst
on mobile), and useControllerNav's autoFocusMain programmatically focused
the first card on mount and every route change, painting the ring with no
controller input at all.

Gate it behind html.controller-nav, toggled by actual input modality:
arrow-key navigation and gamepad connection turn it on, any pointerdown
turns it off. autoFocusMain now no-ops outside directional-nav mode (a
first arrow press focuses the first container instead of spatially
navigating from <body>), so pointer users never get surprise rings while
keyboard/gamepad users keep the exact same navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:38:46 -04:00

731 lines
30 KiB
TypeScript

/**
* Controller / gamepad navigation for Archipelago.
*
* Navigation model (from the design spec):
*
* SIDEBAR (vertical list):
* Up/Down = move between items, wraps top↔bottom, auto-navigates
* Right = jump to first container in main content
* Left = does nothing
*
* MAIN CONTENT (container tile grid):
* Arrows = move between containers spatially (the red tile grid)
* Enter = trigger container's primary action (navigate link / launch)
* Escape = back to sidebar
* Left from leftmost container = back to sidebar
*
* INSIDE CONTAINER (yellow inner controls — entered via second Enter):
* Arrows = move between inner controls spatially
* Escape = exit back to the container tile
* Cannot move to other containers without exiting first
*
* TEXT INPUTS:
* Up/Down = exit field, navigate to nearest element
* Enter = submit (click next button)
* Left/Right = cursor movement (stay in field)
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useControllerStore } from '@/stores/controller'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { playNavSound } from '@/composables/useNavSounds'
// ─── Element Queries ────────────────────────────────────────────
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[data-controller-focus]',
'[data-controller-container]',
].join(', ')
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
el =>
!el.hasAttribute('disabled') &&
el.offsetParent !== null &&
!el.hasAttribute('data-controller-ignore') &&
!el.closest('[data-controller-ignore]')
)
}
/** Sidebar items */
function getSidebarElements(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
return zone ? getFocusableElements(zone) : []
}
/** Main zone containers only — the [C] tile grid */
function getContainers(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]')).filter(
el => el.offsetParent !== null
)
}
/** Nav bar items [N] — focusable elements in main zone that are NOT inside any container
* (mode-switcher buttons, tab buttons, search inputs above the grid) */
function getNavBarItems(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return getFocusableElements(zone).filter(el =>
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
)
}
function isNavBarItem(el: HTMLElement | null): boolean {
if (!el) return false
if (!isInZone(el, 'main')) return false
if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false
// On container-free pages (e.g. Settings), don't classify elements as nav bar items —
// let them fall through to the main zone handler which supports linear up/down/right nav.
const zone = document.querySelector('[data-controller-zone="main"]')
if (zone && !zone.querySelector('[data-controller-container]')) return false
return true
}
/** Inner focusables within a container (buttons, links — not the container itself) */
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
return getFocusableElements(container).filter(
el => el !== container && !el.hasAttribute('data-controller-container')
)
}
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
if (!el) return false
return !!el.closest(`[data-controller-zone="${zone}"]`)
}
function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false
const container = el.closest('[data-controller-container]')
return !!container && container !== el
}
function isContainer(el: HTMLElement | null): boolean {
return !!el?.hasAttribute('data-controller-container')
}
// ─── Spatial Navigation ─────────────────────────────────────────
function findNearestInDirection(
from: HTMLElement,
candidates: HTMLElement[],
direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null {
const fromRect = from.getBoundingClientRect()
const fromCX = fromRect.left + fromRect.width / 2
const fromCY = fromRect.top + fromRect.height / 2
const threshold = 50
const filtered = candidates.filter(el => {
if (el === from) return false
const r = el.getBoundingClientRect()
switch (direction) {
case 'left': return r.right <= fromRect.left + threshold
case 'right': return r.left >= fromRect.right - threshold
case 'up': return r.bottom <= fromRect.top + threshold
case 'down': return r.top >= fromRect.bottom - threshold
}
})
if (!filtered.length) return null
const scored = filtered.map(el => {
const r = el.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const isVertical = direction === 'up' || direction === 'down'
const overlap = isVertical
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
return { el, overlap, dist }
})
scored.sort((a, b) => {
const isVertical = direction === 'up' || direction === 'down'
// For vertical nav: prefer closest element first, use overlap as tiebreaker.
// This prevents a distant full-width element from winning over a closer narrow one.
if (isVertical) {
// Both have overlap — prefer closer distance
if (a.overlap > 0 && b.overlap > 0) {
if (a.dist !== b.dist) return a.dist - b.dist
if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
}
// One has overlap, the other doesn't — prefer the one with overlap
if (a.overlap !== b.overlap) return b.overlap - a.overlap
return a.dist - b.dist
}
// Horizontal: overlap first (same row), then distance
if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.dist - b.dist
})
return scored[0]?.el ?? null
}
// ─── Focus Memory ───────────────────────────────────────────────
const zoneFocusMemory = new Map<string, HTMLElement>()
function rememberFocus(zone: string, el: HTMLElement) {
zoneFocusMemory.set(zone, el)
}
function recallFocus(zone: string): HTMLElement | null {
const el = zoneFocusMemory.get(zone)
if (!el) return null
if (document.contains(el) && el.offsetParent !== null) return el
zoneFocusMemory.delete(zone)
return null
}
// ─── Focus Helper ───────────────────────────────────────────────
function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
playNavSound(sound)
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
// ─── Nav-Ring Modality ──────────────────────────────────────────
// The orange focus ring must only render while the user is actually
// navigating directionally (arrows / gamepad). Ring visibility is gated in
// CSS behind html.controller-nav; directional input turns it on, any
// pointer input (tap, click) turns it off. Without this gate the ring
// painted on plain :focus — every tap on a tabindex card, and every
// programmatic route-change autofocus, lit it up with no controller in
// sight (worst on mobile).
function setNavRing(on: boolean) {
document.documentElement.classList.toggle('controller-nav', on)
}
function navRingActive(): boolean {
return document.documentElement.classList.contains('controller-nav')
}
// ─── Main Composable ────────────────────────────────────────────
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const route = useRoute()
const router = useRouter()
const store = useControllerStore()
const isControllerActive = ref(false)
const gamepadCount = ref(0)
watch([isControllerActive, gamepadCount], () => {
store.setActive(isControllerActive.value)
store.setGamepadCount(gamepadCount.value)
}, { immediate: true })
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
let pollIntervalId: ReturnType<typeof setInterval> | null = null
function checkGamepads() {
const gamepads = navigator.getGamepads?.()
const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
if (count !== gamepadCount.value) {
gamepadCount.value = count
isControllerActive.value = count > 0
}
}
// ─── Keyboard Handler ───────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
if (!navKeys.includes(e.key)) return
const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (
e.key === 'Enter' &&
target.tagName === 'INPUT' &&
(target as HTMLInputElement).type !== 'submit' &&
!target.hasAttribute('data-controller-no-submit')
) {
// Enter in input: click next button (submit pattern). Live-filter
// search boxes opt out via data-controller-no-submit — the "next
// focusable element" after a search field is often an unrelated
// button (a clear icon, a sideload/upload action) that just happens
// to sit next to it in the DOM, not a submit action for the query.
e.preventDefault()
const all = getFocusableElements(containerRef?.value ?? document)
const idx = all.indexOf(target)
const next = idx >= 0 ? all[idx + 1] : undefined
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
next.focus()
next.click()
} else if (next) {
next.focus()
}
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Up/Down: exit field, navigate spatially
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
const all = getFocusableElements(containerRef?.value ?? document)
const candidates = all.filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) {
focusEl(nearest)
} else {
// Spatial nav failed — try containers directly (e.g. search bar → first container)
const containers = getContainers()
const containerNearest = containers.length
? findNearestInDirection(target, containers, dir)
: null
if (containerNearest) {
focusEl(containerNearest)
} else {
// Last fallback: tab order
const idx = all.indexOf(target)
const fallback = dir === 'down' ? all[idx + 1] : all[idx - 1]
if (fallback) focusEl(fallback)
}
}
return
}
// Left/Right: cursor movement in field, but exit at edges
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const input = target as HTMLInputElement
const atStart = input.selectionStart === 0 && input.selectionEnd === 0
const atEnd = input.selectionStart === (input.value?.length ?? 0)
if ((e.key === 'ArrowLeft' && atStart) || (e.key === 'ArrowRight' && atEnd)) {
e.preventDefault()
const dir = e.key === 'ArrowLeft' ? 'left' as const : 'right' as const
const all = getFocusableElements(containerRef?.value ?? document)
const candidates = all.filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) focusEl(nearest)
}
return
}
// Other keys (Escape): handled below.
if (e.key !== 'Escape') return
}
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
if (e.key === 'Escape') {
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
// Inside container inner controls → exit to container
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
if (container && container.tabIndex >= 0) {
focusEl(container, 'back')
e.preventDefault()
return
}
}
// On a container or anywhere in main → go to sidebar
if (isInZone(activeEl, 'main')) {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) {
rememberFocus('main', activeEl)
focusEl(target, 'back')
e.preventDefault()
}
return
}
// Detail pages: go back
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
playNavSound('back')
window.history.back()
e.preventDefault()
}
return
}
// ── ENTER ───────────────────────────────────────────────
if (e.key === 'Enter') {
e.preventDefault()
if (isContainer(activeEl)) {
// Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Prioritised action: launch button
if (activeEl.hasAttribute('data-controller-launch')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Primary link (e.g. dashboard cards with a[href])
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
if (primaryLink) {
playNavSound('action')
primaryLink.click()
return
}
// Fallback: first non-disabled action button (skip uninstall/delete buttons)
const inner = getInnerFocusables(activeEl)
const actionBtn = inner.find(el =>
(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') &&
!el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') &&
!el.closest('[class*="absolute top"]')
) ?? inner[0]
if (actionBtn) {
focusEl(actionBtn, 'action')
return
}
// Last resort: click the container itself (triggers goToApp on AppCard)
playNavSound('action')
activeEl.click()
return
}
// Regular element: click it
if (activeEl) {
playNavSound('action')
activeEl.click()
}
return
}
// ── ARROW KEYS ──────────────────────────────────────────
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
e.preventDefault()
// Mark controller as active
isControllerActive.value = true
setNavRing(true)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
// Nothing focused yet (fresh page, or focus was lost) — enter nav mode
// on the first arrow press by focusing the first container, instead of
// spatially navigating from <body>.
if (!activeEl || activeEl === document.body || activeEl === document.documentElement) {
autoFocusMain()
return
}
const dir = e.key === 'ArrowLeft' ? 'left' as const
: e.key === 'ArrowRight' ? 'right' as const
: e.key === 'ArrowUp' ? 'up' as const
: 'down' as const
// ── SIDEBAR ─────────────────────────────────────────────
if (isInZone(activeEl, 'sidebar')) {
const items = getSidebarElements()
const idx = items.indexOf(activeEl)
if (dir === 'up' || dir === 'down') {
// Linear wrap
if (idx < 0) return
const nextIdx = dir === 'down'
? (idx >= items.length - 1 ? 0 : idx + 1)
: (idx <= 0 ? items.length - 1 : idx - 1)
const next = items[nextIdx]
if (next && next !== activeEl) {
focusEl(next)
// Auto-navigate sidebar links (not buttons — Logout etc. require Enter)
if (next.tagName === 'A') {
const href = (next as HTMLAnchorElement).getAttribute('href')
if (href?.startsWith('/')) router.push(href).catch(() => {})
}
}
return
}
if (dir === 'right') {
// Jump to first container in main
rememberFocus('sidebar', activeEl)
const remembered = recallFocus('main')
// Only use remembered if it's a container (not a nav bar button)
const target = (remembered && isContainer(remembered)) ? remembered : null
const containers = getContainers()
const dest = target ?? containers[0]
if (dest) {
focusEl(dest)
} else {
// Check if this is a container-free page (e.g. Settings) — focus first button immediately
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
const hasAnyContainers = zone?.querySelector('[data-controller-container]')
if (!hasAnyContainers && zone) {
const focusable = getFocusableElements(zone)
if (focusable[0]) { focusEl(focusable[0]); return }
}
// Containers not rendered yet (route transition / animation in progress)
// Poll until they appear, up to 1s
let attempts = 0
const poll = setInterval(() => {
attempts++
const retryContainers = getContainers()
if (retryContainers[0]) {
clearInterval(poll)
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
// Last resort: focus first focusable element
if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) }
}
}, 100)
}
return
}
// Left from sidebar: does nothing
return
}
// ── INSIDE CONTAINER (inner controls) ───────────────────
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement
const inner = getInnerFocusables(container)
const next = findNearestInDirection(activeEl, inner, dir)
if (next) focusEl(next)
// Can't leave container via arrows — must use Escape
return
}
// ── NAV BAR [N] — secondary controls above the grid ────
if (isNavBarItem(activeEl)) {
const navItems = getNavBarItems()
if (dir === 'left' || dir === 'right') {
// Spatial nav between nav bar items
const next = findNearestInDirection(activeEl, navItems, dir)
if (next) { focusEl(next); return }
// Left from leftmost nav item → sidebar
if (dir === 'left') {
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
}
return
}
if (dir === 'down' || dir === 'up') {
// Up/Down from standalone element → find nearest focusable (container or button) in direction.
// Searches containers + standalone elements together so mixed pages (Settings) don't jump.
if (dir === 'down') rememberFocus('navBar', activeEl)
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const target = findNearestInDirection(activeEl, allFocusable, dir)
if (target) { focusEl(target); return }
}
}
return
}
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
if (isInZone(activeEl, 'main')) {
const containers = getContainers()
// Try spatial nav to containers + standalone focusables (not inner buttons).
// This handles mixed pages (e.g. Settings) where containers and buttons coexist.
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
const navTargets = zone ? getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
) : containers
const next = findNearestInDirection(activeEl, navTargets, dir)
if (next) {
rememberFocus('main', next)
focusEl(next)
return
}
// Up from top-row container → nav bar, or previous focusable (linear pages like Settings)
if (dir === 'up') {
const remembered = recallFocus('navBar')
if (remembered) { focusEl(remembered); return }
const navItems = getNavBarItems()
if (navItems.length) {
const nearest = findNearestInDirection(activeEl, navItems, 'up')
if (nearest) { focusEl(nearest); return }
const first = navItems[0]
if (first) { focusEl(first); return }
}
// No nav bar items — try any focusable element above (linear page nav)
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const above = findNearestInDirection(activeEl, allFocusable, 'up')
if (above) { rememberFocus('main', above); focusEl(above) }
}
return
}
// Left from leftmost container → sidebar
if (dir === 'left') {
rememberFocus('main', activeEl)
const remembered = recallFocus('sidebar')
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = remembered ?? activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
return
}
// At grid edges: try containers + nav bar items as fallback
// (prevents dead ends, but never jumps into container inner controls)
if (dir === 'down' || dir === 'right') {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
if (fallback) {
rememberFocus('main', fallback)
focusEl(fallback)
}
}
}
return
}
// ── FALLBACK: unhandled focusable element ───────────────
// Covers standalone buttons/links in empty/error states, modals, etc.
// that aren't inside a recognized zone or container.
if (dir === 'left') {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) { rememberFocus('main', activeEl); focusEl(target) }
} else {
// Exclude container inner buttons to prevent focus getting lost
const all = getFocusableElements().filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const next = findNearestInDirection(activeEl, all, dir)
if (next) focusEl(next)
}
}
// ─── Gamepad Detection ──────────────────────────────────────
function handleGamepadConnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
isControllerActive.value = true
setNavRing(true)
}
function handleGamepadDisconnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
isControllerActive.value = gamepadCount.value > 0
}
// ─── Scroll Support ────────────────────────────────────────
function handleWheel(e: WheelEvent) {
// Scroll the container UNDER THE POINTER, not the focused element. Real
// wheel events always target the element beneath the cursor, so walking up
// from e.target matches native behaviour. Using document.activeElement here
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
// peer list, still focused after a click) instead of the panel actually
// being hovered — producing a double-scroll where both moved at once.
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
if (!start) return
let p: HTMLElement | null = start
while (p) {
const style = getComputedStyle(p)
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
return
}
p = p.parentElement
}
}
// ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() {
// Only when the user is actually in directional-nav mode. For pointer
// and touch users this programmatic focus painted the first card's
// focus ring on every route change with zero controller input.
if (!navRingActive()) return
const active = document.activeElement as HTMLElement | null
// Don't steal focus from inputs, modals, or sidebar
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
if (document.querySelector('[role="dialog"]')) return
if (isInZone(active, 'sidebar')) return
requestAnimationFrame(() => {
// Re-check sidebar after RAF — user may still be navigating
if (isInZone(document.activeElement as HTMLElement, 'sidebar')) return
const remembered = recallFocus('main')
if (remembered) { remembered.focus({ preventScroll: true }); return }
const containers = getContainers()
if (containers[0]) containers[0].focus({ preventScroll: true })
})
}
watch(() => route.path, () => {
zoneFocusMemory.delete('main')
zoneFocusMemory.delete('navBar')
setTimeout(autoFocusMain, 150)
})
// ─── Lifecycle ─────────────────────────────────────────────
// Any pointer interaction ends directional-nav mode — the ring should
// never linger once the user reaches for mouse or touch.
function handlePointerDown() {
setNavRing(false)
}
onMounted(() => {
checkGamepads()
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('pointerdown', handlePointerDown, { capture: true, passive: true })
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
pollIntervalId = setInterval(() => checkGamepads(), 500)
setTimeout(autoFocusMain, 300)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('pointerdown', handlePointerDown, { capture: true } as EventListenerOptions)
window.removeEventListener('wheel', handleWheel)
window.removeEventListener('gamepadconnected', handleGamepadConnected)
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
if (pollIntervalId) clearInterval(pollIntervalId)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
setNavRing(false)
})
return { isControllerActive, gamepadCount }
}