@eifi1/ui-kit 0.5.1 → 0.6.1

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 (136) 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 +103 -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/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. 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
+ }
@@ -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
+ }