@eifi1/ui-kit 0.8.1 → 0.10.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 (204) hide show
  1. package/README.md +25 -11
  2. package/dist/components/alert-banner.d.ts +32 -5
  3. package/dist/components/alert-banner.js +57 -14
  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 +66 -0
  10. package/dist/components/bulk-action-bar.js +149 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/calculator.d.ts +6 -0
  13. package/dist/components/chip.d.ts +58 -10
  14. package/dist/components/chip.js +91 -7
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/copy-button.d.ts +13 -4
  17. package/dist/components/copy-button.js +17 -4
  18. package/dist/components/copy-button.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +122 -91
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/date-picker.d.ts +89 -3
  25. package/dist/components/date-picker.js +163 -68
  26. package/dist/components/date-picker.js.map +1 -1
  27. package/dist/components/description-list.d.ts +43 -6
  28. package/dist/components/description-list.js +91 -11
  29. package/dist/components/description-list.js.map +1 -1
  30. package/dist/components/dialog-frame.d.ts +7 -0
  31. package/dist/components/dialog-frame.js.map +1 -1
  32. package/dist/components/disclosure.d.ts +68 -4
  33. package/dist/components/disclosure.js +33 -15
  34. package/dist/components/disclosure.js.map +1 -1
  35. package/dist/components/file-dropzone.d.ts +3 -4
  36. package/dist/components/file-dropzone.js +2 -1
  37. package/dist/components/file-dropzone.js.map +1 -1
  38. package/dist/components/floating-panel.d.ts +29 -2
  39. package/dist/components/floating-panel.js +16 -2
  40. package/dist/components/floating-panel.js.map +1 -1
  41. package/dist/components/full-bleed-dialog.d.ts +23 -4
  42. package/dist/components/full-bleed-dialog.js +11 -2
  43. package/dist/components/full-bleed-dialog.js.map +1 -1
  44. package/dist/components/list.d.ts +174 -0
  45. package/dist/components/list.js +215 -0
  46. package/dist/components/list.js.map +1 -0
  47. package/dist/components/menu-item.d.ts +99 -0
  48. package/dist/components/menu-item.js +83 -0
  49. package/dist/components/menu-item.js.map +1 -0
  50. package/dist/components/modal.d.ts +23 -1
  51. package/dist/components/modal.js +36 -10
  52. package/dist/components/modal.js.map +1 -1
  53. package/dist/components/number-field.d.ts +6 -0
  54. package/dist/components/number-input.d.ts +6 -0
  55. package/dist/components/numpad-sheet.d.ts +6 -0
  56. package/dist/components/page-contents.js +1 -2
  57. package/dist/components/page-contents.js.map +1 -1
  58. package/dist/components/page-header.d.ts +37 -0
  59. package/dist/components/page-header.js +36 -0
  60. package/dist/components/page-header.js.map +1 -0
  61. package/dist/components/progress-bar.d.ts +16 -2
  62. package/dist/components/progress-bar.js +4 -2
  63. package/dist/components/progress-bar.js.map +1 -1
  64. package/dist/components/series-chart-labels.d.ts +3 -0
  65. package/dist/components/series-chart-labels.js +2 -1
  66. package/dist/components/series-chart-labels.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +16 -0
  68. package/dist/components/series-chart.js +137 -3
  69. package/dist/components/series-chart.js.map +1 -1
  70. package/dist/components/status-dot.d.ts +49 -0
  71. package/dist/components/status-dot.js +52 -0
  72. package/dist/components/status-dot.js.map +1 -0
  73. package/dist/components/table.d.ts +39 -6
  74. package/dist/components/table.js +43 -4
  75. package/dist/components/table.js.map +1 -1
  76. package/dist/components/text.d.ts +63 -0
  77. package/dist/components/text.js +23 -0
  78. package/dist/components/text.js.map +1 -0
  79. package/dist/components/toast.d.ts +167 -0
  80. package/dist/components/toast.js +226 -0
  81. package/dist/components/toast.js.map +1 -0
  82. package/dist/components/toggle-group.d.ts +29 -1
  83. package/dist/components/toggle-group.js +52 -3
  84. package/dist/components/toggle-group.js.map +1 -1
  85. package/dist/components/tooltip.d.ts +38 -15
  86. package/dist/components/tooltip.js +85 -47
  87. package/dist/components/tooltip.js.map +1 -1
  88. package/dist/components/ui.d.ts +139 -16
  89. package/dist/components/ui.js +136 -26
  90. package/dist/components/ui.js.map +1 -1
  91. package/dist/components/use-table-state.d.ts +1 -1
  92. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  93. package/dist/data-table.d.ts +1 -1
  94. package/dist/hooks/use-close-transition.d.ts +7 -1
  95. package/dist/hooks/use-close-transition.js +5 -2
  96. package/dist/hooks/use-close-transition.js.map +1 -1
  97. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  98. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  99. package/dist/hooks/use-overlay-history.js +84 -4
  100. package/dist/hooks/use-overlay-history.js.map +1 -1
  101. package/dist/i18n/defaults.d.ts +6 -0
  102. package/dist/i18n/defaults.js +11 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/kit-labels.d.ts +11 -0
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  107. package/dist/i18n/locales/de-CH.d.ts +6 -0
  108. package/dist/i18n/locales/de-informal.d.ts +6 -0
  109. package/dist/i18n/locales/de.d.ts +6 -0
  110. package/dist/i18n/locales/de.js +31 -1
  111. package/dist/i18n/locales/de.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +6 -0
  113. package/dist/i18n/locales/es.js +31 -1
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +6 -0
  116. package/dist/i18n/locales/fr.js +31 -1
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +6 -0
  119. package/dist/i18n/locales/hu.js +31 -1
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +6 -0
  122. package/dist/i18n/locales/it.js +31 -1
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +6 -0
  125. package/dist/i18n/locales/zh.js +31 -1
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/index.d.ts +18 -8
  128. package/dist/index.js +14 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/lib/clipping.d.ts +9 -0
  131. package/dist/lib/clipping.js +14 -0
  132. package/dist/lib/clipping.js.map +1 -0
  133. package/dist/search/command-palette.d.ts +67 -2
  134. package/dist/search/command-palette.js +147 -33
  135. package/dist/search/command-palette.js.map +1 -1
  136. package/dist/search/global-search.d.ts +166 -0
  137. package/dist/search/global-search.js +293 -0
  138. package/dist/search/global-search.js.map +1 -0
  139. package/dist/search/search-index.d.ts +105 -0
  140. package/dist/search/search-index.js +142 -0
  141. package/dist/search/search-index.js.map +1 -0
  142. package/dist/search.d.ts +3 -1
  143. package/dist/search.js +2 -0
  144. package/dist/search.js.map +1 -1
  145. package/dist/wizard/stepper-nav.d.ts +10 -1
  146. package/dist/wizard/stepper-nav.js +3 -2
  147. package/dist/wizard/stepper-nav.js.map +1 -1
  148. package/dist/wizard/types.d.ts +2 -2
  149. package/dist/wizard/types.js.map +1 -1
  150. package/dist/wizard/use-wizard.js +1 -1
  151. package/dist/wizard/use-wizard.js.map +1 -1
  152. package/dist/wizard/wizard-summary.js +3 -3
  153. package/dist/wizard/wizard-summary.js.map +1 -1
  154. package/package.json +25 -12
  155. package/src/components/alert-banner.tsx +117 -18
  156. package/src/components/breadcrumbs.tsx +168 -0
  157. package/src/components/bulk-action-bar.tsx +256 -0
  158. package/src/components/chip.tsx +164 -13
  159. package/src/components/copy-button.tsx +29 -7
  160. package/src/components/data-table.tsx +81 -5
  161. package/src/components/date-picker.tsx +377 -118
  162. package/src/components/description-list.tsx +168 -15
  163. package/src/components/dialog-frame.tsx +7 -0
  164. package/src/components/disclosure.tsx +116 -28
  165. package/src/components/file-dropzone.tsx +9 -10
  166. package/src/components/floating-panel.tsx +46 -2
  167. package/src/components/full-bleed-dialog.tsx +42 -5
  168. package/src/components/list.tsx +417 -0
  169. package/src/components/menu-item.tsx +214 -0
  170. package/src/components/modal.tsx +80 -17
  171. package/src/components/page-contents.tsx +4 -4
  172. package/src/components/page-header.tsx +68 -0
  173. package/src/components/progress-bar.tsx +27 -3
  174. package/src/components/series-chart-labels.ts +4 -0
  175. package/src/components/series-chart.tsx +253 -3
  176. package/src/components/status-dot.tsx +99 -0
  177. package/src/components/table.tsx +91 -6
  178. package/src/components/text.tsx +89 -0
  179. package/src/components/toast.tsx +441 -0
  180. package/src/components/toggle-group.tsx +82 -3
  181. package/src/components/tooltip.tsx +186 -100
  182. package/src/components/ui.tsx +321 -41
  183. package/src/hooks/use-close-transition.ts +14 -5
  184. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  185. package/src/hooks/use-overlay-history.ts +181 -4
  186. package/src/i18n/defaults.ts +10 -0
  187. package/src/i18n/kit-labels.tsx +10 -0
  188. package/src/i18n/locales/de.ts +30 -0
  189. package/src/i18n/locales/es.ts +30 -0
  190. package/src/i18n/locales/fr.ts +30 -0
  191. package/src/i18n/locales/hu.ts +30 -0
  192. package/src/i18n/locales/it.ts +30 -0
  193. package/src/i18n/locales/zh.ts +30 -0
  194. package/src/index.ts +30 -0
  195. package/src/lib/clipping.ts +16 -0
  196. package/src/search/command-palette.tsx +241 -31
  197. package/src/search/global-search.tsx +525 -0
  198. package/src/search/search-index.ts +295 -0
  199. package/src/search.ts +2 -0
  200. package/src/wizard/stepper-nav.tsx +13 -2
  201. package/src/wizard/types.ts +2 -2
  202. package/src/wizard/use-wizard.ts +3 -3
  203. package/src/wizard/wizard-summary.tsx +14 -10
  204. package/tokens.css +97 -0
@@ -6,13 +6,16 @@ import { dirOf, type Direction } from "../lib/direction";
6
6
  import { addDaysIso, parseIsoDate } from "../lib/dates";
7
7
  import {
8
8
  DEFAULT_DATE_PICKER_LABELS,
9
+ DEFAULT_PICKER_SHEET_LABELS,
9
10
  useKitLabels,
10
11
  useKitLocale,
11
12
  type DatePickerLabels,
12
13
  } from "../i18n/kit-labels";
13
14
  import { splitTriggerAria } from "./trigger-aria";
14
15
  import type { TriggerAria } from "./trigger-aria";
15
- import { Button, FieldLabel, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
16
+ import { Button, FieldLabel, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
17
+ import { FullBleedDialog } from "./full-bleed-dialog";
18
+ import { useMediaQuery } from "../hooks/use-media-query";
16
19
  import { MiniCalendar, type MiniCalendarProps } from "./mini-calendar";
17
20
  import { Popover } from "./popover";
18
21
  import { Tooltip } from "./tooltip";
@@ -66,6 +69,51 @@ function formatDate(iso: string, locale: string | undefined, options?: Intl.Date
66
69
  return parseIsoDate(iso)?.toLocaleDateString(locale, options) ?? "";
67
70
  }
68
71
 
72
+ /**
73
+ * Everything the kit's own trigger button carries, handed to a custom one
74
+ * ({@link DateRangePickerProps.renderTrigger}) to spread onto its `<button>`. Spread
75
+ * it WHOLE: the ref is how focus comes back when the panel closes, the id pair and
76
+ * `aria-labelledby` are the 0.5.1 naming (an external `<label htmlFor>`, a form
77
+ * library's `aria-describedby`), and `role`/`aria-haspopup`/`aria-controls`/
78
+ * `aria-expanded` are what tell a screen reader this opens a calendar and whether it
79
+ * is open. `className` is the kit's field look (42px with a label, `pe-9` for the
80
+ * glyph the field draws at its end) — merge yours after it with `cn`.
81
+ */
82
+ export interface DateTriggerAttributes {
83
+ ref: RefObject<HTMLButtonElement | null>;
84
+ type: "button";
85
+ id?: string;
86
+ role: "combobox";
87
+ "aria-haspopup": "dialog";
88
+ "aria-controls": string;
89
+ "aria-expanded": boolean;
90
+ "aria-labelledby"?: string;
91
+ "aria-label"?: string;
92
+ "aria-describedby"?: string;
93
+ "aria-invalid"?: true;
94
+ disabled?: boolean;
95
+ onClick: () => void;
96
+ className: string;
97
+ }
98
+
99
+ /** A custom trigger, as `DateField` calls it; each picker adapts it to its own props. */
100
+ type TriggerRenderer = (attrs: DateTriggerAttributes, valueId: string) => ReactNode;
101
+
102
+ /** A caller's trigger, as an element of its own: the attributes carry the trigger's
103
+ * ref, and a ref is handed to a child as a prop, not passed to a function in the
104
+ * middle of the parent's render. */
105
+ function CustomTrigger({
106
+ render,
107
+ attrs,
108
+ valueId,
109
+ }: {
110
+ render: TriggerRenderer;
111
+ attrs: DateTriggerAttributes;
112
+ valueId: string;
113
+ }) {
114
+ return render(attrs, valueId);
115
+ }
116
+
69
117
  /**
70
118
  * The field-shaped button that opens the calendar.
71
119
  *
@@ -86,9 +134,12 @@ function DateFieldTrigger({
86
134
  invalid,
87
135
  panelId,
88
136
  aria,
137
+ renderTrigger,
89
138
  }: {
90
139
  /** The caller's naming/description attributes, routed here from the field. */
91
140
  aria: TriggerAria;
141
+ /** Draw the button yourself, with these attributes — see {@link DateTriggerAttributes}. */
142
+ renderTrigger?: TriggerRenderer;
92
143
  open: boolean;
93
144
  toggle: () => void;
94
145
  triggerRef: RefObject<HTMLButtonElement | null>;
@@ -123,47 +174,55 @@ function DateFieldTrigger({
123
174
  // is this trigger, every time, however the panel was opened.
124
175
  }, [open, triggerRef]);
125
176
 
177
+ // One attribute object, worn either by the kit's own button below or by a caller's
178
+ // (`renderTrigger`) — so a custom trigger cannot quietly fall out of step with the
179
+ // naming and state this one announces.
180
+ const attrs: DateTriggerAttributes = {
181
+ ref: triggerRef,
182
+ type: "button",
183
+ disabled,
184
+ onClick: toggle,
185
+ // The name has to carry the VALUE. A bare `aria-label={label}` — which is what
186
+ // this was — replaces the button's text outright, so the one field in the form
187
+ // whose entire job is to show a date announced "Due date" and stopped there,
188
+ // and a screen-reader user could not read back what they had picked without
189
+ // opening the calendar and hunting for the selected day.
190
+ //
191
+ // Two ids rather than one composed string (`common.fieldValue`): "label: value"
192
+ // is a sentence, and a name reference is spoken as the two texts in order with
193
+ // no punctuation to translate at all. The hidden twin of the FieldLabel lives
194
+ // in DateField, next to the label it copies.
195
+ id: aria.id,
196
+ "aria-labelledby": aria["aria-label"] && !aria["aria-labelledby"] ? undefined : labelledBy,
197
+ "aria-label": aria["aria-label"],
198
+ "aria-describedby": aria["aria-describedby"],
199
+ // `role="combobox"` on a button that opens a calendar is the APG date-picker
200
+ // shape, and it is what makes the next two lines legal: `button` supports
201
+ // neither `aria-expanded` nor `aria-invalid`, so the previous markup set an
202
+ // invalid state that announced nothing (ESLint's `role-supports-aria-props`).
203
+ //
204
+ // `aria-haspopup="dialog"` is now honest — it was not when this comment first
205
+ // said so. `Popover` gained a real `role="dialog"` in the same wave, so the
206
+ // trigger's promise and the panel's role finally agree. The phone sheet is a
207
+ // `role="dialog"` too, and carries the same id.
208
+ role: "combobox",
209
+ "aria-haspopup": "dialog",
210
+ "aria-controls": panelId,
211
+ "aria-expanded": open,
212
+ "aria-invalid":
213
+ invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined,
214
+ className: cn(
215
+ FIELD_TRIGGER,
216
+ "pe-9",
217
+ padded && FIELD_FLOATING_PAD,
218
+ disabled && "cursor-not-allowed opacity-50",
219
+ invalid && FIELD_INVALID,
220
+ ),
221
+ };
222
+ if (renderTrigger) return <CustomTrigger render={renderTrigger} attrs={attrs} valueId={valueId} />;
223
+
126
224
  return (
127
- <button
128
- ref={triggerRef}
129
- type="button"
130
- disabled={disabled}
131
- onClick={toggle}
132
- // The name has to carry the VALUE. A bare `aria-label={label}` — which is what
133
- // this was — replaces the button's text outright, so the one field in the form
134
- // whose entire job is to show a date announced "Due date" and stopped there,
135
- // and a screen-reader user could not read back what they had picked without
136
- // opening the calendar and hunting for the selected day.
137
- //
138
- // Two ids rather than one composed string (`common.fieldValue`): "label: value"
139
- // is a sentence, and a name reference is spoken as the two texts in order with
140
- // no punctuation to translate at all. The hidden twin of the FieldLabel lives
141
- // in DateField, next to the label it copies.
142
- id={aria.id}
143
- aria-labelledby={aria["aria-label"] && !aria["aria-labelledby"] ? undefined : labelledBy}
144
- aria-label={aria["aria-label"]}
145
- aria-describedby={aria["aria-describedby"]}
146
- // `role="combobox"` on a button that opens a calendar is the APG date-picker
147
- // shape, and it is what makes the next two lines legal: `button` supports
148
- // neither `aria-expanded` nor `aria-invalid`, so the previous markup set an
149
- // invalid state that announced nothing (ESLint's `role-supports-aria-props`).
150
- //
151
- // `aria-haspopup="dialog"` is now honest — it was not when this comment first
152
- // said so. `Popover` gained a real `role="dialog"` in the same wave, so the
153
- // trigger's promise and the panel's role finally agree.
154
- role="combobox"
155
- aria-haspopup="dialog"
156
- aria-controls={panelId}
157
- aria-expanded={open}
158
- aria-invalid={invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined}
159
- className={cn(
160
- FIELD_TRIGGER,
161
- "pe-9",
162
- padded && FIELD_FLOATING_PAD,
163
- disabled && "cursor-not-allowed opacity-50",
164
- invalid && FIELD_INVALID,
165
- )}
166
- >
225
+ <button {...attrs}>
167
226
  <span id={valueId} className={cn("truncate", !hasValue && "text-[var(--text-placeholder)]")}>
168
227
  {/* `|| " "` (a NON-BREAKING space) — triggerText is "" when there is no value and no
169
228
  placeholder was passed. An empty span has no line box, so the
@@ -184,6 +243,24 @@ function DateFieldTrigger({
184
243
  );
185
244
  }
186
245
 
246
+ /**
247
+ * How a panel's body and its action row are put on screen — the popover stacks them,
248
+ * the phone sheet pins the actions in its footer. Handed to the panel rather than
249
+ * applied around it because the actions belong to the panel's own draft state
250
+ * (`RangePanel`), while the frame they sit in belongs to the field.
251
+ */
252
+ type PanelFrame = (body: ReactNode, actions?: ReactNode) => ReactNode;
253
+
254
+ const popoverFrame: PanelFrame = (body, actions) =>
255
+ actions ? (
256
+ <div className="flex flex-col gap-2">
257
+ {body}
258
+ <div className="flex items-center justify-end gap-2 border-t border-[var(--border)] pt-2">{actions}</div>
259
+ </div>
260
+ ) : (
261
+ body
262
+ );
263
+
187
264
  /** A field-styled trigger that opens a portalled MiniCalendar. Shared by the
188
265
  * single- and range-date pickers below. */
189
266
  function DateField({
@@ -198,6 +275,9 @@ function DateField({
198
275
  width,
199
276
  onClear,
200
277
  invalid,
278
+ sheet = false,
279
+ sheetBackCloses = true,
280
+ renderTrigger,
201
281
  children,
202
282
  ...rest
203
283
  }: Omit<ComponentPropsWithoutRef<"div">, "children"> & {
@@ -215,7 +295,13 @@ function DateField({
215
295
  /** Popover panel width; omit for the default (a bare calendar). */
216
296
  width?: number;
217
297
  onClear: () => void;
218
- children: (close: () => void) => ReactNode;
298
+ /** Open as a full-screen sheet instead of the popover — the picker decides when
299
+ * (below {@link PHONE_QUERY}). */
300
+ sheet?: boolean;
301
+ /** The sheet's `FullBleedDialog backCloses`. */
302
+ sheetBackCloses?: boolean;
303
+ renderTrigger?: TriggerRenderer;
304
+ children: (close: () => void, frame: PanelFrame) => ReactNode;
219
305
  }) {
220
306
  const showClear = Boolean(clearable && hasValue && !disabled);
221
307
  const [aria, wrapperRest] = splitTriggerAria(rest);
@@ -233,6 +319,83 @@ function DateField({
233
319
  // its own interactive "?" hint in it, and naming a button with that reads the hint
234
320
  // out as part of the field's name.
235
321
  const named = typeof label === "string";
322
+ // The sheet's open flag. Only the phone branch owns one: on desktop `Popover` owns
323
+ // it, so the two can never disagree about what is on screen.
324
+ const [sheetOpen, setSheetOpen] = useState(false);
325
+ const sheetTriggerRef = useRef<HTMLButtonElement>(null);
326
+ const closeSheet = () => setSheetOpen(false);
327
+ const sheetLabels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS);
328
+ const sheetFrame: PanelFrame = (body, actions) => (
329
+ <FullBleedDialog
330
+ open
331
+ onClose={closeSheet}
332
+ // The same id the popover panel wears, so the trigger's `aria-controls` holds in
333
+ // both presentations; named the way the popover is.
334
+ id={panelId}
335
+ aria-label={panelLabel}
336
+ header={panelLabel}
337
+ // `pickerSheet.close`: the one "close this full-screen picker" string the kit
338
+ // already translates, rather than a second key saying the same thing.
339
+ closeLabel={sheetLabels.close}
340
+ backCloses={sheetBackCloses}
341
+ dir={dir}
342
+ // Portalled to <body>, the sheet is not inside the field's wrapper, so a
343
+ // document-level "outside click" listener — a Popover or dropdown this field
344
+ // sits in — would read a tap on a day as outside and unmount the cell before
345
+ // its click (Keksdose dev#477, the fix `PickerSheet` carries).
346
+ onMouseDown={(e) => e.stopPropagation()}
347
+ footer={actions}
348
+ >
349
+ {body}
350
+ </FullBleedDialog>
351
+ );
352
+ const fieldTrigger = ({
353
+ open,
354
+ toggle,
355
+ ref,
356
+ }: {
357
+ open: boolean;
358
+ toggle: () => void;
359
+ ref: RefObject<HTMLButtonElement | null>;
360
+ }) => (
361
+ <DateFieldTrigger
362
+ open={open}
363
+ toggle={() => {
364
+ setDir(dirOf(rootRef.current));
365
+ toggle();
366
+ }}
367
+ triggerRef={ref}
368
+ triggerText={triggerText}
369
+ hasValue={hasValue}
370
+ // ALWAYS a name reference, never `undefined`. A `<button>` takes its
371
+ // accessible name from its contents, so an unlabelled trigger used to
372
+ // announce its own date text for free. `role="combobox"` does not —
373
+ // content is excluded from name computation for that role — so leaving
374
+ // this undefined made an unlabelled date field announce nothing at all.
375
+ // Named: "label, value". Unnamed: the value alone, which is what the
376
+ // button was saying before.
377
+ //
378
+ // A caller's own reference comes first. And when the caller gives the field
379
+ // an `id` (so an external `<label htmlFor>` can point at it), the trigger
380
+ // lists ITSELF first: a self-reference in `aria-labelledby` is resolved
381
+ // from the element's native label (accname 2B → 2D), so "Due date" from
382
+ // that <label> is spoken before the value, instead of being overridden.
383
+ labelledBy={[
384
+ aria["aria-labelledby"],
385
+ named ? labelId : !aria["aria-labelledby"] && aria.id ? aria.id : undefined,
386
+ valueId,
387
+ ]
388
+ .filter(Boolean)
389
+ .join(" ")}
390
+ aria={aria}
391
+ panelId={panelId}
392
+ valueId={valueId}
393
+ padded={label !== undefined}
394
+ disabled={disabled}
395
+ invalid={invalid}
396
+ renderTrigger={renderTrigger}
397
+ />
398
+ );
236
399
  return (
237
400
  // The caller's attributes land here, on the field's own box — the trigger inside is
238
401
  // named by `aria-labelledby` and must keep the id pair it is given.
@@ -250,54 +413,26 @@ function DateField({
250
413
  {label}
251
414
  </span>
252
415
  )}
253
- <Popover
254
- width={width}
255
- panelId={panelId}
256
- // Named for what it is. Unnamed, it fell back to `popover.panel`, and a date
257
- // field announced its calendar as "Popover" — in English, in every language.
258
- labels={{ panel: panelLabel }}
259
- dir={dir}
260
- trigger={({ open, toggle, ref }) => (
261
- <DateFieldTrigger
262
- open={open}
263
- toggle={() => {
264
- setDir(dirOf(rootRef.current));
265
- toggle();
266
- }}
267
- triggerRef={ref}
268
- triggerText={triggerText}
269
- hasValue={hasValue}
270
- // ALWAYS a name reference, never `undefined`. A `<button>` takes its
271
- // accessible name from its contents, so an unlabelled trigger used to
272
- // announce its own date text for free. `role="combobox"` does not —
273
- // content is excluded from name computation for that role — so leaving
274
- // this undefined made an unlabelled date field announce nothing at all.
275
- // Named: "label, value". Unnamed: the value alone, which is what the
276
- // button was saying before.
277
- //
278
- // A caller's own reference comes first. And when the caller gives the field
279
- // an `id` (so an external `<label htmlFor>` can point at it), the trigger
280
- // lists ITSELF first: a self-reference in `aria-labelledby` is resolved
281
- // from the element's native label (accname 2B → 2D), so "Due date" from
282
- // that <label> is spoken before the value, instead of being overridden.
283
- labelledBy={[
284
- aria["aria-labelledby"],
285
- named ? labelId : !aria["aria-labelledby"] && aria.id ? aria.id : undefined,
286
- valueId,
287
- ]
288
- .filter(Boolean)
289
- .join(" ")}
290
- aria={aria}
291
- panelId={panelId}
292
- valueId={valueId}
293
- padded={label !== undefined}
294
- disabled={disabled}
295
- invalid={invalid}
296
- />
297
- )}
298
- >
299
- {(close) => children(close)}
300
- </Popover>
416
+ {sheet ? (
417
+ <>
418
+ {fieldTrigger({ open: sheetOpen, toggle: () => setSheetOpen((v) => !v), ref: sheetTriggerRef })}
419
+ {/* Mounted only while open, like the popover's children: the panel's draft is
420
+ born with it, so every open starts again from the committed value. */}
421
+ {sheetOpen && children(closeSheet, sheetFrame)}
422
+ </>
423
+ ) : (
424
+ <Popover
425
+ width={width}
426
+ panelId={panelId}
427
+ // Named for what it is. Unnamed, it fell back to `popover.panel`, and a date
428
+ // field announced its calendar as "Popover" — in English, in every language.
429
+ labels={{ panel: panelLabel }}
430
+ dir={dir}
431
+ trigger={fieldTrigger}
432
+ >
433
+ {(close) => children(close, popoverFrame)}
434
+ </Popover>
435
+ )}
301
436
  {showClear ? (
302
437
  <button
303
438
  type="button"
@@ -612,6 +747,66 @@ export interface DateRangePickerProps extends DatePickerBaseProps {
612
747
  preset?: string | null;
613
748
  /** See {@link DateRangeCommit}. Default `"immediate"`. */
614
749
  commit?: DateRangeCommit;
750
+ /**
751
+ * Draw the trigger yourself. keksdose's report range field
752
+ * (reports/report-range-field.tsx, "Why this is app-level") is why: its trigger
753
+ * NAMES the active preset ("Last 3 months", the window as a muted suffix) where the
754
+ * kit's shows two dates, sits inside a Tooltip, and is squared off on one side so the
755
+ * granularity control can join it flush at the field's 42px.
756
+ *
757
+ * Spread `props.triggerProps` onto a `<button>` whole and put `props.valueProps` on
758
+ * the element that shows the value — see {@link DateTriggerAttributes} for what each
759
+ * part is for. The field around it (label, hidden name twin, clear/calendar glyph,
760
+ * popover or phone sheet) is unchanged.
761
+ */
762
+ renderTrigger?: (props: DateRangeTriggerRenderProps) => ReactNode;
763
+ /**
764
+ * Below {@link PHONE_QUERY} the panel opens as a full-screen sheet (`FullBleedDialog`)
765
+ * with presets in a grid above the calendar and Apply pinned in the footer, instead
766
+ * of the 440px popover that hung off a 360px screen (keksdose's report range field,
767
+ * which hand-rolled exactly this). This is its `backCloses`: on by default, so Back
768
+ * dismisses the sheet; off for a caller whose commit rewrites the URL with
769
+ * `replaceState` in the same tick — keksdose's `?preset/from/to` — where the router
770
+ * would overwrite the sheet's history marker.
771
+ */
772
+ sheetBackCloses?: boolean;
773
+ /**
774
+ * With `commit="apply"`: a line above the calendar saying what Apply would commit —
775
+ * keksdose's report range writes "Custom · 1 Mar 2026 – …" there
776
+ * (report-range-field.tsx), the one place a preset armed and then nudged by a day is
777
+ * seen to have become "Custom", and a half-made range to still lack its end. Called
778
+ * with the DRAFT on every change, in the popover and the phone sheet alike; `preset`
779
+ * is the preset the column marks for it (none once a day was picked by hand), `to` is
780
+ * `""` until the second click. The line is a polite live region, so a screen reader
781
+ * hears the draft change as the calendar is used.
782
+ *
783
+ * Ignored in `"immediate"` mode, where there is no draft: every click is the value.
784
+ */
785
+ renderDraftSummary?: (draft: DateRangeDraftSummary) => ReactNode;
786
+ }
787
+
788
+ /** What {@link DateRangePickerProps.renderDraftSummary} is called with. */
789
+ export interface DateRangeDraftSummary {
790
+ from: string;
791
+ to: string;
792
+ /** The preset the column marks for the draft, if any. */
793
+ preset: DateRangePickerPreset | undefined;
794
+ }
795
+
796
+ /** What {@link DateRangePickerProps.renderTrigger} is called with. */
797
+ export interface DateRangeTriggerRenderProps {
798
+ /** Spread onto the `<button>`, whole. Merge your class after `triggerProps.className`. */
799
+ triggerProps: DateTriggerAttributes;
800
+ /** Put on the element showing the value: the trigger is named "label, value" by
801
+ * reference to this id. Without it the name is the label alone. */
802
+ valueProps: { id: string };
803
+ open: boolean;
804
+ from: string;
805
+ to: string;
806
+ /** The preset the column marks for the committed range, if any — the one to name. */
807
+ preset: DateRangePickerPreset | undefined;
808
+ /** What the kit's own trigger would show: the formatted range, or the placeholder. */
809
+ text: string;
615
810
  }
616
811
 
617
812
  interface RangeDraft {
@@ -648,11 +843,15 @@ function PresetColumn({
648
843
  marked,
649
844
  label,
650
845
  onPick,
846
+ sheet,
651
847
  }: {
652
848
  presets: readonly DateRangePickerPreset[];
653
849
  marked: number;
654
850
  label: string;
655
851
  onPick: (p: DateRangePickerPreset) => void;
852
+ /** The phone sheet's shape: a two-column grid ABOVE the calendar with 44px rows —
853
+ * a 128px column beside a month grid leaves the grid ~200px on a 360px screen. */
854
+ sheet?: boolean;
656
855
  }) {
657
856
  return (
658
857
  // `border-e`/`pe`, not `-r`: the preset column is on the START side, and the rule
@@ -660,7 +859,11 @@ function PresetColumn({
660
859
  <div
661
860
  role="group"
662
861
  aria-label={label}
663
- className="flex w-32 shrink-0 flex-col gap-0.5 border-e border-[var(--border)] pe-2"
862
+ className={
863
+ sheet
864
+ ? "grid grid-cols-2 gap-0.5"
865
+ : "flex w-32 shrink-0 flex-col gap-0.5 border-e border-[var(--border)] pe-2"
866
+ }
664
867
  >
665
868
  {presets.map((p, i) => {
666
869
  const selected = i === marked;
@@ -672,7 +875,9 @@ function PresetColumn({
672
875
  data-preset={p.id}
673
876
  onClick={() => onPick(p)}
674
877
  className={cn(
675
- "rounded px-2 py-1.5 text-start text-xs",
878
+ "rounded px-2 text-start",
879
+ // 44px on a phone: the kit's touch-target size (`SHEET_ROW_CLASS`).
880
+ sheet ? "min-h-11 py-2 text-sm" : "py-1.5 text-xs",
676
881
  selected
677
882
  ? "bg-[var(--bg-active)] font-medium text-[var(--text-primary)]"
678
883
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
@@ -700,9 +905,12 @@ function RangePanel({
700
905
  controlled,
701
906
  commit,
702
907
  close,
908
+ frame,
909
+ sheet,
703
910
  commitRange,
704
911
  calendarProps,
705
912
  labels,
913
+ renderDraftSummary,
706
914
  }: {
707
915
  from: string;
708
916
  to: string;
@@ -711,9 +919,13 @@ function RangePanel({
711
919
  controlled: boolean;
712
920
  commit: DateRangeCommit;
713
921
  close: () => void;
922
+ /** Where the body and the Apply row go — see `PanelFrame`. */
923
+ frame: PanelFrame;
924
+ sheet: boolean;
714
925
  commitRange: (from: string, to: string, presetId: string | undefined) => void;
715
926
  calendarProps: Pick<MiniCalendarProps, "locale" | "min" | "max" | "labels">;
716
927
  labels: DatePickerLabels;
928
+ renderDraftSummary?: DateRangePickerProps["renderDraftSummary"];
717
929
  }) {
718
930
  const drafting = commit === "apply";
719
931
  const [draft, setDraft] = useState<RangeDraft>({
@@ -728,7 +940,12 @@ function RangePanel({
728
940
  : -1;
729
941
  const complete = Boolean(draft.from && draft.to);
730
942
 
731
- const calendar = (
943
+ const summary =
944
+ drafting && renderDraftSummary
945
+ ? renderDraftSummary({ from: draft.from, to: draft.to, preset: presets?.[marked] })
946
+ : null;
947
+
948
+ const picker = (
732
949
  <MiniCalendar
733
950
  {...calendarProps}
734
951
  focusOnOpen
@@ -745,11 +962,30 @@ function RangePanel({
745
962
  }}
746
963
  />
747
964
  );
965
+ // The summary heads the CALENDAR's column, not the panel: beside a preset column it
966
+ // reads as the calendar's caption, and in the sheet it sits between the presets and
967
+ // the month it describes. `null` renders no line — a caller may opt out per draft —
968
+ // but keeps the wrapper, so the calendar is not remounted (and its month and focus
969
+ // lost) when a line comes or goes.
970
+ const calendar =
971
+ drafting && renderDraftSummary ? (
972
+ <div className="flex flex-col gap-2">
973
+ {summary != null && (
974
+ <div aria-live="polite" data-draft-summary="" className="text-xs text-[var(--text-muted)]">
975
+ {summary}
976
+ </div>
977
+ )}
978
+ {picker}
979
+ </div>
980
+ ) : (
981
+ picker
982
+ );
748
983
 
749
984
  const body =
750
985
  presets && presets.length > 0 ? (
751
- <div className="flex gap-3">
986
+ <div className={sheet ? "flex flex-col gap-3" : "flex gap-3"}>
752
987
  <PresetColumn
988
+ sheet={sheet}
753
989
  presets={presets}
754
990
  marked={marked}
755
991
  label={labels.presets}
@@ -770,29 +1006,30 @@ function RangePanel({
770
1006
  calendar
771
1007
  );
772
1008
 
773
- if (!drafting) return body;
774
- return (
775
- <div className="flex flex-col gap-2">
776
- {body}
777
- <div className="flex items-center justify-end gap-2 border-t border-[var(--border)] pt-2">
778
- <Button type="button" variant="ghost" onClick={close}>
779
- {labels.cancel}
780
- </Button>
781
- <Button
782
- type="button"
783
- // A half-made `(from, "")` would commit a window with no end — a blank
784
- // report, one click into a two-click gesture.
785
- disabled={!complete}
786
- onClick={() => {
787
- if (!complete) return;
788
- commitRange(draft.from, draft.to, draft.presetId);
789
- close();
790
- }}
791
- >
792
- {labels.apply}
793
- </Button>
794
- </div>
795
- </div>
1009
+ if (!drafting) return frame(body);
1010
+ return frame(
1011
+ body,
1012
+ <>
1013
+ {/* `flex-1` in the sheet: two thumb-wide halves of the footer, not two small
1014
+ buttons in its corner. */}
1015
+ <Button type="button" variant="ghost" className={sheet ? "flex-1" : undefined} onClick={close}>
1016
+ {labels.cancel}
1017
+ </Button>
1018
+ <Button
1019
+ type="button"
1020
+ className={sheet ? "flex-1" : undefined}
1021
+ // A half-made `(from, "")` would commit a window with no end — a blank
1022
+ // report, one click into a two-click gesture.
1023
+ disabled={!complete}
1024
+ onClick={() => {
1025
+ if (!complete) return;
1026
+ commitRange(draft.from, draft.to, draft.presetId);
1027
+ close();
1028
+ }}
1029
+ >
1030
+ {labels.apply}
1031
+ </Button>
1032
+ </>,
796
1033
  );
797
1034
  }
798
1035
 
@@ -813,6 +1050,9 @@ export function DateRangePicker({
813
1050
  presets,
814
1051
  preset,
815
1052
  commit = "immediate",
1053
+ renderTrigger,
1054
+ sheetBackCloses,
1055
+ renderDraftSummary,
816
1056
  calendarLabels,
817
1057
  label,
818
1058
  clearable,
@@ -837,6 +1077,7 @@ export function DateRangePicker({
837
1077
  : `${a}${separator}…`
838
1078
  : (placeholder ?? "");
839
1079
  const hasPresets = Boolean(presets && presets.length > 0);
1080
+ const sheet = useMediaQuery(PHONE_QUERY, false);
840
1081
  const commitRange = (f: string, t: string, presetId: string | undefined) => {
841
1082
  setOwnPreset(presetId);
842
1083
  // Two arguments when no preset is involved — exactly the 0.7 call, so a caller's
@@ -858,8 +1099,23 @@ export function DateRangePicker({
858
1099
  hasValue={Boolean(from || to)}
859
1100
  triggerText={triggerText}
860
1101
  onClear={() => commitRange("", "", undefined)}
1102
+ sheet={sheet}
1103
+ sheetBackCloses={sheetBackCloses}
1104
+ renderTrigger={
1105
+ renderTrigger &&
1106
+ ((triggerProps, valueId) =>
1107
+ renderTrigger({
1108
+ triggerProps,
1109
+ valueProps: { id: valueId },
1110
+ open: triggerProps["aria-expanded"],
1111
+ from,
1112
+ to,
1113
+ preset: presets?.[markedPreset(presets, from, to, activeId, controlled)],
1114
+ text: triggerText,
1115
+ }))
1116
+ }
861
1117
  >
862
- {(close) => (
1118
+ {(close, frame) => (
863
1119
  <RangePanel
864
1120
  from={from}
865
1121
  to={to}
@@ -868,9 +1124,12 @@ export function DateRangePicker({
868
1124
  controlled={controlled}
869
1125
  commit={commit}
870
1126
  close={close}
1127
+ frame={frame}
1128
+ sheet={sheet}
871
1129
  commitRange={commitRange}
872
1130
  calendarProps={{ locale, min, max, labels: calendarLabels }}
873
1131
  labels={text}
1132
+ renderDraftSummary={renderDraftSummary}
874
1133
  />
875
1134
  )}
876
1135
  </DateField>