All checks were successful
Deploy production / deploy (push) Successful in 1m15s
358 lines
8.7 KiB
Svelte
358 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
|
|
import History from 'lucide-svelte/icons/history';
|
|
import { eventLabel, monthYear, shortDate, type TimelineEvent } from './shared';
|
|
|
|
// One timeline for the whole setup: parts were swapped one at a time, so every install or
|
|
// removal date is a tick. Drag snaps to ticks within ±10px; arrow keys jump between them.
|
|
let {
|
|
min,
|
|
max,
|
|
events,
|
|
today = max,
|
|
value = $bindable()
|
|
}: {
|
|
min: string;
|
|
/** Last day on the slider; later than `today` when parts are ordered but not here yet. */
|
|
max: string;
|
|
today?: string;
|
|
events: TimelineEvent[];
|
|
value: string;
|
|
} = $props();
|
|
|
|
const DAY = 86_400_000;
|
|
const toDay = (d: string) => Math.round(Date.parse(`${d}T00:00:00Z`) / DAY);
|
|
const fromDay = (n: number) => new Date(n * DAY).toISOString().slice(0, 10);
|
|
|
|
const lo = $derived(toDay(min));
|
|
const hi = $derived(toDay(max));
|
|
const span = $derived(Math.max(1, hi - lo));
|
|
const ticks = $derived(
|
|
events
|
|
.filter((e) => e.date >= min && e.date <= max)
|
|
.map((e) => ({
|
|
...e,
|
|
day: toDay(e.date),
|
|
pct: ((toDay(e.date) - lo) / span) * 100,
|
|
upcoming: e.date > today
|
|
}))
|
|
);
|
|
const years = $derived.by(() => {
|
|
const out: { y: number; pct: number }[] = [];
|
|
for (let y = Number(min.slice(0, 4)) + 1; y <= Number(max.slice(0, 4)); y++)
|
|
out.push({ y, pct: ((toDay(`${y}-01-01`) - lo) / span) * 100 });
|
|
return out;
|
|
});
|
|
const current = $derived(toDay(value));
|
|
/** Where today sits on the track when the slider runs into the future (ordered parts). */
|
|
const nowPct = $derived(today < max ? ((toDay(today) - lo) / span) * 100 : null);
|
|
/** Latest change on or before the chosen day. */
|
|
const last = $derived([...ticks].reverse().find((t) => t.day <= current) ?? null);
|
|
|
|
let width = $state(0);
|
|
let hover = $state<(typeof ticks)[number] | null>(null);
|
|
|
|
function oninput(e: Event) {
|
|
let day = Number((e.currentTarget as HTMLInputElement).value);
|
|
const perPx = span / Math.max(1, width);
|
|
let best: number | null = null;
|
|
for (const t of ticks)
|
|
if (
|
|
Math.abs(t.day - day) <= 10 * perPx &&
|
|
(best === null || Math.abs(t.day - day) < Math.abs(best - day))
|
|
)
|
|
best = t.day;
|
|
if (best !== null) day = best;
|
|
value = fromDay(day);
|
|
}
|
|
function onkeydown(e: KeyboardEvent) {
|
|
if (e.shiftKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
|
e.preventDefault();
|
|
const next =
|
|
e.key === 'ArrowRight'
|
|
? (ticks.find((t) => t.day > current)?.day ?? hi)
|
|
: ([...ticks].reverse().find((t) => t.day < current)?.day ?? lo);
|
|
value = fromDay(next);
|
|
}
|
|
</script>
|
|
|
|
<div class="slider">
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 sm:flex-nowrap">
|
|
<span class="icon" aria-hidden="true"><History size={16} /></span>
|
|
<div class="track order-last sm:order-none" bind:clientWidth={width}>
|
|
<div class="rail"></div>
|
|
{#if nowPct !== null}<div class="future" style:left="{nowPct}%"></div>{/if}
|
|
<div class="fill" style:width="{((current - lo) / span) * 100}%"></div>
|
|
{#if nowPct !== null}<span class="now" style:left="{nowPct}%" title="today"></span>{/if}
|
|
{#each years.filter((y, i) => width > 520 || i % 2 === 0) as y (y.y)}
|
|
<span class="year" style:left="{y.pct}%">{y.y}</span>
|
|
{/each}
|
|
{#each ticks as t (t.date)}
|
|
<button
|
|
type="button"
|
|
class="tick"
|
|
class:past={t.day <= current}
|
|
class:on={t.day === current}
|
|
class:upcoming={t.upcoming}
|
|
style:left="{t.pct}%"
|
|
aria-label="{shortDate(t.date)}: {eventLabel(t)}"
|
|
onmouseenter={() => (hover = t)}
|
|
onmouseleave={() => (hover = null)}
|
|
onfocus={() => (hover = t)}
|
|
onblur={() => (hover = null)}
|
|
onclick={() => (value = t.date)}
|
|
></button>
|
|
{/each}
|
|
<input
|
|
type="range"
|
|
min={lo}
|
|
max={hi}
|
|
step="1"
|
|
value={current}
|
|
{oninput}
|
|
{onkeydown}
|
|
aria-label="Show the setup on"
|
|
aria-valuetext={shortDate(value)}
|
|
/>
|
|
{#if hover}
|
|
<div class="tip" style:left="clamp(8rem, {hover.pct}%, calc(100% - 8rem))">
|
|
<strong>{monthYear(hover.date)}</strong>
|
|
{eventLabel(hover)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="when ml-auto sm:ml-0">
|
|
<span class="date tabular-nums">{shortDate(value)}</span>
|
|
<button type="button" class="today" disabled={value === today} onclick={() => (value = today)}
|
|
><RotateCcw size={13} /> today</button
|
|
>
|
|
</div>
|
|
</div>
|
|
<p class="last" aria-live="polite">
|
|
{#if value > today}<span class="soon">upcoming</span> ·
|
|
{/if}{#if last}
|
|
<span class="text-[var(--site-text)]">{monthYear(last.date)}</span> · {eventLabel(last)}
|
|
{:else}
|
|
before the first part
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<style>
|
|
.slider {
|
|
padding: 0.4rem 0.9rem 0.3rem;
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 1rem;
|
|
background: color-mix(in srgb, var(--site-surface) 92%, transparent);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.9rem;
|
|
height: 1.9rem;
|
|
flex-shrink: 0;
|
|
border-radius: 0.6rem;
|
|
color: #fff;
|
|
background: linear-gradient(135deg, #22d3ee, #7c3aed);
|
|
}
|
|
.track {
|
|
position: relative;
|
|
flex: 1 1 100%;
|
|
height: 2.1rem;
|
|
}
|
|
@media (min-width: 640px) {
|
|
.track {
|
|
flex: 1 1 0;
|
|
}
|
|
}
|
|
.rail,
|
|
.fill {
|
|
position: absolute;
|
|
top: 0.55rem;
|
|
left: 0;
|
|
height: 0.3rem;
|
|
border-radius: 999px;
|
|
}
|
|
.rail {
|
|
right: 0;
|
|
background: var(--site-hover);
|
|
}
|
|
.fill {
|
|
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
|
|
}
|
|
.year {
|
|
position: absolute;
|
|
top: 1.15rem;
|
|
transform: translateX(-50%);
|
|
font-size: 0.66rem;
|
|
color: var(--site-muted);
|
|
pointer-events: none;
|
|
}
|
|
.year::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 50%;
|
|
top: -0.35rem;
|
|
width: 1px;
|
|
height: 0.25rem;
|
|
background: var(--site-border);
|
|
}
|
|
.tick {
|
|
position: absolute;
|
|
top: 0.3rem;
|
|
z-index: 2;
|
|
width: 0.5rem;
|
|
height: 0.8rem;
|
|
margin-left: -0.25rem;
|
|
border-radius: 999px;
|
|
background: var(--site-muted);
|
|
opacity: 0.6;
|
|
transition:
|
|
transform 0.12s,
|
|
opacity 0.12s,
|
|
background 0.12s;
|
|
}
|
|
.tick.past {
|
|
background: var(--site-text);
|
|
opacity: 0.9;
|
|
}
|
|
.tick.upcoming {
|
|
background: var(--site-bg);
|
|
box-shadow: inset 0 0 0 1.5px #db2777;
|
|
opacity: 1;
|
|
}
|
|
.future {
|
|
position: absolute;
|
|
top: 0.55rem;
|
|
right: 0;
|
|
height: 0.3rem;
|
|
border-radius: 999px;
|
|
background: repeating-linear-gradient(90deg, #db2777 0 3px, transparent 3px 7px);
|
|
opacity: 0.55;
|
|
}
|
|
.now {
|
|
position: absolute;
|
|
top: 0.2rem;
|
|
z-index: 1;
|
|
width: 2px;
|
|
height: 1rem;
|
|
margin-left: -1px;
|
|
border-radius: 1px;
|
|
background: var(--site-text);
|
|
opacity: 0.7;
|
|
pointer-events: none;
|
|
}
|
|
.soon {
|
|
color: #db2777;
|
|
font-weight: 600;
|
|
}
|
|
.tick.on,
|
|
.tick:hover,
|
|
.tick:focus-visible {
|
|
transform: scaleY(1.45);
|
|
background: #db2777;
|
|
opacity: 1;
|
|
}
|
|
input[type='range'] {
|
|
position: absolute;
|
|
inset: 0 0 auto 0;
|
|
width: 100%;
|
|
height: 1.4rem;
|
|
margin: 0;
|
|
background: transparent;
|
|
appearance: none;
|
|
cursor: pointer;
|
|
z-index: 1;
|
|
}
|
|
input[type='range']::-webkit-slider-runnable-track {
|
|
height: 1.4rem;
|
|
background: transparent;
|
|
}
|
|
input[type='range']::-moz-range-track {
|
|
background: transparent;
|
|
}
|
|
input[type='range']::-webkit-slider-thumb {
|
|
appearance: none;
|
|
width: 1.15rem;
|
|
height: 1.15rem;
|
|
margin-top: 0.125rem;
|
|
border-radius: 999px;
|
|
background: var(--site-text);
|
|
border: 3px solid var(--site-bg);
|
|
box-shadow:
|
|
0 0 0 1px var(--site-border),
|
|
0 4px 12px rgb(0 0 0 / 0.4);
|
|
}
|
|
input[type='range']::-moz-range-thumb {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border-radius: 999px;
|
|
background: var(--site-text);
|
|
border: 3px solid var(--site-bg);
|
|
}
|
|
input[type='range']:focus-visible {
|
|
outline: none;
|
|
}
|
|
input[type='range']:focus-visible::-webkit-slider-thumb {
|
|
box-shadow: 0 0 0 3px #7c3aed;
|
|
}
|
|
.tip {
|
|
position: absolute;
|
|
bottom: calc(100% + 0.35rem);
|
|
z-index: 30;
|
|
transform: translateX(-50%);
|
|
max-width: 22rem;
|
|
width: max-content;
|
|
padding: 0.35rem 0.6rem;
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.6rem;
|
|
background: var(--site-bg);
|
|
font-size: 0.75rem;
|
|
color: var(--site-muted);
|
|
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.6);
|
|
pointer-events: none;
|
|
}
|
|
.tip strong {
|
|
color: var(--site-text);
|
|
margin-right: 0.3rem;
|
|
}
|
|
.when {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.date {
|
|
min-width: 6.6rem;
|
|
text-align: right;
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
}
|
|
.today {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.6rem;
|
|
padding: 0.25rem 0.55rem;
|
|
font-size: 0.78rem;
|
|
transition: background 0.15s;
|
|
}
|
|
.today:hover:not(:disabled) {
|
|
background: var(--site-hover);
|
|
}
|
|
.today:disabled {
|
|
opacity: 0.4;
|
|
}
|
|
.last {
|
|
margin: 0.05rem 0 0 2.65rem;
|
|
font-size: 0.72rem;
|
|
color: var(--site-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
</style>
|