129 lines
3.7 KiB
Svelte
129 lines
3.7 KiB
Svelte
<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={bucket.count === 1
|
|
? m.timeline_bar_aria_singular({ when: formatTickLabel(bucket.month, getLocale()) })
|
|
: m.timeline_bar_aria_plural({
|
|
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 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
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>
|
|
/* Timeline-specific tokens (--timeline-bar-idle, --timeline-bar-outside) live
|
|
in layout.css next to the rest of the design tokens; this <style> only
|
|
consumes them. */
|
|
.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;
|
|
}
|
|
|
|
/* Gate hover under (hover: hover) so emulated mouse events on touch devices
|
|
don't leave a tapped bar stuck in :hover until the next tap elsewhere. */
|
|
@media (hover: hover) {
|
|
.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>
|