/** * 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(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('[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 return isInZone(el, 'main') && !el.hasAttribute('data-controller-container') && !el.closest('[data-controller-container]') } /** 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() 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' }) } // ─── 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 | null = null let pollIntervalId: ReturnType | 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') { // Enter in input: click next button (submit pattern) 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('.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('[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('[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('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 if (keyNavTimeout) clearTimeout(keyNavTimeout) keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000) 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 { // 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) // No containers on this page (e.g. Settings) — focus first focusable element const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null if (z) { const f = getFocusableElements(z); 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('.nav-tab-active') const target = activeTab ?? getSidebarElements()[0] if (target) focusEl(target) } return } if (dir === 'down') { // Down from nav bar → jump to containers (remember tab for Up return) rememberFocus('navBar', activeEl) const containers = getContainers() const nearest = findNearestInDirection(activeEl, containers, 'down') if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return } // Fallback: just focus first container if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return } // Containers not rendered yet — poll until they appear let attempts = 0 const poll = setInterval(() => { attempts++ const retryContainers = getContainers() if (retryContainers[0]) { clearInterval(poll) rememberFocus('main', retryContainers[0]) focusEl(retryContainers[0]) } else if (attempts >= 10) { clearInterval(poll) } }, 100) return } // Up from nav bar → nothing (use Escape to go to sidebar) return } // ── MAIN ZONE: CONTAINER TILE GRID [C] ────────────────── if (isInZone(activeEl, 'main')) { const containers = getContainers() // Try spatial nav to another container const next = findNearestInDirection(activeEl, containers, 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('.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('.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 } 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) { const active = document.activeElement as HTMLElement | null if (!active) return let p = active.parentElement 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() { 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 ───────────────────────────────────────────── onMounted(() => { checkGamepads() window.addEventListener('keydown', handleKeyDown, 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('wheel', handleWheel) window.removeEventListener('gamepadconnected', handleGamepadConnected) window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected) if (pollIntervalId) clearInterval(pollIntervalId) if (keyNavTimeout) clearTimeout(keyNavTimeout) }) return { isControllerActive, gamepadCount } }