fix(ui): show the controller focus ring only during actual arrow/gamepad navigation
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m42s
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m42s
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>
This commit is contained in:
parent
e9cfc234cd
commit
92d221bbf1
@ -199,6 +199,22 @@ function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
|
||||
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 }) {
|
||||
@ -396,9 +412,18 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
|
||||
// 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
|
||||
@ -611,6 +636,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
|
||||
isControllerActive.value = true
|
||||
setNavRing(true)
|
||||
}
|
||||
|
||||
function handleGamepadDisconnected() {
|
||||
@ -644,6 +670,10 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
// ─── 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
|
||||
@ -668,9 +698,16 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
|
||||
// ─── 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)
|
||||
@ -680,11 +717,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
|
||||
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 }
|
||||
|
||||
@ -270,9 +270,13 @@ input[type="radio"]:active + * {
|
||||
|
||||
/* Containers: console-style focus — lift + ambient orange glow.
|
||||
Pure glow approach — no border-color or outline changes, avoids
|
||||
Chromium compositor bugs with border-radius on translateZ(0) layers. */
|
||||
[data-controller-container]:focus-visible,
|
||||
[data-controller-container]:focus {
|
||||
Chromium compositor bugs with border-radius on translateZ(0) layers.
|
||||
Gated behind html.controller-nav (set by useControllerNav only while
|
||||
the user navigates with arrows/gamepad; cleared on any pointer input).
|
||||
Ungated, the plain :focus selector painted the ring on every tap of a
|
||||
tabindex card — the "gamepad selection with no gamepad" bug on mobile. */
|
||||
html.controller-nav [data-controller-container]:focus-visible,
|
||||
html.controller-nav [data-controller-container]:focus {
|
||||
outline: none;
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
box-shadow:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user