@eifi1/ui-kit 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +20 -16
  2. package/dist/components/alert-banner.d.ts +26 -1
  3. package/dist/components/alert-banner.js +10 -7
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -0
  6. package/dist/components/bulk-action-bar.d.ts +51 -5
  7. package/dist/components/bulk-action-bar.js +88 -41
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +58 -3
  10. package/dist/components/button-group.js +56 -5
  11. package/dist/components/button-group.js.map +1 -1
  12. package/dist/components/calculator.d.ts +2 -0
  13. package/dist/components/calendar-heatmap.d.ts +124 -0
  14. package/dist/components/calendar-heatmap.js +295 -0
  15. package/dist/components/calendar-heatmap.js.map +1 -0
  16. package/dist/components/choice-card.d.ts +34 -2
  17. package/dist/components/choice-card.js +49 -0
  18. package/dist/components/choice-card.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +1 -0
  20. package/dist/components/data-table.js +2 -0
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/field.d.ts +43 -0
  23. package/dist/components/field.js +52 -0
  24. package/dist/components/field.js.map +1 -0
  25. package/dist/components/file-button.d.ts +1 -0
  26. package/dist/components/file-dropzone.d.ts +1 -0
  27. package/dist/components/floating-panel.d.ts +147 -4
  28. package/dist/components/floating-panel.js +188 -28
  29. package/dist/components/floating-panel.js.map +1 -1
  30. package/dist/components/list.d.ts +14 -1
  31. package/dist/components/list.js +7 -1
  32. package/dist/components/list.js.map +1 -1
  33. package/dist/components/menu-item.d.ts +11 -1
  34. package/dist/components/menu-item.js +6 -1
  35. package/dist/components/menu-item.js.map +1 -1
  36. package/dist/components/mini-calendar.d.ts +70 -5
  37. package/dist/components/mini-calendar.js +166 -62
  38. package/dist/components/mini-calendar.js.map +1 -1
  39. package/dist/components/nav-pills.d.ts +66 -0
  40. package/dist/components/nav-pills.js +65 -0
  41. package/dist/components/nav-pills.js.map +1 -0
  42. package/dist/components/number-field.d.ts +2 -0
  43. package/dist/components/number-input.d.ts +2 -0
  44. package/dist/components/numpad-sheet.d.ts +2 -0
  45. package/dist/components/progress-bar.d.ts +31 -2
  46. package/dist/components/progress-bar.js +73 -7
  47. package/dist/components/progress-bar.js.map +1 -1
  48. package/dist/components/series-chart.d.ts +16 -0
  49. package/dist/components/series-chart.js +4 -1
  50. package/dist/components/series-chart.js.map +1 -1
  51. package/dist/components/settings-fields.d.ts +1 -0
  52. package/dist/components/status-dot.d.ts +6 -2
  53. package/dist/components/status-dot.js +6 -1
  54. package/dist/components/status-dot.js.map +1 -1
  55. package/dist/components/table.d.ts +40 -8
  56. package/dist/components/table.js +41 -7
  57. package/dist/components/table.js.map +1 -1
  58. package/dist/components/text.d.ts +8 -1
  59. package/dist/components/text.js +1 -0
  60. package/dist/components/text.js.map +1 -1
  61. package/dist/components/time-input.d.ts +1 -0
  62. package/dist/components/toggle-group.d.ts +15 -0
  63. package/dist/components/toggle-group.js +23 -3
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/tooltip.d.ts +11 -3
  66. package/dist/components/tooltip.js +2 -0
  67. package/dist/components/tooltip.js.map +1 -1
  68. package/dist/components/ui.d.ts +55 -4
  69. package/dist/components/ui.js +84 -7
  70. package/dist/components/ui.js.map +1 -1
  71. package/dist/components/user-avatar.d.ts +25 -3
  72. package/dist/components/user-avatar.js +32 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/hooks/use-file-drop.d.ts +1 -0
  75. package/dist/i18n/defaults.d.ts +2 -0
  76. package/dist/i18n/defaults.js +2 -0
  77. package/dist/i18n/defaults.js.map +1 -1
  78. package/dist/i18n/kit-labels.d.ts +3 -0
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  81. package/dist/i18n/locales/de-CH.d.ts +2 -0
  82. package/dist/i18n/locales/de-informal.d.ts +2 -0
  83. package/dist/i18n/locales/de.d.ts +2 -0
  84. package/dist/i18n/locales/de.js +9 -1
  85. package/dist/i18n/locales/de.js.map +1 -1
  86. package/dist/i18n/locales/es.d.ts +2 -0
  87. package/dist/i18n/locales/es.js +9 -1
  88. package/dist/i18n/locales/es.js.map +1 -1
  89. package/dist/i18n/locales/fr.d.ts +2 -0
  90. package/dist/i18n/locales/fr.js +9 -1
  91. package/dist/i18n/locales/fr.js.map +1 -1
  92. package/dist/i18n/locales/hu.d.ts +2 -0
  93. package/dist/i18n/locales/hu.js +9 -1
  94. package/dist/i18n/locales/hu.js.map +1 -1
  95. package/dist/i18n/locales/it.d.ts +2 -0
  96. package/dist/i18n/locales/it.js +9 -1
  97. package/dist/i18n/locales/it.js.map +1 -1
  98. package/dist/i18n/locales/zh.d.ts +2 -0
  99. package/dist/i18n/locales/zh.js +9 -1
  100. package/dist/i18n/locales/zh.js.map +1 -1
  101. package/dist/index.d.ts +14 -10
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/lib/clipping.d.ts +21 -5
  105. package/dist/lib/clipping.js +3 -0
  106. package/dist/lib/clipping.js.map +1 -1
  107. package/dist/lib/dates.d.ts +17 -1
  108. package/dist/lib/dates.js +19 -0
  109. package/dist/lib/dates.js.map +1 -1
  110. package/dist/rhf/form.d.ts +1 -0
  111. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  112. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  113. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  114. package/dist/rhf.d.ts +2 -0
  115. package/dist/rhf.js +1 -0
  116. package/dist/rhf.js.map +1 -1
  117. package/dist/shell/topbar-action-menu.d.ts +73 -3
  118. package/dist/shell/topbar-action-menu.js +100 -27
  119. package/dist/shell/topbar-action-menu.js.map +1 -1
  120. package/dist/shell.d.ts +2 -1
  121. package/dist/wizard/stepper-nav.d.ts +1 -0
  122. package/dist/wizard/wizard-context.d.ts +7 -1
  123. package/dist/wizard/wizard-context.js +4 -0
  124. package/dist/wizard/wizard-context.js.map +1 -1
  125. package/dist/wizard.d.ts +2 -1
  126. package/package.json +1 -1
  127. package/src/components/alert-banner.tsx +36 -7
  128. package/src/components/bulk-action-bar.tsx +163 -57
  129. package/src/components/button-group.tsx +124 -4
  130. package/src/components/calendar-heatmap.tsx +504 -0
  131. package/src/components/choice-card.tsx +97 -1
  132. package/src/components/data-table.tsx +7 -0
  133. package/src/components/field.tsx +137 -0
  134. package/src/components/floating-panel.tsx +366 -15
  135. package/src/components/list.tsx +28 -2
  136. package/src/components/menu-item.tsx +22 -1
  137. package/src/components/mini-calendar.tsx +245 -54
  138. package/src/components/nav-pills.tsx +145 -0
  139. package/src/components/progress-bar.tsx +129 -11
  140. package/src/components/series-chart.tsx +21 -0
  141. package/src/components/status-dot.tsx +10 -2
  142. package/src/components/table.tsx +85 -11
  143. package/src/components/text.tsx +9 -1
  144. package/src/components/toggle-group.tsx +48 -3
  145. package/src/components/tooltip.tsx +17 -3
  146. package/src/components/ui.tsx +149 -11
  147. package/src/components/user-avatar.tsx +57 -3
  148. package/src/i18n/defaults.ts +2 -0
  149. package/src/i18n/kit-labels.tsx +2 -0
  150. package/src/i18n/locales/de.ts +9 -0
  151. package/src/i18n/locales/es.ts +9 -0
  152. package/src/i18n/locales/fr.ts +9 -0
  153. package/src/i18n/locales/hu.ts +9 -0
  154. package/src/i18n/locales/it.ts +9 -0
  155. package/src/i18n/locales/zh.ts +9 -0
  156. package/src/index.ts +5 -0
  157. package/src/lib/clipping.ts +22 -4
  158. package/src/lib/dates.ts +37 -0
  159. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  160. package/src/rhf.ts +2 -0
  161. package/src/shell/topbar-action-menu.tsx +239 -49
  162. package/src/wizard/wizard-context.tsx +9 -0
@@ -0,0 +1,504 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { dirOf, horizontalStep } from "../lib/direction";
5
+ import { addMonthsClamped, localeWeekStart, parseIsoDate, toLocalIso } from "../lib/dates";
6
+ import { useKitLabels, useKitLocale, useKitWeekStart } from "../i18n/kit-labels";
7
+ import { Tooltip } from "./tooltip";
8
+ import type { WeekDay } from "./mini-calendar";
9
+
10
+ /**
11
+ * Every string the heatmap speaks — the `calendarHeatmap` namespace of
12
+ * `<UiKitProvider labels>`, overridable per instance through `labels`. Dates and
13
+ * values arrive already formatted (in `locale`, and through `formatValue`), so the
14
+ * functions only arrange them.
15
+ */
16
+ export interface CalendarHeatmapLabels {
17
+ /** Accessible name of the grid. */
18
+ grid: string;
19
+ /**
20
+ * One day: its accessible name, and the tooltip unless `tooltip` is given. `date`
21
+ * is the whole date in `locale` ("Monday, 14 September 2026"), `value` the day's
22
+ * figure through `formatValue`.
23
+ */
24
+ day: (date: string, value: string) => string;
25
+ /** The two ends of the legend's scale. */
26
+ less: string;
27
+ more: string;
28
+ /** `maxDays` cut the window: how many of its earliest days are not drawn. */
29
+ truncated: (count: number) => string;
30
+ }
31
+
32
+ /** English defaults, exported as the `calendarHeatmap` namespace of `UiKitLabels`. */
33
+ export const DEFAULT_CALENDAR_HEATMAP_LABELS: CalendarHeatmapLabels = {
34
+ grid: "Daily values",
35
+ day: (date, value) => `${date}: ${value}`,
36
+ less: "Less",
37
+ more: "More",
38
+ truncated: (count) =>
39
+ `Showing the most recent days; ${count} earlier ${count === 1 ? "day is" : "days are"} not shown.`,
40
+ };
41
+
42
+ /** One day's figure. Several entries for one date are added up. */
43
+ export interface CalendarHeatmapDatum {
44
+ /** "YYYY-MM-DD". */
45
+ date: string;
46
+ value: number;
47
+ }
48
+
49
+ /** What `tooltip` is told about the day under the pointer. */
50
+ export interface CalendarHeatmapDay {
51
+ iso: string;
52
+ date: Date;
53
+ /** The day's figure; `0` when `data` has no entry for it (see `hasData`). */
54
+ value: number;
55
+ hasData: boolean;
56
+ /** `0` = empty … `levels` = the busiest. */
57
+ level: number;
58
+ /** The whole date in `locale`. */
59
+ formattedDate: string;
60
+ /** `value` through `formatValue`. */
61
+ formattedValue: string;
62
+ }
63
+
64
+ /**
65
+ * `onSelect` is omitted from the `<div>` attributes for the reason `MiniCalendar` gives:
66
+ * the DOM event of that name is a text-selection event, and this one answers with a date.
67
+ */
68
+ export interface CalendarHeatmapProps extends Omit<ComponentPropsWithoutRef<"div">, "onSelect"> {
69
+ data: readonly CalendarHeatmapDatum[];
70
+ /** The window, inclusive, as "YYYY-MM-DD". */
71
+ from: string;
72
+ to: string;
73
+ /**
74
+ * `"weeks"` (default): the contribution-graph shape — one COLUMN per week, the
75
+ * weekdays down the side, month names along the top; a year fits in ~53 small
76
+ * squares and scrolls sideways where it does not. `"month"`: a 7-column calendar
77
+ * with day numbers in large square cells, for a single month on a phone (switch on
78
+ * `useMediaQuery` and narrow `from`/`to` to the month).
79
+ */
80
+ layout?: "weeks" | "month";
81
+ /** The day shown as selected ("YYYY-MM-DD"). */
82
+ selected?: string;
83
+ /** Makes each day a button: a click (or Enter/Space) selects it — a drill-down into
84
+ * that day's rows, a day panel. Without it the days are focusable but inert. */
85
+ onSelect?: (iso: string) => void;
86
+ /** How a value is written in the tooltip and the day's name. Default: `Intl.NumberFormat`
87
+ * in `locale`. Pass a currency formatter for money. */
88
+ formatValue?: (value: number) => string;
89
+ /** Tooltip content for a day. Default: `labels.day(date, value)`. */
90
+ tooltip?: (day: CalendarHeatmapDay) => ReactNode;
91
+ /**
92
+ * Tag each tooltip `data-private`, so the host's demo-mode rule blurs it (the
93
+ * attribute `Tooltip redact` and `StatTile sensitive` carry). ON by default: a heatmap
94
+ * is almost always the user's own figures, and a missing tag is silent — the grid
95
+ * looks anonymous and the bubble spells the amount out on hover.
96
+ */
97
+ sensitive?: boolean;
98
+ /** The value that reaches the top of the scale. Default: the largest value ON SCREEN,
99
+ * so a peak outside the window does not wash the drawn days out to empty. */
100
+ max?: number;
101
+ /** How many non-empty intensity steps. Default 4. */
102
+ levels?: number;
103
+ /** The scale's colour, as any CSS colour. Default `var(--brand)`; each step is this
104
+ * mixed into `--bg-surface`, so the ramp follows the theme. */
105
+ color?: string;
106
+ /**
107
+ * The most days to draw. A longer window keeps its LATEST `maxDays` and says how many
108
+ * it left out (`labels.truncated`) rather than silently ending. Default: no cap.
109
+ */
110
+ maxDays?: number;
111
+ /** Draw the Less … More legend. Default `true`. */
112
+ legend?: boolean;
113
+ /** BCP 47 tag for the dates, month and weekday names and numbers. */
114
+ locale?: string;
115
+ /** `0` = Sunday … `6` = Saturday. Default: the provider's, else the locale's. */
116
+ weekStartsOn?: WeekDay;
117
+ labels?: Partial<CalendarHeatmapLabels>;
118
+ className?: string;
119
+ }
120
+
121
+ const DAY_NAME_FORMAT: Intl.DateTimeFormatOptions = {
122
+ weekday: "long",
123
+ day: "numeric",
124
+ month: "long",
125
+ year: "numeric",
126
+ };
127
+
128
+ /** The empty day: a wash of the text colour, like `--bg-hover`, so it reads as a cell
129
+ * on both themes without claiming any value. */
130
+ const EMPTY_FILL = "color-mix(in oklab, var(--text-primary) 8%, var(--bg-surface))";
131
+
132
+ const weekdayIndex = (d: Date, weekStart: WeekDay) => (d.getDay() - weekStart + 7) % 7;
133
+
134
+ /** The step a value falls on: 0 for nothing, else 1…levels by its share of `max`. */
135
+ export function heatmapLevel(value: number, max: number, levels: number): number {
136
+ if (!(value > 0) || !(max > 0) || levels < 1) return 0;
137
+ return Math.min(levels, Math.max(1, Math.ceil((value / max) * levels)));
138
+ }
139
+
140
+ /** How much of `color` a step mixes into the surface: 20% for the first step, 100% for
141
+ * the last — the floor keeps the smallest value visibly apart from an empty day. */
142
+ function levelPercent(level: number, levels: number): number {
143
+ if (levels <= 1) return 100;
144
+ return Math.round(20 + (80 * (level - 1)) / (levels - 1));
145
+ }
146
+
147
+ function levelFill(level: number, levels: number, color: string): string {
148
+ if (level === 0) return EMPTY_FILL;
149
+ return `color-mix(in oklab, ${color} ${levelPercent(level, levels)}%, var(--bg-surface))`;
150
+ }
151
+
152
+ /**
153
+ * Days as a grid of shaded squares — how much happened on each, at a glance
154
+ * (a contribution graph, a spending calendar).
155
+ *
156
+ * **Every day is a cell you can reach.** The grid is a `role="grid"` with ONE tab
157
+ * stop (the roving tabindex, as in `MiniCalendar`); the arrows move by the grid's own
158
+ * geometry — in `"weeks"` ↑/↓ are the previous/next day and ←/→ a week, in `"month"`
159
+ * the other way round, mirrored in RTL — PageUp/PageDown move a month and Home/End go
160
+ * to the window's first/last day. Each cell is named with its whole date AND its value,
161
+ * because the colour is not something a screen reader can say, and carries
162
+ * `data-day="YYYY-MM-DD"` as its identity for tests and for the host.
163
+ *
164
+ * The tooltip is portalled (a year of squares always sits in a sideways scroller) and
165
+ * `data-private` unless `sensitive={false}`. Cell size in `"weeks"` is the CSS variable
166
+ * `--heatmap-cell` (default `0.75rem`): `style={{ "--heatmap-cell": "1rem" }}`.
167
+ */
168
+ export function CalendarHeatmap({
169
+ data,
170
+ from,
171
+ to,
172
+ layout = "weeks",
173
+ selected,
174
+ onSelect,
175
+ formatValue: formatValueProp,
176
+ tooltip,
177
+ sensitive = true,
178
+ max: maxProp,
179
+ levels = 4,
180
+ color = "var(--brand)",
181
+ maxDays,
182
+ legend = true,
183
+ locale: localeProp,
184
+ weekStartsOn,
185
+ labels: labelsProp,
186
+ className,
187
+ "aria-label": ariaLabel,
188
+ ...rest
189
+ }: CalendarHeatmapProps) {
190
+ const labels = useKitLabels("calendarHeatmap", DEFAULT_CALENDAR_HEATMAP_LABELS, labelsProp);
191
+ const locale = useKitLocale(localeProp);
192
+ const providerWeekStart = useKitWeekStart();
193
+ const weekStart: WeekDay = weekStartsOn ?? providerWeekStart ?? localeWeekStart(locale);
194
+ const numberFormat = useMemo(() => new Intl.NumberFormat(locale), [locale]);
195
+ const formatValue = formatValueProp ?? ((v: number) => numberFormat.format(v));
196
+
197
+ const values = useMemo(() => {
198
+ const m = new Map<string, number>();
199
+ for (const p of data) m.set(p.date, (m.get(p.date) ?? 0) + p.value);
200
+ return m;
201
+ }, [data]);
202
+
203
+ // The window, trimmed from the FRONT: the days a person looks for are the recent
204
+ // ones, and a cap that kept the oldest would end the calendar before today.
205
+ const { days, dropped } = useMemo(() => {
206
+ const start = parseIsoDate(from);
207
+ const end = parseIsoDate(to);
208
+ const list: Date[] = [];
209
+ if (!start || !end || end < start) return { days: list, dropped: 0 };
210
+ const cursor = new Date(end);
211
+ const cap = maxDays && maxDays > 0 ? maxDays : Infinity;
212
+ while (cursor >= start && list.length < cap) {
213
+ list.unshift(new Date(cursor));
214
+ cursor.setDate(cursor.getDate() - 1);
215
+ }
216
+ const first = list[0];
217
+ const trimmed = first ? Math.round((first.getTime() - start.getTime()) / 86_400_000) : 0;
218
+ return { days: list, dropped: Math.max(0, trimmed) };
219
+ }, [from, to, maxDays]);
220
+
221
+ const firstIso = days.length ? toLocalIso(days[0]) : "";
222
+ const lastIso = days.length ? toLocalIso(days[days.length - 1]) : "";
223
+
224
+ const max = useMemo(() => {
225
+ if (maxProp !== undefined) return maxProp;
226
+ let m = 0;
227
+ for (const d of days) m = Math.max(m, values.get(toLocalIso(d)) ?? 0);
228
+ return m;
229
+ }, [maxProp, days, values]);
230
+
231
+ const clampIso = (iso: string) => (iso < firstIso ? firstIso : iso > lastIso ? lastIso : iso);
232
+
233
+ // The roving tab stop: the selected day, else the latest one.
234
+ const [activeState, setActiveIso] = useState(() => selected || lastIso);
235
+ const [lastSelected, setLastSelected] = useState(selected);
236
+ if (lastSelected !== selected) {
237
+ setLastSelected(selected);
238
+ if (selected) setActiveIso(selected);
239
+ }
240
+ const activeIso = days.length ? clampIso(activeState || lastIso) : "";
241
+
242
+ const gridRef = useRef<HTMLDivElement>(null);
243
+ const scrollerRef = useRef<HTMLDivElement>(null);
244
+ const pendingFocus = useRef<string | null>(null);
245
+ useEffect(() => {
246
+ const iso = pendingFocus.current;
247
+ if (!iso) return;
248
+ pendingFocus.current = null;
249
+ gridRef.current?.querySelector<HTMLElement>(`[data-day="${iso}"]`)?.focus();
250
+ });
251
+
252
+ // Open the sideways scroller on the latest weeks, not the oldest. In RTL the end is
253
+ // on the left, where browsers count scrollLeft down from 0.
254
+ //
255
+ // And KEEP it there while the box changes size — a sidebar or a contents rail that
256
+ // appears after mount narrows the scroller, which leaves scrollLeft where it was and
257
+ // so shows older weeks than it opened on. The pin holds only while the view sits at
258
+ // the end: a user who scrolls back through the year unpins it, and a resize then
259
+ // leaves their position alone. (Scrolling back to the end pins it again.)
260
+ useLayoutEffect(() => {
261
+ const el = scrollerRef.current;
262
+ if (!el || layout !== "weeks") return;
263
+ const toEnd = () => {
264
+ el.scrollLeft = dirOf(el) === "rtl" ? -el.scrollWidth : el.scrollWidth;
265
+ };
266
+ toEnd();
267
+ if (typeof ResizeObserver === "undefined") return;
268
+ // |scrollLeft| covers both directions: 0 → max in LTR, 0 → -max in RTL.
269
+ let pinned = true;
270
+ const onScroll = () => {
271
+ pinned = Math.abs(el.scrollLeft) >= el.scrollWidth - el.clientWidth - 1;
272
+ };
273
+ const ro = new ResizeObserver(() => {
274
+ if (pinned) toEnd();
275
+ });
276
+ el.addEventListener("scroll", onScroll, { passive: true });
277
+ ro.observe(el);
278
+ return () => {
279
+ ro.disconnect();
280
+ el.removeEventListener("scroll", onScroll);
281
+ };
282
+ }, [layout, firstIso, lastIso]);
283
+
284
+ const formatDay = (d: Date) => d.toLocaleDateString(locale, DAY_NAME_FORMAT);
285
+
286
+ const weekdayLabels = useMemo(() => {
287
+ const ref = new Date(2024, 0, 7 + weekStart); // 7 January 2024 was a Sunday
288
+ return Array.from({ length: 7 }, (_, i) => {
289
+ const d = new Date(ref);
290
+ d.setDate(ref.getDate() + i);
291
+ return {
292
+ short: d.toLocaleDateString(locale, { weekday: "short" }),
293
+ long: d.toLocaleDateString(locale, { weekday: "long" }),
294
+ };
295
+ });
296
+ }, [locale, weekStart]);
297
+
298
+ const lead = days.length ? weekdayIndex(days[0], weekStart) : 0;
299
+ const weekCount = Math.ceil((lead + days.length) / 7);
300
+ /** The day at position `i` of the week-aligned sequence (blanks before the first). */
301
+ const dayAt = (i: number): Date | null => (i >= lead && i - lead < days.length ? days[i - lead] : null);
302
+
303
+ const move = (e: KeyboardEvent<HTMLElement>) => {
304
+ const current = parseIsoDate(activeIso);
305
+ if (!current) return;
306
+ const next = new Date(current);
307
+ const step = horizontalStep(e.key, e.currentTarget);
308
+ // In "weeks" a row is a weekday, so sideways is a week and up/down is a day.
309
+ const across = layout === "weeks" ? 7 : 1;
310
+ const down = layout === "weeks" ? 1 : 7;
311
+ switch (step ? "horizontal" : e.key) {
312
+ case "horizontal":
313
+ next.setDate(next.getDate() + step * across);
314
+ break;
315
+ case "ArrowUp":
316
+ next.setDate(next.getDate() - down);
317
+ break;
318
+ case "ArrowDown":
319
+ next.setDate(next.getDate() + down);
320
+ break;
321
+ case "PageUp":
322
+ next.setTime(addMonthsClamped(next, -1).getTime());
323
+ break;
324
+ case "PageDown":
325
+ next.setTime(addMonthsClamped(next, 1).getTime());
326
+ break;
327
+ case "Home":
328
+ next.setTime(days[0].getTime());
329
+ break;
330
+ case "End":
331
+ next.setTime(days[days.length - 1].getTime());
332
+ break;
333
+ default:
334
+ return;
335
+ }
336
+ e.preventDefault();
337
+ const iso = clampIso(toLocalIso(next));
338
+ if (iso === activeIso) return;
339
+ pendingFocus.current = iso;
340
+ setActiveIso(iso);
341
+ };
342
+
343
+ const legendLevels = Array.from({ length: levels + 1 }, (_, l) => l);
344
+
345
+ const cell = (d: Date, wrapClass: string, cellClass: string, withNumber: boolean) => {
346
+ const iso = toLocalIso(d);
347
+ const raw = values.get(iso);
348
+ const value = raw ?? 0;
349
+ const level = heatmapLevel(value, max, levels);
350
+ const fill = levelFill(level, levels, color);
351
+ const formattedDate = formatDay(d);
352
+ const formattedValue = formatValue(value);
353
+ const name = labels.day(formattedDate, formattedValue);
354
+ const label = tooltip
355
+ ? tooltip({ iso, date: d, value, hasData: raw !== undefined, level, formattedDate, formattedValue })
356
+ : name;
357
+ const isSelected = selected === iso;
358
+ // Light text once the fill is the stronger half of the mix — `--bg-surface` is the
359
+ // colour the scale is mixed INTO, so it is the one that contrasts with its far end
360
+ // on either theme.
361
+ const strong = level > 0 && levelPercent(level, levels) >= 45;
362
+ const shared = {
363
+ role: "gridcell",
364
+ "data-day": iso,
365
+ "data-level": level,
366
+ tabIndex: iso === activeIso ? 0 : -1,
367
+ "aria-label": name,
368
+ "aria-selected": onSelect ? isSelected : undefined,
369
+ onKeyDown: move,
370
+ onFocus: () => {
371
+ if (iso !== activeIso) setActiveIso(iso);
372
+ },
373
+ style: { background: fill, color: strong ? "var(--bg-surface)" : "var(--text-primary)" },
374
+ className: cn(
375
+ cellClass,
376
+ isSelected && "ring-2 ring-[var(--text-primary)]",
377
+ onSelect && "cursor-pointer hover:ring-1 hover:ring-[var(--brand)]",
378
+ ),
379
+ } as const;
380
+ const inner = withNumber ? (
381
+ <span aria-hidden className="text-[11px] leading-none tabular-nums">
382
+ {numberFormat.format(d.getDate())}
383
+ </span>
384
+ ) : null;
385
+ return (
386
+ <Tooltip key={iso} label={label} portal redact={sensitive} role="none" className={wrapClass}>
387
+ {onSelect ? (
388
+ <button type="button" {...shared} onClick={() => onSelect(iso)}>
389
+ {inner}
390
+ </button>
391
+ ) : (
392
+ <div {...shared}>{inner}</div>
393
+ )}
394
+ </Tooltip>
395
+ );
396
+ };
397
+
398
+ const gridName = ariaLabel ?? labels.grid;
399
+ let body: ReactNode;
400
+ if (layout === "weeks") {
401
+ const columns = { gridTemplateColumns: `var(--heatmap-label, 2rem) repeat(${weekCount}, var(--heatmap-cell, 0.75rem))` } as CSSProperties;
402
+ const cellSize = "size-[var(--heatmap-cell,0.75rem)]";
403
+ // A month's name over the first column that holds its 1st — or over the first
404
+ // column, when the window opens far enough before the next month for it to fit.
405
+ const monthLabels = Array.from({ length: weekCount }, (_, w) => {
406
+ for (let r = 0; r < 7; r++) {
407
+ const d = dayAt(w * 7 + r);
408
+ if (d && (d.getDate() === 1 || (w === 0 && r === lead && d.getDate() <= 14))) {
409
+ return d.toLocaleDateString(locale, { month: "short" });
410
+ }
411
+ }
412
+ return "";
413
+ });
414
+ body = (
415
+ <div ref={scrollerRef} className="overflow-x-auto pb-1">
416
+ <div ref={gridRef} role="grid" aria-label={gridName} className="inline-grid gap-0.5">
417
+ {/* Decoration: every cell's name already carries its month. */}
418
+ <div aria-hidden className="grid gap-0.5 text-[10px] leading-3 text-[var(--text-muted)]" style={columns}>
419
+ <span />
420
+ {monthLabels.map((m, w) => (
421
+ <span key={w} className="overflow-visible whitespace-nowrap">
422
+ {m}
423
+ </span>
424
+ ))}
425
+ </div>
426
+ {Array.from({ length: 7 }, (_, r) => (
427
+ <div key={r} role="row" className="grid gap-0.5" style={columns}>
428
+ <div
429
+ role="rowheader"
430
+ aria-label={weekdayLabels[r].long}
431
+ className="overflow-hidden pe-1 text-[10px] leading-3 text-[var(--text-muted)]"
432
+ >
433
+ {/* Every other weekday, as a contribution graph does: seven labels
434
+ at this size are a smear. The name is there for all seven. */}
435
+ {r % 2 === 1 ? weekdayLabels[r].short : ""}
436
+ </div>
437
+ {Array.from({ length: weekCount }, (_, w) => {
438
+ const d = dayAt(w * 7 + r);
439
+ return d ? (
440
+ cell(d, cellSize, cn(cellSize, "block rounded-sm"), false)
441
+ ) : (
442
+ <div key={`pad-${w}`} role="gridcell" className={cellSize} />
443
+ );
444
+ })}
445
+ </div>
446
+ ))}
447
+ </div>
448
+ </div>
449
+ );
450
+ } else {
451
+ const rows = Array.from({ length: weekCount }, (_, w) => w);
452
+ body = (
453
+ <div ref={gridRef} role="grid" aria-label={gridName} className="grid gap-1">
454
+ <div role="row" className="grid grid-cols-7 gap-1">
455
+ {weekdayLabels.map((w, i) => (
456
+ <div
457
+ key={i}
458
+ role="columnheader"
459
+ aria-label={w.long}
460
+ className="pb-0.5 text-center text-[10px] uppercase tracking-wide text-[var(--text-muted)]"
461
+ >
462
+ {w.short}
463
+ </div>
464
+ ))}
465
+ </div>
466
+ {rows.map((w) => (
467
+ <div key={w} role="row" className="grid grid-cols-7 gap-1">
468
+ {Array.from({ length: 7 }, (_, c) => {
469
+ const d = dayAt(w * 7 + c);
470
+ return d ? (
471
+ cell(d, "aspect-square w-full", "flex h-full w-full items-center justify-center rounded", true)
472
+ ) : (
473
+ <div key={`pad-${c}`} role="gridcell" className="aspect-square" />
474
+ );
475
+ })}
476
+ </div>
477
+ ))}
478
+ </div>
479
+ );
480
+ }
481
+
482
+ return (
483
+ <div {...rest} className={cn("min-w-0", className)}>
484
+ {dropped > 0 && (
485
+ <div className="mb-2 text-xs text-[var(--text-muted)]">{labels.truncated(dropped)}</div>
486
+ )}
487
+ {body}
488
+ {legend && (
489
+ <div aria-hidden className="mt-2 flex items-center justify-end gap-1 text-[10px] text-[var(--text-muted)]">
490
+ <span className="me-0.5">{labels.less}</span>
491
+ {legendLevels.map((l) => (
492
+ <span
493
+ key={l}
494
+ data-legend-level={l}
495
+ className="size-2.5 rounded-sm"
496
+ style={{ background: levelFill(l, levels, color) }}
497
+ />
498
+ ))}
499
+ <span className="ms-0.5">{labels.more}</span>
500
+ </div>
501
+ )}
502
+ </div>
503
+ );
504
+ }
@@ -1,5 +1,5 @@
1
1
  import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useRef } from "react";
2
- import type { ChangeEvent, ComponentType, InputHTMLAttributes, ReactNode } from "react";
2
+ import type { ButtonHTMLAttributes, ChangeEvent, ComponentType, InputHTMLAttributes, ReactNode } from "react";
3
3
  import { Check, Minus } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { assignRef, hasMessage, mergeDescribedBy } from "./choice-parts";
@@ -218,6 +218,102 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
218
218
  });
219
219
  ChoiceCard.displayName = "ChoiceCard";
220
220
 
221
+ /* ── ActionCard ──────────────────────────────────────────────────────────── */
222
+
223
+ /** The colour of {@link ActionCardProps.meta}. */
224
+ export type ActionCardMetaTone = "muted" | "warning" | "danger" | "info" | "success";
225
+
226
+ const META_TONE: Record<ActionCardMetaTone, string> = {
227
+ muted: "text-[var(--text-muted)]",
228
+ warning: "text-[var(--warning)]",
229
+ danger: "text-[var(--danger)]",
230
+ info: "text-[var(--info)]",
231
+ success: "text-[var(--success)]",
232
+ };
233
+
234
+ export interface ActionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "title"> {
235
+ /** What the card does — the button's accessible name. */
236
+ title: ReactNode;
237
+ /** A line or two on what it means. Attached with `aria-describedby`. */
238
+ description?: ReactNode;
239
+ /**
240
+ * What it costs, or what else to know — a third line, said as prose and not as fine
241
+ * print (keksdose's custody choice: both options are legitimate and neither is free,
242
+ * so a card listing only benefits would be selling rather than explaining). Attached
243
+ * with `aria-describedby` after the description.
244
+ */
245
+ meta?: ReactNode;
246
+ /** The meta line's colour. Default `muted`; keksdose's cost line is `warning`. */
247
+ metaTone?: ActionCardMetaTone;
248
+ /** A Lucide icon (or any component taking a `className`), shown at the card's start. */
249
+ icon?: ChoiceCardProps["icon"];
250
+ }
251
+
252
+ /**
253
+ * A {@link ChoiceCard} that ACTS rather than holds a state: a `<button>` with the
254
+ * card's icon, title and description, plus a `meta` line, that runs `onClick` the
255
+ * moment it is pressed. keksdose's privacy enrolment (privacy-enroll-dialog:345,
256
+ * `CustodyOption`) offers two custody modes this way — picking one IS the next step,
257
+ * so there is no checked state to show and no "Continue" to press after it.
258
+ *
259
+ * A separate component rather than `ChoiceCard as="button"`: a ChoiceCard is an
260
+ * `<input>` (its ref, its `checked`, its form value), and a button shares none of it.
261
+ * Named by the title alone and described by the rest, so a screen reader hears
262
+ * "Keep the key yourself, button" and then the explanation, not one long name.
263
+ */
264
+ export const ActionCard = forwardRef<HTMLButtonElement, ActionCardProps>(function ActionCard(
265
+ { title, description, meta, metaTone = "muted", icon: Icon, className, id, type = "button", ...rest },
266
+ ref,
267
+ ) {
268
+ const generated = useId();
269
+ const baseId = id ?? generated;
270
+ const titleId = `${baseId}-title`;
271
+ const descriptionId = `${baseId}-description`;
272
+ const metaId = `${baseId}-meta`;
273
+ const showDescription = hasMessage(description);
274
+ const showMeta = hasMessage(meta);
275
+ return (
276
+ <button
277
+ ref={ref}
278
+ id={id}
279
+ type={type}
280
+ {...rest}
281
+ aria-labelledby={rest["aria-labelledby"] ?? titleId}
282
+ aria-describedby={mergeDescribedBy(
283
+ rest["aria-describedby"],
284
+ showDescription && descriptionId,
285
+ showMeta && metaId,
286
+ )}
287
+ className={cn(
288
+ "flex w-full items-start gap-3 rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-3 text-start shadow-sm transition-colors",
289
+ "hover:border-[var(--brand)] hover:bg-[var(--bg-hover)]",
290
+ // The outline, as on ChoiceCard: the card is what the eye is on.
291
+ "focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
292
+ "disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-[var(--border)] disabled:hover:bg-[var(--bg-surface)]",
293
+ className,
294
+ )}
295
+ >
296
+ {Icon && <Icon aria-hidden className="mt-0.5 size-5 shrink-0 text-[var(--text-secondary)]" />}
297
+ <span className="min-w-0 flex-1 space-y-1">
298
+ <span id={titleId} className="block text-sm font-medium leading-5 text-[var(--text-primary)]">
299
+ {title}
300
+ </span>
301
+ {showDescription && (
302
+ <span id={descriptionId} className="block text-sm text-[var(--text-secondary)]">
303
+ {description}
304
+ </span>
305
+ )}
306
+ {showMeta && (
307
+ <span id={metaId} className={cn("block text-sm", META_TONE[metaTone])}>
308
+ {meta}
309
+ </span>
310
+ )}
311
+ </span>
312
+ </button>
313
+ );
314
+ });
315
+ ActionCard.displayName = "ActionCard";
316
+
221
317
  /* ── ChoiceCardGroup ─────────────────────────────────────────────────────── */
222
318
 
223
319
  export interface ChoiceCardOption<T extends string> {
@@ -1427,6 +1427,11 @@ export function DataTable<T>({
1427
1427
  // density (`group-data-[density=compact]/table:…`) without being told twice.
1428
1428
  data-density={density}
1429
1429
  data-frame={frame ? undefined : "none"}
1430
+ // The frame clips (`overflow-clip`), so it is a clipping container for a
1431
+ // Tooltip's auto-portal — said as an attribute as well, because jsdom computes no
1432
+ // classes and a table-cell tooltip must portal under test just as it does in the
1433
+ // browser (see `CLIPS_ATTRIBUTE`). The bare root clips nothing and is not marked.
1434
+ data-clips={frame ? "" : undefined}
1430
1435
  className={cn(
1431
1436
  "group/table",
1432
1437
  frame ? "overflow-clip" : "min-w-0",
@@ -1535,6 +1540,8 @@ export function DataTable<T>({
1535
1540
  other half of the `w-0 min-w-full` fix on the expansion cell below
1536
1541
  (feedback #104: "expanding an item resizes the columns"). */}
1537
1542
  <div
1543
+ // The scroller: marked for the Tooltip's auto-portal (see the root above).
1544
+ data-clips=""
1538
1545
  className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "flex-1 min-h-0")}
1539
1546
  style={fillHeight ? undefined : { maxHeight: maxBodyHeight }}
1540
1547
  >