|
| 1 | +import { useRouter } from "next/router"; |
| 2 | +import { ReactNode, useEffect, useRef } from "react"; |
| 3 | +import { FOCUS_ELEMS } from "../common/constans"; |
| 4 | + |
| 5 | +type Direction = "up" | "down" | "left" | "right"; |
| 6 | +type GamepadAction = Direction | "click"; |
| 7 | + |
| 8 | +type Candidate = { |
| 9 | + element: HTMLElement; |
| 10 | + rect: DOMRect; |
| 11 | + centerX: number; |
| 12 | + centerY: number; |
| 13 | +}; |
| 14 | + |
| 15 | +const EXTRA_FOCUS_ELEMS = [ |
| 16 | + '[role="button"]', |
| 17 | + '[role="checkbox"]', |
| 18 | + '[role="combobox"]', |
| 19 | + '[role="radio"]', |
| 20 | + '[role="slider"]', |
| 21 | + '[role="switch"]', |
| 22 | + '[role="tab"]', |
| 23 | + '[role="menuitem"]', |
| 24 | + '[role="option"]', |
| 25 | + '[data-focusable="true"]', |
| 26 | +].join(", "); |
| 27 | + |
| 28 | +const FOCUSABLE_SELECTOR = `${FOCUS_ELEMS}, ${EXTRA_FOCUS_ELEMS}`; |
| 29 | +const OVERLAY_SELECTOR = '[role="dialog"], [role="menu"], [role="listbox"]'; |
| 30 | +const DISABLED_PAGES = new Set(["stream", "test", "nativeTest", "map"]); |
| 31 | + |
| 32 | +const INITIAL_REPEAT_DELAY_MS = 280; |
| 33 | +const REPEAT_INTERVAL_MS = 130; |
| 34 | +const AXIS_PRESS_THRESHOLD = 0.55; |
| 35 | +const AXIS_RELEASE_THRESHOLD = 0.35; |
| 36 | + |
| 37 | +const isHTMLElement = (value: unknown): value is HTMLElement => { |
| 38 | + return value instanceof HTMLElement; |
| 39 | +}; |
| 40 | + |
| 41 | +const isVisible = (element: HTMLElement) => { |
| 42 | + const rect = element.getBoundingClientRect(); |
| 43 | + const style = window.getComputedStyle(element); |
| 44 | + return ( |
| 45 | + rect.width > 0 && |
| 46 | + rect.height > 0 && |
| 47 | + style.visibility !== "hidden" && |
| 48 | + style.display !== "none" |
| 49 | + ); |
| 50 | +}; |
| 51 | + |
| 52 | +const isFocusable = (element: HTMLElement) => { |
| 53 | + if (!isVisible(element)) { |
| 54 | + return false; |
| 55 | + } |
| 56 | + |
| 57 | + if (element.closest("[aria-hidden='true'], [inert]")) { |
| 58 | + return false; |
| 59 | + } |
| 60 | + |
| 61 | + if ( |
| 62 | + element.hasAttribute("disabled") || |
| 63 | + element.getAttribute("aria-disabled") === "true" |
| 64 | + ) { |
| 65 | + return false; |
| 66 | + } |
| 67 | + |
| 68 | + return true; |
| 69 | +}; |
| 70 | + |
| 71 | +const getCandidates = (root: ParentNode): Candidate[] => { |
| 72 | + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)) |
| 73 | + .filter(isHTMLElement) |
| 74 | + .filter(isFocusable) |
| 75 | + .map((element) => { |
| 76 | + const rect = element.getBoundingClientRect(); |
| 77 | + return { |
| 78 | + element, |
| 79 | + rect, |
| 80 | + centerX: rect.left + rect.width / 2, |
| 81 | + centerY: rect.top + rect.height / 2, |
| 82 | + }; |
| 83 | + }); |
| 84 | +}; |
| 85 | + |
| 86 | +const getActiveScope = () => { |
| 87 | + const activeElement = document.activeElement; |
| 88 | + if (isHTMLElement(activeElement)) { |
| 89 | + const activeOverlay = activeElement.closest(OVERLAY_SELECTOR); |
| 90 | + if (activeOverlay && isHTMLElement(activeOverlay) && isVisible(activeOverlay)) { |
| 91 | + return activeOverlay; |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + const overlays = Array.from(document.querySelectorAll(OVERLAY_SELECTOR)) |
| 96 | + .filter(isHTMLElement) |
| 97 | + .filter(isVisible) |
| 98 | + .filter((overlay) => getCandidates(overlay).length > 0); |
| 99 | + |
| 100 | + return overlays[overlays.length - 1] || document.body; |
| 101 | +}; |
| 102 | + |
| 103 | +const isSameElement = (a: HTMLElement, b: HTMLElement) => a === b; |
| 104 | + |
| 105 | +const sortByVisualOrder = (items: Candidate[]) => { |
| 106 | + return [...items].sort((a, b) => { |
| 107 | + const verticalDelta = a.rect.top - b.rect.top; |
| 108 | + if (Math.abs(verticalDelta) > 8) { |
| 109 | + return verticalDelta; |
| 110 | + } |
| 111 | + return a.rect.left - b.rect.left; |
| 112 | + }); |
| 113 | +}; |
| 114 | + |
| 115 | +const overlaps = (aStart: number, aEnd: number, bStart: number, bEnd: number) => { |
| 116 | + return Math.max(aStart, bStart) <= Math.min(aEnd, bEnd); |
| 117 | +}; |
| 118 | + |
| 119 | +const isInDirection = (from: Candidate, to: Candidate, direction: Direction) => { |
| 120 | + switch (direction) { |
| 121 | + case "up": |
| 122 | + return to.centerY < from.centerY - 4; |
| 123 | + case "down": |
| 124 | + return to.centerY > from.centerY + 4; |
| 125 | + case "left": |
| 126 | + return to.centerX < from.centerX - 4; |
| 127 | + case "right": |
| 128 | + return to.centerX > from.centerX + 4; |
| 129 | + } |
| 130 | +}; |
| 131 | + |
| 132 | +const scoreCandidate = (from: Candidate, to: Candidate, direction: Direction) => { |
| 133 | + const isVertical = direction === "up" || direction === "down"; |
| 134 | + const primaryDistance = isVertical |
| 135 | + ? Math.abs(to.centerY - from.centerY) |
| 136 | + : Math.abs(to.centerX - from.centerX); |
| 137 | + const perpendicularDistance = isVertical |
| 138 | + ? Math.abs(to.centerX - from.centerX) |
| 139 | + : Math.abs(to.centerY - from.centerY); |
| 140 | + const hasOverlap = isVertical |
| 141 | + ? overlaps(from.rect.left, from.rect.right, to.rect.left, to.rect.right) |
| 142 | + : overlaps(from.rect.top, from.rect.bottom, to.rect.top, to.rect.bottom); |
| 143 | + |
| 144 | + return primaryDistance * 3 + perpendicularDistance + (hasOverlap ? 0 : 220); |
| 145 | +}; |
| 146 | + |
| 147 | +const findNextCandidate = ( |
| 148 | + candidates: Candidate[], |
| 149 | + activeElement: HTMLElement | null, |
| 150 | + direction: Direction |
| 151 | +) => { |
| 152 | + if (candidates.length < 1) { |
| 153 | + return null; |
| 154 | + } |
| 155 | + |
| 156 | + if (!activeElement) { |
| 157 | + return sortByVisualOrder(candidates)[0]; |
| 158 | + } |
| 159 | + |
| 160 | + const current = |
| 161 | + candidates.find((candidate) => isSameElement(candidate.element, activeElement)) || |
| 162 | + candidates.find((candidate) => candidate.element.contains(activeElement)); |
| 163 | + |
| 164 | + if (!current) { |
| 165 | + return sortByVisualOrder(candidates)[0]; |
| 166 | + } |
| 167 | + |
| 168 | + return candidates |
| 169 | + .filter((candidate) => !isSameElement(candidate.element, current.element)) |
| 170 | + .filter((candidate) => isInDirection(current, candidate, direction)) |
| 171 | + .sort((a, b) => scoreCandidate(current, a, direction) - scoreCandidate(current, b, direction))[0] || null; |
| 172 | +}; |
| 173 | + |
| 174 | +const focusElement = (element: HTMLElement) => { |
| 175 | + document.documentElement.classList.add("gamepad-navigation-active"); |
| 176 | + element.focus({ preventScroll: true }); |
| 177 | + element.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); |
| 178 | +}; |
| 179 | + |
| 180 | +const clickElement = (element: HTMLElement) => { |
| 181 | + document.documentElement.classList.add("gamepad-navigation-active"); |
| 182 | + element.click(); |
| 183 | +}; |
| 184 | + |
| 185 | +const getPrimaryGamepad = () => { |
| 186 | + const gamepads = navigator.getGamepads(); |
| 187 | + for (let index = 0; index < gamepads.length; index++) { |
| 188 | + if (gamepads[index]?.connected) { |
| 189 | + return gamepads[index]; |
| 190 | + } |
| 191 | + } |
| 192 | + return null; |
| 193 | +}; |
| 194 | + |
| 195 | +const getAxisDirection = (axisValue: number, negative: Direction, positive: Direction) => { |
| 196 | + if (axisValue <= -AXIS_PRESS_THRESHOLD) { |
| 197 | + return negative; |
| 198 | + } |
| 199 | + if (axisValue >= AXIS_PRESS_THRESHOLD) { |
| 200 | + return positive; |
| 201 | + } |
| 202 | + return null; |
| 203 | +}; |
| 204 | + |
| 205 | +const getAction = (gamepad: Gamepad, lastAxisAction: Direction | null): GamepadAction | null => { |
| 206 | + if (gamepad.buttons[0]?.pressed) { |
| 207 | + return "click"; |
| 208 | + } |
| 209 | + if (gamepad.buttons[12]?.pressed) { |
| 210 | + return "up"; |
| 211 | + } |
| 212 | + if (gamepad.buttons[13]?.pressed) { |
| 213 | + return "down"; |
| 214 | + } |
| 215 | + if (gamepad.buttons[14]?.pressed) { |
| 216 | + return "left"; |
| 217 | + } |
| 218 | + if (gamepad.buttons[15]?.pressed) { |
| 219 | + return "right"; |
| 220 | + } |
| 221 | + |
| 222 | + const xAxis = Number(gamepad.axes[0]) || 0; |
| 223 | + const yAxis = Number(gamepad.axes[1]) || 0; |
| 224 | + if (Math.abs(xAxis) < AXIS_RELEASE_THRESHOLD && Math.abs(yAxis) < AXIS_RELEASE_THRESHOLD) { |
| 225 | + return null; |
| 226 | + } |
| 227 | + |
| 228 | + const dominantDirection = Math.abs(xAxis) > Math.abs(yAxis) |
| 229 | + ? getAxisDirection(xAxis, "left", "right") |
| 230 | + : getAxisDirection(yAxis, "up", "down"); |
| 231 | + |
| 232 | + return dominantDirection || lastAxisAction; |
| 233 | +}; |
| 234 | + |
| 235 | +const getPageName = (pathname: string) => { |
| 236 | + const parts = pathname.split("/").filter(Boolean); |
| 237 | + return parts[parts.length - 1] || ""; |
| 238 | +}; |
| 239 | + |
| 240 | +export default function GamepadNavigationProvider({ children }: { children: ReactNode }) { |
| 241 | + const router = useRouter(); |
| 242 | + const heldActionRef = useRef<GamepadAction | null>(null); |
| 243 | + const lastAxisActionRef = useRef<Direction | null>(null); |
| 244 | + const nextRepeatAtRef = useRef(0); |
| 245 | + const rafRef = useRef<number | null>(null); |
| 246 | + |
| 247 | + useEffect(() => { |
| 248 | + const clearInputState = () => { |
| 249 | + heldActionRef.current = null; |
| 250 | + lastAxisActionRef.current = null; |
| 251 | + nextRepeatAtRef.current = 0; |
| 252 | + }; |
| 253 | + |
| 254 | + const handlePointerInput = () => { |
| 255 | + document.documentElement.classList.remove("gamepad-navigation-active"); |
| 256 | + }; |
| 257 | + |
| 258 | + window.addEventListener("mousedown", handlePointerInput, true); |
| 259 | + window.addEventListener("touchstart", handlePointerInput, true); |
| 260 | + window.addEventListener("keydown", handlePointerInput, true); |
| 261 | + |
| 262 | + const runAction = (action: GamepadAction) => { |
| 263 | + const scope = getActiveScope(); |
| 264 | + const candidates = getCandidates(scope); |
| 265 | + const activeElement = isHTMLElement(document.activeElement) |
| 266 | + ? document.activeElement |
| 267 | + : null; |
| 268 | + |
| 269 | + if (action === "click") { |
| 270 | + const currentCandidate = activeElement |
| 271 | + ? candidates.find((candidate) => candidate.element === activeElement) || |
| 272 | + candidates.find((candidate) => candidate.element.contains(activeElement)) |
| 273 | + : null; |
| 274 | + const target = currentCandidate?.element || sortByVisualOrder(candidates)[0]?.element; |
| 275 | + if (target) { |
| 276 | + clickElement(target); |
| 277 | + } |
| 278 | + return; |
| 279 | + } |
| 280 | + |
| 281 | + const nextCandidate = findNextCandidate(candidates, activeElement, action); |
| 282 | + if (nextCandidate) { |
| 283 | + focusElement(nextCandidate.element); |
| 284 | + } |
| 285 | + }; |
| 286 | + |
| 287 | + const update = () => { |
| 288 | + const pageName = getPageName(router.pathname); |
| 289 | + if (DISABLED_PAGES.has(pageName)) { |
| 290 | + clearInputState(); |
| 291 | + rafRef.current = window.requestAnimationFrame(update); |
| 292 | + return; |
| 293 | + } |
| 294 | + |
| 295 | + const gamepad = getPrimaryGamepad(); |
| 296 | + const action = gamepad ? getAction(gamepad, lastAxisActionRef.current) : null; |
| 297 | + const now = performance.now(); |
| 298 | + |
| 299 | + if (!action) { |
| 300 | + clearInputState(); |
| 301 | + } else if (action !== heldActionRef.current) { |
| 302 | + runAction(action); |
| 303 | + heldActionRef.current = action; |
| 304 | + lastAxisActionRef.current = action === "click" ? null : action; |
| 305 | + nextRepeatAtRef.current = now + INITIAL_REPEAT_DELAY_MS; |
| 306 | + } else if (action !== "click" && now >= nextRepeatAtRef.current) { |
| 307 | + runAction(action); |
| 308 | + nextRepeatAtRef.current = now + REPEAT_INTERVAL_MS; |
| 309 | + } |
| 310 | + |
| 311 | + rafRef.current = window.requestAnimationFrame(update); |
| 312 | + }; |
| 313 | + |
| 314 | + rafRef.current = window.requestAnimationFrame(update); |
| 315 | + |
| 316 | + return () => { |
| 317 | + window.removeEventListener("mousedown", handlePointerInput, true); |
| 318 | + window.removeEventListener("touchstart", handlePointerInput, true); |
| 319 | + window.removeEventListener("keydown", handlePointerInput, true); |
| 320 | + if (rafRef.current !== null) { |
| 321 | + window.cancelAnimationFrame(rafRef.current); |
| 322 | + } |
| 323 | + }; |
| 324 | + }, [router.pathname]); |
| 325 | + |
| 326 | + return <>{children}</>; |
| 327 | +} |
0 commit comments