@eifi1/ui-kit 0.9.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 (243) hide show
  1. package/README.md +37 -23
  2. package/dist/components/alert-banner.d.ts +57 -5
  3. package/dist/components/alert-banner.js +63 -17
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +112 -0
  10. package/dist/components/bulk-action-bar.js +196 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/button-group.d.ts +58 -3
  13. package/dist/components/button-group.js +56 -5
  14. package/dist/components/button-group.js.map +1 -1
  15. package/dist/components/calculator.d.ts +6 -0
  16. package/dist/components/calendar-heatmap.d.ts +124 -0
  17. package/dist/components/calendar-heatmap.js +295 -0
  18. package/dist/components/calendar-heatmap.js.map +1 -0
  19. package/dist/components/chip.d.ts +31 -5
  20. package/dist/components/chip.js +85 -5
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +34 -2
  23. package/dist/components/choice-card.js +49 -0
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/copy-button.d.ts +14 -4
  26. package/dist/components/copy-button.js +17 -4
  27. package/dist/components/copy-button.js.map +1 -1
  28. package/dist/components/data-table.js +2 -0
  29. package/dist/components/data-table.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +22 -2
  31. package/dist/components/date-picker.js +11 -3
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/description-list.d.ts +43 -6
  34. package/dist/components/description-list.js +91 -11
  35. package/dist/components/description-list.js.map +1 -1
  36. package/dist/components/dialog-frame.d.ts +7 -0
  37. package/dist/components/dialog-frame.js.map +1 -1
  38. package/dist/components/disclosure.d.ts +57 -2
  39. package/dist/components/disclosure.js +24 -12
  40. package/dist/components/disclosure.js.map +1 -1
  41. package/dist/components/field.d.ts +43 -0
  42. package/dist/components/field.js +52 -0
  43. package/dist/components/field.js.map +1 -0
  44. package/dist/components/file-button.d.ts +1 -0
  45. package/dist/components/file-dropzone.d.ts +4 -4
  46. package/dist/components/file-dropzone.js +2 -1
  47. package/dist/components/file-dropzone.js.map +1 -1
  48. package/dist/components/floating-panel.d.ts +147 -4
  49. package/dist/components/floating-panel.js +188 -28
  50. package/dist/components/floating-panel.js.map +1 -1
  51. package/dist/components/full-bleed-dialog.d.ts +23 -4
  52. package/dist/components/full-bleed-dialog.js +11 -2
  53. package/dist/components/full-bleed-dialog.js.map +1 -1
  54. package/dist/components/list.d.ts +187 -0
  55. package/dist/components/list.js +221 -0
  56. package/dist/components/list.js.map +1 -0
  57. package/dist/components/menu-item.d.ts +109 -0
  58. package/dist/components/menu-item.js +88 -0
  59. package/dist/components/menu-item.js.map +1 -0
  60. package/dist/components/mini-calendar.d.ts +70 -5
  61. package/dist/components/mini-calendar.js +166 -62
  62. package/dist/components/mini-calendar.js.map +1 -1
  63. package/dist/components/modal.d.ts +23 -1
  64. package/dist/components/modal.js +36 -10
  65. package/dist/components/modal.js.map +1 -1
  66. package/dist/components/nav-pills.d.ts +66 -0
  67. package/dist/components/nav-pills.js +65 -0
  68. package/dist/components/nav-pills.js.map +1 -0
  69. package/dist/components/number-field.d.ts +6 -0
  70. package/dist/components/number-input.d.ts +6 -0
  71. package/dist/components/numpad-sheet.d.ts +6 -0
  72. package/dist/components/page-contents.js +1 -2
  73. package/dist/components/page-contents.js.map +1 -1
  74. package/dist/components/page-header.d.ts +37 -0
  75. package/dist/components/page-header.js +36 -0
  76. package/dist/components/page-header.js.map +1 -0
  77. package/dist/components/progress-bar.d.ts +31 -2
  78. package/dist/components/progress-bar.js +73 -7
  79. package/dist/components/progress-bar.js.map +1 -1
  80. package/dist/components/series-chart-labels.d.ts +3 -0
  81. package/dist/components/series-chart-labels.js +2 -1
  82. package/dist/components/series-chart-labels.js.map +1 -1
  83. package/dist/components/series-chart.d.ts +32 -0
  84. package/dist/components/series-chart.js +141 -4
  85. package/dist/components/series-chart.js.map +1 -1
  86. package/dist/components/settings-fields.d.ts +1 -0
  87. package/dist/components/status-dot.d.ts +53 -0
  88. package/dist/components/status-dot.js +57 -0
  89. package/dist/components/status-dot.js.map +1 -0
  90. package/dist/components/table.d.ts +78 -13
  91. package/dist/components/table.js +84 -11
  92. package/dist/components/table.js.map +1 -1
  93. package/dist/components/text.d.ts +70 -0
  94. package/dist/components/text.js +24 -0
  95. package/dist/components/text.js.map +1 -0
  96. package/dist/components/time-input.d.ts +1 -0
  97. package/dist/components/toast.d.ts +167 -0
  98. package/dist/components/toast.js +226 -0
  99. package/dist/components/toast.js.map +1 -0
  100. package/dist/components/toggle-group.d.ts +44 -1
  101. package/dist/components/toggle-group.js +73 -4
  102. package/dist/components/toggle-group.js.map +1 -1
  103. package/dist/components/tooltip.d.ts +46 -15
  104. package/dist/components/tooltip.js +87 -47
  105. package/dist/components/tooltip.js.map +1 -1
  106. package/dist/components/ui.d.ts +158 -7
  107. package/dist/components/ui.js +191 -10
  108. package/dist/components/ui.js.map +1 -1
  109. package/dist/components/user-avatar.d.ts +25 -3
  110. package/dist/components/user-avatar.js +32 -3
  111. package/dist/components/user-avatar.js.map +1 -1
  112. package/dist/hooks/use-close-transition.d.ts +7 -1
  113. package/dist/hooks/use-close-transition.js +5 -2
  114. package/dist/hooks/use-close-transition.js.map +1 -1
  115. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  116. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  117. package/dist/hooks/use-file-drop.d.ts +1 -0
  118. package/dist/i18n/defaults.d.ts +6 -0
  119. package/dist/i18n/defaults.js +11 -1
  120. package/dist/i18n/defaults.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +11 -0
  122. package/dist/i18n/kit-labels.js.map +1 -1
  123. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  124. package/dist/i18n/locales/de-CH.d.ts +6 -0
  125. package/dist/i18n/locales/de-informal.d.ts +6 -0
  126. package/dist/i18n/locales/de.d.ts +6 -0
  127. package/dist/i18n/locales/de.js +30 -2
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +6 -0
  130. package/dist/i18n/locales/es.js +30 -2
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +6 -0
  133. package/dist/i18n/locales/fr.js +30 -2
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +6 -0
  136. package/dist/i18n/locales/hu.js +30 -2
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +6 -0
  139. package/dist/i18n/locales/it.js +30 -2
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +6 -0
  142. package/dist/i18n/locales/zh.js +30 -2
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/index.d.ts +27 -15
  145. package/dist/index.js +15 -0
  146. package/dist/index.js.map +1 -1
  147. package/dist/lib/clipping.d.ts +25 -0
  148. package/dist/lib/clipping.js +17 -0
  149. package/dist/lib/clipping.js.map +1 -0
  150. package/dist/lib/dates.d.ts +17 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/rhf/form.d.ts +1 -0
  154. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  155. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  156. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  157. package/dist/rhf.d.ts +2 -0
  158. package/dist/rhf.js +1 -0
  159. package/dist/rhf.js.map +1 -1
  160. package/dist/search/command-palette.d.ts +19 -2
  161. package/dist/search/command-palette.js +16 -5
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/search/global-search.d.ts +26 -1
  164. package/dist/search/global-search.js +29 -13
  165. package/dist/search/global-search.js.map +1 -1
  166. package/dist/search.d.ts +1 -1
  167. package/dist/shell/topbar-action-menu.d.ts +73 -3
  168. package/dist/shell/topbar-action-menu.js +100 -27
  169. package/dist/shell/topbar-action-menu.js.map +1 -1
  170. package/dist/shell.d.ts +2 -1
  171. package/dist/wizard/stepper-nav.d.ts +1 -0
  172. package/dist/wizard/stepper-nav.js +1 -1
  173. package/dist/wizard/stepper-nav.js.map +1 -1
  174. package/dist/wizard/types.d.ts +2 -2
  175. package/dist/wizard/types.js.map +1 -1
  176. package/dist/wizard/use-wizard.js +1 -1
  177. package/dist/wizard/use-wizard.js.map +1 -1
  178. package/dist/wizard/wizard-context.d.ts +7 -1
  179. package/dist/wizard/wizard-context.js +4 -0
  180. package/dist/wizard/wizard-context.js.map +1 -1
  181. package/dist/wizard/wizard-summary.js +3 -3
  182. package/dist/wizard/wizard-summary.js.map +1 -1
  183. package/dist/wizard.d.ts +2 -1
  184. package/package.json +16 -12
  185. package/src/components/alert-banner.tsx +149 -21
  186. package/src/components/breadcrumbs.tsx +168 -0
  187. package/src/components/bulk-action-bar.tsx +362 -0
  188. package/src/components/button-group.tsx +124 -4
  189. package/src/components/calendar-heatmap.tsx +504 -0
  190. package/src/components/chip.tsx +128 -7
  191. package/src/components/choice-card.tsx +97 -1
  192. package/src/components/copy-button.tsx +29 -7
  193. package/src/components/data-table.tsx +7 -0
  194. package/src/components/date-picker.tsx +49 -1
  195. package/src/components/description-list.tsx +168 -15
  196. package/src/components/dialog-frame.tsx +7 -0
  197. package/src/components/disclosure.tsx +93 -21
  198. package/src/components/field.tsx +137 -0
  199. package/src/components/file-dropzone.tsx +9 -10
  200. package/src/components/floating-panel.tsx +366 -15
  201. package/src/components/full-bleed-dialog.tsx +42 -5
  202. package/src/components/list.tsx +443 -0
  203. package/src/components/menu-item.tsx +235 -0
  204. package/src/components/mini-calendar.tsx +245 -54
  205. package/src/components/modal.tsx +80 -17
  206. package/src/components/nav-pills.tsx +145 -0
  207. package/src/components/page-contents.tsx +4 -4
  208. package/src/components/page-header.tsx +68 -0
  209. package/src/components/progress-bar.tsx +129 -11
  210. package/src/components/series-chart-labels.ts +4 -0
  211. package/src/components/series-chart.tsx +274 -3
  212. package/src/components/status-dot.tsx +107 -0
  213. package/src/components/table.tsx +176 -17
  214. package/src/components/text.tsx +97 -0
  215. package/src/components/toast.tsx +441 -0
  216. package/src/components/toggle-group.tsx +128 -4
  217. package/src/components/tooltip.tsx +200 -100
  218. package/src/components/ui.tsx +381 -14
  219. package/src/components/user-avatar.tsx +57 -3
  220. package/src/hooks/use-close-transition.ts +14 -5
  221. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  222. package/src/i18n/defaults.ts +10 -0
  223. package/src/i18n/kit-labels.tsx +10 -0
  224. package/src/i18n/locales/de.ts +29 -0
  225. package/src/i18n/locales/es.ts +29 -0
  226. package/src/i18n/locales/fr.ts +29 -0
  227. package/src/i18n/locales/hu.ts +29 -0
  228. package/src/i18n/locales/it.ts +29 -0
  229. package/src/i18n/locales/zh.ts +29 -0
  230. package/src/index.ts +31 -0
  231. package/src/lib/clipping.ts +34 -0
  232. package/src/lib/dates.ts +37 -0
  233. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  234. package/src/rhf.ts +2 -0
  235. package/src/search/command-palette.tsx +42 -5
  236. package/src/search/global-search.tsx +59 -18
  237. package/src/shell/topbar-action-menu.tsx +239 -49
  238. package/src/wizard/stepper-nav.tsx +2 -1
  239. package/src/wizard/types.ts +2 -2
  240. package/src/wizard/use-wizard.ts +3 -3
  241. package/src/wizard/wizard-context.tsx +9 -0
  242. package/src/wizard/wizard-summary.tsx +14 -10
  243. package/tokens.css +97 -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
+ }