@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
@@ -1,10 +1,17 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, KeyboardEvent } from "react";
2
+ import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from "react";
3
3
  import { ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { horizontalStep } from "../lib/direction";
6
6
  import { useAnnounce } from "../hooks/use-announce";
7
- import { parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
7
+ import {
8
+ addMonthsClamped,
9
+ localeWeekStart,
10
+ monthKey,
11
+ parseIsoDate,
12
+ sameYmd,
13
+ toLocalIso,
14
+ } from "../lib/dates";
8
15
  import { useKitLabels, useKitLocale, useKitWeekStart } from "../i18n/kit-labels";
9
16
 
10
17
  /**
@@ -80,8 +87,10 @@ const DAY_NAME_FORMAT: Intl.DateTimeFormatOptions = {
80
87
  * before this, a closed prop list dropped both (audit §api-design).
81
88
  */
82
89
  export interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">, "onSelect"> {
83
- from: string;
84
- to: string;
90
+ /** The selection's start as "YYYY-MM-DD"; `""` (the default) for none. */
91
+ from?: string;
92
+ /** The selection's end; `""` (the default) for none. */
93
+ to?: string;
85
94
  /**
86
95
  * BCP 47 tag for the month caption, the weekday heads, the day numbers and the
87
96
  * spoken dates. Optional since the kit grew `<UiKitProvider locale>`: a calendar
@@ -122,47 +131,92 @@ export interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">,
122
131
  * merely by existing.
123
132
  */
124
133
  focusOnOpen?: boolean;
134
+ /**
135
+ * `"sm"` (default) is the compact picker: 32px round days, the size the date
136
+ * pickers and the data-table filter draw. `"lg"` is a MONTH VIEW for a page — tall
137
+ * cells in a ruled grid, the day number in the top corner and {@link renderDay}'s
138
+ * content under it — the calendar a planner page hand-built before this existed.
139
+ * Both are the same grid underneath: the same roles, roving tabindex, keys, RTL
140
+ * mirroring and week start.
141
+ */
142
+ size?: "sm" | "lg";
143
+ /**
144
+ * Custom content for one day: events, an amount, a dot. Called for every day of the
145
+ * month on show; return `null` for none.
146
+ *
147
+ * ⚠️ **It renders INSIDE the day's button** — a dot under the number at `"sm"`, a
148
+ * column under it at `"lg"` — so it must not be interactive: no links, no buttons,
149
+ * no inputs. A control inside a control is invalid HTML, and a screen reader lands
150
+ * on the day and never reaches what is inside it. Events that are themselves
151
+ * clickable are shown here as plain text, and the DAY is what is clicked: pass
152
+ * `mode="single"` and let `onSelect` open a day panel (a drawer, a popover) that
153
+ * lists that day's events as real links.
154
+ *
155
+ * The day's accessible name stays the full date; this content is attached to it as
156
+ * its DESCRIPTION (`aria-describedby`), so "Monday, 14 September 2026" is read
157
+ * first and "Rent due · Plumber" after it. Anything decorative (a coloured dot)
158
+ * should be `aria-hidden` or carry an `sr-only` word for what it means.
159
+ */
160
+ renderDay?: (day: Date, state: MiniCalendarDayState) => ReactNode;
161
+ /**
162
+ * The month on show, as "YYYY-MM". FOLLOWED rather than controlled, exactly like
163
+ * `from`/`to`: when it changes the grid moves there, and in between the user pages
164
+ * freely — every move to another month is reported through {@link onMonthChange}.
165
+ * For a month page whose own header (a month picker, a Today button) drives the
166
+ * calendar. Left out, the grid opens on the selection or today.
167
+ */
168
+ month?: string;
169
+ /** Called with "YYYY-MM" whenever the USER moves the grid to another month (the
170
+ * arrows, PageUp/PageDown, arrowing off the edge). */
171
+ onMonthChange?: (month: string) => void;
172
+ /**
173
+ * Hide the built-in previous/next arrows and month caption, for a page that draws
174
+ * its own. The caption is kept for assistive technology (visually hidden), because
175
+ * it is what names the grid.
176
+ */
177
+ hideNavigation?: boolean;
125
178
  /** Extra classes for the calendar's root. */
126
179
  className?: string;
127
180
  }
128
181
 
129
- export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
130
-
131
- /**
132
- * The first day of the week in `locale`, as a `Date#getDay` index.
133
- *
134
- * `Intl.Locale#getWeekInfo()` is the standard spelling; V8 shipped it first as the
135
- * `weekInfo` accessor, and Firefox has neither yet — hence both reads and a Monday
136
- * fallback, which is ISO 8601 and what this grid did unconditionally before. Its
137
- * `firstDay` counts 1 = Monday … 7 = Sunday, so `% 7` maps it onto `getDay`.
138
- */
139
- function localeWeekStart(locale: string | undefined): WeekDay {
140
- try {
141
- const tag = locale ?? new Intl.DateTimeFormat().resolvedOptions().locale;
142
- const loc = new Intl.Locale(tag) as Intl.Locale & {
143
- getWeekInfo?: () => { firstDay: number };
144
- weekInfo?: { firstDay: number };
145
- };
146
- const firstDay = (loc.getWeekInfo?.() ?? loc.weekInfo)?.firstDay;
147
- if (typeof firstDay === "number") return (firstDay % 7) as WeekDay;
148
- } catch {
149
- // A malformed tag throws from `Intl.Locale`; the calendar still has to render.
150
- }
151
- return 1;
182
+ /** What {@link MiniCalendarProps.renderDay} is told about the day it draws. */
183
+ export interface MiniCalendarDayState {
184
+ /** The day as "YYYY-MM-DD". */
185
+ iso: string;
186
+ /** The day number as drawn, in the calendar's locale. */
187
+ label: string;
188
+ today: boolean;
189
+ /** Outside `min`/`max`. */
190
+ disabled: boolean;
191
+ /** Part of the selection — an end or a day between. */
192
+ selected: boolean;
193
+ rangeStart: boolean;
194
+ rangeEnd: boolean;
195
+ /** Strictly between the two ends of a range. */
196
+ inRange: boolean;
197
+ /** The day that holds the tab stop. */
198
+ active: boolean;
199
+ size: "sm" | "lg";
152
200
  }
153
201
 
202
+ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
203
+
154
204
  /** Where `d` sits in a week that starts on `weekStart` (the first column = 0). */
155
205
  const weekdayIndex = (d: Date, weekStart: WeekDay) => (d.getDay() - weekStart + 7) % 7;
156
206
 
157
- /** Move `d` by whole months, keeping the day of the month where the target has one.
158
- * `setMonth` alone ROLLS OVER — 31 January + 1 month is 3 March — which would skip
159
- * February entirely for anyone paging through the year with PageDown. */
160
- function stepMonths(d: Date, months: number): Date {
161
- const day = d.getDate();
162
- const out = new Date(d.getFullYear(), d.getMonth() + months, 1);
163
- const lastOfTarget = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate();
164
- out.setDate(Math.min(day, lastOfTarget));
165
- return out;
207
+ /** Move `d` by whole months without rolling over (see {@link addMonthsClamped}). */
208
+ const stepMonths = addMonthsClamped;
209
+
210
+ /** A month-view cell: tall enough for two or three event lines, shorter on a phone. */
211
+ const LG_CELL_HEIGHT = "min-h-16 md:min-h-24";
212
+
213
+ /** The 1st of a "YYYY-MM" month, or null when absent or malformed. */
214
+ const monthStart = (key: string | undefined) => (key ? parseIsoDate(`${key}-01`) : null);
215
+
216
+ /** `day`'s day of the month in `first`'s month, clamped to that month's length. */
217
+ function sameDayIn(first: Date, day: Date): Date {
218
+ const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
219
+ return new Date(first.getFullYear(), first.getMonth(), Math.min(day.getDate(), last));
166
220
  }
167
221
 
168
222
  /**
@@ -187,8 +241,8 @@ function stepMonths(d: Date, months: number): Date {
187
241
  * readable, and the click handler is what refuses them.
188
242
  */
189
243
  export function MiniCalendar({
190
- from,
191
- to,
244
+ from = "",
245
+ to = "",
192
246
  locale: localeProp,
193
247
  weekStartsOn,
194
248
  onSelect,
@@ -197,9 +251,15 @@ export function MiniCalendar({
197
251
  max,
198
252
  labels: labelsProp,
199
253
  focusOnOpen,
254
+ size = "sm",
255
+ renderDay,
256
+ month,
257
+ onMonthChange,
258
+ hideNavigation,
200
259
  className,
201
260
  ...rest
202
261
  }: MiniCalendarProps) {
262
+ const lg = size === "lg";
203
263
  const labels = useKitLabels("miniCalendar", DEFAULT_MINI_CALENDAR_LABELS, labelsProp);
204
264
  const locale = useKitLocale(localeProp);
205
265
  const providerWeekStart = useKitWeekStart();
@@ -232,7 +292,11 @@ export function MiniCalendar({
232
292
  * so a bounded calendar opens on a day it will actually let you pick.
233
293
  */
234
294
  const [activeIso, setActiveIso] = useState(() => {
235
- const start = fromDate ?? toDate ?? new Date();
295
+ let start = fromDate ?? toDate ?? new Date();
296
+ // A `month` wins over the selection's month: the page asked for THAT month. The
297
+ // selection (or today) is kept when it falls inside it, else the 1st.
298
+ const first = monthStart(month);
299
+ if (first && monthKey(start) !== month) start = first;
236
300
  if (minDate && start < minDate) return toLocalIso(minDate);
237
301
  if (maxDate && start > maxDate) return toLocalIso(maxDate);
238
302
  return toLocalIso(start);
@@ -253,6 +317,19 @@ export function MiniCalendar({
253
317
  if (next && toLocalIso(next) !== activeIso) setActiveIso(toLocalIso(next));
254
318
  }
255
319
 
320
+ // …and the caller's month, the same way. Only a CHANGE moves the grid, so paging
321
+ // with the arrows is not snapped back by a parent that ignores `onMonthChange`.
322
+ const [lastMonth, setLastMonth] = useState(month);
323
+ if (lastMonth !== month) {
324
+ setLastMonth(month);
325
+ const first = monthStart(month);
326
+ const current = parseIsoDate(activeIso) ?? new Date();
327
+ if (first && monthKey(current) !== month) {
328
+ // Keep the day of the month, clamped: the 31st in a 30-day month is the 30th.
329
+ setActiveIso(toLocalIso(sameDayIn(first, current)));
330
+ }
331
+ }
332
+
256
333
  const activeDate = parseIsoDate(activeIso) ?? new Date();
257
334
  const view = { year: activeDate.getFullYear(), month: activeDate.getMonth() };
258
335
 
@@ -281,6 +358,7 @@ export function MiniCalendar({
281
358
  d.setDate(ref.getDate() + i);
282
359
  return {
283
360
  narrow: d.toLocaleDateString(locale, { weekday: "narrow" }),
361
+ short: d.toLocaleDateString(locale, { weekday: "short" }),
284
362
  long: d.toLocaleDateString(locale, { weekday: "long" }),
285
363
  };
286
364
  });
@@ -312,6 +390,8 @@ export function MiniCalendar({
312
390
  if (iso === activeIso) return;
313
391
  if (focus) pendingFocus.current = iso;
314
392
  setActiveIso(iso);
393
+ const key = monthKey(d);
394
+ if (key !== monthKey(activeDate)) onMonthChange?.(key);
315
395
  };
316
396
 
317
397
  /**
@@ -414,7 +494,13 @@ export function MiniCalendar({
414
494
  // `className` does everywhere else in the kit; `...rest` first, as in the rest of
415
495
  // this wave, so nothing from outside can take the grid's own wiring away.
416
496
  <div {...rest} className={cn("select-none", className)}>
417
- <div className="flex items-center justify-between pb-1">
497
+ {hideNavigation ? (
498
+ // Still in the tree: the caption is what names the grid.
499
+ <div id={monthId} className="sr-only-fixed">
500
+ {monthLabel}
501
+ </div>
502
+ ) : (
503
+ <div className={cn("flex items-center justify-between", lg ? "pb-2" : "pb-1")}>
418
504
  <button
419
505
  type="button"
420
506
  onClick={() => moveMonth(-1)}
@@ -431,7 +517,12 @@ export function MiniCalendar({
431
517
  <div
432
518
  id={monthId}
433
519
  aria-live="polite"
434
- className="text-xs font-medium capitalize text-[var(--text-secondary)]"
520
+ className={cn(
521
+ "capitalize",
522
+ lg
523
+ ? "text-base font-semibold text-[var(--text-primary)]"
524
+ : "text-xs font-medium text-[var(--text-secondary)]",
525
+ )}
435
526
  >
436
527
  {monthLabel}
437
528
  </div>
@@ -444,31 +535,57 @@ export function MiniCalendar({
444
535
  <ChevronRight className="size-4 rtl:-scale-x-100" aria-hidden />
445
536
  </button>
446
537
  </div>
538
+ )}
447
539
  <div
448
540
  ref={gridRef}
449
541
  role="grid"
450
542
  aria-labelledby={monthId}
451
543
  aria-describedby={mode === "range" ? hintId : undefined}
452
- className="grid gap-y-0.5"
544
+ className={cn(
545
+ "grid",
546
+ // The month view is RULED: a 1px gap over a border-coloured ground draws the
547
+ // lines between the cells, the way a wall calendar does.
548
+ lg
549
+ ? "gap-px overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--border)]"
550
+ : "gap-y-0.5",
551
+ )}
453
552
  >
454
553
  <div
455
554
  role="row"
456
- className="grid grid-cols-7 text-center text-[10px] font-medium uppercase text-[var(--text-placeholder)]"
555
+ className={cn(
556
+ "grid grid-cols-7 text-center font-medium",
557
+ lg
558
+ ? "gap-px text-xs text-[var(--text-muted)]"
559
+ : "text-[10px] uppercase text-[var(--text-placeholder)]",
560
+ )}
457
561
  >
458
562
  {weekdayLabels.map((w, i) => (
459
- <div key={i} role="columnheader" aria-label={w.long} className="py-0.5">
460
- {w.narrow}
563
+ <div
564
+ key={i}
565
+ role="columnheader"
566
+ aria-label={w.long}
567
+ className={lg ? "bg-[var(--bg-surface)] py-2" : "py-0.5"}
568
+ >
569
+ {lg ? w.short : w.narrow}
461
570
  </div>
462
571
  ))}
463
572
  </div>
464
573
  {weeks.map((week, wi) => (
465
574
  // No column gap: the days of a range join into ONE band. With a gap each day
466
575
  // was its own tinted box, and a selected fortnight read as fourteen chips.
467
- <div key={wi} role="row" className="grid grid-cols-7">
576
+ <div key={wi} role="row" className={cn("grid grid-cols-7", lg && "gap-px")}>
468
577
  {week.map((d, di) => {
469
578
  // The padding cells still have to BE cells, or the rows are ragged and
470
579
  // a screen reader's "column 3" stops meaning Wednesday.
471
- if (!d) return <div key={di} role="gridcell" className="h-8" />;
580
+ if (!d) {
581
+ return (
582
+ <div
583
+ key={di}
584
+ role="gridcell"
585
+ className={lg ? cn(LG_CELL_HEIGHT, "bg-[var(--bg-surface-2)]") : "h-8"}
586
+ />
587
+ );
588
+ }
472
589
  const iso = toLocalIso(d);
473
590
  const disabled = isDisabled(d);
474
591
  const isStart = !disabled && fromDate && sameYmd(d, fromDate);
@@ -476,12 +593,29 @@ export function MiniCalendar({
476
593
  const isToday = sameYmd(d, today);
477
594
  const isInRange = !disabled && inRange(d) && !isStart && !isEnd;
478
595
  const isActive = iso === activeIso;
596
+ const isSelected = Boolean(isStart || isEnd || isInRange);
597
+ const label = dayNumber.format(d.getDate());
598
+ const content = renderDay?.(d, {
599
+ iso,
600
+ label,
601
+ today: isToday,
602
+ disabled,
603
+ selected: isSelected,
604
+ rangeStart: Boolean(isStart),
605
+ rangeEnd: Boolean(isEnd),
606
+ inRange: isInRange,
607
+ active: isActive,
608
+ size,
609
+ });
610
+ const hasContent = content != null && content !== false && content !== "";
611
+ const contentId = `${id}-day-${iso}`;
479
612
  // The band behind the day: full width between the ends, half width at an
480
613
  // end (so the round endpoint sits ON the band's tip), rounded where a row
481
614
  // or the month breaks it. Only when both ends exist and differ — a lone
482
- // start is a dot, not a band of one.
615
+ // start is a dot, not a band of one. The month view tints whole cells
616
+ // instead, so it has no band.
483
617
  const banded =
484
- hasBand && !disabled && (isStart || isEnd || isInRange);
618
+ !lg && hasBand && !disabled && (isStart || isEnd || isInRange);
485
619
  const lastOfMonth = d.getDate() === daysInMonth(d);
486
620
  const band = banded
487
621
  ? cn(
@@ -494,7 +628,14 @@ export function MiniCalendar({
494
628
  )
495
629
  : null;
496
630
  return (
497
- <div key={di} role="none" className="relative flex h-8 items-center justify-center">
631
+ <div
632
+ key={di}
633
+ role="none"
634
+ className={cn(
635
+ "relative flex",
636
+ lg ? "min-w-0" : "h-8 items-center justify-center",
637
+ )}
638
+ >
498
639
  {band && <span aria-hidden className={band} />}
499
640
  <button
500
641
  key={di}
@@ -506,12 +647,28 @@ export function MiniCalendar({
506
647
  // control, which is the same reason the pattern exists.
507
648
  tabIndex={isActive ? 0 : -1}
508
649
  aria-label={labels.day(formatDay(d))}
509
- aria-selected={Boolean(isStart || isEnd || isInRange)}
650
+ // The custom content is the day's DESCRIPTION: `aria-label` replaces
651
+ // the button's text as its name, so without this link a screen
652
+ // reader would hear the date and never the events drawn under it.
653
+ aria-describedby={hasContent ? contentId : undefined}
654
+ aria-selected={isSelected}
510
655
  aria-disabled={disabled || undefined}
511
656
  aria-current={isToday ? "date" : undefined}
512
657
  onClick={() => handleClick(d)}
513
658
  onKeyDown={onDayKeyDown}
514
- className={cn(
659
+ className={
660
+ lg
661
+ ? cn(
662
+ "flex w-full min-w-0 flex-col items-stretch gap-1 overflow-hidden p-1.5 text-start text-xs transition-colors",
663
+ LG_CELL_HEIGHT,
664
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]",
665
+ disabled
666
+ ? "cursor-not-allowed bg-[var(--bg-surface)] text-[var(--text-placeholder)]"
667
+ : isSelected
668
+ ? "bg-[var(--brand-bg)] text-[var(--text-primary)] hover:bg-[var(--brand-bg-hover)]"
669
+ : "bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
670
+ )
671
+ : cn(
515
672
  "relative size-8 rounded-full text-xs tabular-nums transition-colors",
516
673
  // A grid you can walk with the keyboard needs to show where the
517
674
  // keyboard IS. Inset because the cells sit half a pixel apart and
@@ -537,9 +694,43 @@ export function MiniCalendar({
537
694
  ? "font-medium text-[var(--brand-muted)] hover:bg-[var(--brand-bg-hover)]"
538
695
  : "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
539
696
  isToday && !isStart && !isEnd && !isInRange && !disabled && "ring-1 ring-inset ring-[var(--border-strong)]",
540
- )}
697
+ )
698
+ }
541
699
  >
542
- {dayNumber.format(d.getDate())}
700
+ {lg ? (
701
+ <>
702
+ <span
703
+ className={cn(
704
+ "inline-flex size-6 shrink-0 items-center justify-center self-start rounded-full tabular-nums",
705
+ isStart || isEnd
706
+ ? "bg-[var(--brand)] font-semibold text-[var(--brand-contrast)]"
707
+ : isToday && !disabled
708
+ ? "font-semibold text-[var(--brand)] ring-1 ring-inset ring-[var(--brand)]"
709
+ : isInRange && "font-medium text-[var(--brand-muted)]",
710
+ )}
711
+ >
712
+ {label}
713
+ </span>
714
+ {hasContent && (
715
+ <span id={contentId} className="flex min-w-0 flex-col gap-0.5">
716
+ {content}
717
+ </span>
718
+ )}
719
+ </>
720
+ ) : (
721
+ <>
722
+ {label}
723
+ {/* Under the number, inside the circle: room for a dot or two. */}
724
+ {hasContent && (
725
+ <span
726
+ id={contentId}
727
+ className="pointer-events-none absolute inset-x-0 bottom-0.5 flex justify-center gap-px leading-none"
728
+ >
729
+ {content}
730
+ </span>
731
+ )}
732
+ </>
733
+ )}
543
734
  </button>
544
735
  </div>
545
736
  );
@@ -1,4 +1,4 @@
1
- import { createContext, useRef, useState } from "react";
1
+ import { createContext, useEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  ComponentPropsWithoutRef,
@@ -11,7 +11,7 @@ import { cn } from "../lib/cn";
11
11
  import { useFocusTrap } from "../hooks/use-focus-trap";
12
12
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
13
13
  import { useOverlayHistory } from "../hooks/use-overlay-history";
14
- import { useCloseTransition } from "../hooks/use-close-transition";
14
+ import { OVERLAY_EXIT_MS, prefersReducedMotion, useCloseTransition } from "../hooks/use-close-transition";
15
15
 
16
16
  /**
17
17
  * Drag the panel by its top strip, in pointer space (dev#460).
@@ -40,6 +40,9 @@ function useDragOffset(enabled: boolean) {
40
40
  };
41
41
 
42
42
  return {
43
+ /** Back to where the panel opens. A `Modal` with an `open` prop outlives its own
44
+ * closes, so "the position resets when the dialog closes" needs saying. */
45
+ reset: () => setOffset(null),
43
46
  style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,
44
47
  handlers: !enabled
45
48
  ? undefined
@@ -120,6 +123,28 @@ export function useBackdropClose(onClose: () => void) {
120
123
  export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
121
124
  /** Invoked on backdrop click and on Escape. */
122
125
  onClose: () => void;
126
+ /**
127
+ * Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.
128
+ *
129
+ * Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,
130
+ * which is every caller before this prop and stays exactly as it was. That idiom has
131
+ * one blind spot: a close the CALLER decides on (a save that succeeded, a
132
+ * `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,
133
+ * because there is nothing left to animate. kastlan's `FormModal` was nothing but an
134
+ * `if (!open) return null` gate in front of this.
135
+ *
136
+ * Passed, the component stays mounted while closed and renders nothing, and a flip
137
+ * to `false` plays the same exit a dismissal does before the panel goes. The focus
138
+ * trap, the scroll lock and the Back-gesture history entry are held only while
139
+ * `open` is true — they are released the moment it turns false, so focus is back on
140
+ * the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,
141
+ * Back, the frame's X) animates first and then calls `onClose`; the `false` that
142
+ * comes back from it is not animated a second time.
143
+ *
144
+ * The body is unmounted once the exit has finished, as it is today, so a form inside
145
+ * still starts fresh on the next open.
146
+ */
147
+ open?: boolean;
123
148
  /** `"alertdialog"` for a dialog that interrupts to ask a question the user must
124
149
  * answer (a confirmation) — screen readers announce it with more urgency. Only the
125
150
  * two dialog roles are allowed: the panel stays modal either way. */
@@ -183,6 +208,7 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
183
208
  */
184
209
  export function Modal({
185
210
  onClose,
211
+ open,
186
212
  children,
187
213
  size = "md",
188
214
  className,
@@ -196,37 +222,68 @@ export function Modal({
196
222
  }: ModalProps) {
197
223
  const panelRef = useRef<HTMLDivElement>(null);
198
224
  const drag = useDragOffset(Boolean(draggable));
225
+ // Without `open`, mounted means open — the constant `true` every hook below used to
226
+ // be given. With it, `open` itself.
227
+ const active = open ?? true;
228
+
229
+ // Whether the last close came through `requestClose`, i.e. has ALREADY been
230
+ // animated. Set in the same batch as the caller's `onClose`, so the render that sees
231
+ // `open` turn false also sees this and does not lower the panel a second time.
232
+ const [dismissed, setDismissed] = useState(false);
233
+ // The exit of a close the caller decided on (only reachable with `open`). Adjusted
234
+ // during render when `open` flips — `Collapse`'s pattern — so the very render that
235
+ // closes it already paints the `-out` classes rather than a frame of nothing.
236
+ const [exiting, setExiting] = useState(false);
237
+ const [prevOpen, setPrevOpen] = useState(open);
238
+ if (open !== prevOpen) {
239
+ setPrevOpen(open);
240
+ setDismissed(false);
241
+ setExiting(open === false && prevOpen === true && !dismissed && !prefersReducedMotion());
242
+ if (open) drag.reset();
243
+ }
244
+ useEffect(() => {
245
+ if (!exiting) return;
246
+ const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);
247
+ return () => clearTimeout(timer);
248
+ }, [exiting]);
249
+
199
250
  // The panel lowers itself before the caller unmounts it (live #320 rework). Every
200
251
  // dismissal below goes through `requestClose`; `onClose` still does the closing, one
201
252
  // animation later. A caller that closes the dialog ITSELF — after a save, say —
202
- // unmounts with no exit, which is the documented limit of this.
203
- const { closing, requestClose } = useCloseTransition(onClose);
204
- const backdropClose = useBackdropClose(requestClose);
253
+ // unmounts with no exit unless it passes `open`, which is what that prop is for.
254
+ const { closing, requestClose } = useCloseTransition(() => {
255
+ setDismissed(true);
256
+ onClose();
257
+ });
258
+ // A panel on its way out after `open` turned false is already closed: a press on
259
+ // its fading backdrop must not report a second close.
260
+ const backdropClose = useBackdropClose(active ? requestClose : () => {});
205
261
  // Back means the same as Escape here. On a phone Escape doesn't exist, so without
206
262
  // this the only way out of a dialog is finding its close button — and Back, the
207
263
  // gesture everyone reaches for, navigated the page underneath instead (#172).
208
- // Mounted only while open, hence the constant `true`.
209
- useOverlayHistory(true, requestClose);
264
+ // Held only while open: a closed `open={false}` dialog must not own a history entry.
265
+ useOverlayHistory(active, requestClose);
210
266
 
211
- // Mounted only while open, hence the constant `true`. Through the shared hook and
212
- // not by hand: this component's own save/restore copy was one half of the pair that
213
- // left the page permanently unscrollable when a dialog containing an open sheet was
214
- // closed — see the note in use-body-scroll-lock.ts.
215
- useBodyScrollLock(true);
267
+ // Through the shared hook and not by hand: this component's own save/restore copy
268
+ // was one half of the pair that left the page permanently unscrollable when a dialog
269
+ // containing an open sheet was closed — see the note in use-body-scroll-lock.ts.
270
+ useBodyScrollLock(active);
216
271
 
217
272
  // Focus the panel itself (not the first field) so opening doesn't pop the mobile
218
273
  // keyboard, while still moving focus into the dialog for keyboard and screen-reader
219
274
  // users. Through the shared hook since this component is where it came from — the
220
275
  // hook adds per-keystroke recomputation, a guard on restoring to a detached node,
221
- // and nesting, none of which this copy had.
222
- useFocusTrap(panelRef, { active: true, initialFocus: "container" });
276
+ // and nesting, none of which this copy had. Released when `open` turns false, which
277
+ // hands focus back to the trigger while the panel is still on its way out.
278
+ useFocusTrap(panelRef, { active, initialFocus: "container" });
279
+ const leaving = closing || exiting;
223
280
 
224
281
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
225
282
  onKeyDown?.(e);
226
283
  if (e.defaultPrevented) return;
227
284
  if (e.key === "Escape") {
228
285
  e.stopPropagation();
229
- requestClose();
286
+ if (active) requestClose();
230
287
  return;
231
288
  }
232
289
  // Tab containment lives in useFocusTrap, which listens on the panel itself.
@@ -237,13 +294,19 @@ export function Modal({
237
294
  // bar (a z-30 context) could paint BELOW a sibling z-30 sticky element like the
238
295
  // expanded transaction editor (feedback #320). The tour/command-palette overlays
239
296
  // already portal for the same reason.
297
+ if (!active && !exiting) return null;
240
298
  return createPortal(
241
299
  <div
300
+ // Out of the accessibility tree for the length of a caller-driven exit: focus is
301
+ // already back on the page, and an `aria-modal` panel that is leaving would hide
302
+ // that page from a screen reader for 220ms. Not `inert` — that would let a tap
303
+ // fall through the fading backdrop to the row underneath.
304
+ aria-hidden={exiting || undefined}
242
305
  className={cn(
243
306
  // Live #320. The panel below rises only where it is bottom-anchored: from md up
244
307
  // it is centred, and a centred box sliding up from off-screen reads as a
245
308
  // different component arriving rather than as the same one settling.
246
- closing ? "animate-overlay-out" : "animate-overlay",
309
+ leaving ? "animate-overlay-out" : "animate-overlay",
247
310
  "fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center",
248
311
  fullBleed ? "p-0 md:p-4" : "p-4",
249
312
  )}
@@ -270,7 +333,7 @@ export function Modal({
270
333
  // the ones where the panel is being dragged.
271
334
  style={{ ...style, ...drag.style }}
272
335
  className={cn(
273
- closing ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
336
+ leaving ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
274
337
  "w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none",
275
338
  { md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
276
339
  // A panel taller than the screen has to scroll ITSELF. The backdrop is