Files
Lumotia/src/lib/components/CompletionSparkline.svelte
Jake dd45f10cd4 polish(phase9): sparkline + badge motion and a11y
Sparkline: friendlier aria-label ("3 completed today. 14 total over
the last 7 days." rather than a numeric list), per-bar <title>
tooltips with absolute date + count, 30 ms stagger entrance via
scaleY animation. Badge: 180 ms opacity + translateY entrance on
mount; conditional render means each new badge re-fires the
animation. Both animations respect prefers-reduced-motion.

The earlier draft tabindex=0 on the SVG was correctly flagged by
svelte-check as noninteractive_tabindex; SVG role="img" + aria-label
is sufficient for SR navigation without putting it in the keyboard
tab order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:17:53 +01:00

93 lines
2.6 KiB
Svelte

<script lang="ts">
// Phase 8. Tiny inline SVG sparkline. 7 bars, oldest to newest.
// Zero-days render as a 1 px baseline stub (never a gap, never zero
// height). The component self-hides when all bars are zero. Caller
// still guards on the user's settings toggle.
//
// Phase 9 polish: friendlier aria-label (sentence-form summary, not a
// numeric list), per-bar <title> tooltips with absolute date + count,
// and a 30 ms-stagger entrance animation that respects
// prefers-reduced-motion. Tabindex deliberately omitted: SVGs with
// role="img" are reachable by screen-reader graphic navigation
// without being part of the keyboard-tab order.
import type { DailyCompletionCount } from "$lib/types/app";
interface Props {
data: DailyCompletionCount[];
/** Total width in px. Default 80. */
width?: number;
/** Total height in px. Default 16. */
height?: number;
}
let { data, width = 80, height = 16 }: Props = $props();
const BAR_GAP = 2;
let maxCount = $derived(Math.max(1, ...data.map((d) => d.count)));
let barWidth = $derived(
(width - BAR_GAP * Math.max(0, data.length - 1)) / Math.max(1, data.length),
);
let total = $derived(data.reduce((sum, d) => sum + d.count, 0));
let today = $derived(data.at(-1)?.count ?? 0);
let ariaLabel = $derived.by(() => {
if (data.length === 0) return "";
return `${today} completed today. ${total} total over the last ${data.length} days.`;
});
let hasAnyCompletion = $derived(data.some((d) => d.count > 0));
</script>
{#if hasAnyCompletion}
<svg
{width}
{height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={ariaLabel}
class="text-text-tertiary"
>
{#each data as d, i}
{@const x = i * (barWidth + BAR_GAP)}
{@const proportion = d.count / maxCount}
{@const barHeight = Math.max(1, Math.round(proportion * height))}
{@const y = height - barHeight}
<rect
{x}
{y}
width={barWidth}
height={barHeight}
fill="currentColor"
opacity={d.count === 0 ? 0.35 : 0.85}
rx="1"
class="bar"
style={`--bar-delay: ${i * 30}ms`}
>
<title>{d.day}: {d.count} {d.count === 1 ? "task" : "tasks"}</title>
</rect>
{/each}
</svg>
{/if}
<style>
.bar {
transform-origin: bottom;
animation: bar-in 240ms ease var(--bar-delay, 0ms) both;
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
@keyframes bar-in {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
</style>