2026-02-17 15:03:34 +00:00
|
|
|
/**
|
2026-02-17 19:19:54 +00:00
|
|
|
* Xbox-style controller / gamepad navigation for Archipelago.
|
|
|
|
|
* - Left: Go to side menu only when on leftmost main content
|
|
|
|
|
* - Right: Go to main content (from side menu)
|
|
|
|
|
* - Main: spatial/grid navigation (up/down/left/right like a game)
|
|
|
|
|
* - Enter enters container's inner actions; actions get celebratory sound
|
2026-02-17 15:03:34 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
2026-02-17 19:19:54 +00:00
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
2026-02-17 15:03:34 +00:00
|
|
|
import { useControllerStore } from '@/stores/controller'
|
|
|
|
|
import { useSpotlightStore } from '@/stores/spotlight'
|
2026-02-18 10:35:04 +00:00
|
|
|
import { useCLIStore } from '@/stores/cli'
|
2026-02-17 21:10:16 +00:00
|
|
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
2026-02-17 19:19:54 +00:00
|
|
|
import { playNavSound } from '@/composables/useNavSounds'
|
2026-02-17 15:03:34 +00:00
|
|
|
|
|
|
|
|
const FOCUSABLE_SELECTOR = [
|
|
|
|
|
'a[href]',
|
|
|
|
|
'button:not([disabled])',
|
|
|
|
|
'input:not([disabled])',
|
|
|
|
|
'select:not([disabled])',
|
|
|
|
|
'textarea:not([disabled])',
|
|
|
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
|
|
|
'[data-controller-focus]',
|
2026-02-17 19:19:54 +00:00
|
|
|
'[data-controller-container]',
|
2026-02-17 15:03:34 +00:00
|
|
|
].join(', ')
|
|
|
|
|
|
|
|
|
|
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
|
|
|
|
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
2026-03-04 05:23:42 +00:00
|
|
|
(el) =>
|
|
|
|
|
!el.hasAttribute('disabled') &&
|
|
|
|
|
el.offsetParent !== null &&
|
|
|
|
|
!el.hasAttribute('data-controller-ignore') &&
|
|
|
|
|
!el.closest('[data-controller-ignore]')
|
2026-02-17 15:03:34 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:19:54 +00:00
|
|
|
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
|
|
|
|
|
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
|
|
|
|
|
if (!container) return []
|
|
|
|
|
return getFocusableElements(container)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
|
|
|
|
if (!el) return false
|
|
|
|
|
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
|
|
|
|
|
return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInsideContainer(el: HTMLElement | null): boolean {
|
|
|
|
|
if (!el) return false
|
|
|
|
|
const container = el.closest('[data-controller-container]')
|
|
|
|
|
return !!container && container !== el
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Spatial navigation: find nearest focusable in direction (game-style grid) */
|
|
|
|
|
function findNearestInDirection(
|
|
|
|
|
from: HTMLElement,
|
|
|
|
|
candidates: HTMLElement[],
|
|
|
|
|
direction: 'up' | 'down' | 'left' | 'right'
|
|
|
|
|
): HTMLElement | null {
|
|
|
|
|
const fromRect = from.getBoundingClientRect()
|
|
|
|
|
const fromCenterX = fromRect.left + fromRect.width / 2
|
|
|
|
|
const fromCenterY = fromRect.top + fromRect.height / 2
|
|
|
|
|
const threshold = 50 // px overlap allowed
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (filtered.length === 0) return null
|
|
|
|
|
|
|
|
|
|
// Pick best: most overlap on perpendicular axis, then closest
|
|
|
|
|
const scored = filtered.map((el) => {
|
|
|
|
|
const r = el.getBoundingClientRect()
|
|
|
|
|
const centerX = r.left + r.width / 2
|
|
|
|
|
const centerY = r.top + r.height / 2
|
|
|
|
|
|
|
|
|
|
let overlap: number
|
|
|
|
|
let dist: number
|
|
|
|
|
switch (direction) {
|
|
|
|
|
case 'left':
|
|
|
|
|
case 'right':
|
|
|
|
|
overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
|
|
|
|
dist = Math.abs(centerX - fromCenterX)
|
|
|
|
|
break
|
|
|
|
|
case 'up':
|
|
|
|
|
case 'down':
|
|
|
|
|
overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
|
|
|
|
dist = Math.abs(centerY - fromCenterY)
|
|
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
overlap = 0
|
|
|
|
|
dist = Infinity
|
|
|
|
|
}
|
|
|
|
|
return { el, overlap, dist }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
scored.sort((a, b) => {
|
|
|
|
|
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
|
|
|
|
return a.dist - b.dist
|
|
|
|
|
})
|
|
|
|
|
return scored[0]?.el ?? null
|
2026-02-17 15:03:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
2026-02-17 19:19:54 +00:00
|
|
|
const route = useRoute()
|
|
|
|
|
const router = useRouter()
|
2026-02-17 15:03:34 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
|
|
|
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
|
|
|
|
if (!navKeys.includes(e.key)) return
|
|
|
|
|
|
|
|
|
|
const target = e.target as HTMLElement
|
|
|
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
|
|
|
|
if (e.key !== 'Escape') return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const root = containerRef?.value ?? document
|
|
|
|
|
const focusable = getFocusableElements(root)
|
|
|
|
|
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
2026-02-17 19:19:54 +00:00
|
|
|
const activeEl = document.activeElement as HTMLElement
|
2026-02-17 15:03:34 +00:00
|
|
|
|
2026-02-17 19:19:54 +00:00
|
|
|
// --- ESCAPE ---
|
2026-02-17 15:03:34 +00:00
|
|
|
if (e.key === 'Escape') {
|
2026-02-17 21:10:16 +00:00
|
|
|
if (useAppLauncherStore().isOpen) {
|
|
|
|
|
useAppLauncherStore().close()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-17 15:03:34 +00:00
|
|
|
if (useSpotlightStore().isOpen) {
|
|
|
|
|
useSpotlightStore().close()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-18 10:35:04 +00:00
|
|
|
if (useCLIStore().isOpen) {
|
|
|
|
|
useCLIStore().close()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-17 19:19:54 +00:00
|
|
|
|
|
|
|
|
if (isInsideContainer(activeEl)) {
|
|
|
|
|
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
|
|
|
|
if (container && container.tabIndex >= 0) {
|
|
|
|
|
playNavSound('back')
|
|
|
|
|
container.focus()
|
|
|
|
|
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
|
|
|
|
|
if (isDetailPage) {
|
|
|
|
|
playNavSound('back')
|
|
|
|
|
window.history.back()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sidebarEls = getElementsInZone('sidebar')
|
|
|
|
|
const firstSidebar = sidebarEls[0]
|
|
|
|
|
if (firstSidebar && isInZone(activeEl, 'main')) {
|
|
|
|
|
playNavSound('back')
|
|
|
|
|
firstSidebar.focus()
|
|
|
|
|
firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
playNavSound('back')
|
|
|
|
|
window.history.back()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:19:54 +00:00
|
|
|
// --- ENTER ---
|
2026-02-17 15:03:34 +00:00
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
if (currentIndex >= 0 && focusable[currentIndex]) {
|
2026-02-17 19:19:54 +00:00
|
|
|
const el = focusable[currentIndex] as HTMLElement
|
|
|
|
|
|
|
|
|
|
if (el.hasAttribute('data-controller-container')) {
|
2026-02-17 20:40:26 +00:00
|
|
|
// Marketplace: Enter = install (click install button)
|
|
|
|
|
if (el.hasAttribute('data-controller-install')) {
|
|
|
|
|
const installBtn = el.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
|
|
|
|
if (installBtn) {
|
|
|
|
|
playNavSound('action')
|
|
|
|
|
installBtn.click()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-17 22:10:38 +00:00
|
|
|
// My Apps: Enter = launch (click Launch button when app is runnable)
|
|
|
|
|
if (el.hasAttribute('data-controller-launch')) {
|
|
|
|
|
const launchBtn = el.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
|
|
|
|
if (launchBtn) {
|
|
|
|
|
playNavSound('action')
|
|
|
|
|
launchBtn.click()
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-17 20:40:26 +00:00
|
|
|
// My Apps, etc: Enter = focus first inner control
|
2026-02-17 19:19:54 +00:00
|
|
|
const inner = getInnerFocusables(el)
|
|
|
|
|
const firstInner = inner[0]
|
|
|
|
|
if (firstInner) {
|
|
|
|
|
playNavSound('action')
|
|
|
|
|
firstInner.focus()
|
|
|
|
|
firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
playNavSound('action')
|
|
|
|
|
el.click()
|
2026-02-17 15:03:34 +00:00
|
|
|
}
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:19:54 +00:00
|
|
|
// --- ARROWS ---
|
2026-02-17 15:03:34 +00:00
|
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
|
|
|
isControllerActive.value = true
|
|
|
|
|
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
|
|
|
|
keyNavTimeout = setTimeout(() => {
|
|
|
|
|
isControllerActive.value = gamepadCount.value > 0
|
|
|
|
|
}, 3000)
|
|
|
|
|
|
2026-02-17 19:19:54 +00:00
|
|
|
const sidebarEls = getElementsInZone('sidebar')
|
|
|
|
|
const mainEls = getElementsInZone('main')
|
|
|
|
|
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
|
|
|
|
|
|
2026-02-18 11:29:05 +00:00
|
|
|
// Right: from sidebar → main
|
2026-02-18 11:30:54 +00:00
|
|
|
// - On Home: go to My Apps container
|
2026-02-18 11:29:05 +00:00
|
|
|
// - On Apps/Marketplace: go to first app container
|
|
|
|
|
// - On Cloud: go to first folder (Pictures)
|
|
|
|
|
// - On Network (server): go to Services container
|
|
|
|
|
// - On Web5: go to Networking Profits container
|
|
|
|
|
// - On Settings: go to Change Password container
|
|
|
|
|
// - Otherwise: go to top right (App Switcher)
|
2026-02-17 20:40:26 +00:00
|
|
|
const mainZone = document.querySelector('[data-controller-zone="main"]')
|
2026-02-18 11:30:54 +00:00
|
|
|
const isHome = /^\/dashboard(\/)?$/.test(route.path)
|
2026-02-18 11:29:05 +00:00
|
|
|
const isAppsOrMarketplace = /^\/dashboard\/(apps|marketplace)(\/|$)/.test(route.path)
|
|
|
|
|
const isCloud = /^\/dashboard\/cloud(\/|$)/.test(route.path)
|
|
|
|
|
const isNetwork = /^\/dashboard\/server(\/|$)/.test(route.path)
|
|
|
|
|
const isWeb5 = /^\/dashboard\/web5(\/|$)/.test(route.path)
|
|
|
|
|
const isSettings = /^\/dashboard\/settings(\/|$)/.test(route.path)
|
2026-02-17 20:40:26 +00:00
|
|
|
const firstAppContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
|
2026-02-18 11:29:05 +00:00
|
|
|
const topRightEntry = mainZone?.querySelector<HTMLElement>('[data-controller-main-entry]')
|
|
|
|
|
const firstFocusableInTopRight = topRightEntry ? getFocusableElements(topRightEntry)[0] : null
|
2026-02-18 11:30:54 +00:00
|
|
|
const firstMain = ((isHome || isAppsOrMarketplace || isCloud || isNetwork || isWeb5 || isSettings) && firstAppContainer)
|
2026-02-18 11:29:05 +00:00
|
|
|
? firstAppContainer
|
|
|
|
|
: (firstFocusableInTopRight ?? mainEls[0])
|
2026-02-17 19:19:54 +00:00
|
|
|
if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) {
|
|
|
|
|
playNavSound('move')
|
|
|
|
|
firstMain.focus()
|
|
|
|
|
firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main zone: spatial navigation (game-style grid)
|
|
|
|
|
if (hasZones && isInZone(activeEl, 'main')) {
|
|
|
|
|
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
|
|
|
|
const next = findNearestInDirection(activeEl, mainEls, dir)
|
|
|
|
|
|
|
|
|
|
if (next) {
|
|
|
|
|
playNavSound('move')
|
|
|
|
|
next.focus()
|
|
|
|
|
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 20:40:26 +00:00
|
|
|
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
|
2026-02-17 19:19:54 +00:00
|
|
|
if (e.key === 'ArrowLeft' && dir === 'left') {
|
2026-02-17 20:40:26 +00:00
|
|
|
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
|
|
|
|
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
|
|
|
|
const target = activeNavTab ?? sidebarEls[0]
|
|
|
|
|
if (target) {
|
2026-02-17 19:19:54 +00:00
|
|
|
playNavSound('move')
|
2026-02-17 20:40:26 +00:00
|
|
|
target.focus()
|
|
|
|
|
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
2026-02-17 19:19:54 +00:00
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inside container: spatial nav among inner elements
|
|
|
|
|
if (isInsideContainer(activeEl)) {
|
|
|
|
|
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
|
|
|
|
if (container) {
|
|
|
|
|
const inner = getInnerFocusables(container)
|
|
|
|
|
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
|
|
|
|
const next = findNearestInDirection(activeEl, inner, dir)
|
|
|
|
|
if (next) {
|
|
|
|
|
playNavSound('move')
|
|
|
|
|
next.focus()
|
|
|
|
|
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 11:29:05 +00:00
|
|
|
// Sidebar: linear up/down with wrap (Home+Up→Logout, Logout+Down→Home)
|
2026-02-17 19:19:54 +00:00
|
|
|
if (isInZone(activeEl, 'sidebar')) {
|
|
|
|
|
const idx = sidebarEls.indexOf(activeEl)
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
const isDown = e.key === 'ArrowDown'
|
2026-02-18 11:29:05 +00:00
|
|
|
let nextIdx: number
|
|
|
|
|
if (isDown) {
|
|
|
|
|
nextIdx = idx >= sidebarEls.length - 1 ? 0 : idx + 1
|
|
|
|
|
} else {
|
|
|
|
|
nextIdx = idx <= 0 ? sidebarEls.length - 1 : idx - 1
|
|
|
|
|
}
|
2026-02-17 19:19:54 +00:00
|
|
|
const next = sidebarEls[nextIdx]
|
|
|
|
|
if (next && next !== activeEl) {
|
|
|
|
|
playNavSound('move')
|
|
|
|
|
next.focus()
|
|
|
|
|
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
if (next.tagName === 'A') {
|
|
|
|
|
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
|
|
|
|
if (href && href.startsWith('/')) router.push(href).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: linear navigation
|
2026-02-17 15:03:34 +00:00
|
|
|
let nextIndex = currentIndex
|
|
|
|
|
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
|
|
|
|
if (focusable.length === 0) return
|
|
|
|
|
|
|
|
|
|
if (currentIndex < 0) {
|
|
|
|
|
nextIndex = isForward ? 0 : focusable.length - 1
|
|
|
|
|
} else {
|
|
|
|
|
nextIndex = isForward ? currentIndex + 1 : currentIndex - 1
|
|
|
|
|
if (nextIndex < 0) nextIndex = focusable.length - 1
|
|
|
|
|
if (nextIndex >= focusable.length) nextIndex = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const next = focusable[nextIndex]
|
|
|
|
|
if (next) {
|
|
|
|
|
playNavSound('move')
|
|
|
|
|
next.focus()
|
|
|
|
|
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
2026-02-17 19:19:54 +00:00
|
|
|
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
|
|
|
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
|
|
|
|
if (href && href.startsWith('/') && next.tagName === 'A') {
|
|
|
|
|
router.push(href).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-17 15:03:34 +00:00
|
|
|
e.preventDefault()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleGamepadInput() {
|
|
|
|
|
checkGamepads()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 20:40:26 +00:00
|
|
|
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */
|
|
|
|
|
function getScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
|
|
|
|
|
let p = el?.parentElement
|
|
|
|
|
while (p) {
|
|
|
|
|
const style = getComputedStyle(p)
|
|
|
|
|
const oy = style.overflowY
|
|
|
|
|
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p
|
|
|
|
|
p = p.parentElement
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Ensure wheel scrolls the scrollable area containing the focused element */
|
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
|
|
|
const active = document.activeElement as HTMLElement | null
|
|
|
|
|
if (!active) return
|
|
|
|
|
const scrollable = getScrollableAncestor(active)
|
|
|
|
|
if (!scrollable) return
|
|
|
|
|
if (e.deltaY !== 0) {
|
|
|
|
|
scrollable.scrollTop += e.deltaY
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
}
|
|
|
|
|
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
|
|
|
|
|
scrollable.scrollLeft += e.deltaX
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 15:03:34 +00:00
|
|
|
onMounted(() => {
|
|
|
|
|
checkGamepads()
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown, true)
|
2026-02-17 20:40:26 +00:00
|
|
|
window.addEventListener('wheel', handleWheel, { passive: false })
|
2026-02-17 15:03:34 +00:00
|
|
|
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
|
|
|
|
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
|
|
|
|
pollIntervalId = setInterval(handleGamepadInput, 500)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
window.removeEventListener('keydown', handleKeyDown, true)
|
2026-02-17 20:40:26 +00:00
|
|
|
window.removeEventListener('wheel', handleWheel)
|
2026-02-17 15:03:34 +00:00
|
|
|
window.removeEventListener('gamepadconnected', handleGamepadConnected)
|
|
|
|
|
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
|
|
|
|
if (pollIntervalId) clearInterval(pollIntervalId)
|
|
|
|
|
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
isControllerActive,
|
|
|
|
|
gamepadCount,
|
|
|
|
|
}
|
|
|
|
|
}
|