mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3064,7 +3064,8 @@
|
||||
},
|
||||
"jumpRail": {
|
||||
"jumpToSection": "Naar sectie springen",
|
||||
"jumpTo": "Naar {label} springen"
|
||||
"jumpTo": "Naar {label} springen",
|
||||
"unknownDate": "Unknown date"
|
||||
},
|
||||
"quickView": {
|
||||
"title": "Snelweergave boek",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user