feat(tv-input): gamepad->keyboard bridge daemon + controller-navigable modals
- archipelago-gamepad-keys: evdev->uinput daemon (pure python3 stdlib) that mirrors any attached controller as a virtual keyboard — D-pad/stick to arrows, A/B to Enter/Escape, X/Y to Space/f, shoulders to Tab/Shift+Tab. The browser sees real keys, so controllers work inside EVERY app iframe (IndeeHub, Jellyfin…) with zero per-app code. Ships via the ISO splice and bootstrap self-heal (kiosk nodes only), hotplug via 5s rescans. Implements docs/tv-input-iframe-apps.md. - useControllerNav: an open [role=dialog][aria-modal] owns navigation — focus is pulled inside, arrows move spatially between its controls, Enter activates, Escape stays with the modal's own close handling. One standard mapping for every modal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
402183c0ae
commit
54ddd043bd
@@ -105,6 +105,14 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||
}
|
||||
|
||||
/** Topmost open modal dialog, if any — it owns navigation while visible. */
|
||||
function getOpenModal(): HTMLElement | null {
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
|
||||
).filter(el => el.offsetParent !== null)
|
||||
return dialogs[dialogs.length - 1] ?? null
|
||||
}
|
||||
|
||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
const container = el.closest('[data-controller-container]')
|
||||
@@ -250,6 +258,42 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const target = e.target as HTMLElement
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// ── MODAL SCOPE ──────────────────────────────────────────
|
||||
// An open dialog owns navigation: focus is pulled inside, arrows move
|
||||
// spatially between its controls, Enter activates. Escape stays with the
|
||||
// modal's own close handling. Standard mapping, no per-modal code.
|
||||
const modal = getOpenModal()
|
||||
if (modal) {
|
||||
if (e.key === 'Escape') return
|
||||
const focusables = getFocusableElements(modal)
|
||||
if (!focusables.length) return
|
||||
if (!activeEl || !modal.contains(activeEl)) {
|
||||
e.preventDefault()
|
||||
const first = focusables[0]
|
||||
if (first) focusEl(first)
|
||||
return
|
||||
}
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
// Typing keys stay with the field; Enter keeps the form-submit
|
||||
// behavior below; only Up/Down leave the field spatially.
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const dir =
|
||||
e.key === 'ArrowDown' ? ('down' as const)
|
||||
: e.key === 'ArrowUp' ? ('up' as const)
|
||||
: e.key === 'ArrowLeft' ? ('left' as const)
|
||||
: ('right' as const)
|
||||
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
|
||||
if (nearest) focusEl(nearest)
|
||||
return
|
||||
}
|
||||
|
||||
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user