- frontend: oxfmt across src, fix no-useless-escape in search-worker - ruff check --fix: replace numeric rule codes with text names in pyproject.toml lint.ignore - ruff format: scanning.py, guibuild.py, release.py
969 lines
28 KiB
TypeScript
969 lines
28 KiB
TypeScript
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<number, FocusableElement[]>
|
|
}
|
|
|
|
export type OutOfBoundsNavigationHandler = (
|
|
context: OutOfBoundsNavigationContext,
|
|
) => HTMLElement | null | undefined
|
|
|
|
// Global focus state
|
|
const focusedElement = ref<HTMLElement | null>(null)
|
|
const isNavigating = ref(false)
|
|
const activeNavigationScope = ref<string | null>(null)
|
|
// Track the "desired" column when moving vertically (to maintain column position across rows of different lengths)
|
|
const desiredCol = ref<number | null>(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<string, ScrollMetrics>()
|
|
|
|
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<HTMLElement>(`.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<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`))
|
|
}
|
|
|
|
function getRowItemCount(row: HTMLElement): number {
|
|
return row.querySelectorAll<HTMLElement>(`.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<HTMLElement>(`.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<HTMLElement>(`[${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<HTMLElement>(`[${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<HTMLElement>("[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<OutOfBoundsNavigationHandler>()
|
|
|
|
function getElementNavigationScope(element: HTMLElement): string | null {
|
|
const owner = element.closest<HTMLElement>("[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<number, FocusableElement[]> {
|
|
const elements = getFocusableElements()
|
|
const byRow = new Map<number, FocusableElement[]>()
|
|
|
|
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<HTMLElement>(`[${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<string, string | number> = {
|
|
[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 }
|