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'
|
local: 'local files'
|
||||||
};
|
};
|
||||||
const now = Date.now();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
@@ -124,14 +159,61 @@
|
|||||||
>(last {s.daily.length} days)</span
|
>(last {s.daily.length} days)</span
|
||||||
>
|
>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex h-32 items-end gap-px rounded-md border border-[var(--site-border)] p-2">
|
<div class="relative">
|
||||||
{#each s.daily as d (d.day)}
|
<div
|
||||||
|
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)}%"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if tipDay && activeDay !== null}
|
||||||
|
{@const pos = (activeDay + 0.5) / s.daily.length}
|
||||||
<div
|
<div
|
||||||
class="flex-1 rounded-sm bg-[#1db954]/80"
|
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="height: {Math.max(d.hours > 0 ? 3 : 0, (d.hours / maxHours) * 100)}%"
|
style="left: calc(0.5rem + {pos} * (100% - 1rem)); transform: translateX({tipShift(pos)})"
|
||||||
title="{dayLabel(d.day)}: {fmtHours(d.hours)} h, {d.plays} plays"
|
aria-hidden="true"
|
||||||
></div>
|
>
|
||||||
{/each}
|
<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>
|
||||||
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
|
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
|
||||||
<span>{dayLabel(s.daily[0]?.day ?? '')}</span><span>today</span>
|
<span>{dayLabel(s.daily[0]?.day ?? '')}</span><span>today</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user