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:
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user