- Rewrite useControllerNav.ts with clean console-style navigation: Sidebar (up/down wrap, right→containers, left→nothing), Container tile grid (spatial nav, no wrap at edges), Nav bar support (up from containers, down to grid), Inner controls (enter drills in, escape exits, trapped arrows) - Add data-controller-container to Mesh, Fleet, Settings pages - Fix Home.vue fragment (modals outside root div) causing Vue warnings - Remove skip-to-content link (handled by controller nav) - Orange ambient glow focus styling matching glass aesthetic - Disable PWA service worker in dev mode (fixes HMR caching) - Add gamepad-nav skill and GAMEPAD-NAV-MAP.md spec document - 39 tests covering all navigation patterns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
535 lines
20 KiB
TypeScript
535 lines
20 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
|
|
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) => {
|
|
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
|
if (a.dist !== b.dist) return a.dist - b.dist
|
|
// Tiebreaker: prefer leftmost for up/down
|
|
if (direction === 'up' || direction === 'down') {
|
|
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
|
|
}
|
|
return 0
|
|
})
|
|
|
|
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' })
|
|
}
|
|
|
|
// ─── 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') {
|
|
// 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 candidates = getFocusableElements(containerRef?.value ?? document).filter(el => el !== target)
|
|
const nearest = findNearestInDirection(target, candidates, dir)
|
|
if (nearest) focusEl(nearest)
|
|
return
|
|
}
|
|
// Left/Right: stay in field (cursor movement). 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)) {
|
|
// Container has a primary action link (the > chevron)?
|
|
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
|
|
if (activeEl.hasAttribute('data-controller-install')) {
|
|
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
|
if (btn) { playNavSound('action'); btn.click(); return }
|
|
}
|
|
if (activeEl.hasAttribute('data-controller-launch')) {
|
|
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
|
if (btn) { playNavSound('action'); btn.click(); return }
|
|
}
|
|
// Default: click the primary link to navigate to that section
|
|
if (primaryLink) {
|
|
playNavSound('action')
|
|
primaryLink.click()
|
|
return
|
|
}
|
|
// No primary link — drill into inner controls
|
|
const inner = getInnerFocusables(activeEl)
|
|
if (inner[0]) {
|
|
focusEl(inner[0], 'action')
|
|
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
|
|
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')
|
|
const containers = getContainers()
|
|
const target = remembered ?? containers[0]
|
|
if (target) focusEl(target)
|
|
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') {
|
|
// Down from nav bar → first container
|
|
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
|
|
}
|
|
|
|
// Up from nav bar → nothing
|
|
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 (if exists)
|
|
if (dir === 'up') {
|
|
const navItems = getNavBarItems()
|
|
if (navItems.length) {
|
|
const nearest = findNearestInDirection(activeEl, navItems, 'up')
|
|
if (nearest) { focusEl(nearest); return }
|
|
// Fallback: first nav bar item
|
|
const first = navItems[0]
|
|
if (first) focusEl(first)
|
|
}
|
|
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 (down/right with no target): do nothing
|
|
return
|
|
}
|
|
}
|
|
|
|
// ─── 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
|
|
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
|
|
if (document.querySelector('[role="dialog"]')) return
|
|
|
|
requestAnimationFrame(() => {
|
|
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')
|
|
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 }
|
|
}
|