diff --git a/frontend/src/lib/document/TimelineDensityFilter.svelte b/frontend/src/lib/document/TimelineDensityFilter.svelte index 5c8e99b6..5f7b8946 100644 --- a/frontend/src/lib/document/TimelineDensityFilter.svelte +++ b/frontend/src/lib/document/TimelineDensityFilter.svelte @@ -8,6 +8,7 @@ import { selectionBoundaryTo, formatTickLabel } from '$lib/document/timeline'; +import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte'; import { getLocale } from '$lib/paraglide/runtime'; import TimelineBars from '$lib/document/TimelineBars.svelte'; import TimelineYAxis from '$lib/document/TimelineYAxis.svelte'; @@ -73,25 +74,7 @@ const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1)); const hasSelection = $derived(from !== '' || to !== ''); -let dragStartIndex: number | null = $state(null); -let dragEndIndex: number | null = $state(null); let rowEl: HTMLDivElement | undefined = $state(); -// Set when a pointerup just emitted a selection so the synthesized click that -// follows mouse interaction is suppressed (we'd otherwise emit twice). Keyboard -// Enter/Space on a focused button still fires `click` without preceding -// pointerdown, so the click path remains the keyboard accessibility surface. -let suppressClick = $state(false); -const isDragging = $derived(dragStartIndex !== null); -const dragLowIndex = $derived( - dragStartIndex !== null && dragEndIndex !== null - ? Math.min(dragStartIndex, dragEndIndex) - : dragStartIndex -); -const dragHighIndex = $derived( - dragStartIndex !== null && dragEndIndex !== null - ? Math.max(dragStartIndex, dragEndIndex) - : dragStartIndex -); function clearSelection() { onchange({ from: '', to: '' }); @@ -103,10 +86,8 @@ function isSelected(label: string): boolean { return labelFrom >= from && labelFrom <= to; } -function isInDragPreview(index: number): boolean { - if (!isDragging) return false; - if (dragLowIndex === null || dragHighIndex === null) return false; - return index >= dragLowIndex && index <= dragHighIndex; +function isYearLabel(label: string): boolean { + return label.length === 4; } function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) { @@ -138,102 +119,39 @@ function indexFromClientX(clientX: number): number | null { return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth))); } -function handleDocumentMove(e: PointerEvent) { - const idx = indexFromClientX(e.clientX); - if (idx !== null) dragEndIndex = idx; -} - -function handleDocumentUp() { - cleanupDragListeners(); - finalizeDrag(); -} - -function handleDocumentCancel() { - cleanupDragListeners(); - dragStartIndex = null; - dragEndIndex = null; -} - -function cleanupDragListeners() { - document.removeEventListener('pointermove', handleDocumentMove); - document.removeEventListener('pointerup', handleDocumentUp); - document.removeEventListener('pointercancel', handleDocumentCancel); -} +const drag = createTimelineDrag({ + indexFromClientX, + labelAt: (i) => filled[i]?.month, + isYearLabel, + emit: emitSelection +}); // Strip any in-flight document listeners if the component unmounts mid-drag // (route change, view toggle, breakpoint drop). Without this they survive on // document and keep writing to torn-down state cells. -$effect(() => { - return cleanupDragListeners; -}); +$effect(() => drag.cleanup); -function finalizeDrag() { - if (dragStartIndex === null || dragEndIndex === null) return; - const start = dragStartIndex; - const end = dragEndIndex; - dragStartIndex = null; - dragEndIndex = null; - const isRangeDrag = start !== end; - const startLabel = filled[start]?.month; - // Range drag → atomic zoom + filter. - // Same-bar release on a year bar → zoom into that year's months. - // Same-bar release on a month bar → filter only. - const includeZoom = isRangeDrag || (!!startLabel && isYearLabel(startLabel)); - emitSelection(start, end, includeZoom); - // Suppress the synthesized click that follows pointerup so we don't - // double-emit. Keyboard Enter/Space fires click without a preceding - // pointerup and stays the keyboard accessibility surface. - suppressClick = true; - queueMicrotask(() => { - suppressClick = false; - }); -} - -function handlePointerDown(event: PointerEvent, index: number) { - if (event.button !== 0) return; - dragStartIndex = index; - dragEndIndex = index; - document.addEventListener('pointermove', handleDocumentMove); - document.addEventListener('pointerup', handleDocumentUp); - document.addEventListener('pointercancel', handleDocumentCancel); -} - -function handlePointerEnter(index: number) { - if (!isDragging) return; - dragEndIndex = index; -} - -function isYearLabel(label: string): boolean { - return label.length === 4; -} - -function handleClick(index: number) { - if (suppressClick) { - suppressClick = false; - return; - } - const label = filled[index]?.month; - // Click on a year bar zooms into that year so the user can see month-level - // density. Click on a month bar just filters. - const includeZoom = !!label && isYearLabel(label); - emitSelection(index, index, includeZoom); +function isInDragPreview(index: number): boolean { + if (!drag.isDragging) return false; + if (drag.lowIndex === null || drag.highIndex === null) return false; + return index >= drag.lowIndex && index <= drag.highIndex; } const dragWindowLeftPct = $derived.by(() => { - if (!isDragging || dragLowIndex === null || filled.length === 0) return 0; - return (dragLowIndex / filled.length) * 100; + if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0; + return (drag.lowIndex / filled.length) * 100; }); const dragWindowRightPct = $derived.by(() => { - if (!isDragging || dragHighIndex === null || filled.length === 0) return 100; - return ((filled.length - dragHighIndex - 1) / filled.length) * 100; + if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100; + return ((filled.length - drag.highIndex - 1) / filled.length) * 100; }); // While dragging, expose the live preview range to assistive tech via a // polite live region. Empty text outside drag avoids announcing residual state. const dragLiveMessage = $derived.by(() => { - if (!isDragging || dragLowIndex === null || dragHighIndex === null) return ''; - const fromLabel = filled[dragLowIndex]?.month; - const toLabel = filled[dragHighIndex]?.month; + if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return ''; + const fromLabel = filled[drag.lowIndex]?.month; + const toLabel = filled[drag.highIndex]?.month; if (!fromLabel || !toLabel) return ''; return m.timeline_dragging_aria_live({ from: formatTickLabel(fromLabel, getLocale()), @@ -259,13 +177,13 @@ const dragLiveMessage = $derived.by(() => { barAreaHeight={BAR_AREA_HEIGHT} isSelected={isSelected} isInDragPreview={isInDragPreview} - isDragging={isDragging} + isDragging={drag.isDragging} dragWindowLeftPct={dragWindowLeftPct} dragWindowRightPct={dragWindowRightPct} bind:rowEl={rowEl} - onbarpointerdown={handlePointerDown} - onbarpointerenter={handlePointerEnter} - onbarclick={handleClick} + onbarpointerdown={drag.pointerDown} + onbarpointerenter={drag.pointerEnter} + onbarclick={drag.click} /> diff --git a/frontend/src/lib/document/useTimelineDrag.svelte.test.ts b/frontend/src/lib/document/useTimelineDrag.svelte.test.ts new file mode 100644 index 00000000..2bf473b0 --- /dev/null +++ b/frontend/src/lib/document/useTimelineDrag.svelte.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { flushSync } from 'svelte'; + +const { createTimelineDrag } = await import('./useTimelineDrag.svelte'); + +type EmitCall = { start: number; end: number; includeZoom: boolean }; + +function makeOpts(overrides: Partial[0]> = {}) { + const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label + const calls: EmitCall[] = []; + const opts = { + indexFromClientX: vi.fn(() => 0), + labelAt: (i: number) => labels[i], + isYearLabel: (l: string) => l.length === 4, + emit: (start: number, end: number, includeZoom: boolean) => { + calls.push({ start, end, includeZoom }); + }, + ...overrides + }; + return { opts, calls }; +} + +function pointerDownEvent(button = 0): PointerEvent { + return { button } as unknown as PointerEvent; +} + +describe('createTimelineDrag', () => { + beforeEach(() => { + // Reset listeners attached to the JSDOM document between tests + document.removeEventListener('pointermove', () => undefined); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts with no drag in progress and null low/high indices', () => { + const { opts } = makeOpts(); + const drag = createTimelineDrag(opts); + expect(drag.isDragging).toBe(false); + expect(drag.lowIndex).toBeNull(); + expect(drag.highIndex).toBeNull(); + }); + + it('pointerDown on a primary button enters drag state at that index', () => { + const { opts } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 1); + flushSync(); + expect(drag.isDragging).toBe(true); + expect(drag.lowIndex).toBe(1); + expect(drag.highIndex).toBe(1); + }); + + it('pointerDown on a non-primary button is ignored', () => { + const { opts } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(2), 1); + flushSync(); + expect(drag.isDragging).toBe(false); + }); + + it('pointerEnter during drag widens the range high boundary', () => { + const { opts } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 0); + drag.pointerEnter(2); + flushSync(); + expect(drag.lowIndex).toBe(0); + expect(drag.highIndex).toBe(2); + }); + + it('pointerEnter outside drag is a no-op', () => { + const { opts } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.pointerEnter(2); + flushSync(); + expect(drag.isDragging).toBe(false); + expect(drag.lowIndex).toBeNull(); + }); + + it('click on a month bar emits filter only (no zoom)', () => { + const { opts, calls } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.click(0); + expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]); + }); + + it('click on a year bar emits filter + zoom atomically', () => { + const { opts, calls } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.click(3); // labels[3] = '1915' (year label) + expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]); + }); + + it('range drag commits emit with zoom in ascending order', async () => { + const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) }); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 0); + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 })); + document.dispatchEvent(new PointerEvent('pointerup')); + flushSync(); + expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]); + expect(drag.isDragging).toBe(false); + }); + + it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => { + const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) }); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 2); + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 })); + document.dispatchEvent(new PointerEvent('pointerup')); + flushSync(); + // emit receives raw start/end — orchestrator's emit() handles ordering + expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]); + }); + + it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => { + const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) }); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 0); + document.dispatchEvent(new PointerEvent('pointerup')); + flushSync(); + expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]); + }); + + it('pointercancel resets state without emitting', () => { + const { opts, calls } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 1); + document.dispatchEvent(new PointerEvent('pointercancel')); + flushSync(); + expect(calls).toEqual([]); + expect(drag.isDragging).toBe(false); + }); + + it('click after pointerup is suppressed (no double emit)', async () => { + const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) }); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 0); + document.dispatchEvent(new PointerEvent('pointerup')); + flushSync(); + drag.click(0); // synthesized click after pointerup + expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click + }); + + it('cleanup removes document pointer listeners', () => { + const removeSpy = vi.spyOn(document, 'removeEventListener'); + const { opts } = makeOpts(); + const drag = createTimelineDrag(opts); + drag.pointerDown(pointerDownEvent(0), 0); + drag.cleanup(); + const removed = removeSpy.mock.calls.map((c) => c[0]); + expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel'])); + }); +}); diff --git a/frontend/src/lib/document/useTimelineDrag.svelte.ts b/frontend/src/lib/document/useTimelineDrag.svelte.ts new file mode 100644 index 00000000..f35d623e --- /dev/null +++ b/frontend/src/lib/document/useTimelineDrag.svelte.ts @@ -0,0 +1,118 @@ +type DragOptions = { + /** + * Maps a viewport X coordinate to a bar index, or null if outside the row. + * The orchestrator owns the row element bound from `TimelineBars`. + */ + indexFromClientX(clientX: number): number | null; + /** Returns the bucket label at index, or undefined if out of range. */ + labelAt(index: number): string | undefined; + /** + * True when a label represents an aggregated year bar — controls click-to-zoom + * semantics (clicking a year zooms into its 12 months; clicking a month doesn't). + */ + isYearLabel(label: string): boolean; + /** + * Emits a selection. `includeZoom` is true for a range drag or a click on a + * year bar; false for a click on a month bar. + */ + emit(startIndex: number, endIndex: number, includeZoom: boolean): void; +}; + +/** + * Drag state machine for the timeline density widget. Exposes: + * - `isDragging`, `lowIndex`, `highIndex` reactive read-onlies for the UI + * - `pointerDown` / `pointerEnter` / `click` handlers for bar buttons + * - `cleanup()` to drop document-level listeners on component unmount + * + * Document-level listeners (pointermove, pointerup, pointercancel) keep drag + * tracking alive while the cursor leaves the original bar or the timeline row. + * `cleanup()` must be called from a Svelte `$effect` teardown so a route change + * mid-drag does not leak listeners. + */ +export function createTimelineDrag(opts: DragOptions) { + let startIndex = $state(null); + let endIndex = $state(null); + // Set after a pointerup-driven emit so the synthesized click that follows is + // suppressed (we'd otherwise emit twice). Keyboard Enter/Space fires click + // without preceding pointerdown, so click stays the keyboard surface. + let suppressClick = $state(false); + + function handleDocumentMove(e: PointerEvent) { + const idx = opts.indexFromClientX(e.clientX); + if (idx !== null) endIndex = idx; + } + + function handleDocumentUp() { + cleanup(); + finalizeDrag(); + } + + function handleDocumentCancel() { + cleanup(); + startIndex = null; + endIndex = null; + } + + function cleanup() { + document.removeEventListener('pointermove', handleDocumentMove); + document.removeEventListener('pointerup', handleDocumentUp); + document.removeEventListener('pointercancel', handleDocumentCancel); + } + + function finalizeDrag() { + if (startIndex === null || endIndex === null) return; + const start = startIndex; + const end = endIndex; + startIndex = null; + endIndex = null; + const isRangeDrag = start !== end; + const startLabel = opts.labelAt(start); + // Range drag → atomic zoom + filter. Same-bar release on a year bar → + // zoom into that year's months. Same-bar release on a month bar → + // filter only. + const includeZoom = isRangeDrag || (!!startLabel && opts.isYearLabel(startLabel)); + opts.emit(start, end, includeZoom); + suppressClick = true; + queueMicrotask(() => { + suppressClick = false; + }); + } + + return { + get isDragging() { + return startIndex !== null; + }, + get lowIndex() { + if (startIndex === null) return null; + if (endIndex === null) return startIndex; + return Math.min(startIndex, endIndex); + }, + get highIndex() { + if (startIndex === null) return null; + if (endIndex === null) return startIndex; + return Math.max(startIndex, endIndex); + }, + pointerDown(event: PointerEvent, index: number) { + if (event.button !== 0) return; + startIndex = index; + endIndex = index; + document.addEventListener('pointermove', handleDocumentMove); + document.addEventListener('pointerup', handleDocumentUp); + document.addEventListener('pointercancel', handleDocumentCancel); + }, + pointerEnter(index: number) { + if (startIndex === null) return; + endIndex = index; + }, + click(index: number) { + if (suppressClick) { + suppressClick = false; + return; + } + const label = opts.labelAt(index); + const includeZoom = !!label && opts.isYearLabel(label); + opts.emit(index, index, includeZoom); + }, + cleanup + }; +}