@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import type {
|
|
4
4
|
ComponentPropsWithoutRef,
|
|
@@ -18,6 +18,7 @@ import { type AnchorRect } from "../hooks/use-anchored-rect";
|
|
|
18
18
|
import { useAnchoredPanel, type AnchoredPanel } from "../hooks/use-anchored-panel";
|
|
19
19
|
import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
|
|
20
20
|
import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
21
|
+
import { hasMessage, mergeDescribedBy } from "./choice-parts";
|
|
21
22
|
|
|
22
23
|
export interface ComboOption<V extends string | number> {
|
|
23
24
|
value: V;
|
|
@@ -34,15 +35,126 @@ export interface ComboOption<V extends string | number> {
|
|
|
34
35
|
group?: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Where a combobox's rows come from, and how a query narrows them. Shared by the
|
|
40
|
+
* trigger pickers ({@link useComboboxCore}) and the inline {@link Autocomplete}, so
|
|
41
|
+
* the two cannot drift in what a failed lookup or a too-short query does.
|
|
42
|
+
*/
|
|
43
|
+
export interface OptionSourceOptions<V extends string | number> {
|
|
44
|
+
/** Already-loaded options (client-side filtered unless `filter` is false), also
|
|
45
|
+
* used to resolve labels. */
|
|
39
46
|
options?: ComboOption<V>[];
|
|
40
|
-
/** Async option source, debounced and race-safe; stale responses are ignored
|
|
47
|
+
/** Async option source, debounced and race-safe; stale responses are ignored,
|
|
48
|
+
* and a rejection is caught and reported as `failed`. */
|
|
41
49
|
loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
|
|
42
50
|
/** External loading flag, OR-ed with the internal async state. */
|
|
43
51
|
loading?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Narrow `options` by the query (label/sublabel substring). Default `true`.
|
|
54
|
+
*
|
|
55
|
+
* `false` shows `options` exactly as given, in the given order — for a list the
|
|
56
|
+
* caller has already searched and ranked on a server (Keksdose's geocoder), which a
|
|
57
|
+
* second, client-side substring filter could only get wrong: a provider that
|
|
58
|
+
* matches "Bahnhofstr." to "Bahnhofstrasse" is right, and a label filter would
|
|
59
|
+
* throw that row away.
|
|
60
|
+
*/
|
|
61
|
+
filter?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Below this many (trimmed) characters nothing is offered and `loadOptions` is not
|
|
64
|
+
* called. Default `0` — the pickers list everything on open. Set it for a source
|
|
65
|
+
* that is costly or rate-limited: one letter against a geocoder matches half a
|
|
66
|
+
* continent, and a request per opened panel is a request nobody asked for.
|
|
67
|
+
*/
|
|
68
|
+
minChars?: number;
|
|
69
|
+
/** Quiet time after the last keystroke before `loadOptions` runs. Default 150 ms. */
|
|
70
|
+
debounceMs?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OptionSource<V extends string | number> {
|
|
74
|
+
results: ComboOption<V>[];
|
|
75
|
+
busy: boolean;
|
|
76
|
+
/** The last lookup for this query REJECTED. The rows are emptied with it: results
|
|
77
|
+
* from an earlier query under a failed one would read as the answer to it. */
|
|
78
|
+
failed: boolean;
|
|
79
|
+
/** The query is shorter than `minChars`, so nothing was asked. */
|
|
80
|
+
tooShort: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The rows half of a combobox: client-side filtering, or a debounced, race-safe
|
|
85
|
+
* `loadOptions` with its loading and failure states.
|
|
86
|
+
*
|
|
87
|
+
* `active` gates the async side — a closed list asks nothing. Loading is DERIVED
|
|
88
|
+
* ("the settled answer is for a different query") rather than flagged on in the
|
|
89
|
+
* effect, so there is no render between a keystroke and the debounce in which an
|
|
90
|
+
* empty list could claim "No results" for a question still being asked.
|
|
91
|
+
*/
|
|
92
|
+
export function useOptionSource<V extends string | number>({
|
|
93
|
+
options,
|
|
94
|
+
loadOptions,
|
|
95
|
+
loading,
|
|
96
|
+
filter = true,
|
|
97
|
+
minChars = 0,
|
|
98
|
+
debounceMs = 150,
|
|
99
|
+
query,
|
|
100
|
+
active,
|
|
101
|
+
}: OptionSourceOptions<V> & { query: string; active: boolean }): OptionSource<V> {
|
|
102
|
+
const isAsync = typeof loadOptions === "function";
|
|
103
|
+
const tooShort = query.trim().length < minChars;
|
|
104
|
+
const [settled, setSettled] = useState<{
|
|
105
|
+
query: string | null;
|
|
106
|
+
results: ComboOption<V>[];
|
|
107
|
+
failed: boolean;
|
|
108
|
+
}>({ query: null, results: [], failed: false });
|
|
109
|
+
const loadRef = useRef(loadOptions);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
loadRef.current = loadOptions;
|
|
112
|
+
});
|
|
113
|
+
const reqId = useRef(0);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
// Bumped even when nothing is asked, so a request still in flight from a longer
|
|
117
|
+
// query cannot land after the user has deleted back below `minChars`.
|
|
118
|
+
const id = ++reqId.current;
|
|
119
|
+
if (!active || !isAsync || tooShort) return;
|
|
120
|
+
const t = setTimeout(
|
|
121
|
+
async () => {
|
|
122
|
+
try {
|
|
123
|
+
const r = await loadRef.current!(query);
|
|
124
|
+
if (reqId.current === id) setSettled({ query, results: r, failed: false });
|
|
125
|
+
} catch {
|
|
126
|
+
// The one path the old `try/finally` had no answer for: the list kept the
|
|
127
|
+
// previous query's rows and said nothing (Keksdose proposal §2).
|
|
128
|
+
if (reqId.current === id) setSettled({ query, results: [], failed: true });
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
query ? debounceMs : 0,
|
|
132
|
+
);
|
|
133
|
+
return () => clearTimeout(t);
|
|
134
|
+
}, [query, active, isAsync, tooShort, debounceMs]);
|
|
135
|
+
|
|
136
|
+
const clientResults = useMemo(() => {
|
|
137
|
+
if (isAsync) return [];
|
|
138
|
+
const src = options ?? [];
|
|
139
|
+
const q = query.trim().toLowerCase();
|
|
140
|
+
if (!filter || !q) return src;
|
|
141
|
+
return src.filter(
|
|
142
|
+
(o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q),
|
|
143
|
+
);
|
|
144
|
+
}, [isAsync, options, query, filter]);
|
|
145
|
+
|
|
146
|
+
const pending = isAsync && active && !tooShort && settled.query !== query;
|
|
147
|
+
const failed = isAsync && !tooShort && settled.failed;
|
|
148
|
+
return {
|
|
149
|
+
results: tooShort ? [] : isAsync ? (failed ? [] : settled.results) : clientResults,
|
|
150
|
+
busy: Boolean(loading) || pending,
|
|
151
|
+
failed: failed && !pending,
|
|
152
|
+
tooShort,
|
|
153
|
+
};
|
|
44
154
|
}
|
|
45
155
|
|
|
156
|
+
export type ComboboxCoreOptions<V extends string | number> = OptionSourceOptions<V>;
|
|
157
|
+
|
|
46
158
|
export interface ComboboxCore<V extends string | number> {
|
|
47
159
|
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
48
160
|
panelRef: RefObject<HTMLDivElement | null>;
|
|
@@ -55,6 +167,10 @@ export interface ComboboxCore<V extends string | number> {
|
|
|
55
167
|
setActive: Dispatch<SetStateAction<number>>;
|
|
56
168
|
results: ComboOption<V>[];
|
|
57
169
|
busy: boolean;
|
|
170
|
+
/** See {@link OptionSource}. */
|
|
171
|
+
failed: boolean;
|
|
172
|
+
tooShort: boolean;
|
|
173
|
+
minChars: number;
|
|
58
174
|
rect: AnchorRect | null;
|
|
59
175
|
/** Where the dropdown goes, clamped to the visible viewport (feedback #135). */
|
|
60
176
|
placement: AnchoredPanel;
|
|
@@ -75,8 +191,8 @@ export interface ComboboxCore<V extends string | number> {
|
|
|
75
191
|
*/
|
|
76
192
|
export function useComboboxCore<V extends string | number>({
|
|
77
193
|
options,
|
|
78
|
-
|
|
79
|
-
|
|
194
|
+
minChars = 0,
|
|
195
|
+
...source
|
|
80
196
|
}: ComboboxCoreOptions<V>): ComboboxCore<V> {
|
|
81
197
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
82
198
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
@@ -85,13 +201,6 @@ export function useComboboxCore<V extends string | number>({
|
|
|
85
201
|
const [query, setQuery] = useState("");
|
|
86
202
|
const [active, setActive] = useState(0);
|
|
87
203
|
|
|
88
|
-
const isAsync = typeof loadOptions === "function";
|
|
89
|
-
const [asyncResults, setAsyncResults] = useState<ComboOption<V>[]>([]);
|
|
90
|
-
const [asyncLoading, setAsyncLoading] = useState(false);
|
|
91
|
-
const loadRef = useRef(loadOptions);
|
|
92
|
-
loadRef.current = loadOptions;
|
|
93
|
-
const reqId = useRef(0);
|
|
94
|
-
|
|
95
204
|
const close = () => setOpen(false);
|
|
96
205
|
// Escape unmounts the panel the user is typing in; without this the browser drops
|
|
97
206
|
// focus on <body>, so the caret disappears and the next Tab restarts at the top of
|
|
@@ -131,35 +240,15 @@ export function useComboboxCore<V extends string | number>({
|
|
|
131
240
|
return () => cancelAnimationFrame(id);
|
|
132
241
|
}, [open]);
|
|
133
242
|
|
|
134
|
-
// Debounced, race-safe async search (only when loadOptions is provided)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
} finally {
|
|
144
|
-
if (reqId.current === id) setAsyncLoading(false);
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
const t = setTimeout(run, query ? 150 : 0);
|
|
148
|
-
return () => clearTimeout(t);
|
|
149
|
-
}, [query, open, isAsync]);
|
|
150
|
-
|
|
151
|
-
const clientResults = useMemo(() => {
|
|
152
|
-
if (isAsync) return [];
|
|
153
|
-
const src = options ?? [];
|
|
154
|
-
const q = query.trim().toLowerCase();
|
|
155
|
-
if (!q) return src;
|
|
156
|
-
return src.filter(
|
|
157
|
-
(o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q),
|
|
158
|
-
);
|
|
159
|
-
}, [isAsync, options, query]);
|
|
160
|
-
|
|
161
|
-
const results = isAsync ? asyncResults : clientResults;
|
|
162
|
-
const busy = Boolean(loading) || (isAsync && asyncLoading);
|
|
243
|
+
// Debounced, race-safe async search (only when loadOptions is provided) or the
|
|
244
|
+
// client-side filter — see {@link useOptionSource}.
|
|
245
|
+
const { results, busy, failed, tooShort } = useOptionSource<V>({
|
|
246
|
+
...source,
|
|
247
|
+
options,
|
|
248
|
+
minChars,
|
|
249
|
+
query,
|
|
250
|
+
active: open,
|
|
251
|
+
});
|
|
163
252
|
|
|
164
253
|
// Accumulate every option we've seen so a selected value can render its label
|
|
165
254
|
// even after the async list has moved on to other query results.
|
|
@@ -188,6 +277,9 @@ export function useComboboxCore<V extends string | number>({
|
|
|
188
277
|
setActive,
|
|
189
278
|
results,
|
|
190
279
|
busy,
|
|
280
|
+
failed,
|
|
281
|
+
tooShort,
|
|
282
|
+
minChars,
|
|
191
283
|
rect,
|
|
192
284
|
placement,
|
|
193
285
|
cacheRef,
|
|
@@ -196,6 +288,45 @@ export function useComboboxCore<V extends string | number>({
|
|
|
196
288
|
};
|
|
197
289
|
}
|
|
198
290
|
|
|
291
|
+
/** The error line under a field — the same type as `ui.tsx`'s (module-private) one,
|
|
292
|
+
* so a combobox's message is indistinguishable from an Input's. */
|
|
293
|
+
const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* {@link Input}'s `error` for the combobox family: the message under the field, its
|
|
297
|
+
* id on the control's `aria-describedby` (merged, never replacing — the hint the
|
|
298
|
+
* caller pointed at stays first), and `invalid` implied. ui.tsx's `useFieldError` is
|
|
299
|
+
* module-private, so this restates it on the same rules `choice-parts.ts` does.
|
|
300
|
+
*/
|
|
301
|
+
export function useComboboxFieldError(
|
|
302
|
+
error: ReactNode,
|
|
303
|
+
invalid: boolean | undefined,
|
|
304
|
+
describedBy?: string,
|
|
305
|
+
) {
|
|
306
|
+
const errorId = useId();
|
|
307
|
+
const has = hasMessage(error);
|
|
308
|
+
return {
|
|
309
|
+
isInvalid: Boolean(invalid) || has,
|
|
310
|
+
describedBy: mergeDescribedBy(describedBy, has && errorId),
|
|
311
|
+
errorEl: has ? (
|
|
312
|
+
<p id={errorId} className={FIELD_ERROR_CLASS}>
|
|
313
|
+
{error}
|
|
314
|
+
</p>
|
|
315
|
+
) : null,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The anchored suggestion list's box and rows — one look for {@link Combobox},
|
|
320
|
+
* {@link InlineEntityCombobox} and {@link Autocomplete}. */
|
|
321
|
+
export const SUGGESTION_LIST_CLASS =
|
|
322
|
+
"max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg";
|
|
323
|
+
|
|
324
|
+
export const suggestionRowClass = (isActive: boolean) =>
|
|
325
|
+
cn(
|
|
326
|
+
"block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
|
|
327
|
+
isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
|
|
328
|
+
);
|
|
329
|
+
|
|
199
330
|
/**
|
|
200
331
|
* Keep the option the keyboard is on inside the visible part of the list.
|
|
201
332
|
*
|
|
@@ -236,6 +367,8 @@ export interface ComboboxPanelProps<V extends string | number>
|
|
|
236
367
|
* desktop, where the panel sits under the field that already says it. */
|
|
237
368
|
sheetTitle?: ReactNode;
|
|
238
369
|
closeLabel?: string;
|
|
370
|
+
/** Default: `combobox.loadError` from the {@link UiKitProvider}, else English. */
|
|
371
|
+
loadErrorLabel?: string;
|
|
239
372
|
}
|
|
240
373
|
|
|
241
374
|
/**
|
|
@@ -277,6 +410,7 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
277
410
|
createContent,
|
|
278
411
|
sheetTitle,
|
|
279
412
|
closeLabel,
|
|
413
|
+
loadErrorLabel,
|
|
280
414
|
className,
|
|
281
415
|
style,
|
|
282
416
|
...rest
|
|
@@ -290,6 +424,8 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
290
424
|
setQuery,
|
|
291
425
|
results,
|
|
292
426
|
busy,
|
|
427
|
+
failed,
|
|
428
|
+
tooShort,
|
|
293
429
|
active,
|
|
294
430
|
setActive,
|
|
295
431
|
closeToTrigger,
|
|
@@ -337,6 +473,51 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
337
473
|
}
|
|
338
474
|
};
|
|
339
475
|
|
|
476
|
+
// What the list holds, said in ONE place that is both on screen and a polite
|
|
477
|
+
// live region. Focus stays in the search box, so the list changing under it was
|
|
478
|
+
// silent — a reader typed and heard nothing back, and a failed lookup was the same
|
|
479
|
+
// silence as a slow one. Outside the listbox, which may own only options.
|
|
480
|
+
//
|
|
481
|
+
// Three different empties, and only one of them is "nothing matched": a lookup
|
|
482
|
+
// that failed must not claim there is nothing to find, and a query under
|
|
483
|
+
// `minChars` was never asked. With rows showing, the count is for the reader only.
|
|
484
|
+
const message: ReactNode =
|
|
485
|
+
busy && results.length === 0 ? (
|
|
486
|
+
// The ellipsis is the picture; the words are for a reader, who otherwise met
|
|
487
|
+
// an empty-looking list with a lone "…" in it — or, in most readers, nothing
|
|
488
|
+
// at all, since punctuation alone is skipped.
|
|
489
|
+
<>
|
|
490
|
+
<span aria-hidden>…</span>
|
|
491
|
+
<span className="sr-only">{labels.loading}</span>
|
|
492
|
+
</>
|
|
493
|
+
) : !busy && results.length === 0 && !showCreate ? (
|
|
494
|
+
failed ? (
|
|
495
|
+
(loadErrorLabel ?? labels.loadError)
|
|
496
|
+
) : tooShort ? (
|
|
497
|
+
labels.minChars(core.minChars)
|
|
498
|
+
) : (
|
|
499
|
+
(emptyLabel ?? labels.noResults)
|
|
500
|
+
)
|
|
501
|
+
) : null;
|
|
502
|
+
// `relative` so the sr-only text has a local containing block (see
|
|
503
|
+
// sr-only-containment.test).
|
|
504
|
+
const announcement = (
|
|
505
|
+
<div
|
|
506
|
+
role="status"
|
|
507
|
+
aria-live="polite"
|
|
508
|
+
className={cn(
|
|
509
|
+
"relative",
|
|
510
|
+
message !== null && cn("py-2 text-sm", isPhone ? "px-4" : "px-3"),
|
|
511
|
+
failed ? "text-[var(--danger)]" : "text-[var(--text-muted)]",
|
|
512
|
+
)}
|
|
513
|
+
>
|
|
514
|
+
{message ??
|
|
515
|
+
(results.length > 0 && (
|
|
516
|
+
<span className="sr-only">{labels.resultCount(results.length)}</span>
|
|
517
|
+
))}
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
|
|
340
521
|
if (!core.open || typeof document === "undefined") return null;
|
|
341
522
|
|
|
342
523
|
const list = (
|
|
@@ -346,21 +527,6 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
346
527
|
aria-multiselectable={multi}
|
|
347
528
|
className={cn("min-h-0 flex-1 overflow-y-auto py-1", isPhone && "flex-none")}
|
|
348
529
|
>
|
|
349
|
-
{busy && results.length === 0 && (
|
|
350
|
-
// The ellipsis is the picture; the words are for a reader, who otherwise met
|
|
351
|
-
// an empty-looking list with a lone "…" in it — or, in most readers, nothing
|
|
352
|
-
// at all, since punctuation alone is skipped. `relative` so the sr-only text
|
|
353
|
-
// has a local containing block (see sr-only-containment.test).
|
|
354
|
-
<li className="relative px-3 py-2 text-sm text-[var(--text-muted)]">
|
|
355
|
-
<span aria-hidden>…</span>
|
|
356
|
-
<span className="sr-only">{labels.loading}</span>
|
|
357
|
-
</li>
|
|
358
|
-
)}
|
|
359
|
-
{!busy && results.length === 0 && !showCreate && (
|
|
360
|
-
<li className="px-3 py-2 text-sm text-[var(--text-muted)]">
|
|
361
|
-
{emptyLabel ?? labels.noResults}
|
|
362
|
-
</li>
|
|
363
|
-
)}
|
|
364
530
|
{results.map((o, i) => {
|
|
365
531
|
const selected = isSelected(o.value);
|
|
366
532
|
// One heading per group rather than a grey suffix on every row (#136), and
|
|
@@ -488,7 +654,10 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
488
654
|
inputRef={inputRef}
|
|
489
655
|
closeLabel={closeLabel}
|
|
490
656
|
>
|
|
491
|
-
<div onKeyDown={onKeyDown}>
|
|
657
|
+
<div onKeyDown={onKeyDown} className="relative">
|
|
658
|
+
{list}
|
|
659
|
+
{announcement}
|
|
660
|
+
</div>
|
|
492
661
|
</PickerSheet>
|
|
493
662
|
);
|
|
494
663
|
}
|
|
@@ -534,6 +703,7 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
534
703
|
{/* One list, two containers: the desktop panel and the phone sheet render
|
|
535
704
|
the same rows through the same handlers (live #200). */}
|
|
536
705
|
{list}
|
|
706
|
+
{announcement}
|
|
537
707
|
</div>,
|
|
538
708
|
document.body,
|
|
539
709
|
);
|
|
@@ -8,13 +8,19 @@ import { useDropdown } from "./dropdown";
|
|
|
8
8
|
import { useAnchoredPanel } from "../hooks/use-anchored-panel";
|
|
9
9
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
10
10
|
import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
SUGGESTION_LIST_CLASS,
|
|
13
|
+
suggestionRowClass,
|
|
14
|
+
useActiveOptionScroll,
|
|
15
|
+
useComboboxFieldError,
|
|
16
|
+
type ComboOption,
|
|
17
|
+
} from "./combobox-core";
|
|
12
18
|
import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
13
19
|
|
|
14
20
|
// One look for both combobox flavors below — the suggestion list and its rows
|
|
15
|
-
// must stay pixel-identical between the free-text and the id-keyed variant
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
// must stay pixel-identical between the free-text and the id-keyed variant (and
|
|
22
|
+
// `Autocomplete`, which shares them from the core).
|
|
23
|
+
const LIST_CLASS = SUGGESTION_LIST_CLASS;
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* "This focus came from a mouse button that is not the left one" — for the two
|
|
@@ -105,11 +111,7 @@ function SuggestionList({
|
|
|
105
111
|
);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
const rowClass =
|
|
109
|
-
cn(
|
|
110
|
-
"block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
|
|
111
|
-
isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
|
|
112
|
-
);
|
|
114
|
+
const rowClass = suggestionRowClass;
|
|
113
115
|
|
|
114
116
|
/**
|
|
115
117
|
* Four of the div's own attributes are omitted because this component already owns
|
|
@@ -133,6 +135,14 @@ export interface ComboboxProps
|
|
|
133
135
|
* delete the `[&_input]:…` copy of {@link FIELD_INVALID} it had been carrying
|
|
134
136
|
* because this component had no `invalid` of its own. */
|
|
135
137
|
invalid?: boolean;
|
|
138
|
+
/** What is wrong with the value, as {@link Input}'s `error`: rendered under the
|
|
139
|
+
* field, on the `<input>`'s `aria-describedby`, and implies `invalid` (lenkbank's
|
|
140
|
+
* scope fields, which moved here off `<Input list>` + `<datalist>`). */
|
|
141
|
+
error?: ReactNode;
|
|
142
|
+
/** As `<input disabled>`: no focus, no list, the field's settled look. The chevron
|
|
143
|
+
* stops toggling with it — it is a mouse target the input's own `disabled` does
|
|
144
|
+
* not reach. */
|
|
145
|
+
disabled?: boolean;
|
|
136
146
|
/** Heading an option belongs under. Supplying it makes this list read exactly
|
|
137
147
|
* like {@link InlineEntityCombobox}'s — one heading per group with its rows
|
|
138
148
|
* indented beneath — instead of a flat list (feedback #136 rework: the payee
|
|
@@ -211,6 +221,8 @@ export function Combobox({
|
|
|
211
221
|
closeLabel,
|
|
212
222
|
createLabel,
|
|
213
223
|
invalid,
|
|
224
|
+
error,
|
|
225
|
+
disabled,
|
|
214
226
|
optionAdornment,
|
|
215
227
|
autoFocus,
|
|
216
228
|
onBlur,
|
|
@@ -218,6 +230,7 @@ export function Combobox({
|
|
|
218
230
|
"aria-label": ariaLabel,
|
|
219
231
|
...rest
|
|
220
232
|
}: ComboboxProps) {
|
|
233
|
+
const field = useComboboxFieldError(error, invalid);
|
|
221
234
|
const generated = useId();
|
|
222
235
|
const fieldId = id ?? generated;
|
|
223
236
|
// Derived from the GENERATED id, never from `id`: a caller's id is theirs to
|
|
@@ -373,8 +386,10 @@ export function Combobox({
|
|
|
373
386
|
aria-controls={listboxId}
|
|
374
387
|
aria-activedescendant={activeId}
|
|
375
388
|
aria-autocomplete="list"
|
|
376
|
-
aria-invalid={
|
|
389
|
+
aria-invalid={field.isInvalid || undefined}
|
|
390
|
+
aria-describedby={field.describedBy}
|
|
377
391
|
autoComplete="off"
|
|
392
|
+
disabled={disabled}
|
|
378
393
|
autoFocus={autoFocus}
|
|
379
394
|
onBlur={onBlur}
|
|
380
395
|
onMouseDown={primaryOnly.onMouseDown}
|
|
@@ -444,21 +459,30 @@ export function Combobox({
|
|
|
444
459
|
// the desktop list is capped at 8 rows — so jumping it would be worth
|
|
445
460
|
// almost nothing and would cost the one gesture that field is used with.
|
|
446
461
|
}}
|
|
447
|
-
className={cn(
|
|
462
|
+
className={cn(
|
|
463
|
+
FIELD_BASE,
|
|
464
|
+
label !== undefined && FIELD_FLOATING_PAD,
|
|
465
|
+
"pr-9",
|
|
466
|
+
field.isInvalid && FIELD_INVALID,
|
|
467
|
+
)}
|
|
448
468
|
/>
|
|
449
469
|
<ChevronDown
|
|
450
470
|
aria-hidden
|
|
451
471
|
onMouseDown={(e) => {
|
|
452
472
|
// Toggle on the chevron without stealing focus from the input.
|
|
453
473
|
e.preventDefault();
|
|
454
|
-
setOpen((o) => !o);
|
|
474
|
+
if (!disabled) setOpen((o) => !o);
|
|
455
475
|
}}
|
|
456
|
-
className=
|
|
476
|
+
className={cn(
|
|
477
|
+
"absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
478
|
+
disabled ? "opacity-50" : "cursor-pointer",
|
|
479
|
+
)}
|
|
457
480
|
/>
|
|
458
481
|
</div>
|
|
482
|
+
{field.errorEl}
|
|
459
483
|
{isPhone && (
|
|
460
484
|
<PickerSheet
|
|
461
|
-
open={open}
|
|
485
|
+
open={open && !disabled}
|
|
462
486
|
onClose={() => setOpen(false)}
|
|
463
487
|
title={label}
|
|
464
488
|
// The sheet's input IS the field: this is a free-text control, so what is
|
|
@@ -523,7 +547,7 @@ export function Combobox({
|
|
|
523
547
|
</ul>
|
|
524
548
|
</PickerSheet>
|
|
525
549
|
)}
|
|
526
|
-
{!isPhone && open && (matches.length > 0 || createRow) && (
|
|
550
|
+
{!isPhone && open && !disabled && (matches.length > 0 || createRow) && (
|
|
527
551
|
<SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
|
|
528
552
|
{createRow && (
|
|
529
553
|
<li role="presentation">
|
|
@@ -609,6 +633,8 @@ export interface InlineEntityComboboxProps<V extends string | number>
|
|
|
609
633
|
value: V | null;
|
|
610
634
|
/** Required and unanswered — see {@link Combobox}'s `invalid`. */
|
|
611
635
|
invalid?: boolean;
|
|
636
|
+
/** What is wrong with the value — see {@link Combobox}'s `error`. */
|
|
637
|
+
error?: ReactNode;
|
|
612
638
|
/** A picked/typed option emits its id; emptying the field emits `null`. */
|
|
613
639
|
onChange: (v: V | null) => void;
|
|
614
640
|
options: ComboOption<V>[];
|
|
@@ -676,9 +702,11 @@ export function InlineEntityCombobox<V extends string | number>({
|
|
|
676
702
|
clearable,
|
|
677
703
|
clearLabel,
|
|
678
704
|
invalid,
|
|
705
|
+
error,
|
|
679
706
|
"aria-label": ariaLabel,
|
|
680
707
|
...rest
|
|
681
708
|
}: InlineEntityComboboxProps<V>) {
|
|
709
|
+
const field = useComboboxFieldError(error, invalid);
|
|
682
710
|
const generated = useId();
|
|
683
711
|
const fieldId = id ?? generated;
|
|
684
712
|
// See the twin above on why these hang off the generated id.
|
|
@@ -816,7 +844,8 @@ export function InlineEntityCombobox<V extends string | number>({
|
|
|
816
844
|
aria-controls={listboxId}
|
|
817
845
|
aria-activedescendant={activeId}
|
|
818
846
|
aria-autocomplete="list"
|
|
819
|
-
aria-invalid={
|
|
847
|
+
aria-invalid={field.isInvalid || undefined}
|
|
848
|
+
aria-describedby={field.describedBy}
|
|
820
849
|
autoComplete="off"
|
|
821
850
|
disabled={disabled}
|
|
822
851
|
autoFocus={autoFocus}
|
|
@@ -875,7 +904,12 @@ export function InlineEntityCombobox<V extends string | number>({
|
|
|
875
904
|
// Home/End stay with the caret — see the note in {@link Combobox}: this
|
|
876
905
|
// field is editable, and its text is what `reconcile` judges.
|
|
877
906
|
}}
|
|
878
|
-
className={cn(
|
|
907
|
+
className={cn(
|
|
908
|
+
FIELD_BASE,
|
|
909
|
+
label !== undefined && FIELD_FLOATING_PAD,
|
|
910
|
+
"pr-9",
|
|
911
|
+
field.isInvalid && FIELD_INVALID,
|
|
912
|
+
)}
|
|
879
913
|
/>
|
|
880
914
|
{showClear ? (
|
|
881
915
|
<button
|
|
@@ -906,9 +940,11 @@ export function InlineEntityCombobox<V extends string | number>({
|
|
|
906
940
|
<ChevronDown
|
|
907
941
|
aria-hidden
|
|
908
942
|
onMouseDown={(e) => {
|
|
909
|
-
// Toggle on the chevron without stealing focus from the input
|
|
943
|
+
// Toggle on the chevron without stealing focus from the input — and
|
|
944
|
+
// not at all on a disabled field, which the input's own `disabled`
|
|
945
|
+
// does not stop from here.
|
|
910
946
|
e.preventDefault();
|
|
911
|
-
setOpen((o) => !o);
|
|
947
|
+
if (!disabled) setOpen((o) => !o);
|
|
912
948
|
}}
|
|
913
949
|
className={cn(
|
|
914
950
|
"absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
|
|
@@ -917,9 +953,10 @@ export function InlineEntityCombobox<V extends string | number>({
|
|
|
917
953
|
/>
|
|
918
954
|
)}
|
|
919
955
|
</div>
|
|
956
|
+
{field.errorEl}
|
|
920
957
|
{isPhone && (
|
|
921
958
|
<PickerSheet
|
|
922
|
-
open={open}
|
|
959
|
+
open={open && !disabled}
|
|
923
960
|
// Closing without choosing keeps the value: `text` was never emptied, so
|
|
924
961
|
// reconcile has nothing to undo — it just puts the label back.
|
|
925
962
|
onClose={reconcile}
|
|
@@ -966,7 +1003,7 @@ export function InlineEntityCombobox<V extends string | number>({
|
|
|
966
1003
|
</ul>
|
|
967
1004
|
</PickerSheet>
|
|
968
1005
|
)}
|
|
969
|
-
{!isPhone && open && matches.length > 0 && (
|
|
1006
|
+
{!isPhone && open && !disabled && matches.length > 0 && (
|
|
970
1007
|
<SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
|
|
971
1008
|
{matches.map((o, i) => (
|
|
972
1009
|
// A group heading is emitted at each group boundary rather than repeating
|
|
@@ -4,7 +4,12 @@ import { Check } from "lucide-react";
|
|
|
4
4
|
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./ui";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from "./dropdown";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_COMMON_LABELS,
|
|
9
|
+
DEFAULT_CURRENCY_LABELS,
|
|
10
|
+
useKitLabels,
|
|
11
|
+
useKitLocale,
|
|
12
|
+
} from "../i18n/kit-labels";
|
|
8
13
|
|
|
9
14
|
export interface CurrencyOption {
|
|
10
15
|
code: string;
|
|
@@ -159,6 +164,7 @@ export function CurrencySelect({
|
|
|
159
164
|
const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
|
|
160
165
|
// `labels` > `currency` from the provider > English.
|
|
161
166
|
const text = useKitLabels("currency", DEFAULT_CURRENCY_LABELS, labels);
|
|
167
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
162
168
|
const locale = useKitLocale();
|
|
163
169
|
|
|
164
170
|
const selected = getCurrency(value);
|
|
@@ -180,6 +186,14 @@ export function CurrencySelect({
|
|
|
180
186
|
|
|
181
187
|
const listboxId = `${useId()}-listbox`;
|
|
182
188
|
|
|
189
|
+
// The trigger's NAME. A combobox does not take its name from its content, and the
|
|
190
|
+
// floating label is a <span>, not a <label for> — so without `aria-label` from the
|
|
191
|
+
// caller this trigger had no name at all (keksdose's test suite, 0.5.0). Composed the
|
|
192
|
+
// way MultiSelect and the entity comboboxes compose theirs, "label: value", with the
|
|
193
|
+
// `currency` word standing in for a missing label so it is never nameless.
|
|
194
|
+
const fieldName = typeof label === "string" ? label : text.currency;
|
|
195
|
+
const name = ariaLabel ?? common.fieldValue(fieldName, selected ? selected.code : (placeholder ?? text.currency));
|
|
196
|
+
|
|
183
197
|
return (
|
|
184
198
|
// The wrapper takes `rest`; the trigger takes the name. This field's label is a
|
|
185
199
|
// floating <span> rather than a <label for>, so a caller who wants the control to
|
|
@@ -188,7 +202,7 @@ export function CurrencySelect({
|
|
|
188
202
|
{label !== undefined && <FieldLabel>{label}</FieldLabel>}
|
|
189
203
|
<button
|
|
190
204
|
type="button"
|
|
191
|
-
aria-label={
|
|
205
|
+
aria-label={name}
|
|
192
206
|
onClick={() => setOpen((v) => !v)}
|
|
193
207
|
// The implicit `button` role supports neither `aria-expanded` nor
|
|
194
208
|
// `aria-invalid`, so this trigger previously wore a red border and announced
|