@eifi1/ui-kit 0.5.0 → 0.6.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 (144) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +22 -7
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/currency-select.js +10 -2
  20. package/dist/components/currency-select.js.map +1 -1
  21. package/dist/components/danger-confirm.d.ts +91 -0
  22. package/dist/components/danger-confirm.js +181 -0
  23. package/dist/components/danger-confirm.js.map +1 -0
  24. package/dist/components/date-picker.js +19 -7
  25. package/dist/components/date-picker.js.map +1 -1
  26. package/dist/components/dialog-frame.d.ts +84 -0
  27. package/dist/components/dialog-frame.js +86 -0
  28. package/dist/components/dialog-frame.js.map +1 -0
  29. package/dist/components/disclosure.d.ts +108 -0
  30. package/dist/components/disclosure.js +127 -0
  31. package/dist/components/disclosure.js.map +1 -0
  32. package/dist/components/entity-combobox.d.ts +17 -3
  33. package/dist/components/entity-combobox.js +25 -5
  34. package/dist/components/entity-combobox.js.map +1 -1
  35. package/dist/components/file-button.d.ts +161 -0
  36. package/dist/components/file-button.js +225 -0
  37. package/dist/components/file-button.js.map +1 -0
  38. package/dist/components/file-dropzone.d.ts +72 -23
  39. package/dist/components/file-dropzone.js +219 -94
  40. package/dist/components/file-dropzone.js.map +1 -1
  41. package/dist/components/icon-picker.d.ts +72 -0
  42. package/dist/components/icon-picker.js +104 -0
  43. package/dist/components/icon-picker.js.map +1 -0
  44. package/dist/components/mini-calendar.d.ts +3 -0
  45. package/dist/components/mini-calendar.js +4 -3
  46. package/dist/components/mini-calendar.js.map +1 -1
  47. package/dist/components/modal.d.ts +8 -1
  48. package/dist/components/modal.js +4 -2
  49. package/dist/components/modal.js.map +1 -1
  50. package/dist/components/month-picker.js +16 -5
  51. package/dist/components/month-picker.js.map +1 -1
  52. package/dist/components/multi-entity-combobox.d.ts +16 -3
  53. package/dist/components/multi-entity-combobox.js +25 -5
  54. package/dist/components/multi-entity-combobox.js.map +1 -1
  55. package/dist/components/number-field.d.ts +41 -1
  56. package/dist/components/number-field.js +42 -10
  57. package/dist/components/number-field.js.map +1 -1
  58. package/dist/components/number-input.d.ts +35 -2
  59. package/dist/components/number-input.js +35 -4
  60. package/dist/components/number-input.js.map +1 -1
  61. package/dist/components/numpad-sheet.d.ts +7 -0
  62. package/dist/components/popover.js +4 -1
  63. package/dist/components/popover.js.map +1 -1
  64. package/dist/components/search-field.d.ts +16 -0
  65. package/dist/components/search-field.js +29 -7
  66. package/dist/components/search-field.js.map +1 -1
  67. package/dist/components/signature-pad.d.ts +43 -1
  68. package/dist/components/signature-pad.js +74 -2
  69. package/dist/components/signature-pad.js.map +1 -1
  70. package/dist/components/swatch-picker.d.ts +69 -0
  71. package/dist/components/swatch-picker.js +75 -0
  72. package/dist/components/swatch-picker.js.map +1 -0
  73. package/dist/components/tile-radio.d.ts +50 -0
  74. package/dist/components/tile-radio.js +140 -0
  75. package/dist/components/tile-radio.js.map +1 -0
  76. package/dist/components/toggle-group.d.ts +27 -5
  77. package/dist/components/toggle-group.js +22 -15
  78. package/dist/components/toggle-group.js.map +1 -1
  79. package/dist/components/trigger-aria.d.ts +18 -0
  80. package/dist/components/trigger-aria.js +25 -0
  81. package/dist/components/trigger-aria.js.map +1 -0
  82. package/dist/components/ui.d.ts +164 -18
  83. package/dist/components/ui.js +207 -34
  84. package/dist/components/ui.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +7 -0
  86. package/dist/i18n/defaults.js +13 -2
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +38 -5
  89. package/dist/i18n/kit-labels.js +12 -4
  90. package/dist/i18n/kit-labels.js.map +1 -1
  91. package/dist/index.d.ts +16 -7
  92. package/dist/index.js +12 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/table-text.d.ts +127 -0
  95. package/dist/lib/table-text.js +82 -0
  96. package/dist/lib/table-text.js.map +1 -0
  97. package/dist/rhf/form.d.ts +79 -0
  98. package/dist/rhf/form.js +143 -0
  99. package/dist/rhf/form.js.map +1 -0
  100. package/dist/rhf.d.ts +4 -0
  101. package/dist/rhf.js +3 -0
  102. package/dist/rhf.js.map +1 -0
  103. package/dist/shell/app-shell.js +3 -1
  104. package/dist/shell/app-shell.js.map +1 -1
  105. package/dist/table-text.d.ts +1 -0
  106. package/dist/table-text.js +3 -0
  107. package/dist/table-text.js.map +1 -0
  108. package/package.json +14 -1
  109. package/src/components/autocomplete.tsx +425 -0
  110. package/src/components/chip.tsx +43 -7
  111. package/src/components/choice-card.tsx +305 -0
  112. package/src/components/combobox-core.tsx +228 -58
  113. package/src/components/combobox.tsx +58 -21
  114. package/src/components/currency-select.tsx +16 -2
  115. package/src/components/danger-confirm.tsx +286 -0
  116. package/src/components/date-picker.tsx +31 -6
  117. package/src/components/dialog-frame.tsx +179 -0
  118. package/src/components/disclosure.tsx +259 -0
  119. package/src/components/entity-combobox.tsx +41 -6
  120. package/src/components/file-button.tsx +431 -0
  121. package/src/components/file-dropzone.tsx +323 -117
  122. package/src/components/icon-picker.tsx +181 -0
  123. package/src/components/mini-calendar.tsx +7 -3
  124. package/src/components/modal.tsx +10 -2
  125. package/src/components/month-picker.tsx +22 -4
  126. package/src/components/multi-entity-combobox.tsx +40 -6
  127. package/src/components/number-field.tsx +86 -10
  128. package/src/components/number-input.tsx +79 -2
  129. package/src/components/popover.tsx +4 -1
  130. package/src/components/search-field.tsx +49 -6
  131. package/src/components/signature-pad.tsx +112 -0
  132. package/src/components/swatch-picker.tsx +141 -0
  133. package/src/components/tile-radio.tsx +228 -0
  134. package/src/components/toggle-group.tsx +54 -18
  135. package/src/components/trigger-aria.ts +42 -0
  136. package/src/components/ui.tsx +427 -40
  137. package/src/i18n/defaults.ts +12 -1
  138. package/src/i18n/kit-labels.tsx +45 -5
  139. package/src/index.ts +19 -0
  140. package/src/lib/table-text.ts +265 -0
  141. package/src/rhf/form.tsx +300 -0
  142. package/src/rhf.ts +9 -0
  143. package/src/shell/app-shell.tsx +3 -1
  144. package/src/table-text.ts +8 -0
@@ -0,0 +1,286 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { Button, Input, Spinner } from "./ui";
6
+
7
+ /**
8
+ * Every string the tile renders — the `dangerConfirm` namespace of
9
+ * `<UiKitProvider labels>`, overridable per instance through `labels`. The arm and
10
+ * confirm buttons are usually worded per action ("Delete budget"), which is what the
11
+ * `armLabel` / `confirmLabel` props are for; these are the fallbacks.
12
+ */
13
+ export interface DangerConfirmLabels {
14
+ /** The button that arms the tile. */
15
+ arm: string;
16
+ /** The button that runs the action once the guards are satisfied. */
17
+ confirm: string;
18
+ cancel: string;
19
+ /** The warning above the fields, when no `prompt` is given. */
20
+ prompt: string;
21
+ /** Label of the password field (`requirePassword`). */
22
+ password: string;
23
+ /** Label of the type-to-confirm field, given the phrase to type. A FUNCTION, like
24
+ * every message that carries a value: where the phrase sits in the sentence moves
25
+ * with the language. */
26
+ phrase: (phrase: string) => string;
27
+ }
28
+
29
+ export const DEFAULT_DANGER_CONFIRM_LABELS: DangerConfirmLabels = {
30
+ arm: "Delete…",
31
+ confirm: "Delete",
32
+ cancel: "Cancel",
33
+ prompt: "This cannot be undone.",
34
+ password: "Password",
35
+ phrase: (phrase) => `Type “${phrase}” to confirm`,
36
+ };
37
+
38
+ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
39
+ /**
40
+ * Runs the action, with the entered password when `requirePassword` is on.
41
+ *
42
+ * Return a promise and the tile manages itself: busy until it settles, disarmed
43
+ * (fields wiped) when it resolves, still armed when it rejects — so the user can
44
+ * correct a wrong password and retry. The rejection is not swallowed for you to
45
+ * miss: handle it (and show why) in the caller, as with any mutation.
46
+ */
47
+ onConfirm: (password?: string) => void | Promise<unknown>;
48
+ /** Show a password field; confirm stays disabled until it is filled. */
49
+ requirePassword?: boolean;
50
+ /** Show a "type <phrase> to confirm" field; confirm stays disabled until the field
51
+ * matches exactly (case-sensitive, surrounding spaces ignored). */
52
+ phrase?: string;
53
+ /** The warning above the fields. Defaults to `labels.prompt`. */
54
+ prompt?: ReactNode;
55
+ /** `"danger"` (default) for what cannot be undone; `"warning"` for what can, at a
56
+ * cost (loading demo data over your own). Colours the prompt and the confirm. */
57
+ tone?: "danger" | "warning";
58
+ /** Visible text of the arm button; defaults to `labels.arm`. */
59
+ armLabel?: ReactNode;
60
+ /** Visible text of the confirm button; defaults to `labels.confirm`. */
61
+ confirmLabel?: ReactNode;
62
+ /** The action is running: confirm shows a spinner and nothing can be pressed. For a
63
+ * caller that tracks the mutation itself (a `useMutation`'s `isPending`); a
64
+ * promise returned from `onConfirm` does the same on its own. */
65
+ busy?: boolean;
66
+ /** The arm button is disabled. */
67
+ disabled?: boolean;
68
+ /**
69
+ * Why the action is not available — a read-only demo, a write lock, a missing
70
+ * permission. Disables the arm button like `disabled`, and SAYS so: the sentence is
71
+ * shown under the button and attached to it with `aria-describedby`, and the button
72
+ * stays focusable (`aria-disabled`) so a keyboard user can land on it and hear why.
73
+ *
74
+ * On the ARM button rather than the confirm: arming asks for a password, and asking
75
+ * for a password for a write that can never land is the worse of the two.
76
+ */
77
+ lockedReason?: ReactNode;
78
+ /** Controlled armed state. The parent can then collapse the tile from a mutation's
79
+ * own `onSuccess` without returning a promise. */
80
+ armed?: boolean;
81
+ /** Called with the next armed state — on arm, cancel, and a resolved `onConfirm`. */
82
+ onArmedChange?: (armed: boolean) => void;
83
+ /** User-facing strings; see {@link DangerConfirmLabels}. */
84
+ labels?: Partial<DangerConfirmLabels>;
85
+ }
86
+
87
+ /**
88
+ * An "arm → confirm" tile for destructive actions: one button, which expands into a
89
+ * warning, an optional password field, an optional type-to-confirm field and a
90
+ * confirm that stays disabled until every guard is satisfied.
91
+ *
92
+ * Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
93
+ * budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
94
+ * part was its write-lock hook, which is `lockedReason` here.
95
+ *
96
+ * The fields are a `<form>`, so Enter confirms once the guards allow it. They are
97
+ * wiped every time the tile disarms — a password must not sit in a collapsed tile.
98
+ * Arming moves focus to the first field (or Cancel, when there is none — never to the
99
+ * destructive button itself), and disarming moves it back to the arm button, so the
100
+ * keyboard user is never left on an element that just vanished.
101
+ */
102
+ export function DangerConfirm({
103
+ onConfirm,
104
+ requirePassword,
105
+ phrase,
106
+ prompt,
107
+ tone = "danger",
108
+ armLabel,
109
+ confirmLabel,
110
+ busy: busyProp,
111
+ disabled,
112
+ lockedReason,
113
+ armed: armedProp,
114
+ onArmedChange,
115
+ labels: labelsProp,
116
+ className,
117
+ ...rest
118
+ }: DangerConfirmProps) {
119
+ const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
120
+ const [armedState, setArmedState] = useState(false);
121
+ const armed = armedProp ?? armedState;
122
+ const [password, setPassword] = useState("");
123
+ const [typed, setTyped] = useState("");
124
+ const [pending, setPending] = useState(false);
125
+ const busy = Boolean(busyProp) || pending;
126
+ // `readOnly` rather than `disabled` on the fields while busy: disabling the field
127
+ // that has focus (Enter was pressed in it) drops focus to <body>.
128
+ const promptId = useId();
129
+ const reasonId = useId();
130
+
131
+ // The kit's Button takes no ref, so the two buttons focus is moved to are found by id.
132
+ const armId = useId();
133
+ const cancelId = useId();
134
+ const firstFieldRef = useRef<HTMLInputElement>(null);
135
+ // Focus moves only after a transition — never on mount, so a tile that renders armed
136
+ // (controlled) does not take the page's focus merely by existing.
137
+ const moveFocus = useRef(false);
138
+ const mounted = useRef(true);
139
+ useEffect(() => {
140
+ mounted.current = true;
141
+ return () => {
142
+ mounted.current = false;
143
+ };
144
+ }, []);
145
+
146
+ // A disarm from anywhere (cancel, a resolved confirm, the parent) wipes the fields.
147
+ // During render, like NumberField's draft, so no frame shows a collapsed tile that
148
+ // still holds a password.
149
+ const [wasArmed, setWasArmed] = useState(armed);
150
+ if (wasArmed !== armed) {
151
+ setWasArmed(armed);
152
+ if (!armed) {
153
+ setPassword("");
154
+ setTyped("");
155
+ }
156
+ }
157
+
158
+ const prevArmed = useRef(armed);
159
+ useEffect(() => {
160
+ const changed = prevArmed.current !== armed;
161
+ prevArmed.current = armed;
162
+ const byUser = moveFocus.current;
163
+ moveFocus.current = false;
164
+ if (!changed) return;
165
+ if (armed) {
166
+ if (byUser) (firstFieldRef.current ?? document.getElementById(cancelId))?.focus();
167
+ return;
168
+ }
169
+ // A controlled parent collapsing the tile from its own `onSuccess` did not go
170
+ // through `setArmed`; if focus went down with the form, bring it back too.
171
+ const lost = document.activeElement === null || document.activeElement === document.body;
172
+ if (byUser || lost) document.getElementById(armId)?.focus();
173
+ }, [armed, armId, cancelId]);
174
+
175
+ const setArmed = (next: boolean) => {
176
+ moveFocus.current = true;
177
+ if (armedProp === undefined) setArmedState(next);
178
+ onArmedChange?.(next);
179
+ };
180
+
181
+ const locked = lockedReason !== undefined && lockedReason !== null && lockedReason !== false && lockedReason !== "";
182
+ const passwordOk = !requirePassword || password !== "";
183
+ const phraseOk = phrase === undefined || typed.trim() === phrase;
184
+ const canConfirm = passwordOk && phraseOk && !busy;
185
+
186
+ const submit = (e: FormEvent) => {
187
+ e.preventDefault();
188
+ if (!canConfirm) return;
189
+ const result = onConfirm(requirePassword ? password : undefined);
190
+ if (!result || typeof (result as Promise<unknown>).then !== "function") return;
191
+ setPending(true);
192
+ (result as Promise<unknown>).then(
193
+ () => {
194
+ if (!mounted.current) return;
195
+ setPending(false);
196
+ setArmed(false);
197
+ },
198
+ () => {
199
+ // Stays armed, fields kept: the caller shows why, the user retries.
200
+ if (mounted.current) setPending(false);
201
+ },
202
+ );
203
+ };
204
+
205
+ const toneText = tone === "warning" ? "text-[var(--warning)]" : "text-[var(--danger)]";
206
+
207
+ if (!armed) {
208
+ return (
209
+ <div {...rest} className={cn("space-y-1", className)}>
210
+ <Button
211
+ id={armId}
212
+ type="button"
213
+ variant={tone === "warning" ? "secondary" : "danger"}
214
+ disabled={disabled}
215
+ aria-disabled={locked || undefined}
216
+ aria-describedby={locked ? reasonId : undefined}
217
+ onClick={() => {
218
+ if (!locked) setArmed(true);
219
+ }}
220
+ className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
221
+ >
222
+ {armLabel ?? labels.arm}
223
+ </Button>
224
+ {locked && (
225
+ <p id={reasonId} className="text-xs text-[var(--text-muted)]">
226
+ {lockedReason}
227
+ </p>
228
+ )}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ return (
234
+ <div {...rest} className={cn("space-y-2", className)}>
235
+ <form
236
+ className="space-y-2"
237
+ aria-describedby={promptId}
238
+ aria-busy={busy || undefined}
239
+ onSubmit={submit}
240
+ noValidate
241
+ >
242
+ <p id={promptId} className={cn("text-xs font-medium", toneText)}>
243
+ {prompt ?? labels.prompt}
244
+ </p>
245
+ {phrase !== undefined && (
246
+ <Input
247
+ ref={firstFieldRef}
248
+ label={labels.phrase(phrase)}
249
+ value={typed}
250
+ autoComplete="off"
251
+ autoCapitalize="off"
252
+ spellCheck={false}
253
+ readOnly={busy}
254
+ onChange={(e) => setTyped(e.target.value)}
255
+ />
256
+ )}
257
+ {requirePassword && (
258
+ <Input
259
+ // The phrase field takes the ref when both are there — it comes first.
260
+ ref={phrase === undefined ? firstFieldRef : undefined}
261
+ type="password"
262
+ autoComplete="current-password"
263
+ label={labels.password}
264
+ value={password}
265
+ readOnly={busy}
266
+ onChange={(e) => setPassword(e.target.value)}
267
+ />
268
+ )}
269
+ <div className="flex flex-wrap gap-2">
270
+ <Button id={cancelId} type="button" variant="ghost" disabled={busy} onClick={() => setArmed(false)}>
271
+ {labels.cancel}
272
+ </Button>
273
+ <Button
274
+ type="submit"
275
+ variant={tone === "warning" ? "primary" : "danger"}
276
+ disabled={!canConfirm}
277
+ aria-busy={busy || undefined}
278
+ >
279
+ {busy && <Spinner label={null} className="h-4 w-4" />}
280
+ {confirmLabel ?? labels.confirm}
281
+ </Button>
282
+ </div>
283
+ </form>
284
+ </div>
285
+ );
286
+ }
@@ -9,6 +9,8 @@ import {
9
9
  useKitLocale,
10
10
  type DatePickerLabels,
11
11
  } from "../i18n/kit-labels";
12
+ import { splitTriggerAria } from "./trigger-aria";
13
+ import type { TriggerAria } from "./trigger-aria";
12
14
  import { FieldLabel, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
13
15
  import { MiniCalendar, type MiniCalendarProps } from "./mini-calendar";
14
16
  import { Popover } from "./popover";
@@ -82,7 +84,10 @@ function DateFieldTrigger({
82
84
  disabled,
83
85
  invalid,
84
86
  panelId,
87
+ aria,
85
88
  }: {
89
+ /** The caller's naming/description attributes, routed here from the field. */
90
+ aria: TriggerAria;
86
91
  open: boolean;
87
92
  toggle: () => void;
88
93
  triggerRef: RefObject<HTMLButtonElement | null>;
@@ -133,7 +138,10 @@ function DateFieldTrigger({
133
138
  // is a sentence, and a name reference is spoken as the two texts in order with
134
139
  // no punctuation to translate at all. The hidden twin of the FieldLabel lives
135
140
  // in DateField, next to the label it copies.
136
- aria-labelledby={labelledBy}
141
+ id={aria.id}
142
+ aria-labelledby={aria["aria-label"] && !aria["aria-labelledby"] ? undefined : labelledBy}
143
+ aria-label={aria["aria-label"]}
144
+ aria-describedby={aria["aria-describedby"]}
137
145
  // `role="combobox"` on a button that opens a calendar is the APG date-picker
138
146
  // shape, and it is what makes the next two lines legal: `button` supports
139
147
  // neither `aria-expanded` nor `aria-invalid`, so the previous markup set an
@@ -146,7 +154,7 @@ function DateFieldTrigger({
146
154
  aria-haspopup="dialog"
147
155
  aria-controls={panelId}
148
156
  aria-expanded={open}
149
- aria-invalid={invalid || undefined}
157
+ aria-invalid={invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined}
150
158
  className={cn(
151
159
  FIELD_TRIGGER,
152
160
  "pr-9",
@@ -209,6 +217,7 @@ function DateField({
209
217
  children: (close: () => void) => ReactNode;
210
218
  }) {
211
219
  const showClear = Boolean(clearable && hasValue && !disabled);
220
+ const [aria, wrapperRest] = splitTriggerAria(rest);
212
221
  const id = useId();
213
222
  const labelId = `${id}-label`;
214
223
  const valueId = `${id}-value`;
@@ -220,7 +229,7 @@ function DateField({
220
229
  return (
221
230
  // The caller's attributes land here, on the field's own box — the trigger inside is
222
231
  // named by `aria-labelledby` and must keep the id pair it is given.
223
- <div {...rest} className={cn("relative", className)}>
232
+ <div {...wrapperRest} className={cn("relative", className)}>
224
233
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
225
234
  {/* The visible FieldLabel is a plain span, not a `<label htmlFor>`, so it names
226
235
  nothing on its own — this hidden twin is what the trigger is named by.
@@ -254,7 +263,20 @@ function DateField({
254
263
  // this undefined made an unlabelled date field announce nothing at all.
255
264
  // Named: "label, value". Unnamed: the value alone, which is what the
256
265
  // button was saying before.
257
- labelledBy={named ? `${labelId} ${valueId}` : valueId}
266
+ //
267
+ // A caller's own reference comes first. And when the caller gives the field
268
+ // an `id` (so an external `<label htmlFor>` can point at it), the trigger
269
+ // lists ITSELF first: a self-reference in `aria-labelledby` is resolved
270
+ // from the element's native label (accname 2B → 2D), so "Due date" from
271
+ // that <label> is spoken before the value, instead of being overridden.
272
+ labelledBy={[
273
+ aria["aria-labelledby"],
274
+ named ? labelId : !aria["aria-labelledby"] && aria.id ? aria.id : undefined,
275
+ valueId,
276
+ ]
277
+ .filter(Boolean)
278
+ .join(" ")}
279
+ aria={aria}
258
280
  panelId={panelId}
259
281
  valueId={valueId}
260
282
  padded={label !== undefined}
@@ -415,9 +437,12 @@ export function DatePicker({
415
437
  // whichever of the two is actually the root. Naming the shared props rather than
416
438
  // letting `...rest` carry them is what makes that choice possible.
417
439
  const wrapped = Boolean(step || today);
440
+ const [triggerAria, wrapperRest] = splitTriggerAria(rest);
418
441
  const field = (
419
442
  <DateField
420
- {...(wrapped ? {} : rest)}
443
+ // With step/today buttons the flex row is the root and takes the caller's props —
444
+ // except the ones that name the field, which still belong on its trigger.
445
+ {...(wrapped ? triggerAria : rest)}
421
446
  label={label}
422
447
  clearable={clearable}
423
448
  clearLabel={text.clear}
@@ -469,7 +494,7 @@ export function DatePicker({
469
494
  // varies with whether it carries a floating label. The field's trigger is nested
470
495
  // inside DateField, so its inner corners are squared from here.
471
496
  <div
472
- {...rest}
497
+ {...wrapperRest}
473
498
  className={cn(
474
499
  "flex items-stretch [&>*:not(:first-child)]:-ms-px",
475
500
  step && "[&_[role=combobox]]:rounded-s-none",
@@ -0,0 +1,179 @@
1
+ import { useContext, useId } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { X } from "lucide-react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import { Modal, ModalCloseContext } from "./modal";
7
+ import type { ModalProps } from "./modal";
8
+ import { useKitLabels } from "../i18n/kit-labels";
9
+
10
+ export interface DialogFrameLabels {
11
+ /** Accessible name of the header's X, when {@link DialogFrameProps.closeButton} shows it. */
12
+ close: string;
13
+ }
14
+
15
+ export const DEFAULT_DIALOG_FRAME_LABELS: DialogFrameLabels = { close: "Close" };
16
+
17
+ /**
18
+ * `ModalProps` minus the three this component owns: the NAME (`labelledBy` — the
19
+ * heading's id is generated inside and never reaches the caller), the content
20
+ * (`children` is the body here, not the whole panel) and `title`, which is the dialog's
21
+ * HEADING and a ReactNode rather than the browser's tooltip string — the collision
22
+ * `PickerSheet` met first. Everything else, `size`, `draggable`, `fullBleed`,
23
+ * `onKeyDown`, a `data-tour` anchor, still reaches the `Modal`.
24
+ */
25
+ export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "children" | "title"> {
26
+ /** The heading, and therefore the dialog's accessible name (`aria-labelledby`). */
27
+ title: ReactNode;
28
+ /** The smaller line under the heading; wired to `aria-describedby`. */
29
+ description?: ReactNode;
30
+ /**
31
+ * The heading's level. `h2` by default, which is what every dialog in both apps
32
+ * writes; a prop for a page that nests its demos under a real heading.
33
+ */
34
+ headingAs?: "h1" | "h2" | "h3" | "h4";
35
+ /**
36
+ * The row under the body — buttons, in the caller's own order and variants (the two
37
+ * apps disagree about the cancel button's variant, so the frame has no opinion on
38
+ * it). Stays put while the body scrolls.
39
+ *
40
+ * A FUNCTION receives the panel's animated close: `(close) => <Button
41
+ * onClick={close}>Cancel</Button>` lowers the panel the way Escape does, where
42
+ * calling `onClose` directly unmounts it at once.
43
+ */
44
+ actions?: ReactNode | ((close: () => void) => ReactNode);
45
+ /**
46
+ * Show an X in the header. Off by default: a centred dialog has a backdrop and
47
+ * Escape, and a form dialog has a Cancel. On for a dialog that commits as it goes
48
+ * and has no actions row, where the X is the only visible way out.
49
+ */
50
+ closeButton?: boolean;
51
+ /** Default: `dialogFrame.close` from the {@link UiKitProvider}, else "Close". */
52
+ closeLabel?: string;
53
+ /** Extra classes for the scrolling body (default spacing `space-y-3`). */
54
+ bodyClassName?: string;
55
+ /** The body. */
56
+ children: ReactNode;
57
+ }
58
+
59
+ /**
60
+ * A {@link Modal} with the frame every caller was writing by hand: a heading, an
61
+ * optional description, an optional X, a body that scrolls, and an actions row that
62
+ * does not.
63
+ *
64
+ * 34 dialogs across the two apps framed themselves — a heading with an id invented per
65
+ * file (and spelt three ways), four visible type sizes for one thing, ten spellings of
66
+ * one right-aligned button row — and the package's own feedback dialog shipped with no
67
+ * accessible name at all. This makes the name unforgettable: the heading's id comes
68
+ * from `useId()` and goes straight to `Modal`'s `labelledBy`, so a framed dialog cannot
69
+ * announce as just "dialog", and two open instances cannot share an id.
70
+ *
71
+ * ## It wraps, it does not change `Modal`
72
+ *
73
+ * `Modal` keeps `labelledBy` and every existing caller compiles untouched. What the
74
+ * frame changes is inside the panel: the panel becomes a flex column with no padding
75
+ * of its own, and only the BODY scrolls. The panel's `max-h-full` is still the outer
76
+ * bound, so a tall form keeps its heading and its Save button on screen instead of
77
+ * scrolling them away with the fields. A caller's own `className` still wins (it is
78
+ * tailwind-merged last), which is how a full-screen phone sheet is spelt:
79
+ * `fullBleed className="h-[100dvh] max-w-full rounded-none md:h-auto md:rounded-lg"`.
80
+ *
81
+ * ## What it is not
82
+ *
83
+ * Not `FullBleedDialog`: that is the phone's full-screen editor with its own `open`,
84
+ * Back handling and a required X, and it already draws a frame of its own. And not a
85
+ * form: submit handling, a pending label and close-on-success stay the caller's.
86
+ * Focus lands on the panel, as `Modal` decides — not on the first field, so opening
87
+ * does not pop a phone's keyboard; an `autoFocus` in the body overrides that from the
88
+ * caller's side, and should be a decision rather than a habit.
89
+ */
90
+ export function DialogFrame({
91
+ title,
92
+ description,
93
+ headingAs: Heading = "h2",
94
+ actions,
95
+ closeButton = false,
96
+ closeLabel,
97
+ bodyClassName,
98
+ className,
99
+ children,
100
+ "aria-describedby": describedBy,
101
+ ...modal
102
+ }: DialogFrameProps) {
103
+ const titleId = useId();
104
+ const descriptionId = useId();
105
+ const hasDescription = description !== undefined && description !== null;
106
+
107
+ return (
108
+ <Modal
109
+ {...modal}
110
+ labelledBy={titleId}
111
+ // The caller's own description (a warning inside the body, say) is ADDED to the
112
+ // frame's, not traded for it: both are the dialog's.
113
+ aria-describedby={[hasDescription ? descriptionId : undefined, describedBy].filter(Boolean).join(" ") || undefined}
114
+ // `overflow-hidden` replaces the panel's own `overflow-y-auto` (tailwind-merge
115
+ // treats them as one group), `p-0` its `p-4`: the scroller and the padding move
116
+ // to the body, which is the only part that should move.
117
+ className={cn("flex flex-col overflow-hidden p-0", className)}
118
+ >
119
+ <div className="flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3">
120
+ <div className="min-w-0">
121
+ <Heading id={titleId} className="text-lg font-semibold leading-snug text-[var(--text-primary)]">
122
+ {title}
123
+ </Heading>
124
+ {hasDescription && (
125
+ <p id={descriptionId} className="mt-0.5 text-sm text-[var(--text-muted)]">
126
+ {description}
127
+ </p>
128
+ )}
129
+ </div>
130
+ {closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
131
+ </div>
132
+ <div
133
+ className={cn(
134
+ // `min-h-0` is what lets a flex child shrink below its content and scroll;
135
+ // `last:pb-4` closes a frame that has no actions row under it.
136
+ "min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-3 last:pb-4",
137
+ bodyClassName,
138
+ )}
139
+ >
140
+ {children}
141
+ </div>
142
+ {actions !== undefined && actions !== null && <FrameActions actions={actions} onClose={modal.onClose} />}
143
+ </Modal>
144
+ );
145
+ }
146
+
147
+ /** The X. A component of its own so it can read the panel's animated close, which
148
+ * only exists INSIDE the `Modal` (the frame's own body runs outside it). */
149
+ function FrameClose({ label, onClose }: { label?: string; onClose: () => void }) {
150
+ const close = useContext(ModalCloseContext) ?? onClose;
151
+ const labels = useKitLabels("dialogFrame", DEFAULT_DIALOG_FRAME_LABELS, label === undefined ? undefined : { close: label });
152
+ return (
153
+ <button
154
+ type="button"
155
+ onClick={close}
156
+ aria-label={labels.close}
157
+ className="-me-1.5 -mt-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
158
+ >
159
+ <X aria-hidden className="size-5" />
160
+ </button>
161
+ );
162
+ }
163
+
164
+ function FrameActions({
165
+ actions,
166
+ onClose,
167
+ }: {
168
+ actions: NonNullable<DialogFrameProps["actions"]>;
169
+ onClose: () => void;
170
+ }) {
171
+ const close = useContext(ModalCloseContext) ?? onClose;
172
+ return (
173
+ // The border marks where the scrolling stops; `flex-wrap` keeps three long
174
+ // translated labels on a phone from pushing the row wider than the sheet.
175
+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-4 py-3">
176
+ {typeof actions === "function" ? actions(close) : actions}
177
+ </div>
178
+ );
179
+ }