feat: enhance typography and accessibility in design system

- Added new UI heading utility with specific styles in typography.css.
- Updated typography tests to include new UI heading role and ensure proper size and styling.
- Documented design system audit findings, focusing on accessibility improvements and component consistency.
- Introduced CalendarExamples component to demonstrate calendar functionality with habit tracking.
- Created a shared Field component for consistent labeling and hinting of form controls.
- Implemented comprehensive accessibility tests for calendar and editor components, ensuring proper focus management and keyboard navigation.
- Added structure tests for Field and Card components to verify correct ID generation and heading levels.
This commit is contained in:
syntaxbullet
2026-09-04 13:44:31 +02:00
parent bfa54851c2
commit dde5e77317
19 changed files with 1077 additions and 681 deletions

View File

@@ -1,7 +1,10 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field";
import { Button } from "./primitives";
import {
describeDay,
calendarTimeline,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
@@ -32,7 +35,9 @@ export function CalendarHeatmap({
historyLabel?: string;
legend?: ReactNode;
}) {
const [months, setMonths] = useState<MonthView>(12);
const [months, setMonths] = useState<MonthView | "custom">(12);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.at(-1)?.date;
@@ -47,46 +52,55 @@ export function CalendarHeatmap({
if (onSelectDate) onSelectDate(date);
else setLocalSelectedDate(date);
}
const days = anchor ? visibleCalendarDays(allDays, months, anchor) : [];
const days = months === "custom" && customRange
? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to)
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [];
const selected =
days.find((day) => day.date === selectedDate) ??
days.find((day) => day.date === anchor) ??
days[0];
const offset = days[0]
? new Date(`${days[0].date}T12:00:00Z`).getUTCDay()
: 0;
const weeks = Math.ceil((days.length + offset) / 7);
// Date keys and the shared timeline remain stable when the chart resizes.
const timeline = calendarTimeline(days);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const scrollArea = useRef<HTMLDivElement | null>(null);
const inspectorId = useId();
const instructionsId = useId();
const rangeErrorId = useId();
const pendingFocusDate = useRef<string | null>(null);
const [focusRequest, setFocusRequest] = useState(0);
const selectedIndex = selected ? days.indexOf(selected) : -1;
useEffect(() => {
const container = scrollArea.current;
const button = buttons.current[selectedIndex];
if (!container || !button) return;
const cellBounds = button.getBoundingClientRect();
const bounds = container.getBoundingClientRect();
if (cellBounds.right > bounds.right - 5)
container.scrollLeft += cellBounds.right - bounds.right + 5;
else if (cellBounds.left < bounds.left + 5)
container.scrollLeft += cellBounds.left - bounds.left - 5;
}, [months, selectedIndex]);
// Controlled editors may reject a selection to preserve an unsaved draft.
// Only move focus after that date is actually selected, using the new window.
useLayoutEffect(() => {
if (pendingFocusDate.current !== null)
buttons.current[selectedIndex]?.focus();
pendingFocusDate.current = null;
}, [focusRequest, selectedIndex, selected?.date]);
function selectAndFocus(date: string) {
pendingFocusDate.current = date;
// A rejected controlled selection still needs a render to restore click
// focus and consume the request, so a later update cannot reuse it.
setFocusRequest((request) => request + 1);
setSelectedDate(date);
}
if (!selected) return <p>No calendar dates to display.</p>;
return (
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
>
<div className="ds-calendar-range-toolbar">
<span className="ds-calendar-range-label" aria-live="polite">
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
day: months === "custom" ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
})}{" "}
–{" "}
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
day: months === "custom" ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
})}
@@ -107,126 +121,160 @@ export function CalendarHeatmap({
{value}m
</button>
))}
</div>
</div>
<div
ref={scrollArea}
className="ds-calendar-scroll"
role="group"
aria-label={label}
>
<div
className="ds-calendar-inner"
style={{
minWidth:
months === 12
? 0
: Math.max(compact ? 280 : 600, weeks * 14 + 38),
}}
>
<div
className="ds-calendar-months"
aria-hidden="true"
style={{
gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))`,
<button
type="button"
aria-pressed={months === "custom"}
onClick={() => {
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
setRangeError("");
setMonths("custom");
}}
>
{days.map(
(day, index) =>
(index === 0 || day.date.endsWith("-01")) && (
<span
key={day.date}
style={{ gridColumn: Math.floor((index + offset) / 7) + 1 }}
>
{new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
"en",
{ month: "short", timeZone: "UTC" },
)}
</span>
),
)}
</div>
<div className="ds-calendar-body">
<div className="ds-calendar-weekdays" aria-hidden="true">
<span>Mon</span>
<span>Wed</span>
<span>Fri</span>
</div>
<div
className="ds-calendar-grid"
style={{
gridTemplateColumns: `repeat(${weeks}, 1fr)`,
}}
>
{Array.from({ length: offset }, (_, index) => (
<span key={`padding-${index}`} aria-hidden="true" />
))}
{days.map((day, index) => (
<button
key={day.date}
ref={(element) => {
buttons.current[index] = element;
}}
type="button"
className={`ds-day ds-day--${day.state}`}
style={{
backgroundColor:
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
aria-pressed={day.date === selected.date}
aria-describedby={inspectorId}
tabIndex={day.date === selected.date ? 0 : -1}
onClick={() => setSelectedDate(day.date)}
onKeyDown={(event) => {
const offset = {
ArrowDown: 1,
ArrowUp: -1,
ArrowRight: 7,
ArrowLeft: -7,
}[event.key];
const next =
event.key === "Home"
? 0
: event.key === "End"
? days.length - 1
: offset !== undefined
? Math.max(
0,
Math.min(days.length - 1, index + offset),
)
: undefined;
if (next !== undefined) {
event.preventDefault();
setSelectedDate(days[next]!.date);
buttons.current[next]?.focus();
}
}}
Custom
</button>
</div>
</div>
{months === "custom" && customRange && (
<form
className="ds-calendar-custom-range"
aria-label={`${label} custom timeframe`}
noValidate
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const start = form.elements.namedItem("from") as HTMLInputElement;
const end = form.elements.namedItem("to") as HTMLInputElement;
const isAvailable = (input: HTMLInputElement) =>
input.validity.valid && input.value !== "" &&
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
if (!isAvailable(start) || !isAvailable(end)) {
setRangeError("Choose both dates within the available history.");
(!isAvailable(start) ? start : end).focus();
return;
}
if (start.value > end.value) {
setRangeError("The end date must be on or after the start date.");
end.focus();
return;
}
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
setRangeError("There are no calendar dates in this range.");
return;
}
setCustomRange({ from: start.value, to: end.value });
setRangeError("");
}}
>
{(["from", "to"] as const).map((name) => (
<Field key={name} label={name === "from" ? "Start date" : "End date"}>
{(id) => (
<input
id={id}
name={name}
type="date"
required
min={allDays[0]!.date}
max={allDays.at(-1)!.date}
defaultValue={customRange[name]}
aria-invalid={rangeError ? true : undefined}
aria-describedby={rangeError ? rangeErrorId : undefined}
onChange={() => setRangeError("")}
/>
))}
</div>
)}
</Field>
))}
<Button type="submit" variant="secondary">Apply range</Button>
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
</form>
)}
<div
className="ds-calendar-viewport"
role="group"
aria-label={label}
aria-describedby={instructionsId}
>
<div className="ds-calendar-timeline">
<div className="ds-calendar-months" aria-hidden="true">
{timeline.months.map(({ month, column }, index) => (
<div className="ds-calendar-month-label" key={month} style={{
gridColumn: `${column} / ${timeline.months[index + 1]?.column ?? timeline.weeks + 1}`,
}}>
{new Date(`${month}-01T12:00:00Z`).toLocaleDateString("en", {
month: "short", timeZone: "UTC",
})}
</div>
))}
</div>
<div className="ds-calendar-weekdays" aria-hidden="true">
<span>Mon</span>
<span>Wed</span>
<span>Fri</span>
</div>
<div className="ds-calendar-grid">
{timeline.entries.map(({ day, index, column, row }) => (
<button
key={day.date}
ref={(element) => {
buttons.current[index] = element;
}}
type="button"
className={`ds-day ds-day--${day.state}`}
style={{
gridColumn: column,
gridRow: row,
backgroundColor:
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
aria-pressed={day.date === selected.date}
aria-describedby={instructionsId}
tabIndex={day.date === selected.date ? 0 : -1}
onClick={() => selectAndFocus(day.date)}
onKeyDown={(event) => {
const offset = {
ArrowDown: 1,
ArrowUp: -1,
ArrowRight: 7,
ArrowLeft: -7,
}[event.key];
const next =
event.key === "Home"
? 0
: event.key === "End"
? days.length - 1
: offset !== undefined
? Math.max(
0,
Math.min(days.length - 1, index + offset),
)
: undefined;
if (next !== undefined) {
event.preventDefault();
selectAndFocus(days[next]!.date);
}
}}
/>
))}
</div>
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months} months{" "}
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<span className="ds-muted">
{compact
? "Select a day · Arrow keys"
: "Select a day to inspect · Arrow keys to move"}
<span id={instructionsId} className="ds-muted">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</span>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">