@eifi1/ui-kit 0.8.0 → 0.9.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 (107) hide show
  1. package/README.md +4 -0
  2. package/dist/components/amount-input.d.ts +2 -0
  3. package/dist/components/calculator.d.ts +2 -0
  4. package/dist/components/chip.d.ts +27 -5
  5. package/dist/components/chip.js +6 -2
  6. package/dist/components/chip.js.map +1 -1
  7. package/dist/components/data-table-filter-popover.d.ts +1 -1
  8. package/dist/components/data-table-filters.d.ts +1 -1
  9. package/dist/components/data-table.d.ts +1 -1
  10. package/dist/components/data-table.js +122 -91
  11. package/dist/components/data-table.js.map +1 -1
  12. package/dist/components/date-picker.d.ts +69 -3
  13. package/dist/components/date-picker.js +152 -65
  14. package/dist/components/date-picker.js.map +1 -1
  15. package/dist/components/disclosure.d.ts +11 -2
  16. package/dist/components/disclosure.js +10 -4
  17. package/dist/components/disclosure.js.map +1 -1
  18. package/dist/components/floating-panel.d.ts +29 -2
  19. package/dist/components/floating-panel.js +16 -2
  20. package/dist/components/floating-panel.js.map +1 -1
  21. package/dist/components/full-bleed-dialog.js +3 -1
  22. package/dist/components/full-bleed-dialog.js.map +1 -1
  23. package/dist/components/number-field.d.ts +2 -0
  24. package/dist/components/number-input.d.ts +2 -0
  25. package/dist/components/numpad-sheet.d.ts +2 -0
  26. package/dist/components/progress-bar.d.ts +16 -2
  27. package/dist/components/progress-bar.js +4 -2
  28. package/dist/components/progress-bar.js.map +1 -1
  29. package/dist/components/treemap.js +4 -1
  30. package/dist/components/treemap.js.map +1 -1
  31. package/dist/components/ui.d.ts +44 -14
  32. package/dist/components/ui.js +28 -22
  33. package/dist/components/ui.js.map +1 -1
  34. package/dist/components/use-table-state.d.ts +1 -1
  35. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  36. package/dist/data-table.d.ts +1 -1
  37. package/dist/hooks/use-overlay-history.js +84 -4
  38. package/dist/hooks/use-overlay-history.js.map +1 -1
  39. package/dist/i18n/defaults.d.ts +2 -0
  40. package/dist/i18n/defaults.js +2 -0
  41. package/dist/i18n/defaults.js.map +1 -1
  42. package/dist/i18n/kit-labels.d.ts +3 -0
  43. package/dist/i18n/kit-labels.js.map +1 -1
  44. package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
  45. package/dist/i18n/locales/de-CH.d.ts +2 -0
  46. package/dist/i18n/locales/de-informal.d.ts +2 -0
  47. package/dist/i18n/locales/de.d.ts +2 -0
  48. package/dist/i18n/locales/de.js +10 -0
  49. package/dist/i18n/locales/de.js.map +1 -1
  50. package/dist/i18n/locales/es.d.ts +2 -0
  51. package/dist/i18n/locales/es.js +10 -0
  52. package/dist/i18n/locales/es.js.map +1 -1
  53. package/dist/i18n/locales/fr.d.ts +2 -0
  54. package/dist/i18n/locales/fr.js +10 -0
  55. package/dist/i18n/locales/fr.js.map +1 -1
  56. package/dist/i18n/locales/hu.d.ts +2 -0
  57. package/dist/i18n/locales/hu.js +10 -0
  58. package/dist/i18n/locales/hu.js.map +1 -1
  59. package/dist/i18n/locales/it.d.ts +2 -0
  60. package/dist/i18n/locales/it.js +10 -0
  61. package/dist/i18n/locales/it.js.map +1 -1
  62. package/dist/i18n/locales/zh.d.ts +2 -0
  63. package/dist/i18n/locales/zh.js +10 -0
  64. package/dist/i18n/locales/zh.js.map +1 -1
  65. package/dist/index.d.ts +5 -3
  66. package/dist/index.js +2 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/search/command-palette.d.ts +49 -1
  69. package/dist/search/command-palette.js +132 -29
  70. package/dist/search/command-palette.js.map +1 -1
  71. package/dist/search/global-search.d.ts +141 -0
  72. package/dist/search/global-search.js +277 -0
  73. package/dist/search/global-search.js.map +1 -0
  74. package/dist/search/search-index.d.ts +105 -0
  75. package/dist/search/search-index.js +142 -0
  76. package/dist/search/search-index.js.map +1 -0
  77. package/dist/search.d.ts +2 -0
  78. package/dist/search.js +2 -0
  79. package/dist/search.js.map +1 -1
  80. package/dist/wizard/stepper-nav.d.ts +10 -1
  81. package/dist/wizard/stepper-nav.js +2 -1
  82. package/dist/wizard/stepper-nav.js.map +1 -1
  83. package/package.json +12 -3
  84. package/src/components/chip.tsx +36 -6
  85. package/src/components/data-table.tsx +81 -5
  86. package/src/components/date-picker.tsx +328 -117
  87. package/src/components/disclosure.tsx +24 -8
  88. package/src/components/floating-panel.tsx +46 -2
  89. package/src/components/full-bleed-dialog.tsx +7 -2
  90. package/src/components/progress-bar.tsx +27 -3
  91. package/src/components/treemap.tsx +3 -0
  92. package/src/components/ui.tsx +95 -37
  93. package/src/hooks/use-overlay-history.ts +181 -4
  94. package/src/i18n/defaults.ts +2 -0
  95. package/src/i18n/kit-labels.tsx +2 -0
  96. package/src/i18n/locales/de.ts +10 -0
  97. package/src/i18n/locales/es.ts +10 -0
  98. package/src/i18n/locales/fr.ts +10 -0
  99. package/src/i18n/locales/hu.ts +10 -0
  100. package/src/i18n/locales/it.ts +10 -0
  101. package/src/i18n/locales/zh.ts +10 -0
  102. package/src/index.ts +4 -0
  103. package/src/search/command-palette.tsx +200 -27
  104. package/src/search/global-search.tsx +484 -0
  105. package/src/search/search-index.ts +295 -0
  106. package/src/search.ts +2 -0
  107. package/src/wizard/stepper-nav.tsx +11 -1
@@ -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,45 @@ 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
+
775
+ /** What {@link DateRangePickerProps.renderTrigger} is called with. */
776
+ export interface DateRangeTriggerRenderProps {
777
+ /** Spread onto the `<button>`, whole. Merge your class after `triggerProps.className`. */
778
+ triggerProps: DateTriggerAttributes;
779
+ /** Put on the element showing the value: the trigger is named "label, value" by
780
+ * reference to this id. Without it the name is the label alone. */
781
+ valueProps: { id: string };
782
+ open: boolean;
783
+ from: string;
784
+ to: string;
785
+ /** The preset the column marks for the committed range, if any — the one to name. */
786
+ preset: DateRangePickerPreset | undefined;
787
+ /** What the kit's own trigger would show: the formatted range, or the placeholder. */
788
+ text: string;
615
789
  }
616
790
 
617
791
  interface RangeDraft {
@@ -648,11 +822,15 @@ function PresetColumn({
648
822
  marked,
649
823
  label,
650
824
  onPick,
825
+ sheet,
651
826
  }: {
652
827
  presets: readonly DateRangePickerPreset[];
653
828
  marked: number;
654
829
  label: string;
655
830
  onPick: (p: DateRangePickerPreset) => void;
831
+ /** The phone sheet's shape: a two-column grid ABOVE the calendar with 44px rows —
832
+ * a 128px column beside a month grid leaves the grid ~200px on a 360px screen. */
833
+ sheet?: boolean;
656
834
  }) {
657
835
  return (
658
836
  // `border-e`/`pe`, not `-r`: the preset column is on the START side, and the rule
@@ -660,7 +838,11 @@ function PresetColumn({
660
838
  <div
661
839
  role="group"
662
840
  aria-label={label}
663
- className="flex w-32 shrink-0 flex-col gap-0.5 border-e border-[var(--border)] pe-2"
841
+ className={
842
+ sheet
843
+ ? "grid grid-cols-2 gap-0.5"
844
+ : "flex w-32 shrink-0 flex-col gap-0.5 border-e border-[var(--border)] pe-2"
845
+ }
664
846
  >
665
847
  {presets.map((p, i) => {
666
848
  const selected = i === marked;
@@ -672,7 +854,9 @@ function PresetColumn({
672
854
  data-preset={p.id}
673
855
  onClick={() => onPick(p)}
674
856
  className={cn(
675
- "rounded px-2 py-1.5 text-start text-xs",
857
+ "rounded px-2 text-start",
858
+ // 44px on a phone: the kit's touch-target size (`SHEET_ROW_CLASS`).
859
+ sheet ? "min-h-11 py-2 text-sm" : "py-1.5 text-xs",
676
860
  selected
677
861
  ? "bg-[var(--bg-active)] font-medium text-[var(--text-primary)]"
678
862
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
@@ -700,6 +884,8 @@ function RangePanel({
700
884
  controlled,
701
885
  commit,
702
886
  close,
887
+ frame,
888
+ sheet,
703
889
  commitRange,
704
890
  calendarProps,
705
891
  labels,
@@ -711,6 +897,9 @@ function RangePanel({
711
897
  controlled: boolean;
712
898
  commit: DateRangeCommit;
713
899
  close: () => void;
900
+ /** Where the body and the Apply row go — see `PanelFrame`. */
901
+ frame: PanelFrame;
902
+ sheet: boolean;
714
903
  commitRange: (from: string, to: string, presetId: string | undefined) => void;
715
904
  calendarProps: Pick<MiniCalendarProps, "locale" | "min" | "max" | "labels">;
716
905
  labels: DatePickerLabels;
@@ -748,8 +937,9 @@ function RangePanel({
748
937
 
749
938
  const body =
750
939
  presets && presets.length > 0 ? (
751
- <div className="flex gap-3">
940
+ <div className={sheet ? "flex flex-col gap-3" : "flex gap-3"}>
752
941
  <PresetColumn
942
+ sheet={sheet}
753
943
  presets={presets}
754
944
  marked={marked}
755
945
  label={labels.presets}
@@ -770,29 +960,30 @@ function RangePanel({
770
960
  calendar
771
961
  );
772
962
 
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>
963
+ if (!drafting) return frame(body);
964
+ return frame(
965
+ body,
966
+ <>
967
+ {/* `flex-1` in the sheet: two thumb-wide halves of the footer, not two small
968
+ buttons in its corner. */}
969
+ <Button type="button" variant="ghost" className={sheet ? "flex-1" : undefined} onClick={close}>
970
+ {labels.cancel}
971
+ </Button>
972
+ <Button
973
+ type="button"
974
+ className={sheet ? "flex-1" : undefined}
975
+ // A half-made `(from, "")` would commit a window with no end — a blank
976
+ // report, one click into a two-click gesture.
977
+ disabled={!complete}
978
+ onClick={() => {
979
+ if (!complete) return;
980
+ commitRange(draft.from, draft.to, draft.presetId);
981
+ close();
982
+ }}
983
+ >
984
+ {labels.apply}
985
+ </Button>
986
+ </>,
796
987
  );
797
988
  }
798
989
 
@@ -813,6 +1004,8 @@ export function DateRangePicker({
813
1004
  presets,
814
1005
  preset,
815
1006
  commit = "immediate",
1007
+ renderTrigger,
1008
+ sheetBackCloses,
816
1009
  calendarLabels,
817
1010
  label,
818
1011
  clearable,
@@ -837,6 +1030,7 @@ export function DateRangePicker({
837
1030
  : `${a}${separator}…`
838
1031
  : (placeholder ?? "");
839
1032
  const hasPresets = Boolean(presets && presets.length > 0);
1033
+ const sheet = useMediaQuery(PHONE_QUERY, false);
840
1034
  const commitRange = (f: string, t: string, presetId: string | undefined) => {
841
1035
  setOwnPreset(presetId);
842
1036
  // Two arguments when no preset is involved — exactly the 0.7 call, so a caller's
@@ -858,8 +1052,23 @@ export function DateRangePicker({
858
1052
  hasValue={Boolean(from || to)}
859
1053
  triggerText={triggerText}
860
1054
  onClear={() => commitRange("", "", undefined)}
1055
+ sheet={sheet}
1056
+ sheetBackCloses={sheetBackCloses}
1057
+ renderTrigger={
1058
+ renderTrigger &&
1059
+ ((triggerProps, valueId) =>
1060
+ renderTrigger({
1061
+ triggerProps,
1062
+ valueProps: { id: valueId },
1063
+ open: triggerProps["aria-expanded"],
1064
+ from,
1065
+ to,
1066
+ preset: presets?.[markedPreset(presets, from, to, activeId, controlled)],
1067
+ text: triggerText,
1068
+ }))
1069
+ }
861
1070
  >
862
- {(close) => (
1071
+ {(close, frame) => (
863
1072
  <RangePanel
864
1073
  from={from}
865
1074
  to={to}
@@ -868,6 +1077,8 @@ export function DateRangePicker({
868
1077
  controlled={controlled}
869
1078
  commit={commit}
870
1079
  close={close}
1080
+ frame={frame}
1081
+ sheet={sheet}
871
1082
  commitRange={commitRange}
872
1083
  calendarProps={{ locale, min, max, labels: calendarLabels }}
873
1084
  labels={text}