@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.
- package/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +101 -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/chip.d.ts +22 -7
- 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/currency-select.js +10 -2
- package/dist/components/currency-select.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/date-picker.js +19 -7
- package/dist/components/date-picker.js.map +1 -1
- 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 +225 -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/month-picker.js +16 -5
- package/dist/components/month-picker.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/popover.js +4 -1
- package/dist/components/popover.js.map +1 -1
- 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/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/trigger-aria.d.ts +18 -0
- package/dist/components/trigger-aria.js +25 -0
- package/dist/components/trigger-aria.js.map +1 -0
- package/dist/components/ui.d.ts +164 -18
- package/dist/components/ui.js +207 -34
- 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 +425 -0
- package/src/components/chip.tsx +43 -7
- 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/currency-select.tsx +16 -2
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/date-picker.tsx +31 -6
- 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 +431 -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/month-picker.tsx +22 -4
- 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/popover.tsx +4 -1
- 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/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/trigger-aria.ts +42 -0
- package/src/components/ui.tsx +427 -40
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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 {...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{...
|
|
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
|
+
}
|