refactor(synced-scroll): rewrite with global metrics + per-row clamping

Replace the fragile per-row tail/maxVirtual state with a clean global-
metrics approach:

- Measure cardWidth, stride, paddingLeft, viewportWidth, rightDeadzone
  once from the first synced row; use the same values for all rows.
- Per-row max scroll computed purely from math:
    max(0, paddingLeft + (N-1)*stride - maxVisibleLeft)
  where maxVisibleLeft = viewportWidth - cardWidth - rightDeadzone.
- Global virtual offset (syncedRowsCurrentOffset/targetOffset) is shared
  across all rows; each row clamps independently in applySyncedRowScroll.
- Tail is a global constant (= rightDeadzone) applied to all rows.
- Fix scrolling-back bug: desiredOffset was stuck at currentOffset when
  moving left; now always computed from anchor column position.
- Avoid full animation sweep on view entry: init current/target offset
  from existing DOM scrollLeft on first use.

Also removes per-row --sync-row-max-virtual CSS var and simplifies
.media-row padding to use only --sync-row-tail.
This commit is contained in:
2026-05-25 18:54:21 +00:00
parent eee8c973ed
commit f219840967
3 changed files with 147 additions and 291 deletions
-59
View File
@@ -1097,63 +1097,4 @@ html:not(.mouse-active) .collage-item:not(.collage-featured).nav-focused .collag
display: none;
}
}
@media (max-width: 600px) {
.collage-hero {
height: 45vh;
min-height: 280px;
--h: clamp(280px, 45vh, 600px);
}
.collage-item-0 {
left: 50%;
transform: translateX(-50%);
}
.hex-showcase .hex-clip {
clip-path: none;
aspect-ratio: auto;
height: 100%;
}
.collage-item-1,
.collage-item-2,
.collage-item-3,
.collage-item-4,
.collage-item-5,
.collage-item-6,
.collage-item-7,
.collage-item-8,
.collage-item-9,
.collage-item-10,
.collage-item-11,
.collage-item-12,
.collage-item-13,
.collage-item-14,
.collage-item-15,
.collage-item-16,
.collage-item-17,
.collage-item-18,
.collage-item-19,
.collage-item-20,
.collage-item-21,
.collage-item-22,
.collage-item-23,
.collage-item-24,
.collage-item-25,
.collage-item-26 {
display: none;
}
.collage-item-info {
left: 4%;
right: 4%;
bottom: 15%;
}
.collage-buttons .btn {
padding: 8px 16px;
font-size: 0.85rem;
}
}
</style>
+140 -217
View File
@@ -41,109 +41,105 @@ const SYNC_SCROLL_EASING_MS = 220
const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail"
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
// Kept for backward compatibility; tail is now per-row via CSS custom properties.
let syncedRowsTailPx = 0
let lastSyncedAnchorCol: number | null = null
let lastSyncedRowsAnimationAt: number | null = null
const outOfBoundsHandlers = new Set<OutOfBoundsNavigationHandler>()
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
// 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
rightDeadzone: number
}
let gMetrics: ScrollMetrics | null = null
function invalidateMetrics() {
gMetrics = null
}
function measureGlobalMetrics(): boolean {
const rows = getSyncedRows()
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 rightDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_RIGHT_DEADZONE_VAR).trim()
const rightDeadzone = Number.isFinite(parseFloat(rightDeadzoneRaw))
? Math.max(0, parseFloat(rightDeadzoneRaw))
: deadzoneInset
gMetrics = { cardWidth, stride, paddingLeft, viewportWidth, rightDeadzone }
return true
}
return false
}
function getMetrics(): ScrollMetrics | null {
if (!gMetrics) {
measureGlobalMetrics()
}
return gMetrics
}
// 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() {
if (syncedRowsCurrentOffset !== 0) return
const rows = getSyncedRows()
for (const row of rows) {
const sl = row.scrollLeft
if (sl > 0) {
syncedRowsCurrentOffset = sl
syncedRowsTargetOffset = sl
return
}
}
return null
}
function getSyncedRows(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`))
}
function getSyncedRowMetrics(rows: HTMLElement[]) {
for (const row of rows) {
const cards = Array.from(row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`))
if (cards.length === 0) continue
function getRowItemCount(row: HTMLElement): number {
return row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`).length
}
const firstRect = cards[0].getBoundingClientRect()
const cardWidth = firstRect.width
if (cardWidth <= 0) continue
const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
const rightDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_RIGHT_DEADZONE_VAR).trim()
const rightDeadzoneOverride = rightDeadzoneRaw ? parseFloat(rightDeadzoneRaw) : NaN
let gap = parseFloat(rowStyle.columnGap || rowStyle.gap || "0")
if (cards.length > 1) {
const secondRect = cards[1].getBoundingClientRect()
gap = Math.max(0, secondRect.left - firstRect.left - cardWidth)
}
return {
cardWidth,
stride: cardWidth + gap,
paddingLeft,
viewportWidth: row.clientWidth,
rightDeadzoneOverride: Number.isFinite(rightDeadzoneOverride)
? Math.max(0, rightDeadzoneOverride)
: null,
}
}
return null
// 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()
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 {
const maxOffset = Math.max(0, row.scrollWidth - row.clientWidth)
return Math.min(Math.max(offset, 0), maxOffset)
}
function getRowMaxOffset(row: HTMLElement): number {
return Math.max(0, row.scrollWidth - row.clientWidth)
}
function getRowTail(row: HTMLElement): number {
const raw = row.style.getPropertyValue(SYNC_SCROLL_TAIL_VAR).trim()
return raw ? parseFloat(raw) : 0
}
function getRowNaturalMaxOffset(row: HTMLElement): number {
return Math.max(0, getRowMaxOffset(row) - getRowTail(row))
}
function getTailNeededForOffset(offset: number, row: HTMLElement): number {
const naturalMax = getRowNaturalMaxOffset(row)
if (!Number.isFinite(naturalMax)) return 0
return Math.max(0, offset - naturalMax)
}
function setRowTail(row: HTMLElement, tailPx: number) {
const nextTail = Math.max(0, tailPx)
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, `${nextTail}px`)
}
/** @deprecated Use setRowTail() for per-row tail values. */
function setSyncedRowsTail(tailPx: number, rows: HTMLElement[] = getSyncedRows()) {
const nextTail = Math.max(0, tailPx)
syncedRowsTailPx = nextTail
for (const row of rows) {
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, `${nextTail}px`)
}
}
// Reference the deprecated function so TS doesn't complain; it may be called
// by external code.
void setSyncedRowsTail
/** @deprecated Tail is now per-row. Kept for external compatibility. */
export function getSyncedRowsTailPx(): number {
return syncedRowsTailPx
return Math.min(Math.max(offset, 0), getRowMaxScroll(row))
}
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) {
@@ -152,16 +148,18 @@ function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRow
}
}
function resetAllRowTails(rows: HTMLElement[] = getSyncedRows()) {
function setAllRowTails(tailPx: number, rows: HTMLElement[] = getSyncedRows()) {
const value = `${Math.max(0, tailPx)}px`
for (const row of rows) {
setRowTail(row, 0)
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value)
}
}
function resetSyncedRows(immediate: boolean = false) {
lastSyncedAnchorCol = null
syncedRowsTargetOffset = 0
resetAllRowTails()
invalidateMetrics()
setAllRowTails(0)
if (immediate) {
syncedRowsCurrentOffset = 0
@@ -222,53 +220,45 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
const rows = getSyncedRows()
if (rows.length === 0) return
const metrics = getSyncedRowMetrics(rows)
if (!metrics) return
initCurrentOffsetFromDOM()
const currentOffset = syncedRowsCurrentOffset
const effectiveAnchorOffset = currentOffset
const deadzoneInset = Math.max(
metrics.paddingLeft,
(metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
)
const rightDeadzone = metrics.rightDeadzoneOverride ?? deadzoneInset
const minVisibleLeft = deadzoneInset
const m = getMetrics()
if (!m) return
const itemLeft = m.paddingLeft + anchorCol * m.stride
const maxVisibleLeft = Math.max(
minVisibleLeft,
metrics.viewportWidth - metrics.cardWidth - rightDeadzone,
m.paddingLeft,
m.viewportWidth - m.cardWidth - m.rightDeadzone,
)
const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride
const viewportLeft = itemLeft - effectiveAnchorOffset
let desiredOffset =
viewportLeft < minVisibleLeft
? Math.max(0, itemLeft - minVisibleLeft)
: viewportLeft > maxVisibleLeft
? Math.max(0, itemLeft - maxVisibleLeft)
: currentOffset
// Ideal unclamped offset: position the anchor column at the right edge of
// the safe zone, or 0 if it fits without scrolling.
let desiredOffset = Math.max(0, itemLeft - maxVisibleLeft)
// If the anchor row fits entirely on screen (no scrollable overflow), keep
// it left-aligned and do not generate tail padding for it.
if (anchorRow && getRowMaxOffset(anchorRow) === 0) {
// 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
}
// Compute and apply per-row tail: each row gets exactly the tail it needs
// to reach the desired virtual offset. Rows that don't need tail keep what
// they have (or get none). This is strictly per-row — no global minimum.
// Tail is a fixed viewport constant applied to all rows.
const fixedTail = m.rightDeadzone
let anyTailChanged = false
for (const row of rows) {
const neededTail = getTailNeededForOffset(desiredOffset, row)
const currentTail = getRowTail(row)
const nextTail = Math.max(currentTail, neededTail)
if (Math.abs(nextTail - currentTail) >= 0.5) {
setRowTail(row, nextTail)
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 synchronous layout recalculation so that scrollWidth reflects
// the new tail padding before we compute clamped scroll positions.
// Force layout recalc so scrollWidth is up to date before we clamp.
for (const row of rows) {
void row.scrollWidth
}
@@ -278,16 +268,12 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null
syncedRowsTargetOffset = desiredOffset
if (!anchorRow) {
resetAllRowTails(rows)
setAllRowTails(0, rows)
for (const row of rows) {
void row.scrollWidth
}
}
if (syncedRowsFrame === null) {
syncedRowsCurrentOffset = currentOffset
}
if (Math.abs(syncedRowsTargetOffset - syncedRowsCurrentOffset) < 0.5) {
syncedRowsCurrentOffset = syncedRowsTargetOffset
applySyncedRowScroll(syncedRowsCurrentOffset, rows)
@@ -344,6 +330,7 @@ function syncRowsToElement(element: HTMLElement) {
}
function handleSyncedRowResize() {
invalidateMetrics()
if (lastSyncedAnchorCol === null) {
resetSyncedRows(true)
return
@@ -383,20 +370,32 @@ function ensureElementVisibleVertically(element: HTMLElement) {
}
}
/**
* Get all focusable elements in the DOM, grouped by row
*/
// ---------------------------------------------------------------------------
// 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 getFocusableElements(): FocusableElement[] {
const elements = document.querySelectorAll(`[${FOCUSABLE_ATTR}]`)
const result: FocusableElement[] = []
elements.forEach((el) => {
const htmlEl = el as HTMLElement
// Skip hidden elements
if (htmlEl.offsetParent === null) return
const rect = htmlEl.getBoundingClientRect()
// Skip elements not in viewport or zero-sized
if (rect.width === 0 || rect.height === 0) return
const row = parseInt(htmlEl.getAttribute(ROW_ATTR) || "0", 10)
@@ -412,9 +411,6 @@ function getFocusableElements(): FocusableElement[] {
return result
}
/**
* Get elements grouped by row
*/
function getElementsByRow(): Map<number, FocusableElement[]> {
const elements = getFocusableElements()
const byRow = new Map<number, FocusableElement[]>()
@@ -426,7 +422,6 @@ function getElementsByRow(): Map<number, FocusableElement[]> {
byRow.get(el.row)!.push(el)
}
// Sort each row by column
for (const [, rowElements] of byRow) {
rowElements.sort((a, b) => a.col - b.col)
}
@@ -434,10 +429,6 @@ function getElementsByRow(): Map<number, FocusableElement[]> {
return byRow
}
/**
* Find element by row and col indices
* @param useEntryCol - if true, check for entry-col override on elements
*/
function findElementAt(
row: number,
col: number,
@@ -447,7 +438,6 @@ function findElementAt(
const rowElements = byRow.get(row)
if (!rowElements || rowElements.length === 0) return null
// Check if any element in this row has an entry-col override
if (useEntryCol) {
for (const el of rowElements) {
const entryCol = el.element.getAttribute(ENTRY_COL_ATTR)
@@ -459,11 +449,9 @@ function findElementAt(
}
}
// Find exact match or nearest col
const exact = rowElements.find((e) => e.col === col)
if (exact) return exact
// Find nearest col in this row
let nearest = rowElements[0]
let nearestDist = Math.abs(nearest.col - col)
@@ -495,7 +483,6 @@ function findElementClosestToLogicalViewportX(
for (const candidate of rowElements) {
let candidateCenterX: number
if (metrics) {
// Compare using global synced offset so capped rows do not skew vertical matching.
candidateCenterX =
metrics.paddingLeft +
candidate.col * metrics.stride -
@@ -522,9 +509,6 @@ function findElementClosestToLogicalViewportX(
return nearest
}
/**
* Find next element in direction using row/col indices
*/
function findNextElement(
current: HTMLElement,
direction: NavDirection,
@@ -534,8 +518,7 @@ function findNextElement(
const byRow = getElementsByRow()
if (direction === "left" || direction === "right") {
// Horizontal: move within same row by col index
desiredCol.value = null // Reset desired col on horizontal movement
desiredCol.value = null
const rowElements = byRow.get(currentRow)
if (!rowElements) return null
@@ -543,7 +526,6 @@ function findNextElement(
const delta = direction === "right" ? 1 : -1
const targetCol = currentCol + delta
// Find element with target col in this row
const target = rowElements.find((e) => e.col === targetCol)
if (target?.element) return target.element
@@ -555,7 +537,6 @@ function findNextElement(
byRow,
})
} else {
// Vertical: move to adjacent row, try to maintain column
const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b)
const currentRowIdx = sortedRows.indexOf(currentRow)
@@ -575,16 +556,12 @@ function findNextElement(
}
const targetRow = sortedRows[targetRowIdx]
// Use desired col if set, otherwise use current col
const targetCol = desiredCol.value ?? currentCol
// Set desired col if not already set (first vertical move in a sequence)
if (desiredCol.value === null) {
desiredCol.value = currentCol
}
// Use entry column hook for vertical navigation when present.
const entryTarget = findElementAt(targetRow, targetCol, true)
const targetRowElements = byRow.get(targetRow) ?? []
const hasEntryOverride = targetRowElements.some((el) => el.element.hasAttribute(ENTRY_COL_ATTR))
@@ -592,22 +569,14 @@ function findNextElement(
return entryTarget?.element || null
}
const syncedRows = getSyncedRows()
const metrics = getSyncedRowMetrics(syncedRows)
const logicalCurrentCenterX = metrics
? metrics.paddingLeft +
currentCol * metrics.stride -
syncedRowsCurrentOffset +
metrics.cardWidth / 2
: (() => {
const currentRect = current.getBoundingClientRect()
return currentRect.left + currentRect.width / 2
})()
const m = getMetrics()
const currentRect = current.getBoundingClientRect()
const actualCurrentCenterX = currentRect.left + currentRect.width / 2
const closestByViewport = findElementClosestToLogicalViewportX(
targetRow,
logicalCurrentCenterX,
actualCurrentCenterX,
targetCol,
metrics,
m,
)
const resolved = closestByViewport?.element || entryTarget?.element || null
if (resolved) return resolved
@@ -622,19 +591,14 @@ function findNextElement(
}
}
/**
* Focus an element and scroll it into view
*/
function focusElement(element: HTMLElement | null) {
if (!element) return
// Remove focus from previous element
if (focusedElement.value && focusedElement.value !== element) {
focusedElement.value.classList.remove("nav-focused")
focusedElement.value.blur()
}
// Add focus to new element
element.classList.add("nav-focused")
element.focus({ preventScroll: true })
@@ -644,9 +608,6 @@ function focusElement(element: HTMLElement | null) {
focusedElement.value = element
}
/**
* Get current focus state (row, col) for saving
*/
function getFocusState(): { row: number; col: number } | null {
if (!focusedElement.value) return null
const row = parseInt(focusedElement.value.getAttribute(ROW_ATTR) || "0", 10)
@@ -654,9 +615,6 @@ function getFocusState(): { row: number; col: number } | null {
return { row, col }
}
/**
* Restore focus to element with given row/col
*/
function restoreFocusState(state: { row: number; col: number } | null) {
if (!state) return
@@ -668,9 +626,6 @@ function restoreFocusState(state: { row: number; col: number } | null) {
}
}
/**
* Focus element at specific row/col after a delay (for page transitions)
*/
function focusAt(row: number, col: number, delay: number = 100) {
setTimeout(() => {
const target = findElementAt(row, col)
@@ -680,28 +635,20 @@ function focusAt(row: number, col: number, delay: number = 100) {
}, delay)
}
/**
* Check if we should allow navigation from an input element
*/
function shouldAllowNavigationFromInput(target: HTMLElement, direction: string): boolean {
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA" && !target.isContentEditable) {
return true // Not an input, allow navigation
return true
}
// Always allow up/down navigation from inputs
if (direction === "up" || direction === "down") {
return true
}
// For left/right, only capture if input is empty
const inputEl = target as HTMLInputElement | HTMLTextAreaElement
const value = inputEl.value || ""
return value.length === 0
}
/**
* Handle keyboard navigation
*/
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement
@@ -714,7 +661,6 @@ function handleKeyDown(event: KeyboardEvent) {
if (!direction) return
// Check if we should allow navigation from this element
if (!shouldAllowNavigationFromInput(target, direction)) {
return
}
@@ -722,10 +668,8 @@ function handleKeyDown(event: KeyboardEvent) {
event.preventDefault()
isNavigating.value = true
// Get current focused element or find the first one
let current = focusedElement.value
// If no element is focused, try to get the currently focused element from DOM
if (!current) {
const activeElement = document.activeElement as HTMLElement
if (activeElement && activeElement.hasAttribute(FOCUSABLE_ATTR)) {
@@ -733,7 +677,6 @@ function handleKeyDown(event: KeyboardEvent) {
}
}
// If still no current, focus the first available element
if (!current) {
const elements = getFocusableElements()
if (elements.length > 0) {
@@ -742,16 +685,12 @@ function handleKeyDown(event: KeyboardEvent) {
return
}
// Find and focus the next element using index-based navigation
const next = findNextElement(current, direction)
if (next) {
focusElement(next)
}
}
/**
* Handle Enter key to activate focused element
*/
function handleEnterKey(event: KeyboardEvent) {
if (event.key !== "Enter") return
if (event.defaultPrevented) return
@@ -768,10 +707,6 @@ function handleEnterKey(event: KeyboardEvent) {
}
}
/**
* Install global keyboard navigation handlers
* Should be called once at app initialization
*/
export function installKeyboardNavigation() {
if (handlersInstalled) return
handlersInstalled = true
@@ -782,18 +717,15 @@ export function installKeyboardNavigation() {
document.addEventListener("keydown", handleEnterKey)
window.addEventListener("resize", handleSyncedRowResize, { passive: true })
// Handle mouse clicks to update focus state
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement
const focusable = target.closest(`[${FOCUSABLE_ATTR}]`) as HTMLElement | null
if (focusable) {
desiredCol.value = null // Reset desired col on mouse click
desiredCol.value = null
focusElement(focusable)
}
})
// Middle-click commonly opens links in a background tab.
// Scroll after activation so the source page keeps the clicked item in the safe zone.
document.addEventListener("auxclick", (event) => {
if (event.button !== 1) return
const target = event.target as HTMLElement
@@ -804,7 +736,6 @@ export function installKeyboardNavigation() {
}
})
// Handle focus events from tab navigation
document.addEventListener("focusin", (event) => {
const target = event.target as HTMLElement
if (target.hasAttribute(FOCUSABLE_ATTR)) {
@@ -813,17 +744,13 @@ export function installKeyboardNavigation() {
}
focusedElement.value = target
target.classList.add("nav-focused")
desiredCol.value = null // Reset desired col on focus change
desiredCol.value = null
} else {
resetSyncedRows()
}
})
}
/**
* Composable to access keyboard navigation state
* @deprecated Use installKeyboardNavigation() at app init instead
*/
export function useKeyboardNavigation() {
return {
focusedElement,
@@ -835,10 +762,6 @@ export function useKeyboardNavigation() {
}
}
/**
* Helper to generate navigation attributes for a focusable element
* @param entryCol - optional column to focus when entering this row vertically
*/
export function navAttrs(row: number, col: number, entryCol?: number) {
const attrs: Record<string, string | number> = {
[FOCUSABLE_ATTR]: "true",
+7 -15
View File
@@ -138,6 +138,13 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
-webkit-backdrop-filter: blur(10px) saturate(115%);
}
@media (max-width: 900px) {
.header-after-hero {
/* Match the reduced browse hero height at <=900px. */
top: clamp(350px, 50vh, 600px);
}
}
/* Spacer to reserve space for header in layout */
.header-spacer {
height: calc(var(--header-height) + 20px);
@@ -777,14 +784,6 @@ html.mouse-active .release-item:hover {
--section-padding: 3%;
}
.header-nav {
gap: 10px;
}
.search-input {
width: 140px;
}
.modal-title {
font-size: 1.5rem;
}
@@ -797,13 +796,6 @@ html.mouse-active .release-item:hover {
@media (max-width: 480px) {
:root {
--card-width: 100px;
--header-height: 48px;
}
.header-logo {
height: 32px;
width: 32px;
margin-right: 12px;
}
.media-card-info {