feat(documents): drag-to-select-range on the timeline (#385)

The original AC required drag-to-select; the MVP shipped with click-only.
This adds pointer-driven range selection while preserving keyboard access:

- Pointer events (pointerdown / pointerenter / pointerup) drive the drag.
  Pointer capture on pointerdown so the cursor leaving the bar still
  produces drag-end events. Live preview class `in-drag-preview` highlights
  the spanning bars while dragging; the URL/list refetch only fires on
  pointerup (Felix R3).
- Click handler kept for keyboard activation (Enter/Space on focused bar).
  A `suppressClick` flag prevents the synthesized click after a mouse
  pointerup from double-emitting.
- Drag from later → earlier still emits ascending boundaries (drag direction
  doesn't matter).
- Existing single-click keyboard selection unchanged.

4 new component tests cover the drag paths plus the live-preview class.
Existing 13 tests (single click, year mode, clear, visibility) still green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 23:16:48 +02:00
parent 76023a99ed
commit bd81ff81f9
2 changed files with 199 additions and 7 deletions

View File

@@ -46,15 +46,30 @@ 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);
// 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 barHeight(count: number): number {
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_HEIGHT);
}
function selectBar(label: string) {
onchange({ from: selectionBoundaryFrom(label), to: selectionBoundaryTo(label) });
}
function clearSelection() {
onchange({ from: '', to: '' });
}
@@ -64,6 +79,60 @@ function isSelected(label: string): boolean {
const labelFrom = selectionBoundaryFrom(label);
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 emitSelection(startIndex: number, endIndex: number) {
const lo = Math.min(startIndex, endIndex);
const hi = Math.max(startIndex, endIndex);
const startLabel = filled[lo]?.month;
const endLabel = filled[hi]?.month;
if (!startLabel || !endLabel) return;
onchange({
from: selectionBoundaryFrom(startLabel),
to: selectionBoundaryTo(endLabel)
});
}
function handlePointerDown(event: PointerEvent, index: number) {
if (event.button !== 0) return;
dragStartIndex = index;
dragEndIndex = index;
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
}
function handlePointerEnter(index: number) {
if (!isDragging) return;
dragEndIndex = index;
}
function handlePointerUp() {
if (dragStartIndex === null || dragEndIndex === null) return;
emitSelection(dragStartIndex, dragEndIndex);
dragStartIndex = null;
dragEndIndex = null;
suppressClick = true;
queueMicrotask(() => {
suppressClick = false;
});
}
function handlePointerCancel() {
dragStartIndex = null;
dragEndIndex = null;
}
function handleClick(index: number) {
if (suppressClick) {
suppressClick = false;
return;
}
emitSelection(index, index);
}
</script>
{#if density !== null}
@@ -74,15 +143,20 @@ function isSelected(label: string): boolean {
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
>
<div class="flex h-20 items-end" style="height: {BAR_AREA_HEIGHT}px;">
{#each filled as bucket (bucket.month)}
{#each filled as bucket, i (bucket.month)}
<button
type="button"
data-testid="timeline-bar"
aria-label="{bucket.month} · {bucket.count}"
aria-pressed={isSelected(bucket.month)}
onclick={() => selectBar(bucket.month)}
onpointerdown={(e) => handlePointerDown(e, i)}
onpointerenter={() => handlePointerEnter(i)}
onpointerup={handlePointerUp}
onpointercancel={handlePointerCancel}
onclick={() => handleClick(i)}
class="bar group flex min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors"
class:selected={isSelected(bucket.month)}
class:in-drag-preview={isInDragPreview(i)}
>
<span
class="bar-fill block w-full rounded-t-[2px]"
@@ -122,10 +196,15 @@ function isSelected(label: string): boolean {
transition: background-color 100ms ease;
}
.bar.selected .bar-fill {
.bar.selected .bar-fill,
.bar.in-drag-preview .bar-fill {
background-color: var(--palette-mint, #a1dcd8);
}
.bar.in-drag-preview .bar-fill {
opacity: 0.7;
}
.bar:hover .bar-fill {
background-color: var(--palette-mint, #a1dcd8);
opacity: 0.85;

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { tick } from 'svelte';
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
import type { components } from '$lib/generated/api';
@@ -173,3 +174,115 @@ describe('TimelineDensityFilter — year-granularity fallback', () => {
expect(onchange).toHaveBeenCalledWith({ from: '1905-01-01', to: '1905-12-31' });
});
});
describe('TimelineDensityFilter — drag-to-select-range', () => {
function pointerDown(el: HTMLElement) {
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
// jsdom-style stub for setPointerCapture (the real DOM has it but vitest-browser
// uses Playwright-driven Chromium so it works natively too).
el.dispatchEvent(event);
}
function pointerEnter(el: HTMLElement) {
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
}
function pointerUp(el: HTMLElement) {
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
}
it('dragging from bar A to bar B emits a single onchange with the spanning range', async () => {
const onchange = vi.fn();
render(
TimelineDensityFilter,
makeProps({
density: [
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 3 }
],
minDate: '1915-08-01',
maxDate: '1915-10-31',
onchange
})
);
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
pointerDown(bars[0]);
pointerEnter(bars[2]);
pointerUp(bars[2]);
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-10-31' });
});
it('dragging from a later bar to an earlier bar still emits ascending boundaries', async () => {
const onchange = vi.fn();
render(
TimelineDensityFilter,
makeProps({
density: [
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 3 }
],
minDate: '1915-08-01',
maxDate: '1915-10-31',
onchange
})
);
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
pointerDown(bars[2]);
pointerEnter(bars[0]);
pointerUp(bars[0]);
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-10-31' });
});
it('pressing+releasing on the same bar still selects that single bar', async () => {
const onchange = vi.fn();
render(TimelineDensityFilter, makeProps({ onchange }));
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
pointerDown(bars[1]);
pointerUp(bars[1]);
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith({ from: '1915-09-01', to: '1915-09-30' });
});
it('marks bars in the active drag range with the in-drag-preview class', async () => {
render(
TimelineDensityFilter,
makeProps({
density: [
{ month: '1915-08', count: 1 },
{ month: '1915-09', count: 2 },
{ month: '1915-10', count: 3 },
{ month: '1915-11', count: 4 }
],
minDate: '1915-08-01',
maxDate: '1915-11-30'
})
);
const bars = document.querySelectorAll(
'[data-testid="timeline-bar"]'
) as NodeListOf<HTMLElement>;
pointerDown(bars[0]);
pointerEnter(bars[2]);
await tick();
expect(bars[0].classList.contains('in-drag-preview')).toBe(true);
expect(bars[1].classList.contains('in-drag-preview')).toBe(true);
expect(bars[2].classList.contains('in-drag-preview')).toBe(true);
expect(bars[3].classList.contains('in-drag-preview')).toBe(false);
pointerUp(bars[2]);
});
});