feat(books): add adaptive temporal jump rail

Add bounded, user-scoped temporal buckets for supported date sorts and adapt resolution to populated dates. Keep rail navigation responsive with viewport sizing, sparse-gap compression, and virtual-grid synchronization.
This commit is contained in:
neon
2026-07-16 00:41:46 +00:00
parent 014c0af456
commit 5923ea95a5
34 changed files with 1223 additions and 193 deletions
+201 -50
View File
@@ -1,18 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { TransitionPresets, useElementSize, useMediaQuery, useTransition } from '@vueuse/core'
import type { JumpBucket, JumpBucketKind } from '@bookorbit/types'
import { TransitionPresets, useElementBounding, useMediaQuery, useTransition } from '@vueuse/core'
import type { JumpBucket, JumpBucketKind, TemporalJumpBucketGranularity } from '@bookorbit/types'
import { useI18n } from 'vue-i18n'
import { formatDate, formatNumber } from '@/i18n/formatters'
const { t } = useI18n()
const { t, locale } = useI18n()
const SLOT_PX = 20
const RAIL_PAD_PX = 8
const SCROLL_PULSE_MS = 900
const MAX_TEMPORAL_SLOTS = 64
type RailSlot = {
key: string
label: string
displayLabel: string
accessibleLabel: string
bucket: JumpBucket | null
}
@@ -20,8 +24,11 @@ const props = defineProps<{
visible: boolean
buckets: JumpBucket[]
kind: JumpBucketKind
granularity?: TemporalJumpBucketGranularity | null
activeKey: string | null
template?: string[]
maxSlots?: number
viewport?: HTMLElement | null
}>()
const emit = defineEmits<{
@@ -30,29 +37,163 @@ const emit = defineEmits<{
}>()
const railEl = ref<HTMLElement | null>(null)
const { height: railHeight } = useElementSize(railEl)
const viewportTarget = computed(() => props.viewport ?? null)
const { top: viewportTop, height: viewportHeight } = useElementBounding(viewportTarget)
const railPositionStyle = computed(() => {
if (viewportHeight.value <= 0) return undefined
return {
top: `${viewportTop.value + viewportHeight.value / 2}px`,
maxHeight: `${viewportHeight.value * 0.85}px`,
}
})
function temporalLabels(bucket: JumpBucket): Pick<RailSlot, 'label' | 'displayLabel' | 'accessibleLabel'> {
if (bucket.isUnknown) {
return { label: t('book.jumpRail.unknownDate'), displayLabel: '?', accessibleLabel: t('book.jumpRail.unknownDate') }
}
const granularity = props.granularity
if (!granularity) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
if (granularity.unit === 'year') {
const start = Number(bucket.key)
if (!Number.isFinite(start)) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
const startLabel = formatNumber(start, { useGrouping: false })
if (granularity.step === 1) return { label: startLabel, displayLabel: startLabel, accessibleLabel: startLabel }
const endLabel = formatNumber(start + granularity.step - 1, { useGrouping: false })
const range = `${startLabel}-${endLabel}`
return { label: range, displayLabel: startLabel, accessibleLabel: range }
}
const dateKey = granularity.unit === 'month' ? `${bucket.key}-01` : bucket.key
const date = new Date(`${dateKey}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return { label: bucket.label, displayLabel: bucket.label, accessibleLabel: bucket.label }
if (granularity.unit === 'month') {
const fullLabel = formatDate(date, { month: 'long', year: 'numeric', timeZone: 'UTC' })
return {
label: fullLabel,
displayLabel: bucket.key.endsWith('-01') ? formatDate(date, { year: 'numeric', timeZone: 'UTC' }) : '',
accessibleLabel: fullLabel,
}
}
const fullLabel = formatDate(date, { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
return {
label: fullLabel,
displayLabel: bucket.key.endsWith('-01-01')
? formatDate(date, { year: 'numeric', timeZone: 'UTC' })
: bucket.key.endsWith('-01')
? formatDate(date, { month: 'short', timeZone: 'UTC' })
: '',
accessibleLabel: fullLabel,
}
}
function shiftDateKey(key: string, unit: 'day' | 'month', amount: number): string | null {
const date = new Date(`${unit === 'month' ? `${key}-01` : key}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return null
if (unit === 'month') date.setUTCMonth(date.getUTCMonth() + amount)
else date.setUTCDate(date.getUTCDate() + amount)
const shifted = date.toISOString().slice(0, 10)
return unit === 'month' ? shifted.slice(0, 7) : shifted
}
function temporalDistance(start: string, end: string, granularity: TemporalJumpBucketGranularity): number | null {
if (granularity.unit === 'year') {
const startYear = Number(start)
const endYear = Number(end)
if (!Number.isFinite(startYear) || !Number.isFinite(endYear)) return null
return Math.abs((endYear - startYear) / granularity.step)
}
const startDate = new Date(`${granularity.unit === 'month' ? `${start}-01` : start}T00:00:00Z`)
const endDate = new Date(`${granularity.unit === 'month' ? `${end}-01` : end}T00:00:00Z`)
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null
if (granularity.unit === 'month') {
return Math.abs((endDate.getUTCFullYear() - startDate.getUTCFullYear()) * 12 + endDate.getUTCMonth() - startDate.getUTCMonth())
}
return Math.abs((endDate.getTime() - startDate.getTime()) / 86_400_000)
}
function shiftTemporalKey(key: string, granularity: TemporalJumpBucketGranularity, amount: number): string | null {
if (granularity.unit === 'year') {
const year = Number(key)
return Number.isFinite(year) ? String(year + amount * granularity.step) : null
}
return shiftDateKey(key, granularity.unit, amount)
}
function temporalTimelineKeys(buckets: JumpBucket[], granularity: TemporalJumpBucketGranularity | null | undefined, maxSlots: number): string[] {
const known = buckets.filter((bucket) => !bucket.isUnknown)
if (!granularity || known.length < 2) return known.map((bucket) => bucket.key)
const capacity = Math.max(known.length, Math.min(MAX_TEMPORAL_SLOTS, Math.max(2, maxSlots)))
const gaps = known.slice(0, -1).map((bucket, index) => {
const next = known[index + 1]!
const distance = temporalDistance(bucket.key, next.key, granularity)
return { start: bucket.key, end: next.key, distance: distance ?? 1, markers: 0 }
})
let remaining = capacity - known.length
while (remaining > 0) {
let selected: (typeof gaps)[number] | null = null
let selectedScore = 0
for (const gap of gaps) {
const missing = Math.max(0, Math.floor(gap.distance) - 1)
if (gap.markers >= missing) continue
const score = missing / (gap.markers + 1)
if (score > selectedScore) {
selected = gap
selectedScore = score
}
}
if (!selected) break
selected.markers += 1
remaining -= 1
}
const keys: string[] = []
for (let index = 0; index < known.length; index++) {
keys.push(known[index]!.key)
const gap = gaps[index]
if (!gap || gap.markers === 0) continue
const startValue = granularity.unit === 'year' ? Number(gap.start) : gap.start
const endValue = granularity.unit === 'year' ? Number(gap.end) : gap.end
const direction = startValue <= endValue ? 1 : -1
for (let marker = 1; marker <= gap.markers; marker++) {
const offset = Math.round((gap.distance * marker) / (gap.markers + 1)) * direction
const key = shiftTemporalKey(gap.start, granularity, offset)
if (key && key !== gap.start && key !== gap.end) keys.push(key)
}
}
return keys
}
const slots = computed<RailSlot[]>(() => {
void locale.value
if (props.kind === 'letter' && props.template) {
const byKey = new Map(props.buckets.map((bucket) => [bucket.key, bucket]))
return props.template.map((key) => ({ key, label: key, bucket: byKey.get(key) ?? null }))
return props.template.map((key) => ({ key, label: key, displayLabel: key, accessibleLabel: key, bucket: byKey.get(key) ?? null }))
}
const all = props.buckets.map((bucket) => ({ key: bucket.key, label: bucket.label, bucket }))
const max = Math.max(2, Math.floor((railHeight.value || 480) / SLOT_PX))
if (all.length <= max) return all
// Thin evenly to fit the rail height, always keeping the first and last.
const step = (all.length - 1) / (max - 1)
const picked: RailSlot[] = []
const used = new Set<number>()
for (let i = 0; i < max; i++) {
const index = Math.round(i * step)
if (used.has(index)) continue
used.add(index)
picked.push(all[index]!)
const byKey = new Map(props.buckets.map((bucket) => [bucket.key, bucket]))
const unknown = props.buckets.find((bucket) => bucket.isUnknown)
const maxKnownSlots = Math.max(2, (props.maxSlots ?? MAX_TEMPORAL_SLOTS) - (unknown ? 1 : 0))
const timelineKeys = temporalTimelineKeys(props.buckets, props.granularity, maxKnownSlots)
if (unknown) timelineKeys.push(unknown.key)
const temporalSlots: RailSlot[] = timelineKeys.map((key) => {
const bucket = byKey.get(key) ?? null
const labels = temporalLabels(bucket ?? { key, label: key, index: -1 })
return { key, ...labels, displayLabel: bucket ? labels.displayLabel : '', bucket }
})
const displayedYears = new Set(temporalSlots.filter((slot) => slot.displayLabel).map((slot) => slot.key.slice(0, 4)))
for (const index of [0, temporalSlots.length - 1]) {
const slot = temporalSlots[index]
if (!slot || slot.displayLabel || slot.bucket?.isUnknown) continue
const yearKey = slot.key.slice(0, 4)
if (displayedYears.has(yearKey)) continue
const year = Number(slot.key.slice(0, 4))
slot.displayLabel = Number.isFinite(year) ? formatNumber(year, { useGrouping: false }) : slot.label
displayedYears.add(yearKey)
}
return picked
return temporalSlots
})
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
@@ -64,23 +205,15 @@ let scrollPulseTimer: ReturnType<typeof setTimeout> | null = null
const engaged = computed(() => hovering.value || scrubbing.value || scrollPulse.value)
const activeIndex = computed(() => (props.activeKey ? slots.value.findIndex((slot) => slot.key === props.activeKey) : -1))
const lozengeVisible = computed(() => activeIndex.value >= 0)
const lozengeIndex = useTransition(
computed(() => Math.max(0, activeIndex.value)),
{ duration: 300, transition: TransitionPresets.easeOutBack, disabled: reducedMotion },
)
const lozengeY = computed(() => RAIL_PAD_PX + lozengeIndex.value * SLOT_PX)
// Hover capsule: a soft pill that glides to the slot under the cursor (mouse
// only). The target index never resets to 0 so the pill springs between
// letters instead of sliding up from the top each time it reappears.
// The target index never resets to 0 so the hover or scrub indicator moves
// between slots instead of sliding up from the top each time it reappears.
const hoveredIndex = ref<number | null>(null)
const hoverTargetIndex = ref(0)
watch(hoveredIndex, (value) => {
if (value !== null) hoverTargetIndex.value = value
})
const hoverVisible = computed(() => hoveredIndex.value !== null)
const previewSlot = computed(() => (hoveredIndex.value === null ? null : (slots.value[hoveredIndex.value] ?? null)))
const hoverThumbIndex = useTransition(hoverTargetIndex, {
duration: 220,
transition: TransitionPresets.easeOutBack,
@@ -138,6 +271,7 @@ function pulseHaptics(pointerType: string) {
function scrubTo(clientY: number, pointerType: string) {
const slot = nearestAvailable(slotAtClientY(clientY))
if (!slot?.bucket) return
hoveredIndex.value = slots.value.indexOf(slot)
if (slot.key === lastScrubKey) return
lastScrubKey = slot.key
pulseHaptics(pointerType)
@@ -162,8 +296,9 @@ function handlePointerEnter(event: PointerEvent) {
if (event.pointerType === 'mouse') hovering.value = true
}
function handlePointerEnd() {
function handlePointerEnd(event: PointerEvent) {
scrubbing.value = false
if (event.pointerType !== 'mouse') hoveredIndex.value = null
}
function handlePointerLeave() {
@@ -198,8 +333,12 @@ function handleAfterLeave() {
<nav
v-if="visible"
ref="railEl"
class="jump-rail fixed right-1.5 top-1/2 z-30 flex max-h-[85vh] -translate-y-1/2 select-none flex-col items-stretch overflow-hidden rounded-full border border-border bg-background/85 py-2 shadow-sm backdrop-blur"
:class="[engaged ? 'px-1.5' : 'px-1', reducedMotion ? '' : 'transition-[padding] duration-300 ease-out']"
class="jump-rail fixed right-1.5 top-1/2 z-30 flex max-h-[85vh] -translate-y-1/2 select-none flex-col items-stretch overflow-visible rounded-2xl border border-primary/20 bg-background/80 py-2 shadow-sm backdrop-blur"
:class="[
kind === 'temporal' ? 'w-14 px-1' : engaged ? 'w-9 px-1.5' : 'w-8 px-1',
reducedMotion ? '' : 'transition-[width,padding] duration-300 ease-out',
]"
:style="railPositionStyle"
:aria-label="t('book.jumpRail.jumpToSection')"
data-testid="jump-rail"
@pointerdown="handlePointerDown"
@@ -209,13 +348,6 @@ function handleAfterLeave() {
@pointercancel="handlePointerEnd"
@pointerleave="handlePointerLeave"
>
<div
v-show="lozengeVisible"
class="jump-rail-lozenge pointer-events-none absolute inset-x-1 top-0 z-0 h-5 rounded-full bg-primary shadow-sm"
:style="{ transform: `translateY(${lozengeY}px)` }"
aria-hidden="true"
/>
<div
v-show="hoverVisible"
class="jump-rail-thumb pointer-events-none absolute inset-x-1 top-0 z-0 h-5 rounded-full bg-foreground/10 transition-opacity duration-150"
@@ -223,26 +355,45 @@ function handleAfterLeave() {
aria-hidden="true"
/>
<div
v-if="kind === 'temporal' && previewSlot"
class="pointer-events-none absolute right-full top-0 z-20 mr-2 flex h-7 -translate-y-1 items-center whitespace-nowrap rounded-md border border-border bg-popover px-2.5 text-xs font-medium tabular-nums text-popover-foreground shadow-md"
:style="{ transform: `translateY(${hoverThumbY - 4}px)` }"
aria-hidden="true"
data-testid="jump-rail-preview"
>
{{ previewSlot.label }}
</div>
<button
v-for="(slot, index) in slots"
:key="slot.key"
type="button"
class="jump-rail-slot relative z-10 flex h-5 shrink-0 items-center justify-center rounded-full px-1 text-center text-[11px] font-medium tabular-nums transition-[color,transform] duration-150"
class="jump-rail-slot relative z-10 h-5 shrink-0 items-center px-0.5 text-center text-[11px] font-medium tabular-nums transition-colors duration-150"
:class="[
slot.bucket
? slot.key === activeKey
? 'text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
: 'cursor-default text-muted-foreground/30',
index === hoveredIndex ? 'scale-[1.35] font-semibold text-foreground' : '',
'flex justify-center rounded-full',
slot.bucket ? 'text-muted-foreground hover:text-foreground' : 'cursor-default text-muted-foreground/30',
index === hoveredIndex && kind === 'letter' ? 'scale-[1.35] font-semibold text-foreground' : '',
]"
:disabled="!slot.bucket"
:aria-label="t('book.jumpRail.jumpTo', { label: slot.label })"
:aria-label="t('book.jumpRail.jumpTo', { label: slot.accessibleLabel })"
:aria-current="slot.key === activeKey ? 'true' : undefined"
:data-key="slot.key"
@click="handleSlotClick(slot)"
>
{{ slot.label }}
<span
v-if="slot.displayLabel"
class="rounded-full px-1.5 leading-4"
:class="slot.key === activeKey ? 'bg-primary font-semibold text-primary-foreground shadow-sm' : ''"
>
{{ slot.displayLabel }}
</span>
<span
v-else
class="rounded-full"
:class="slot.key === activeKey ? 'size-1.5 bg-primary opacity-100' : 'size-1 bg-current opacity-60'"
aria-hidden="true"
/>
</button>
</nav>
</Transition>
@@ -251,7 +402,7 @@ function handleAfterLeave() {
<style scoped>
.jump-rail {
touch-action: none;
contain: layout paint style;
contain: layout style;
}
.jump-rail-slot {
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import type { BookCard } from '@bookorbit/types'
import VirtualBookGrid from './VirtualBookGrid.vue'
const scrollToItemMock = vi.fn<(index: number) => void>()
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
vi.mock('vue-virtual-scroller', () => ({
RecycleScroller: {
@@ -12,8 +12,8 @@ vi.mock('vue-virtual-scroller', () => ({
props: ['items'],
emits: ['update'],
methods: {
scrollToItem(index: number) {
scrollToItemMock(index)
scrollToItem(index: number, options?: { align?: string }) {
scrollToItemMock(index, options)
},
},
template: '<div data-testid="recycle-scroller"><slot v-for="item in items" :key="item.id" :item="item" /></div>',
@@ -236,7 +236,58 @@ describe('VirtualBookGrid', () => {
})
;(wrapper.vm as unknown as { scrollToIndex: (i: number) => void }).scrollToIndex(42)
expect(scrollToItemMock).toHaveBeenCalledWith(42)
expect(scrollToItemMock).toHaveBeenCalledWith(42, { align: 'start' })
})
it('reports index zero at the top and preserves a jump target within the same row', async () => {
const scrollParent = document.createElement('div')
scrollParent.style.overflowY = 'auto'
document.body.append(scrollParent)
const requestAnimationFrameSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 0
})
const books = Array.from({ length: 12 }, (_, index) => makeBook(index + 1))
const wrapper = mount(VirtualBookGrid, {
attachTo: scrollParent,
props: { books, coverSize: 120, gridGap: 12 },
})
vi.spyOn(scrollParent, 'getBoundingClientRect').mockReturnValue({
top: 0,
left: 0,
right: 600,
bottom: 800,
width: 600,
height: 800,
x: 0,
y: 0,
toJSON: () => ({}),
})
vi.spyOn(wrapper.element, 'getBoundingClientRect').mockReturnValue({
top: 0,
left: 0,
right: 600,
bottom: 1600,
width: 600,
height: 1600,
x: 0,
y: 0,
toJSON: () => ({}),
})
await nextTick()
scrollParent.dispatchEvent(new Event('scroll'))
expect(wrapper.emitted('first-visible-index')).toEqual([[0]])
;(wrapper.vm as unknown as { scrollToIndex: (index: number) => void }).scrollToIndex(3)
scrollParent.dispatchEvent(new Event('scroll'))
expect(wrapper.emitted('first-visible-index')).toEqual([[0], [3]])
wrapper.unmount()
scrollParent.remove()
requestAnimationFrameSpy.mockRestore()
})
it('reserves a right gutter when railGutter is set', () => {
@@ -244,7 +295,7 @@ describe('VirtualBookGrid', () => {
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, railGutter: true },
})
expect(wrapper.classes()).toContain('pr-9')
expect(wrapper.classes()).toContain('pr-16')
})
})
@@ -43,7 +43,7 @@ const emit = defineEmits<{
}>()
const containerRef = ref<HTMLElement | null>(null)
const scrollerRef = ref<{ scrollToItem: (index: number) => void } | null>(null)
const scrollerRef = ref<{ scrollToItem: (index: number, options?: { align?: 'start' | 'center' | 'end' | 'auto' }) => void } | null>(null)
const { width: containerWidth } = useElementSize(containerRef)
const { width: windowWidth } = useWindowSize()
@@ -173,6 +173,8 @@ function handleScrollerUpdate(startIndex: number, endIndex: number) {
const firstVisibleIndex = ref(0)
let scrollParent: HTMLElement | null = null
let scrollRafId = 0
let jumpAnchorIndex: number | null = null
let jumpAnchorRowStart: number | null = null
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
for (let node = el?.parentElement ?? null; node; node = node.parentElement) {
@@ -191,14 +193,27 @@ function computeFirstVisibleIndex() {
const row = Math.max(0, Math.floor(scrolledIntoGrid / itemSize.value))
const cols = gridItems.value
const rowStart = Math.min(props.books.length - 1, row * cols)
if (rowStart === firstVisibleIndex.value) return
firstVisibleIndex.value = rowStart
// Report the middle cell of the first visible row, not its first cell. A
// letter bucket usually starts mid-row, so reporting the row start would
// resolve the active bucket to the previous letter (the tail still showing
// in the top-left). The midpoint lands inside the dominant letter of the
// row, which is also the one a jump targets.
const activeIndex = Math.min(props.books.length - 1, rowStart + Math.floor((cols - 1) / 2))
if (jumpAnchorIndex !== null) {
const lastVisibleRow = Math.max(row, Math.floor(Math.max(0, parentRect.bottom - gridRect.top - 1) / itemSize.value))
const anchorRow = Math.floor(jumpAnchorIndex / cols)
if (jumpAnchorRowStart === null) {
if (anchorRow >= row && anchorRow <= lastVisibleRow) {
jumpAnchorRowStart = rowStart
emit('first-visible-index', jumpAnchorIndex)
}
return
}
if (rowStart === jumpAnchorRowStart) {
emit('first-visible-index', jumpAnchorIndex)
return
}
jumpAnchorIndex = null
jumpAnchorRowStart = null
}
// At the top boundary the first rail target must stay active. Below it, the
// midpoint represents the section occupying most of the first visible row.
const activeIndex = row === 0 ? 0 : Math.min(props.books.length - 1, rowStart + Math.floor((cols - 1) / 2))
emit('first-visible-index', activeIndex)
}
@@ -237,15 +252,16 @@ watch(gridItems, (next, prev) => {
})
function scrollToIndex(index: number) {
scrollerRef.value?.scrollToItem(index)
handleScrollParentScroll()
jumpAnchorIndex = index
jumpAnchorRowStart = null
scrollerRef.value?.scrollToItem(index, { align: 'start' })
}
defineExpose({ scrollToIndex })
</script>
<template>
<div ref="containerRef" class="w-full" :class="{ 'pr-9': railGutter }">
<div ref="containerRef" class="w-full" :class="{ 'pr-16': railGutter }">
<div
v-if="!virtualized && useVariableStaticWidths"
class="flex w-full flex-wrap content-start items-end"
@@ -112,19 +112,87 @@ describe('JumpRail', () => {
})
it('renders year buckets without a template', () => {
const wrapper = mountRail({ kind: 'year' as const, buckets: yearBuckets(5), template: undefined })
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 1 },
buckets: yearBuckets(5),
template: undefined,
})
const labels = wrapper.findAll('button').map((slot) => slot.text())
expect(labels).toEqual(['1950', '1951', '1952', '1953', '1954'])
})
it('thins large year sets to fit while keeping first and last', () => {
const wrapper = mountRail({ kind: 'year' as const, buckets: yearBuckets(80), template: undefined })
it('formats multi-year ranges and unknown dates', () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 10 },
buckets: [
{ key: '1900', label: '1900', index: 0 },
{ key: '__unknown__', label: '__unknown__', index: 40, isUnknown: true },
],
template: undefined,
})
const labels = wrapper.findAll('button').map((slot) => slot.text())
expect(labels.length).toBeLessThan(80)
expect(labels[0]).toBe('1950')
expect(labels[labels.length - 1]).toBe('2029')
expect(wrapper.get('button[data-key="1900"]').text()).toBe('1900')
expect(wrapper.get('button[data-key="1900"]').attributes('aria-label')).toContain('1900-1909')
expect(wrapper.get('button[data-key="__unknown__"]').text()).toBe('?')
expect(wrapper.get('button[data-key="__unknown__"]').attributes('aria-label')).toContain('Unknown date')
})
it('fills bounded temporal gaps with disabled dot targets', () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 5 },
buckets: [
{ key: '1900', label: '1900', index: 0 },
{ key: '1920', label: '1920', index: 40 },
],
template: undefined,
})
expect(wrapper.findAll('button').map((button) => button.attributes('data-key'))).toEqual(['1900', '1905', '1910', '1915', '1920'])
expect(wrapper.get('button[data-key="1905"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[data-key="1905"]').text()).toBe('')
})
it('compresses empty time without dropping populated year targets', () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'year', step: 1 },
maxSlots: 5,
buckets: [
{ key: '1974', label: '1974', index: 0 },
{ key: '1988', label: '1988', index: 4 },
{ key: '1989', label: '1989', index: 7 },
],
template: undefined,
})
expect(wrapper.findAll('button')).toHaveLength(5)
expect(wrapper.get('button[data-key="1974"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('button[data-key="1988"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('button[data-key="1989"]').attributes('disabled')).toBeUndefined()
})
it('uses year anchors and dot targets for months with a hover preview', async () => {
const wrapper = mountRail({
kind: 'temporal' as const,
granularity: { unit: 'month', step: 1 },
buckets: [
{ key: '2026-01', label: '2026-01', index: 0 },
{ key: '2026-02', label: '2026-02', index: 10 },
{ key: '2026-03', label: '2026-03', index: 20 },
],
template: undefined,
})
preparePointerRail(wrapper, 60)
expect(wrapper.get('button[data-key="2026-01"]').text()).toBe('2026')
expect(wrapper.get('button[data-key="2026-02"]').text()).toBe('')
await firePointer(wrapper, 'pointermove', { clientY: 30, pointerType: 'mouse' })
expect(wrapper.get('[data-testid="jump-rail-preview"]').text()).toBe('February 2026')
})
it('renders nothing when not visible', () => {
@@ -73,7 +73,7 @@ function bucket(key: string, index: number): JumpBucket {
function mockApi(total: number, buckets: JumpBucket[] = []) {
fetchMock.mockImplementation((url, init) => {
if (url.includes('jump-buckets')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ buckets }) })
return Promise.resolve({ ok: true, json: () => Promise.resolve({ buckets, total, kind: 'letter', granularity: null }) })
}
const body = JSON.parse(String(init?.body)) as { pagination: { page: number } }
return Promise.resolve({ ok: true, json: () => Promise.resolve(pageFor(body.pagination.page, total)) })
@@ -21,7 +21,7 @@ const LETTER_BUCKETS: JumpBucket[] = [
function mockBucketsResponse(buckets: JumpBucket[]) {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ buckets, total: 50 }),
json: () => Promise.resolve({ buckets, total: 50, kind: 'letter', granularity: null }),
})
}
@@ -42,8 +42,9 @@ function makeBuckets(
const query = ref<BookWindowQuery>({ sort: overrides.sort ?? [{ field: 'title', dir: 'asc' }] })
const enabled = ref(overrides.enabledValue ?? true)
const firstVisibleIndex = ref(0)
const composable = useJumpBuckets({ endpoint, query, enabled, firstVisibleIndex })
return { ...composable, endpoint, query, enabled, firstVisibleIndex }
const maxBuckets = ref(24)
const composable = useJumpBuckets({ endpoint, query, enabled, firstVisibleIndex, maxBuckets })
return { ...composable, endpoint, query, enabled, firstVisibleIndex, maxBuckets }
}
describe('useJumpBuckets', () => {
@@ -62,7 +63,7 @@ describe('useJumpBuckets', () => {
})
it('does not fetch when the sort is ineligible', async () => {
const { buckets, kind } = makeBuckets({ sort: [{ field: 'addedAt', dir: 'desc' }] })
const { buckets, kind } = makeBuckets({ sort: [{ field: 'rating', dir: 'desc' }] })
await flush()
expect(fetchMock).not.toHaveBeenCalled()
@@ -70,6 +71,26 @@ describe('useJumpBuckets', () => {
expect(kind.value).toBeNull()
})
it('requests bounded temporal buckets and retains their granularity', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
buckets: [{ key: '2026-07', label: '2026-07', index: 0 }],
total: 50,
kind: 'temporal',
granularity: { unit: 'month', step: 1 },
}),
})
const { kind, granularity } = makeBuckets({ sort: [{ field: 'addedAt', dir: 'desc' }] })
await flush()
expect(kind.value).toBe('temporal')
expect(granularity.value).toEqual({ unit: 'month', step: 1 })
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))
expect(body.maxBuckets).toBe(24)
})
it('does not fetch while disabled and fetches once enabled', async () => {
mockBucketsResponse(LETTER_BUCKETS)
const { buckets, enabled } = makeBuckets({ enabledValue: false })
@@ -1,4 +1,5 @@
import { computed, ref, type Ref } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { jumpBucketKindForSort, type GroupRule, type JumpBucket, type SortSpec } from '@bookorbit/types'
import { useBookProgressRefresh } from './useBookProgressRefresh'
import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow'
@@ -8,6 +9,9 @@ import { useJumpRailGutter } from './useJumpRailGutter'
const LETTER_TEMPLATE = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))]
const MIN_TOTAL_FOR_RAIL = 50
const LIST_CHUNK = 100
const RAIL_SLOT_PX = 20
const RAIL_VERTICAL_FRACTION = 0.85
const RAIL_VERTICAL_PADDING_PX = 16
function isValidScopeId(value: number | null): value is number {
return value !== null && Number.isInteger(value) && value > 0
@@ -24,6 +28,7 @@ export function useBookViewWindow(options: {
listEndpoint: (id: number) => string
bucketsEndpoint: (id: number) => string
viewMode: Ref<string>
railViewport?: Ref<HTMLElement | null>
collapseEnabled?: Ref<boolean>
q?: Ref<string>
}) {
@@ -43,6 +48,14 @@ export function useBookViewWindow(options: {
const window = useBookWindow({ endpoint: listEndpoint, query })
const firstVisibleIndex = ref(0)
const { height: windowHeight } = useWindowSize()
const fallbackRailViewport = ref<HTMLElement | null>(null)
const { height: railViewportHeight } = useElementSize(options.railViewport ?? fallbackRailViewport)
const railCapacity = computed(() => {
const viewportHeight = railViewportHeight.value || windowHeight.value
const available = viewportHeight * RAIL_VERTICAL_FRACTION - RAIL_VERTICAL_PADDING_PX
return Math.min(64, Math.max(8, Math.floor(available / RAIL_SLOT_PX)))
})
function handleFirstVisibleIndex(index: number) {
firstVisibleIndex.value = index
@@ -61,6 +74,7 @@ export function useBookViewWindow(options: {
query,
enabled: railEligible,
firstVisibleIndex,
maxBuckets: railCapacity,
})
useBookProgressRefresh(() => {
@@ -111,6 +125,8 @@ export function useBookViewWindow(options: {
loadMorePrefix,
bucketKind,
buckets: bucketsApi.buckets,
temporalGranularity: bucketsApi.granularity,
railCapacity,
refreshBuckets: bucketsApi.refresh,
railVisible,
activeBucketKey,
@@ -1,8 +1,16 @@
import { computed, shallowRef, watch, type Ref } from 'vue'
import { api } from '@/lib/api'
import { jumpBucketKindForSort, type BookQuery, type JumpBucket, type JumpBucketsResponse } from '@bookorbit/types'
import {
jumpBucketKindForSort,
type JumpBucket,
type JumpBucketsQuery,
type JumpBucketsResponse,
type TemporalJumpBucketGranularity,
} from '@bookorbit/types'
import type { BookWindowQuery } from './useBookWindow'
const MAX_CACHE_ENTRIES = 50
/**
* Fetches the jump buckets for the current query and derives the active
* bucket purely from the first visible row index. Clicks never write the
@@ -15,20 +23,23 @@ export function useJumpBuckets(options: {
query: Ref<BookWindowQuery>
enabled: Ref<boolean>
firstVisibleIndex: Ref<number>
maxBuckets: Ref<number>
}) {
const buckets = shallowRef<JumpBucket[]>([])
const granularity = shallowRef<TemporalJumpBucketGranularity | null>(null)
const loading = shallowRef(false)
const cache = new Map<string, JumpBucket[]>()
const cache = new Map<string, Pick<JumpBucketsResponse, 'buckets' | 'granularity'>>()
let generation = 0
const kind = computed(() => jumpBucketKindForSort(options.query.value.sort))
const cacheKey = computed(() => `${options.endpoint.value ?? ''}|${JSON.stringify(options.query.value)}`)
const cacheKey = computed(() => `${options.endpoint.value ?? ''}|${options.maxBuckets.value}|${JSON.stringify(options.query.value)}`)
async function fetchBuckets() {
const gen = ++generation
const endpoint = options.endpoint.value
if (!endpoint || !options.enabled.value || !kind.value) {
buckets.value = []
granularity.value = null
loading.value = false
return
}
@@ -36,14 +47,21 @@ export function useJumpBuckets(options: {
const key = cacheKey.value
const cached = cache.get(key)
if (cached) {
buckets.value = cached
cache.delete(key)
cache.set(key, cached)
buckets.value = cached.buckets
granularity.value = cached.granularity
loading.value = false
return
}
loading.value = true
try {
const body: BookQuery = { ...options.query.value, pagination: { page: 0, size: 50 } }
const body: JumpBucketsQuery = {
...options.query.value,
pagination: { page: 0, size: 50 },
maxBuckets: options.maxBuckets.value,
}
const res = await api(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -53,10 +71,18 @@ export function useJumpBuckets(options: {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: JumpBucketsResponse = await res.json()
if (gen !== generation) return
cache.set(key, data.buckets)
cache.set(key, { buckets: data.buckets, granularity: data.granularity })
if (cache.size > MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value
if (oldestKey !== undefined) cache.delete(oldestKey)
}
buckets.value = data.buckets
granularity.value = data.granularity
} catch {
if (gen === generation) buckets.value = []
if (gen === generation) {
buckets.value = []
granularity.value = null
}
} finally {
if (gen === generation) loading.value = false
}
@@ -80,5 +106,5 @@ export function useJumpBuckets(options: {
watch([cacheKey, options.enabled], fetchBuckets, { immediate: true })
return { buckets, kind, loading, activeBucket, refresh }
return { buckets, kind, granularity, loading, activeBucket, refresh }
}
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Zu Abschnitt springen",
"jumpTo": "Zu {label} springen"
"jumpTo": "Zu {label} springen",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Schnellansicht des Buchs",
+2 -1
View File
@@ -3022,7 +3022,8 @@
},
"jumpRail": {
"jumpToSection": "Jump to section",
"jumpTo": "Jump to {label}"
"jumpTo": "Jump to {label}",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Book quick view",
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Naar sectie springen",
"jumpTo": "Naar {label} springen"
"jumpTo": "Naar {label} springen",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Snelweergave boek",
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Pular para a seção",
"jumpTo": "Pular para {label}"
"jumpTo": "Pular para {label}",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Visualização rápida do livro",
+2 -1
View File
@@ -3064,7 +3064,8 @@
},
"jumpRail": {
"jumpToSection": "Skoči na razdelek",
"jumpTo": "Skoči na {label}"
"jumpTo": "Skoči na {label}",
"unknownDate": "Unknown date"
},
"quickView": {
"title": "Hitri pregled knjige",
+7 -1
View File
@@ -75,6 +75,7 @@ watch(prefs, () => {
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const mainRef = ref<HTMLElement | null>(null)
const {
booksProxy: books,
@@ -94,6 +95,8 @@ const {
handleJump,
buckets,
bucketKind,
temporalGranularity,
railCapacity,
refreshBuckets,
railVisible,
activeBucketKey,
@@ -105,11 +108,11 @@ const {
listEndpoint: (id) => `/api/v1/collections/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/collections/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { sortModel: tableSortModel } = useViewSort(tableSort, 'collection', collectionId)
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const collectionLoadError = computed(() => collectionsError.value ?? booksError.value)
const { setBookContext } = useBookNavigation()
@@ -664,6 +667,9 @@ defineOptions({ name: 'CollectionView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
:active-key="activeBucketKey"
:template="bucketKind === 'letter' ? letterTemplate : undefined"
@jump="handleJump"
+7 -1
View File
@@ -102,6 +102,7 @@ watch(prefs, () => {
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const mainRef = ref<HTMLElement | null>(null)
const {
booksProxy: books,
@@ -123,6 +124,8 @@ const {
handleJump,
buckets,
bucketKind,
temporalGranularity,
railCapacity,
refreshBuckets,
railVisible,
activeBucketKey,
@@ -134,11 +137,11 @@ const {
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const { setBookContext } = useBookNavigation()
useBookViewContext(slots, total, loadMorePrefix)
@@ -966,6 +969,9 @@ defineOptions({ name: 'HomeView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
:active-key="activeBucketKey"
:template="bucketKind === 'letter' ? letterTemplate : undefined"
@jump="handleJump"
+7 -1
View File
@@ -85,6 +85,7 @@ watch(prefs, () => {
})
const { searchQuery, debouncedQuery, clearSearch } = useViewSearch()
const mainRef = ref<HTMLElement | null>(null)
const {
booksProxy: books,
slots,
@@ -103,6 +104,8 @@ const {
handleJump,
buckets,
bucketKind,
temporalGranularity,
railCapacity,
refreshBuckets,
railVisible,
activeBucketKey,
@@ -114,11 +117,11 @@ const {
listEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/query`,
bucketsEndpoint: (id) => `/api/v1/smart-scopes/${id}/books/jump-buckets`,
viewMode: effectiveViewMode,
railViewport: mainRef,
collapseEnabled: collapseEnabledRef,
q: debouncedQuery,
})
const { setBookContext } = useBookNavigation()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
useBookViewContext(slots, total, loadMorePrefix)
const { smartScopes, loaded: smartScopesLoaded, error: smartScopesError, fetchSmartScopes, deleteSmartScope } = useSmartScopes()
@@ -799,6 +802,9 @@ defineOptions({ name: 'SmartScopeView' })
:visible="railVisible"
:buckets="buckets"
:kind="bucketKind ?? 'letter'"
:granularity="temporalGranularity"
:max-slots="railCapacity"
:viewport="mainRef"
:active-key="activeBucketKey"
:template="bucketKind === 'letter' ? letterTemplate : undefined"
@jump="handleJump"