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
+ };
+}