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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user