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:
64
src/components/design-system/CalendarExamples.tsx
Normal file
64
src/components/design-system/CalendarExamples.tsx
Normal 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 today’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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="There’s no catching up.">
|
||||
<Card headingLevel={4} span="wide" eyebrow="A GENTLE REMINDER" heading="There’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
28
src/components/design-system/Field.tsx
Normal file
28
src/components/design-system/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 habit’s 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 habit’s 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">
|
||||
|
||||
215
src/components/design-system/accessibility.test.tsx
Normal file
215
src/components/design-system/accessibility.test.tsx
Normal 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!);
|
||||
}
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
52
src/components/design-system/structure.test.tsx
Normal file
52
src/components/design-system/structure.test.tsx
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user