music: styled hover/focus/tap tooltip with exact hours and date on the hours-per-day chart
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 12:56:39 +00:00
parent cc34f5751b
commit 85c6f89e36

View File

@@ -25,6 +25,41 @@
local: 'local files'
};
const now = Date.now();
// Hours-per-day chart: a styled tooltip on hover, keyboard focus or tap (roving tabindex,
// arrow keys move between days, Escape closes).
let activeDay = $state<number | null>(null);
let focusDay = $state<number | null>(null);
let bars = $state<HTMLButtonElement[]>([]);
const tabDay = $derived(focusDay ?? s.daily.length - 1);
const tipDay = $derived(activeDay === null ? null : s.daily[activeDay]);
const fullDayLabel = (day: string) =>
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
});
const exactHours = (h: number) =>
`${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`;
const tipShift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%');
function moveDay(event: KeyboardEvent, i: number) {
if (event.key === 'Escape') {
activeDay = null;
return;
}
const last = s.daily.length - 1;
const next: Record<string, number> = {
ArrowLeft: Math.max(0, i - 1),
ArrowRight: Math.min(last, i + 1),
Home: 0,
End: last
};
if (!(event.key in next)) return;
event.preventDefault();
focusDay = next[event.key];
bars[focusDay]?.focus();
}
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -124,15 +159,62 @@
>(last {s.daily.length} days)</span
>
</h2>
<div class="flex h-32 items-end gap-px rounded-md border border-[var(--site-border)] p-2">
{#each s.daily as d (d.day)}
<div class="relative">
<div
class="flex-1 rounded-sm bg-[#1db954]/80"
class="flex h-32 items-end gap-px rounded-md border border-[var(--site-border)] p-2"
role="group"
aria-label="hours listened per day"
onpointerleave={(e) => {
if (e.pointerType === 'mouse') activeDay = null;
}}
>
{#each s.daily as d, i (d.day)}
<button
type="button"
bind:this={bars[i]}
class="group flex h-full min-w-0 flex-1 cursor-default items-end rounded-sm focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--site-text)]"
tabindex={i === tabDay ? 0 : -1}
aria-label="{fullDayLabel(d.day)}: {exactHours(d.hours)}, {d.plays} plays"
onpointerenter={(e) => {
if (e.pointerType === 'mouse') activeDay = i;
}}
onclick={(e) => {
const mouse = (e as PointerEvent).pointerType === 'mouse';
activeDay = !mouse && activeDay === i ? null : i;
}}
onfocus={() => {
focusDay = i;
activeDay = i;
}}
onblur={() => {
if (activeDay === i) activeDay = null;
}}
onkeydown={(e) => moveDay(e, i)}
>
<span
class="block w-full rounded-sm transition-colors {activeDay === i
? 'bg-[#1db954]'
: 'bg-[#1db954]/80 group-hover:bg-[#1db954]'}"
style="height: {Math.max(d.hours > 0 ? 3 : 0, (d.hours / maxHours) * 100)}%"
title="{dayLabel(d.day)}: {fmtHours(d.hours)} h, {d.plays} plays"
></div>
></span>
</button>
{/each}
</div>
{#if tipDay && activeDay !== null}
{@const pos = (activeDay + 0.5) / s.daily.length}
<div
class="pointer-events-none absolute bottom-full z-10 mb-2 rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-1.5 text-sm whitespace-nowrap shadow-lg"
style="left: calc(0.5rem + {pos} * (100% - 1rem)); transform: translateX({tipShift(pos)})"
aria-hidden="true"
>
<span class="block font-semibold tabular-nums">{exactHours(tipDay.hours)}</span>
<span class="block text-xs text-[var(--site-muted)]"
>{fullDayLabel(tipDay.day)} · {tipDay.plays.toLocaleString('en-GB')}
{tipDay.plays === 1 ? 'play' : 'plays'}</span
>
</div>
{/if}
</div>
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
<span>{dayLabel(s.daily[0]?.day ?? '')}</span><span>today</span>
</div>