@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.
- package/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +103 -0
- package/dist/components/autocomplete.js +260 -0
- package/dist/components/autocomplete.js.map +1 -0
- package/dist/components/calculator.d.ts +7 -0
- package/dist/components/checkbox.d.ts +9 -0
- package/dist/components/checkbox.js +7 -2
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +3 -2
- package/dist/components/chip.js +14 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +100 -0
- package/dist/components/choice-card.js +170 -0
- package/dist/components/choice-card.js.map +1 -0
- package/dist/components/combobox-core.d.ts +76 -6
- package/dist/components/combobox-core.js +119 -49
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +12 -2
- package/dist/components/combobox.js +42 -17
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +91 -0
- package/dist/components/danger-confirm.js +181 -0
- package/dist/components/danger-confirm.js.map +1 -0
- package/dist/components/dialog-frame.d.ts +84 -0
- package/dist/components/dialog-frame.js +86 -0
- package/dist/components/dialog-frame.js.map +1 -0
- package/dist/components/disclosure.d.ts +108 -0
- package/dist/components/disclosure.js +127 -0
- package/dist/components/disclosure.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +17 -3
- package/dist/components/entity-combobox.js +25 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +161 -0
- package/dist/components/file-button.js +243 -0
- package/dist/components/file-button.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +72 -23
- package/dist/components/file-dropzone.js +219 -94
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/icon-picker.d.ts +72 -0
- package/dist/components/icon-picker.js +104 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +3 -0
- package/dist/components/mini-calendar.js +4 -3
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +8 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +16 -3
- package/dist/components/multi-entity-combobox.js +25 -5
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +41 -1
- package/dist/components/number-field.js +42 -10
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +35 -2
- package/dist/components/number-input.js +35 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -0
- package/dist/components/search-field.d.ts +16 -0
- package/dist/components/search-field.js +29 -7
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/signature-pad.d.ts +43 -1
- package/dist/components/signature-pad.js +74 -2
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +69 -0
- package/dist/components/swatch-picker.js +75 -0
- package/dist/components/swatch-picker.js.map +1 -0
- package/dist/components/switch.d.ts +9 -0
- package/dist/components/switch.js +7 -2
- package/dist/components/switch.js.map +1 -1
- package/dist/components/tile-radio.d.ts +50 -0
- package/dist/components/tile-radio.js +140 -0
- package/dist/components/tile-radio.js.map +1 -0
- package/dist/components/toggle-group.d.ts +27 -5
- package/dist/components/toggle-group.js +22 -15
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +150 -12
- package/dist/components/ui.js +196 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +13 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +38 -5
- package/dist/i18n/kit-labels.js +12 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/table-text.d.ts +127 -0
- package/dist/lib/table-text.js +82 -0
- package/dist/lib/table-text.js.map +1 -0
- package/dist/rhf/form.d.ts +79 -0
- package/dist/rhf/form.js +143 -0
- package/dist/rhf/form.js.map +1 -0
- package/dist/rhf.d.ts +4 -0
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -0
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/table-text.d.ts +1 -0
- package/dist/table-text.js +3 -0
- package/dist/table-text.js.map +1 -0
- package/package.json +14 -1
- package/src/components/autocomplete.tsx +429 -0
- package/src/components/checkbox.tsx +16 -0
- package/src/components/chip.tsx +24 -2
- package/src/components/choice-card.tsx +305 -0
- package/src/components/combobox-core.tsx +228 -58
- package/src/components/combobox.tsx +58 -21
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/dialog-frame.tsx +179 -0
- package/src/components/disclosure.tsx +259 -0
- package/src/components/entity-combobox.tsx +41 -6
- package/src/components/file-button.tsx +458 -0
- package/src/components/file-dropzone.tsx +323 -117
- package/src/components/icon-picker.tsx +181 -0
- package/src/components/mini-calendar.tsx +7 -3
- package/src/components/modal.tsx +10 -2
- package/src/components/multi-entity-combobox.tsx +40 -6
- package/src/components/number-field.tsx +86 -10
- package/src/components/number-input.tsx +79 -2
- package/src/components/search-field.tsx +49 -6
- package/src/components/signature-pad.tsx +112 -0
- package/src/components/swatch-picker.tsx +141 -0
- package/src/components/switch.tsx +16 -0
- package/src/components/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/ui.tsx +400 -24
- package/src/i18n/defaults.ts +12 -1
- package/src/i18n/kit-labels.tsx +45 -5
- package/src/index.ts +19 -0
- package/src/lib/table-text.ts +265 -0
- package/src/rhf/form.tsx +300 -0
- package/src/rhf.ts +9 -0
- package/src/shell/app-shell.tsx +3 -1
- 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
|
+
}
|