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
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user