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>
93 lines
2.6 KiB
Svelte
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>
|