import { ref } from "vue" export interface FocusableElement { element: HTMLElement row: number col: number } export type NavDirection = "up" | "down" | "left" | "right" export interface OutOfBoundsNavigationContext { current: HTMLElement direction: NavDirection currentRow: number currentCol: number byRow: Map } export type OutOfBoundsNavigationHandler = ( context: OutOfBoundsNavigationContext, ) => HTMLElement | null | undefined // Global focus state const focusedElement = ref(null) const isNavigating = ref(false) const activeNavigationScope = ref(null) // Track the "desired" column when moving vertically (to maintain column position across rows of different lengths) const desiredCol = ref(null) // Track if global handlers are installed let handlersInstalled = false // Track open modal count — when > 0, global keyboard navigation is suspended let modalOpenCount = 0 // Data attribute names 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" const SYNC_SCROLL_FIRST_CONTENT_ROW = 2 const SYNC_SCROLL_DEADZONE_RATIO = 0.18 const SYNC_SCROLL_EASING_MS = 220 const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail" const SYNC_SCROLL_LEFT_DEADZONE_VAR = "--sync-row-left-deadzone" const SYNC_SCROLL_RIGHT_DEADZONE_VAR = "--sync-row-right-deadzone" // --------------------------------------------------------------------------- // Synced scroll state — global virtual offset, per-row clamping // --------------------------------------------------------------------------- let syncedRowsFrame: number | null = null let syncedRowsCurrentOffset = 0 let syncedRowsTargetOffset = 0 let lastSyncedAnchorCol: number | null = null let lastSyncedRowsAnimationAt: number | null = null let activeSyncedScrollGroup = DEFAULT_SYNC_SCROLL_GROUP // Global metrics measured once from the first synced row. All calculations use // these same values for every row to avoid per-row DOM query inconsistencies. interface ScrollMetrics { cardWidth: number stride: number // cardWidth + gap paddingLeft: number viewportWidth: number leftDeadzone: number rightDeadzone: number } const gMetricsByGroup = new Map() function getRowSyncGroup(row: HTMLElement): string { return row.getAttribute(SYNC_SCROLL_GROUP_ATTR) || DEFAULT_SYNC_SCROLL_GROUP } function getSyncRowsByGroup(group: string): HTMLElement[] { return getSyncedRows().filter((row) => getRowSyncGroup(row) === group) } function invalidateMetrics(group?: string) { if (group) { gMetricsByGroup.delete(group) return } gMetricsByGroup.clear() } function measureGlobalMetrics(group: string): boolean { const rows = getSyncRowsByGroup(group) for (const row of rows) { const cards = row.querySelectorAll(`.media-card[${FOCUSABLE_ATTR}]`) if (cards.length < 2) continue const r1 = cards[0].getBoundingClientRect() const r2 = cards[1].getBoundingClientRect() if (r1.width <= 0) continue const cardWidth = r1.width const stride = r2.left - r1.left const rowStyle = window.getComputedStyle(row) const paddingLeft = parseFloat(rowStyle.paddingLeft || "0") const viewportWidth = row.clientWidth const deadzoneInset = Math.max( paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO, ) const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim() const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw)) ? Math.max(0, parseFloat(leftDeadzoneRaw)) : deadzoneInset const rightDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_RIGHT_DEADZONE_VAR).trim() const rightDeadzone = Number.isFinite(parseFloat(rightDeadzoneRaw)) ? Math.max(0, parseFloat(rightDeadzoneRaw)) : deadzoneInset gMetricsByGroup.set(group, { cardWidth, stride, paddingLeft, viewportWidth, leftDeadzone, rightDeadzone, }) return true } return false } function getMetrics(group: string): ScrollMetrics | null { let metrics = gMetricsByGroup.get(group) || null if (!metrics) { measureGlobalMetrics(group) metrics = gMetricsByGroup.get(group) || null } return metrics } // On first navigation into a view, snap the global virtual offset to whatever // the first synced row is already scrolled to. This avoids animating from 0 // every time the view is entered. function initCurrentOffsetFromDOM(group: string) { if (activeSyncedScrollGroup !== group) { stopSyncedRowAnimation() activeSyncedScrollGroup = group syncedRowsCurrentOffset = 0 syncedRowsTargetOffset = 0 } if (syncedRowsCurrentOffset !== 0) return const rows = getSyncRowsByGroup(group) for (const row of rows) { const sl = row.scrollLeft if (sl > 0) { syncedRowsCurrentOffset = sl syncedRowsTargetOffset = sl return } } } function getSyncedRows(): HTMLElement[] { return Array.from(document.querySelectorAll(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)) } function getRowItemCount(row: HTMLElement): number { return row.querySelectorAll(`.media-card[${FOCUSABLE_ATTR}]`).length } // The maximum scroll offset for a row: the exact offset that would put its // last item at the right edge of the safe zone. Computed purely from global // metrics and item count — no DOM queries. function getRowMaxScroll(row: HTMLElement): number { const m = getMetrics(getRowSyncGroup(row)) if (!m) return 0 const n = getRowItemCount(row) if (n === 0) return 0 const lastCol = n - 1 const lastItemLeft = m.paddingLeft + lastCol * m.stride const maxVisibleLeft = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone) return Math.max(0, lastItemLeft - maxVisibleLeft) } function clampRowScrollOffset(row: HTMLElement, offset: number): number { return Math.min(Math.max(offset, 0), getRowMaxScroll(row)) } function applySyncedRowScroll( offset: number, rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup), ) { for (const row of rows) { row.scrollLeft = clampRowScrollOffset(row, offset) } } function setAllRowTails( tailPx: number, rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup), ) { const value = `${Math.max(0, tailPx)}px` for (const row of rows) { row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value) } } function resetSyncedRows(immediate: boolean = false) { lastSyncedAnchorCol = null syncedRowsTargetOffset = 0 invalidateMetrics() setAllRowTails(0) if (immediate) { syncedRowsCurrentOffset = 0 applySyncedRowScroll(0) lastSyncedRowsAnimationAt = null stopSyncedRowAnimation() return } if (Math.abs(syncedRowsCurrentOffset) < 0.5) { syncedRowsCurrentOffset = 0 applySyncedRowScroll(0) lastSyncedRowsAnimationAt = null stopSyncedRowAnimation() return } startSyncedRowAnimation() } function startSyncedRowAnimation() { if (syncedRowsFrame !== null) return syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows) } function stopSyncedRowAnimation() { if (syncedRowsFrame !== null) { window.cancelAnimationFrame(syncedRowsFrame) syncedRowsFrame = null } lastSyncedRowsAnimationAt = null } function animateSyncedRows(now: number) { const rows = getSyncRowsByGroup(activeSyncedScrollGroup) if (rows.length === 0) { stopSyncedRowAnimation() return } const delta = syncedRowsTargetOffset - syncedRowsCurrentOffset const elapsedMs = lastSyncedRowsAnimationAt === null ? 16 : Math.max(1, now - lastSyncedRowsAnimationAt) lastSyncedRowsAnimationAt = now const alpha = 1 - Math.exp(-elapsedMs / SYNC_SCROLL_EASING_MS) syncedRowsCurrentOffset += delta * alpha applySyncedRowScroll(syncedRowsCurrentOffset, rows) if (Math.abs(delta) < 0.5) { syncedRowsCurrentOffset = syncedRowsTargetOffset applySyncedRowScroll(syncedRowsCurrentOffset, rows) stopSyncedRowAnimation() return } syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows) } function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null = null) { const group = anchorRow ? getRowSyncGroup(anchorRow) : DEFAULT_SYNC_SCROLL_GROUP const rows = getSyncRowsByGroup(group) if (rows.length === 0) return initCurrentOffsetFromDOM(group) const m = getMetrics(group) if (!m) return const itemLeft = m.paddingLeft + anchorCol * m.stride const leftVisibleLimit = Math.max(m.paddingLeft, m.leftDeadzone) const rightVisibleLimit = Math.max(m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone) // Keep focus inside the deadzone: no scroll while the focused item remains // between left and right limits. const itemViewportLeft = itemLeft - syncedRowsCurrentOffset let desiredOffset = syncedRowsCurrentOffset if (itemViewportLeft > rightVisibleLimit) { desiredOffset = itemLeft - rightVisibleLimit } else if (itemViewportLeft < leftVisibleLimit) { desiredOffset = itemLeft - leftVisibleLimit } desiredOffset = Math.max(0, desiredOffset) // Clamp to the anchor row's own limit so the global target is always // reachable by the row that drove the navigation. if (anchorRow) { desiredOffset = Math.min(desiredOffset, getRowMaxScroll(anchorRow)) } // If the anchor row fits entirely on screen, keep it left-aligned. if (anchorRow && getRowMaxScroll(anchorRow) === 0) { desiredOffset = 0 } // Tail is a fixed viewport constant applied to all rows. const fixedTail = m.rightDeadzone let anyTailChanged = false for (const row of rows) { const currentTailRaw = row.style.getPropertyValue(SYNC_SCROLL_TAIL_VAR).trim() const currentTail = currentTailRaw ? parseFloat(currentTailRaw) : 0 if (Math.abs(fixedTail - currentTail) >= 0.5) { row.style.setProperty(SYNC_SCROLL_TAIL_VAR, `${fixedTail}px`) anyTailChanged = true } } if (anyTailChanged) { // Force layout recalc so scrollWidth is up to date before we clamp. for (const row of rows) { void row.scrollWidth } } lastSyncedAnchorCol = anchorCol syncedRowsTargetOffset = desiredOffset if (!anchorRow) { setAllRowTails(0, rows) for (const row of rows) { void row.scrollWidth } } if (Math.abs(syncedRowsTargetOffset - syncedRowsCurrentOffset) < 0.5) { syncedRowsCurrentOffset = syncedRowsTargetOffset applySyncedRowScroll(syncedRowsCurrentOffset, rows) stopSyncedRowAnimation() return } startSyncedRowAnimation() } function getLocalSyncedRowCol( anchorRow: HTMLElement, element: HTMLElement, requestedCol: number, ): number { const cards = Array.from( anchorRow.querySelectorAll(`.media-card[${FOCUSABLE_ATTR}]`), ) if (cards.length === 0) return Math.max(0, requestedCol) const cardCols = cards .map((card) => parseInt(card.getAttribute(COL_ATTR) || "", 10)) .filter((col) => Number.isFinite(col)) if (cardCols.length > 0) { const minCol = Math.min(...cardCols) const localFromRequested = requestedCol - minCol return Math.max(0, Math.min(localFromRequested, cards.length - 1)) } const fallbackIndex = cards.indexOf(element) if (fallbackIndex >= 0) return fallbackIndex return Math.max(0, Math.min(requestedCol, cards.length - 1)) } function syncRowsToElement(element: HTMLElement) { const row = parseInt(element.getAttribute(ROW_ATTR) || "0", 10) if (row < SYNC_SCROLL_FIRST_CONTENT_ROW) { resetSyncedRows() return } const currentCol = parseInt(element.getAttribute(COL_ATTR) || "0", 10) const anchorRow = element.closest(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) if (!anchorRow) { resetSyncedRows() return } const requestedCol = desiredCol.value ?? currentCol const anchorCol = getLocalSyncedRowCol(anchorRow, element, requestedCol) updateSyncedRowTarget(anchorCol, anchorRow) } function handleSyncedRowResize() { const activeRow = focusedElement.value?.closest(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) if (activeRow) { invalidateMetrics(getRowSyncGroup(activeRow)) } else { invalidateMetrics() } if (lastSyncedAnchorCol === null) { resetSyncedRows(true) return } updateSyncedRowTarget(lastSyncedAnchorCol, activeRow || null) } function ensureElementVisibleVertically(element: HTMLElement) { const scrollContainer = element.closest("[data-nav-scope]") const viewportRect = scrollContainer?.getBoundingClientRect() || null const scrollByTop = (delta: number) => { if (scrollContainer) { scrollContainer.scrollBy({ top: delta, behavior: "smooth", }) return } window.scrollBy({ top: delta, behavior: "smooth", }) } if (element.hasAttribute("data-nav-release-item")) { const rootStyle = window.getComputedStyle(document.documentElement) const headerHeight = parseFloat(rootStyle.getPropertyValue("--header-height") || "0") const topMargin = headerHeight + 24 const bottomMargin = 24 const rect = element.getBoundingClientRect() const topBoundary = (viewportRect?.top || 0) + topMargin const bottomBoundary = (viewportRect?.bottom || window.innerHeight) - bottomMargin if (rect.top < topBoundary) { scrollByTop(rect.top - topBoundary) return } if (rect.bottom > bottomBoundary) { scrollByTop(rect.bottom - bottomBoundary) } return } const rootStyle = window.getComputedStyle(document.documentElement) const headerHeight = parseFloat(rootStyle.getPropertyValue("--header-height") || "0") const topMargin = headerHeight + 24 const bottomMargin = 24 const rect = element.getBoundingClientRect() const topBoundary = (viewportRect?.top || 0) + topMargin const bottomBoundary = (viewportRect?.bottom || window.innerHeight) - bottomMargin if (rect.top < topBoundary) { scrollByTop(rect.top - topBoundary) return } if (rect.bottom > bottomBoundary) { scrollByTop(rect.bottom - bottomBoundary) } } // --------------------------------------------------------------------------- // Element finding / navigation (unchanged logic, uses getMetrics() now) // --------------------------------------------------------------------------- function resolveOutOfBoundsNavigation(context: OutOfBoundsNavigationContext): HTMLElement | null { const handlers = Array.from(outOfBoundsHandlers) for (let i = handlers.length - 1; i >= 0; i--) { const result = handlers[i]?.(context) if (result) return result } return null } const outOfBoundsHandlers = new Set() function getElementNavigationScope(element: HTMLElement): string | null { const owner = element.closest("[data-nav-scope]") return owner?.getAttribute("data-nav-scope") || null } function isElementInActiveScope(element: HTMLElement): boolean { const scope = activeNavigationScope.value if (!scope) return true const elementScope = getElementNavigationScope(element) // Elements without a scope (for example global header controls) stay reachable. if (!elementScope) return true return elementScope === scope } function clearFocusedElement() { if (!focusedElement.value) return focusedElement.value.classList.remove("nav-focused") focusedElement.value.blur() focusedElement.value = null } function getFocusableElements(): FocusableElement[] { const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`) const result: FocusableElement[] = [] elements.forEach((el) => { const htmlEl = el as HTMLElement if (!isElementInActiveScope(htmlEl)) return if (htmlEl.offsetParent === null) return const rect = htmlEl.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) return const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || "0", 10) const col = parseInt(htmlEl.getAttribute(COL_ATTR) || "0", 10) result.push({ element: htmlEl, row, col, }) }) return result } function getElementsByRow(): Map { const elements = getFocusableElements() const byRow = new Map() for (const el of elements) { if (!byRow.has(el.row)) { byRow.set(el.row, []) } byRow.get(el.row)!.push(el) } for (const [, rowElements] of byRow) { rowElements.sort((a, b) => a.col - b.col) } return byRow } function findElementAt( row: number, col: number, useEntryCol: boolean = false, ): FocusableElement | null { const byRow = getElementsByRow() const rowElements = byRow.get(row) if (!rowElements || rowElements.length === 0) return null if (useEntryCol) { for (const el of rowElements) { const entryCol = el.element.getAttribute(ENTRY_COL_ATTR) if (entryCol !== null) { const overrideCol = parseInt(entryCol, 10) const entryTarget = rowElements.find((e) => e.col === overrideCol) if (entryTarget) return entryTarget } } } const exact = rowElements.find((e) => e.col === col) if (exact) return exact let nearest = rowElements[0] let nearestDist = Math.abs(nearest.col - col) for (const el of rowElements) { const dist = Math.abs(el.col - col) if (dist < nearestDist) { nearest = el nearestDist = dist } } return nearest } function findElementClosestToLogicalViewportX( row: number, logicalViewportCenterX: number, preferredCol: number, metrics: { cardWidth: number; stride: number; paddingLeft: number } | null, ): FocusableElement | null { const byRow = getElementsByRow() const rowElements = byRow.get(row) if (!rowElements || rowElements.length === 0) return null let nearest: FocusableElement | null = null let nearestViewportDist = Number.POSITIVE_INFINITY let nearestColDist = Number.POSITIVE_INFINITY for (const candidate of rowElements) { let candidateCenterX: number if (metrics) { candidateCenterX = metrics.paddingLeft + candidate.col * metrics.stride - syncedRowsCurrentOffset + metrics.cardWidth / 2 } else { const rect = candidate.element.getBoundingClientRect() candidateCenterX = rect.left + rect.width / 2 } const viewportDist = Math.abs(candidateCenterX - logicalViewportCenterX) const colDist = Math.abs(candidate.col - preferredCol) if ( viewportDist < nearestViewportDist || (Math.abs(viewportDist - nearestViewportDist) < 0.5 && colDist < nearestColDist) ) { nearest = candidate nearestViewportDist = viewportDist nearestColDist = colDist } } return nearest } function findNextElement(current: HTMLElement, direction: NavDirection): HTMLElement | null { const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10) const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10) const byRow = getElementsByRow() if (direction === "left" || direction === "right") { desiredCol.value = null const rowElements = byRow.get(currentRow) if (!rowElements) return null const delta = direction === "right" ? 1 : -1 const targetCol = currentCol + delta const target = rowElements.find((e) => e.col === targetCol) if (target?.element) return target.element return resolveOutOfBoundsNavigation({ current, direction, currentRow, currentCol, byRow, }) } else { const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b) const currentRowIdx = sortedRows.indexOf(currentRow) if (currentRowIdx === -1) return null const delta = direction === "down" ? 1 : -1 const targetRowIdx = currentRowIdx + delta if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) { return resolveOutOfBoundsNavigation({ current, direction, currentRow, currentCol, byRow, }) } const targetRow = sortedRows[targetRowIdx] const targetCol = desiredCol.value ?? currentCol if (desiredCol.value === null) { desiredCol.value = currentCol } 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 } const activeRow = current.closest(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) const m = activeRow ? getMetrics(getRowSyncGroup(activeRow)) : null const currentRect = current.getBoundingClientRect() const actualCurrentCenterX = currentRect.left + currentRect.width / 2 const closestByViewport = findElementClosestToLogicalViewportX( targetRow, actualCurrentCenterX, targetCol, m, ) const resolved = closestByViewport?.element || entryTarget?.element || null if (resolved) return resolved return resolveOutOfBoundsNavigation({ current, direction, currentRow, currentCol, byRow, }) } } function focusElement(element: HTMLElement | null, options?: { preserveScroll?: boolean }) { if (!element) return if (!isElementInActiveScope(element)) return if (focusedElement.value && focusedElement.value !== element) { focusedElement.value.classList.remove("nav-focused") focusedElement.value.blur() } element.classList.add("nav-focused") element.focus({ preventScroll: true }) if (!options?.preserveScroll) { ensureElementVisibleVertically(element) syncRowsToElement(element) } focusedElement.value = element } function getFocusState(): { row: number; col: number } | null { if (!focusedElement.value) return null const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || "0", 10) const col = parseInt(focusedElement.value.getAttribute(COL_ATTR) || "0", 10) return { row, col } } function restoreFocusState( state: { row: number; col: number } | null, options?: { preserveScroll?: boolean }, ) { if (!state) return const target = findElementAt(state.row, state.col) if (target) { setTimeout(() => { focusElement(target.element, options) }, 50) } } function focusAt(row: number, col: number, delay: number = 100) { setTimeout(() => { const target = findElementAt(row, col) if (target) { focusElement(target.element) } }, delay) } function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean { if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA" && !target.isContentEditable) { return true } if (direction === "up" || direction === "down") { return true } const inputEl = target as HTMLInputElement | HTMLTextAreaElement const value = inputEl.value || "" return value.length === 0 } function handleKeyDown(event: KeyboardEvent) { if (modalOpenCount > 0) return const target = event.target as HTMLElement const direction = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", }[event.key] as "up" | "down" | "left" | "right" | undefined if (!direction) return if (!shouldAllowNavigationFromInput(target, direction)) { return } event.preventDefault() isNavigating.value = true let current = focusedElement.value if (current && !isElementInActiveScope(current)) { current = null } if (!current) { const activeElement = document.activeElement as HTMLElement if ( activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR) && isElementInActiveScope(activeElement) ) { current = activeElement } } if (!current) { const elements = getFocusableElements() if (elements.length > 0) { focusElement(elements[0].element) } return } const next = findNextElement(current, direction) if (next) { focusElement(next) } } function handleEnterKey(event: KeyboardEvent) { if (modalOpenCount > 0) return if (event.key !== "Enter") return if (event.defaultPrevented) return if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return const target = event.target as HTMLElement if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") { return } if (focusedElement.value && isElementInActiveScope(focusedElement.value)) { event.preventDefault() focusedElement.value.click() } } export function setActiveNavigationScope(scope: string | null) { if (activeNavigationScope.value === scope) return activeNavigationScope.value = scope desiredCol.value = null resetSyncedRows(true) if (focusedElement.value && !isElementInActiveScope(focusedElement.value)) { clearFocusedElement() } } /** * Suspend global keyboard navigation while a modal/popup is open. * Call with `true` when opening, `false` when closing. Supports nesting. */ export function setModalOpen(open: boolean) { modalOpenCount = Math.max(0, modalOpenCount + (open ? 1 : -1)) } export function installKeyboardNavigation() { if (handlersInstalled) return handlersInstalled = true resetSyncedRows(true) document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleEnterKey) window.addEventListener("resize", handleSyncedRowResize, { passive: true }) document.addEventListener("click", (event) => { const target = event.target as HTMLElement const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null if (focusable && isElementInActiveScope(focusable)) { desiredCol.value = null focusElement(focusable) } }) document.addEventListener("auxclick", (event) => { if (event.button !== 1) return const target = event.target as HTMLElement const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null if (focusable && isElementInActiveScope(focusable)) { desiredCol.value = null focusElement(focusable) } }) document.addEventListener("focusin", (event) => { const target = event.target as HTMLElement if (target.hasAttribute(FOCUSABLE_ATTR) && isElementInActiveScope(target)) { if (focusedElement.value && focusedElement.value !== target) { focusedElement.value.classList.remove("nav-focused") } focusedElement.value = target target.classList.add("nav-focused") desiredCol.value = null } else { resetSyncedRows() } }) } export interface SyncedRowScrollSnapshot { rows: [HTMLElement, number][] offset: number targetOffset: number } function snapshotSyncedRowScroll(): SyncedRowScrollSnapshot { return { rows: getSyncedRows().map((row) => [row, row.scrollLeft]), offset: syncedRowsCurrentOffset, targetOffset: syncedRowsTargetOffset, } } function restoreSyncedRowScroll(snapshot: SyncedRowScrollSnapshot) { stopSyncedRowAnimation() for (const [row, scrollLeft] of snapshot.rows) { if (row.isConnected) { row.scrollLeft = scrollLeft } } syncedRowsCurrentOffset = snapshot.offset syncedRowsTargetOffset = snapshot.targetOffset lastSyncedRowsAnimationAt = null } export function useKeyboardNavigation() { return { focusedElement, isNavigating, focusElement, focusAt, getFocusState, restoreFocusState, snapshotSyncedRowScroll, restoreSyncedRowScroll, } } export function navAttrs(row: number, col: number, entryCol?: number) { const attrs: Record = { [FOCUSABLE_ATTR]: "true", [ROW_ATTR]: String(row), [COL_ATTR]: String(col), tabindex: 0, } if (entryCol !== undefined) { attrs[ENTRY_COL_ATTR] = String(entryCol) } return attrs } export function registerOutOfBoundsNavigationHandler(handler: OutOfBoundsNavigationHandler) { outOfBoundsHandlers.add(handler) return () => { outOfBoundsHandlers.delete(handler) } } export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR }