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

@@ -0,0 +1,64 @@
import { useState } from "react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { HabitChart } from "./HabitChart";
import { Button, Checkbox, Counter } from "./primitives";
import { combinedProgress, demoCalendar, HABIT_COLORS, type CalendarDay } from "./calendar-model";
export function CalendarExamples() {
const [water, setWater] = useState(7);
const [read, setRead] = useState(true);
const waterDays = demoCalendar(water, 8);
const readingDays = demoCalendar(Number(read), 1);
const combinedDays: CalendarDay[] = waterDays.map((day, index) => {
const readingDay = readingDays[index]!;
const total = combinedProgress([day, readingDay].map((entry) => ({
...entry,
due: entry.state === "due",
})));
return {
date: day.date,
value: total.completed,
target: total.due,
state: day.state === "future" ? "future" : total.due === 0 ? "not-due" : "due",
};
});
return (
<section className="ds-section" aria-labelledby="calendar-examples-title">
<div className="ds-spec-heading">
<h3 id="calendar-examples-title">Working calendars</h3>
<Button variant="text" onClick={() => { setWater(7); setRead(true); }}>Reset examples </Button>
</div>
<p className="ds-muted type-small">Demo date · September 4, 2026. Change todays progress or select a date to inspect its value.</p>
<div className="ds-habit-chart-grid">
<HabitChart
name="Drink water"
method="Count target"
value={water}
target={8}
unit="glasses"
color={HABIT_COLORS.water}
>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart
name="Read a little"
method="Manual checkbox"
value={Number(read)}
target={1}
unit="reading session"
color={HABIT_COLORS.reading}
>
<Checkbox label="Mark reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
</HabitChart>
</div>
<div className="ds-form-section">
<div className="ds-spec-heading">
<h3>Combined completion</h3>
</div>
<CalendarHeatmap days={combinedDays} unit="habits complete" label="Combined habit calendar" />
<p className="ds-footnote">Only fully completed, scheduled habits count. Partial water progress counts once it reaches 8 glasses; days with nothing scheduled have no score.</p>
</div>
</section>
);
}

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">

View File

@@ -4,6 +4,7 @@ import { useId, type HTMLAttributes, type ReactNode } from "react";
export function Card({
eyebrow,
heading,
headingLevel = 3,
children,
footer,
variant = "soft",
@@ -13,11 +14,13 @@ export function Card({
}: HTMLAttributes<HTMLElement> & {
eyebrow?: string;
heading: ReactNode;
headingLevel?: 2 | 3 | 4 | 5 | 6;
footer?: ReactNode;
variant?: "soft" | "outlined";
span?: "standard" | "wide" | "featured";
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
return (
<article
aria-labelledby={headingId}
@@ -26,7 +29,7 @@ export function Card({
>
<header className="ds-card-header">
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
<h3 id={headingId}>{heading}</h3>
<Heading id={headingId} className="ds-card-title">{heading}</Heading>
</header>
{children && <div className="ds-card-body">{children}</div>}
{footer && <footer className="ds-card-footer">{footer}</footer>}

View File

@@ -17,10 +17,10 @@ export function ContainerShowcase() {
<span className="ds-code">Card · soft / outlined</span>
</div>
<CardGrid>
<Card eyebrow="SOFT SURFACE" heading="A gentle grouping.">
<Card headingLevel={4} eyebrow="SOFT SURFACE" heading="A gentle grouping.">
<p>A neutral fill gathers related content without adding another divider. Use it for summaries, guidance, and a small set of actions.</p>
</Card>
<Card variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
<Card headingLevel={4} variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
<p>A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.</p>
</Card>
</CardGrid>
@@ -31,26 +31,24 @@ export function ContainerShowcase() {
</div>
<CardGrid layout="bento" aria-label="Interactive bento layout example">
<Card
headingLevel={4}
span="featured"
eyebrow="TODAY / YOUR OWN PACE"
heading={<>Small things, <em>adding up.</em></>}
footer={<p className="ds-muted type-small">Interactive example · nothing is saved</p>}
>
<div className="ds-card-summary" role="status">
<p className="type-display">{Number(water === 8) + Number(read)}<span className="type-title ds-muted"> / 2</span></p>
<p>habits complete</p>
</div>
<p>Make room for a glass of water and a few pages. A little progress belongs here, too.</p>
</Card>
<Card eyebrow="DAILY / 8 GLASSES" heading="Drink water" variant="outlined">
<Card headingLevel={4} eyebrow="DAILY / 8 GLASSES" heading="Drink water" variant="outlined">
<Counter label="bento glasses of water" value={water} target={8} onChange={setWater} />
<p className="type-small" role="status">{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}</p>
</Card>
<Card eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
<Card headingLevel={4} eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
<Checkbox label="Reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
<p className="type-small">One page is a place to start.</p>
</Card>
<Card span="wide" eyebrow="A GENTLE REMINDER" heading="Theres no catching up.">
<Card headingLevel={4} span="wide" eyebrow="A GENTLE REMINDER" heading="Theres no catching up.">
<p>Come back to today. Your next small step is enough.</p>
</Card>
</CardGrid>
@@ -58,7 +56,7 @@ export function ContainerShowcase() {
<p className="ds-footnote">Equal grids suit peer items. Bento gives one summary more room; smaller cards hold short tasks. Both collapse in reading order on small screens. Use one surface per group, with spacing inside and no shadows.</p>
<Button variant="text" onClick={() => { setWater(3); setRead(false); }}>Reset card examples </Button>
</div>
<pre className="ds-code ds-container-code"><code>{'<CardGrid layout="bento">\n <Card heading="Today" span="featured">…</Card>\n <Card heading="Water" variant="outlined">…</Card>\n <Card heading="Reading" variant="outlined">…</Card>\n <Card heading="A reminder" span="wide">…</Card>\n</CardGrid>'}</code></pre>
<pre className="ds-code ds-container-code"><code>{'<CardGrid layout="bento">\n <Card heading="Today" headingLevel={4} span="featured">…</Card>\n <Card heading="Water" headingLevel={4} variant="outlined">…</Card>\n <Card heading="Reading" headingLevel={4} variant="outlined">…</Card>\n <Card heading="A reminder" headingLevel={4} span="wide">…</Card>\n</CardGrid>'}</code></pre>
</section>
);
}

View File

@@ -1,4 +1,4 @@
import { useRef, type ReactNode } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
type SystemTab = { id: string; label: string; content: ReactNode };
@@ -7,7 +7,13 @@ export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) {
const { hash } = useLocation();
const navigate = useNavigate();
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
const selected = tabs.findIndex((tab) => hash === `#${tab.id}`);
// Keep bookmarks for retired preview sections useful without mounting them.
const replacementHash = hash === "#playground" ? "#calendar-states"
: hash === "#editing" || hash === "#views" ? "#foundations" : hash;
useEffect(() => {
if (replacementHash !== hash) navigate({ hash: replacementHash }, { replace: true });
}, [hash, replacementHash, navigate]);
const selected = tabs.findIndex((tab) => replacementHash === `#${tab.id}`);
const active = selected < 0 ? 0 : selected;
function select(index: number) {
@@ -44,7 +50,7 @@ export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) {
</button>
))}
</div>
{/* Keep panels mounted so editors and examples retain their local state. */}
{/* Keep panels mounted so examples retain their local state. */}
{tabs.map((tab, index) => (
<div
key={tab.id}

View File

@@ -1,6 +1,7 @@
import { useEffect, useId, useState, type ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { HabitConfig, Schedule } from "../../habits/contracts";
import { Button, Checkbox } from "./primitives";
import { Field } from "./Field";
import { CalendarHeatmap } from "./CalendarHeatmap";
import {
HabitColorPicker,
@@ -18,25 +19,6 @@ import {
validateTasks,
} from "./editing-model";
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: (id: string) => ReactNode;
}) {
const id = useId();
return (
<div className="ds-field">
<label htmlFor={id}>{label}</label>
{children(id)}
{hint && <small>{hint}</small>}
</div>
);
}
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function ScheduleEditor({
@@ -212,6 +194,9 @@ export function EditingWorkbench({
onColorsChange?: (colors: HabitColors) => void;
}) {
const [tab, setTab] = useState<EditorTab>("Habit settings");
const taskFields = useRef<Record<string, HTMLInputElement | null>>({});
const taskEditor = useRef<HTMLFieldSetElement | null>(null);
const taskFocus = useRef<string | "add" | null>(null);
const [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS));
const [selectedId, setSelectedId] = useState("water");
const selected = habits.find((habit) => habit.id === selectedId)!;
@@ -254,6 +239,12 @@ export function EditingWorkbench({
JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
const blocked = backfillError(selectedId, date, progress);
useLayoutEffect(() => {
if (taskFocus.current === "add")
taskEditor.current?.querySelector<HTMLButtonElement>("[data-add-task]")?.focus();
else if (taskFocus.current) taskFields.current[taskFocus.current]?.focus();
taskFocus.current = null;
});
function loadProgress(id: string, nextDate: string) {
const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
@@ -508,9 +499,10 @@ export function EditingWorkbench({
label="Completion method"
hint="Method is fixed in this preview. Choose another habit to try its editor."
>
{(id) => (
{(id, describedBy) => (
<input
id={id}
aria-describedby={describedBy}
readOnly
value={
draft.method === "count"
@@ -626,6 +618,7 @@ export function EditingWorkbench({
</p>
)}
<fieldset
ref={taskEditor}
className="ds-task-editor-fieldset"
disabled={draft.archived}
aria-label="Task definitions"
@@ -637,22 +630,22 @@ export function EditingWorkbench({
</p>
)}
{draft.tasks.map((task, index) => (
<div className="ds-edit-task" key={task.id}>
<fieldset className="ds-edit-task" key={task.id}>
<legend className="ds-code">Step {index + 1}</legend>
<div className="ds-spec-heading">
<span className="ds-code">
STEP {String(index + 1).padStart(2, "0")}
</span>
<Button
variant="text"
aria-label={`Remove ${task.name || "unnamed task"}`}
onClick={() =>
onClick={() => {
taskFocus.current = draft.tasks[index + 1]?.id ?? draft.tasks[index - 1]?.id ?? "add";
setDraft({
...draft,
tasks: draft.tasks.filter(
(item) => item.id !== task.id,
),
})
}
});
setNotice(`${task.name || "Unnamed task"} removed from the draft. Save tasks to apply.`);
}}
>
Remove
</Button>
@@ -661,6 +654,7 @@ export function EditingWorkbench({
{(id) => (
<input
id={id}
ref={(element) => { taskFields.current[task.id] = element; }}
required
maxLength={200}
value={task.name}
@@ -674,24 +668,28 @@ export function EditingWorkbench({
value={task.schedule}
onChange={(schedule) => updateTask(task.id, { schedule })}
/>
</div>
</fieldset>
))}
<Button
variant="secondary"
data-add-task
disabled={draft.tasks.length >= 100}
onClick={() =>
onClick={() => {
const id = crypto.randomUUID();
taskFocus.current = id;
setDraft({
...draft,
tasks: [
...draft.tasks,
{
id: crypto.randomUUID(),
id,
name: "",
schedule: { type: "daily" },
},
],
})
}
});
setNotice("Task added to the draft. Enter its name and schedule.");
}}
>
Add task +
</Button>
@@ -723,9 +721,10 @@ export function EditingWorkbench({
label="Progress date"
hint="Select a date here or in the calendar below."
>
{(id) => (
{(id, describedBy) => (
<input
id={id}
aria-describedby={describedBy}
type="date"
required
min={EDITOR_START}
@@ -790,9 +789,10 @@ export function EditingWorkbench({
label="Actual count"
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
>
{(id) => (
{(id, describedBy) => (
<input
id={id}
aria-describedby={describedBy}
type="number"
required
min={0}

View File

@@ -0,0 +1,28 @@
import { useId, type ReactNode } from "react";
/** A shared label and hint wrapper for native form controls. */
export function Field({
label,
hint,
id: providedId,
className = "",
children,
}: {
label: ReactNode;
hint?: ReactNode;
id?: string;
className?: string;
children: (id: string, describedBy?: string) => ReactNode;
}) {
const generatedId = useId();
const id = providedId ?? generatedId;
const hintId = hint != null ? `${id}-hint` : undefined;
return (
<div className={`ds-field ${className}`}>
<label htmlFor={id}>{label}</label>
{children(id, hintId)}
{hintId && <small id={hintId}>{hint}</small>}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useId } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
import { Field } from "./Field";
export type HabitColors = {
-readonly [K in keyof typeof HABIT_COLORS]: string;
@@ -51,8 +52,7 @@ export function HabitColorPicker({
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote">
A color for this habit and its calendar. Choose a suggested shade or
make it your own.
Choose a suggested shade or enter a custom color for this habits calendar.
</p>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
@@ -83,19 +83,20 @@ export function HabitColorPicker({
))}
</div>
<div className="ds-custom-color">
<div className="ds-field">
<label htmlFor={`${id}-native`}>Custom color</label>
<Field label="Custom color" id={`${id}-native`}>
{(inputId) => (
<input
id={`${id}-native`}
id={inputId}
type="color"
value={valid ? value : "#426582"}
onInput={(event) => onChange(event.currentTarget.value)}
/>
</div>
<div className="ds-field">
<label htmlFor={`${id}-hex`}>Hex color</label>
)}
</Field>
<Field label="Hex color" id={`${id}-hex`}>
{(inputId) => (
<input
id={`${id}-hex`}
id={inputId}
type="text"
spellCheck={false}
autoComplete="off"
@@ -105,10 +106,11 @@ export function HabitColorPicker({
placeholder="#426582"
value={value}
aria-invalid={!valid}
aria-describedby={!valid ? `${id}-error` : undefined}
aria-describedby={`${id}-hint${!valid ? ` ${id}-error` : ""}`}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)}
</Field>
</div>
{valid && (
<div
@@ -138,10 +140,10 @@ export function HabitColorPicker({
)}
<p className="ds-footnote">
{mode === "create"
? "Your chosen color is saved with your habit and used in its progress calendar."
? "Saved with your habit."
: mode === "edit"
? "Save changes to apply this color to your habits calendar. Cancel keeps your current color."
: "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
? "Save to apply this color; Cancel restores the saved color."
: "Charts preview changes immediately. Save keeps the demo color; Cancel reverts it."}
</p>
{!valid && (
<p id={`${id}-error`} role="alert" className="ds-form-feedback">

View File

@@ -0,0 +1,215 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { EditingWorkbench } from "./EditingWorkbench";
import { HabitColorPicker } from "./HabitColorPicker";
import { demoCalendar } from "./calendar-model";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
}
function description(element: Element) {
return (element.getAttribute("aria-describedby") ?? "").split(" ")
.map((id) => document.getElementById(id)?.textContent ?? "").join(" ");
}
test("calendar associates exact keyboard guidance and preserves focus when selection is vetoed", async () => {
let attempted = "";
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate="2026-09-03" onSelectDate={(date) => { attempted = date; }} />));
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
selected.focus();
expect(description(selected)).toContain("Left/right: one week");
expect(description(selected)).not.toContain("of 8 glasses");
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) as unknown as KeyboardEvent));
expect(attempted).toBe("2026-09-02");
expect(document.activeElement).toBe(selected);
expect(selected.tabIndex).toBe(0);
});
test("calendar focuses the accepted date after a controlled month window changes", async () => {
function Calendar() {
const [date, setDate] = useState("2026-09-01");
return <CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={setDate} />;
}
await act(async () => root.render(<Calendar />));
await act(async () => button("3m").click());
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
selected.focus();
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement?.getAttribute("aria-label")).toStartWith("2026-08-31:");
expect(document.activeElement?.getAttribute("aria-pressed")).toBe("true");
expect((document.activeElement as HTMLElement).tabIndex).toBe(0);
});
test("rejected calendar clicks and repeated arrows do not leave stale focus requests", async () => {
let setExternalDate!: (date: string) => void;
function Calendar() {
const [date, setDate] = useState("2026-09-03");
setExternalDate = setDate;
return <><input aria-label="Other control" /><CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={() => {}} /></>;
}
await act(async () => root.render(<Calendar />));
const selected = container.querySelector<HTMLButtonElement>('.ds-day[aria-pressed="true"]')!;
const rejected = container.querySelector<HTMLButtonElement>('.ds-day[aria-label^="2026-09-02:"]')!;
rejected.focus(); // Browser pointer activation focuses the clicked button first.
await act(async () => rejected.click());
expect(document.activeElement).toBe(selected);
for (let attempt = 0; attempt < 2; attempt++) {
await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement).toBe(selected);
}
const other = container.querySelector<HTMLInputElement>('input[aria-label="Other control"]')!;
other.focus();
await act(async () => setExternalDate("2026-09-02"));
expect(document.activeElement).toBe(other);
expect(rejected.getAttribute("aria-pressed")).toBe("true");
});
test("editor controls expose their explanatory hints", async () => {
await act(async () => root.render(<EditingWorkbench />));
expect(description(container.querySelector("input[readonly]")!)).toContain("Method is fixed");
await act(async () => button("Backfill progress").click());
expect(description(container.querySelector('input[type="date"]')!)).toContain("Select a date");
expect(description(container.querySelector('input[type="number"]')!)).toContain("Enter the total, not an increment");
});
test("adding and removing tasks keeps focus in the task editing workflow", async () => {
await act(async () => root.render(<EditingWorkbench />));
await act(async () => button("Tasks").click());
const originalCount = container.querySelectorAll(".ds-edit-task").length;
await act(async () => button("Add task +").click());
const added = container.querySelector<HTMLFieldSetElement>(".ds-edit-task:last-of-type")!;
expect(container.querySelectorAll(".ds-edit-task")).toHaveLength(originalCount + 1);
expect(added.querySelector("legend")?.textContent).toBe(`Step ${originalCount + 1}`);
expect(document.activeElement).toBe(added.querySelector("input"));
await act(async () => added.querySelector<HTMLButtonElement>("button")!.click());
expect(document.activeElement).toBe(container.querySelector(".ds-edit-task:last-of-type input"));
while (container.querySelector(".ds-edit-task")) {
await act(async () => container.querySelector<HTMLButtonElement>(".ds-edit-task button")!.click());
}
expect(document.activeElement).toBe(button("Add task +"));
expect(container.querySelector('[role="status"].ds-save-notice')?.textContent).toContain("removed from the draft");
});
test("invalid custom colors associate both guidance and corrective feedback", async () => {
await act(async () => root.render(<HabitColorPicker value="#bad" onChange={() => {}} />));
const input = container.querySelector('input[type="text"]')!;
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(description(input)).toContain("Choose a suggested shade");
expect(description(input)).toContain("Use a six-digit hex color");
});
test("unified calendar retains every date and navigates across months in every range", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
for (const months of [3, 4, 6, 12]) {
await act(async () => button(`${months}m`).click());
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
expect(dates).toHaveLength({ 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);
for (const day of dates) {
const date = new Date(`${day.getAttribute("aria-label")!.slice(0, 10)}T12:00:00Z`);
expect(Number(day.style.gridRow)).toBe(date.getUTCDay() + 1);
const elapsed = Math.floor((date.getTime() - firstDate.getTime()) / 86_400_000);
expect(Number(day.style.gridColumn)).toBe(Math.floor((elapsed + firstDate.getUTCDay()) / 7) + 1);
}
}
const monthEnd = container.querySelector<HTMLButtonElement>('[aria-label^="2026-08-31:"]')!;
await act(async () => monthEnd.click());
await act(async () => monthEnd.dispatchEvent(new dom.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement?.getAttribute("aria-label")).toStartWith("2026-09-01:");
for (const [key, date] of [["ArrowLeft", "2026-08-25"], ["End", "2026-09-30"], ["Home", "2025-10-01"]]) {
await act(async () => document.activeElement!.dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent));
expect(document.activeElement?.getAttribute("aria-label")).toStartWith(`${date}:`);
expect(document.activeElement?.getAttribute("aria-pressed")).toBe("true");
}
});
test("custom timeframe applies inclusive dates, supports single days and survives preset switches", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
await act(async () => button("Custom").click());
const setDates = (from: string, to: string) => {
container.querySelector<HTMLInputElement>('input[name="from"]')!.value = from;
container.querySelector<HTMLInputElement>('input[name="to"]')!.value = to;
};
setDates("2026-08-31", "2026-09-01");
expect(container.querySelectorAll(".ds-day")).toHaveLength(365);
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Aug 31, 2026");
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("2 days");
expect(container.querySelectorAll('.ds-day[tabindex="0"]')).toHaveLength(1);
await act(async () => button("3m").click());
expect(container.querySelector("form")).toBeNull();
expect(container.querySelectorAll(".ds-day")).toHaveLength(92);
await act(async () => button("Custom").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelector<HTMLInputElement>('input[name="from"]')?.value).toBe("2026-08-31");
setDates("2026-09-04", "2026-09-04");
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("1 day ");
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain("September 4, 2026");
});
test("custom timeframe rejects missing, reversed and unavailable dates without changing the chart", async () => {
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
await act(async () => button("Custom").click());
for (const [from, to, error] of [
["", "2026-09-04", "Choose both dates"],
["2026-09-04", "2026-08-01", "end date must be"],
["2025-01-01", "2026-09-04", "available history"],
]) {
container.querySelector<HTMLInputElement>('input[name="from"]')!.value = from!;
container.querySelector<HTMLInputElement>('input[name="to"]')!.value = to!;
await act(async () => button("Apply range").click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain(error!);
expect(container.querySelectorAll(".ds-day")).toHaveLength(365);
expect(description(container.querySelector("input")!)).toContain(error!);
}
});

View File

@@ -9,8 +9,24 @@ import {
visibleCalendarDays,
MONTH_VIEWS,
legendShades,
calendarTimeline,
} from "./calendar-model";
test("timeline keeps month boundaries in the same week and covers leap days and gaps", () => {
const days = ["2024-02-28", "2024-02-29", "2024-03-01", "2024-03-03", "2024-03-10"]
.map((date) => ({ date, value: 0, target: 1, state: "due" as const }));
const timeline = calendarTimeline(days);
expect(timeline.weeks).toBe(3);
expect(timeline.entries.map(({ column, row }) => [column, row])).toEqual([
[1, 4], [1, 5], [1, 6], [2, 1], [3, 1],
]);
expect(calendarTimeline([])).toEqual({ weeks: 0, entries: [], months: [] });
const year = calendarTimeline(demoCalendar(7, 8));
expect(year.weeks).toBe(53);
expect(year.months).toHaveLength(12);
expect(year.months[3]?.month).toBe("2026-01");
});
test("legends match habit colors and only display possible progress shades", () => {
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
for (const target of [1, 3, 4, 8, 20]) {

View File

@@ -45,6 +45,26 @@ export function visibleCalendarDays(
return days.filter((day) => day.date >= range.from && day.date <= range.to);
}
/** One uninterrupted Sunday-first timeline, including partial boundary weeks. */
export function calendarTimeline(days: CalendarDay[]) {
const first = days[0];
if (!first) return { weeks: 0, entries: [], months: [] };
const start = new Date(`${first.date}T00:00:00Z`);
const origin = start.getTime() - start.getUTCDay() * 86_400_000;
const entries = days.map((day, index) => {
const offset = Math.round((Date.parse(`${day.date}T00:00:00Z`) - origin) / 86_400_000);
return { day, index, column: Math.floor(offset / 7) + 1, row: offset % 7 + 1 };
});
const weeks = entries.at(-1)!.column;
const months = entries.filter(({ day }, index) =>
index === 0 || day.date.slice(0, 7) !== entries[index - 1]!.day.date.slice(0, 7),
).map(({ day, column }) => ({ month: day.date.slice(0, 7), column }))
// A short custom range can cross a month boundary inside a single week.
// The exact date-range caption names both; avoid overlapping axis labels.
.filter((month, index, labels) => index === 0 || month.column !== labels[index - 1]!.column);
return { weeks, entries, months };
}
export const HABIT_COLORS = {
water: "#426582",
reading: "#977344",

View File

@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import { Card } from "./Card";
import { ContainerShowcase } from "./ContainerShowcase";
import { Field } from "./Field";
function render(content: ReactNode) {
const window = new Window();
window.document.body.innerHTML = renderToStaticMarkup(content);
return window.document.body;
}
test("Field connects its label and hint to the rendered control", () => {
const body = render(
<Field id="habit" label="Habit name" hint="Keep it short.">
{(id, describedBy) => <input id={id} aria-describedby={describedBy} />}
</Field>,
);
expect(body.querySelector("label")?.htmlFor).toBe("habit");
expect(body.querySelector("input")?.getAttribute("aria-describedby")).toBe("habit-hint");
expect(body.querySelector("#habit-hint")?.textContent).toBe("Keep it short.");
});
test("Field generates unique control IDs and omits absent hint references", () => {
const body = render(<>
<Field label="First">{(id, describedBy) => <input id={id} aria-describedby={describedBy} />}</Field>
<Field label="Second">{(id, describedBy) => <input id={id} aria-describedby={describedBy} />}</Field>
</>);
const controls = [...body.querySelectorAll("input")];
expect(new Set(controls.map((control) => control.id)).size).toBe(2);
expect(controls.every((control) => !control.hasAttribute("aria-describedby"))).toBe(true);
expect(body.querySelectorAll("small")).toHaveLength(0);
});
test("Card preserves its title styling and accessible name at each heading level", () => {
for (const headingLevel of [2, 3, 4, 5, 6] as const) {
const body = render(<Card heading="A clear boundary" headingLevel={headingLevel}>Content</Card>);
const heading = body.querySelector(`h${headingLevel}.ds-card-title`);
expect(heading?.textContent).toBe("A clear boundary");
expect(body.querySelector("article")?.getAttribute("aria-labelledby")).toBe(heading?.id);
}
expect(render(<Card heading="Default" />).querySelector("h3")?.textContent).toBe("Default");
});
test("Layout uses subsection headings above the card titles", () => {
const body = render(<ContainerShowcase />);
expect(body.querySelectorAll("h2")).toHaveLength(1);
expect(body.querySelectorAll("h3")).toHaveLength(2);
expect(body.querySelectorAll("h4.ds-card-title")).toHaveLength(6);
});