Fix season browser focus: land on first episode on down, ignore parked-cursor hover

- Add data-nav-entry-col-from-above so moving down from the season
  selector always focuses the first episode instead of the tile
  visually closest to the selected season poster; moving back up
  still returns to the current season.
- Require recent real mouse movement before treating mouseover as
  mouse intent: scroll/re-render under a stationary cursor no longer
  activates mouse mode, so sideways season browsing no longer loses
  focus to the episode tile under the parked pointer.
This commit is contained in:
LeoVasanko committed 2026-09-23 21:30:17 +00:00
1 parent 9041061e86
commit 931f00f647
3 files changed
+33 -1

No files matched your search

@@ -548,6 +548,9 @@ function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) {
...navAttrs(coords.row, coords.col),
"data-season-index": seasonIndex,
"data-episode-index": episodeIndex,
// Entering the episode grid from above (the season selector) always
// lands on the first episode instead of the visually closest tile.
...(episodeIndex === 0 ? { "data-nav-entry-col-from-above": "0" } : {}),
}
}
@@ -6,6 +6,7 @@ type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700
const MOUSE_OVER_INTENT_RECENCY_MS = 500
const MOUSE_INTENT_SELECTOR = [
"[data-nav-focusable]",
"button",
@@ -28,6 +29,7 @@ let mouseIdleTimer: number | null = null
let pointerVisible = false
let mouseTravelPx = 0
let lastMouseMoveAt = 0
let lastAnyMouseMoveAt = 0
function clearMouseIdleTimer() {
if (mouseIdleTimer !== null) {
@@ -95,6 +97,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
function handleMouseMove(event: MouseEvent) {
reportUserActivity()
showPointerFromMotion()
lastAnyMouseMoveAt = performance.now()
if (modality === "mouse") {
applyInputState(true)
@@ -111,6 +114,13 @@ function handleMouseOver(event: MouseEvent) {
if (modality === "mouse") return
if (!isMouseIntentTarget(event.target)) return
// Browsers fire mouseover/mouseenter when scrolling or re-rendering moves
// content under a stationary cursor (e.g. sideways season browsing while
// the pointer happens to rest over the episode grid). Without recent real
// pointer motion this is not mouse intent — activating mouse input here
// would let hover handlers steal keyboard/gamepad focus.
if (performance.now() - lastAnyMouseMoveAt > MOUSE_OVER_INTENT_RECENCY_MS) return
// Entering an interactive target indicates likely mouse intent.
activateMouseInput()
}
@@ -36,6 +36,10 @@ const FOCUSABLE_ATTR = "data-nav-focusable"
const ROW_ATTR = "data-nav-row"
const COL_ATTR = "data-nav-col"
const ENTRY_COL_ATTR = "data-nav-entry-col"
// Like ENTRY_COL_ATTR, but only applies when entering the row from above
// (ArrowDown). Lets a row declare a fixed landing column for downward entry
// without hijacking upward or horizontal moves.
const ENTRY_COL_FROM_ABOVE_ATTR = "data-nav-entry-col-from-above"
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
@@ -672,8 +676,23 @@ function findNextElement(
desiredCol.value = currentCol
}
const entryTarget = findElementAt(targetRow, targetCol, true)
const targetRowElements = byRow.get(targetRow) ?? []
if (direction === "down") {
for (const el of targetRowElements) {
const fromAboveCol = el.element.getAttribute(ENTRY_COL_FROM_ABOVE_ATTR)
if (fromAboveCol === null) continue
const fromAboveTarget = targetRowElements.find(
(e) => e.col === parseInt(fromAboveCol, 10),
)
if (fromAboveTarget) {
desiredCol.value = fromAboveTarget.col
return fromAboveTarget.element
}
}
}
const entryTarget = findElementAt(targetRow, targetCol, true)
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
if (hasEntryOverride) {
return entryTarget?.element || null