refactor(documents): extract TimelineBars from density filter (#385)

Splits the bar row + drag-window overlay + bar styling out of the
377-line orchestrator into a single-purpose component. The pointer
choreography (handle{PointerDown,DocumentMove,DocumentUp},
indexFromClientX, cleanupDragListeners) stays in the orchestrator
per Felix's note. Closes part 1 of Felix's component-split concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-08 10:04:38 +02:00
parent 77d282bbeb
commit 00682bac4f
2 changed files with 146 additions and 93 deletions

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { formatTickLabel } from '$lib/document/timeline';
import { getLocale } from '$lib/paraglide/runtime';
import type { components } from '$lib/generated/api';
type MonthBucket = components['schemas']['MonthBucket'];
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
let {
filled,
maxCount,
barAreaHeight,
isSelected,
isInDragPreview,
isDragging,
dragWindowLeftPct,
dragWindowRightPct,
rowEl = $bindable(),
onbarpointerdown,
onbarpointerenter,
onbarclick
}: {
filled: MonthBucket[];
maxCount: number;
barAreaHeight: number;
isSelected: (label: string) => boolean;
isInDragPreview: (index: number) => boolean;
isDragging: boolean;
dragWindowLeftPct: number;
dragWindowRightPct: number;
rowEl?: HTMLDivElement;
onbarpointerdown: (event: PointerEvent, index: number) => void;
onbarpointerenter: (index: number) => void;
onbarclick: (index: number) => void;
} = $props();
function barHeight(count: number): number {
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * barAreaHeight);
}
</script>
<div
bind:this={rowEl}
class="relative flex items-end border-b border-line"
style="height: {barAreaHeight}px;"
>
{#each filled as bucket, i (bucket.month)}
<button
type="button"
data-testid="timeline-bar"
aria-label={m.timeline_bar_aria({
when: formatTickLabel(bucket.month, getLocale()),
count: bucket.count
})}
aria-pressed={isSelected(bucket.month)}
onpointerdown={(e) => onbarpointerdown(e, i)}
onpointerenter={() => onbarpointerenter(i)}
onclick={() => onbarclick(i)}
class="bar group flex h-full 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]"
style="height: {barHeight(bucket.count)}px;"
></span>
</button>
{/each}
{#if isDragging}
<div
class="drag-window"
data-testid="timeline-drag-window"
style="left: {dragWindowLeftPct}%; right: {dragWindowRightPct}%;"
></div>
{/if}
</div>
<style>
:root {
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
--timeline-bar-outside: var(--c-line);
}
:global(.dark) {
/* 3.33:1 against --c-surface (#011526) — clears WCAG 1.4.11 non-text contrast
for large UI elements; previous #0d3358 measured 1.44:1. */
--timeline-bar-idle: #3a6e8c;
--timeline-bar-outside: #1a2735;
}
.bar .bar-fill {
background-color: var(--timeline-bar-idle);
transition: background-color 100ms ease;
}
@media (prefers-reduced-motion: reduce) {
.bar .bar-fill {
transition: none;
}
}
.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;
}
/* Graylog-style range selector window: left/right borders mark the dragged
range, tinted body fills the area. pointer-events:none keeps the bars below
reachable so pointermove still fires their pointerenter. */
.drag-window {
position: absolute;
top: 0;
bottom: 0;
background-color: rgba(161, 220, 216, 0.22);
border-left: 2px solid var(--palette-mint, #a1dcd8);
border-right: 2px solid var(--palette-mint, #a1dcd8);
pointer-events: none;
}
</style>