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 !== '');
|
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 {
|
function barHeight(count: number): number {
|
||||||
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
|
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
|
||||||
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * BAR_AREA_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() {
|
function clearSelection() {
|
||||||
onchange({ from: '', to: '' });
|
onchange({ from: '', to: '' });
|
||||||
}
|
}
|
||||||
@@ -64,6 +79,60 @@ function isSelected(label: string): boolean {
|
|||||||
const labelFrom = selectionBoundaryFrom(label);
|
const labelFrom = selectionBoundaryFrom(label);
|
||||||
return labelFrom >= from && labelFrom <= to;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if density !== null}
|
{#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"
|
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;">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="timeline-bar"
|
data-testid="timeline-bar"
|
||||||
aria-label="{bucket.month} · {bucket.count}"
|
aria-label="{bucket.month} · {bucket.count}"
|
||||||
aria-pressed={isSelected(bucket.month)}
|
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="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:selected={isSelected(bucket.month)}
|
||||||
|
class:in-drag-preview={isInDragPreview(i)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="bar-fill block w-full rounded-t-[2px]"
|
class="bar-fill block w-full rounded-t-[2px]"
|
||||||
@@ -122,10 +196,15 @@ function isSelected(label: string): boolean {
|
|||||||
transition: background-color 100ms ease;
|
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);
|
background-color: var(--palette-mint, #a1dcd8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar.in-drag-preview .bar-fill {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.bar:hover .bar-fill {
|
.bar:hover .bar-fill {
|
||||||
background-color: var(--palette-mint, #a1dcd8);
|
background-color: var(--palette-mint, #a1dcd8);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
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' });
|
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