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:
131
frontend/src/lib/document/TimelineBars.svelte
Normal file
131
frontend/src/lib/document/TimelineBars.svelte
Normal 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>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
formatTickLabel
|
formatTickLabel
|
||||||
} from '$lib/document/timeline';
|
} from '$lib/document/timeline';
|
||||||
import { getLocale } from '$lib/paraglide/runtime';
|
import { getLocale } from '$lib/paraglide/runtime';
|
||||||
|
import TimelineBars from '$lib/document/TimelineBars.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type MonthBucket = components['schemas']['MonthBucket'];
|
type MonthBucket = components['schemas']['MonthBucket'];
|
||||||
@@ -24,7 +25,6 @@ type SelectionEvent = {
|
|||||||
type ZoomEvent = { zoomFrom: string; zoomTo: string };
|
type ZoomEvent = { zoomFrom: string; zoomTo: string };
|
||||||
|
|
||||||
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
||||||
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
|
||||||
// Above this threshold, month bars compress to sub-pixel widths in the flex
|
// Above this threshold, month bars compress to sub-pixel widths in the flex
|
||||||
// row; we collapse to year granularity so each bar stays clickable.
|
// row; we collapse to year granularity so each bar stays clickable.
|
||||||
const MONTH_GRANULARITY_LIMIT = 240;
|
const MONTH_GRANULARITY_LIMIT = 240;
|
||||||
@@ -91,11 +91,6 @@ const dragHighIndex = $derived(
|
|||||||
: dragStartIndex
|
: 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 clearSelection() {
|
function clearSelection() {
|
||||||
onchange({ from: '', to: '' });
|
onchange({ from: '', to: '' });
|
||||||
}
|
}
|
||||||
@@ -264,41 +259,20 @@ const omitTickYear = $derived.by(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div
|
<TimelineBars
|
||||||
bind:this={rowEl}
|
filled={filled}
|
||||||
class="relative flex items-end border-b border-line"
|
maxCount={maxCount}
|
||||||
style="height: {BAR_AREA_HEIGHT}px;"
|
barAreaHeight={BAR_AREA_HEIGHT}
|
||||||
>
|
isSelected={isSelected}
|
||||||
{#each filled as bucket, i (bucket.month)}
|
isInDragPreview={isInDragPreview}
|
||||||
<button
|
isDragging={isDragging}
|
||||||
type="button"
|
dragWindowLeftPct={dragWindowLeftPct}
|
||||||
data-testid="timeline-bar"
|
dragWindowRightPct={dragWindowRightPct}
|
||||||
aria-label={m.timeline_bar_aria({
|
bind:rowEl={rowEl}
|
||||||
when: formatTickLabel(bucket.month, getLocale()),
|
onbarpointerdown={handlePointerDown}
|
||||||
count: bucket.count
|
onbarpointerenter={handlePointerEnter}
|
||||||
})}
|
onbarclick={handleClick}
|
||||||
aria-pressed={isSelected(bucket.month)}
|
/>
|
||||||
onpointerdown={(e) => handlePointerDown(e, i)}
|
|
||||||
onpointerenter={() => handlePointerEnter(i)}
|
|
||||||
onclick={() => handleClick(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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative mt-1 h-4 font-sans text-xs leading-none text-ink-3"
|
class="relative mt-1 h-4 font-sans text-xs leading-none text-ink-3"
|
||||||
@@ -350,55 +324,3 @@ const omitTickYear = $derived.by(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user