@eifi1/ui-kit 0.5.1 → 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 +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 +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/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/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 +425 -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 +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/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/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,259 @@
|
|
|
1
|
+
import { useEffect, useId, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
|
|
7
|
+
/** How long the fold takes, in ms. The same number as the `duration-200` below and as
|
|
8
|
+
* the unmount timer, because they are the same movement. */
|
|
9
|
+
const COLLAPSE_MS = 200;
|
|
10
|
+
|
|
11
|
+
/** Read at the moment of closing, like `useCloseTransition` does: the setting can
|
|
12
|
+
* change under a long-lived page, and jsdom/SSR have no `matchMedia` — where "no
|
|
13
|
+
* animation" is also the only correct answer, since nothing is painting. */
|
|
14
|
+
function prefersReducedMotion(): boolean {
|
|
15
|
+
return (
|
|
16
|
+
typeof window === "undefined" ||
|
|
17
|
+
typeof window.matchMedia !== "function" ||
|
|
18
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `extends` the div's props so an `id` (what a trigger's `aria-controls` points at), a
|
|
24
|
+
* `data-tour` anchor or a test id reaches the element that folds.
|
|
25
|
+
*/
|
|
26
|
+
export interface CollapseProps extends ComponentPropsWithoutRef<"div"> {
|
|
27
|
+
open: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Keep the children mounted while shut (hidden and `inert`) instead of unmounting
|
|
30
|
+
* them once the fold has finished closing.
|
|
31
|
+
*
|
|
32
|
+
* Off by default, and that default is a behaviour contract rather than a
|
|
33
|
+
* performance note: Lenkbank's disclosure bodies FETCH, and a body that only exists
|
|
34
|
+
* while open is what stops a closed card asking — without every caller carrying its
|
|
35
|
+
* own `enabled: open`. On for a body whose state must outlive a close (a half-typed
|
|
36
|
+
* form) or that must be found by the browser's find-in-page.
|
|
37
|
+
*/
|
|
38
|
+
keepMounted?: boolean;
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The fold on its own: content that opens and shuts in place, animated to a height
|
|
44
|
+
* nobody measured.
|
|
45
|
+
*
|
|
46
|
+
* THE TECHNIQUE is `AppShell`'s sidebar group's, lifted out so there is one copy of it.
|
|
47
|
+
* A `grid-template-rows` transition from `0fr` to `1fr` is the one way to animate to a
|
|
48
|
+
* content-sized height without measuring it — so a chart that resizes inside it, or a
|
|
49
|
+
* translated line that wraps, is still exactly as tall as it needs. `visibility` rides
|
|
50
|
+
* the same transition, so a shut body leaves the accessibility tree only once it has
|
|
51
|
+
* finished closing; `inert` takes it out of the tab order immediately.
|
|
52
|
+
*
|
|
53
|
+
* THE CHILDREN are unmounted once closed (see {@link CollapseProps.keepMounted}), but
|
|
54
|
+
* only once the track has finished closing: the track is what animates, so the
|
|
55
|
+
* children have to survive the movement that hides them. Under reduced motion they
|
|
56
|
+
* go at once — there is no movement to wait for — and the opening half is silenced by
|
|
57
|
+
* `motion-reduce:transition-none`, since tokens.css only silences the overlay
|
|
58
|
+
* animations by name.
|
|
59
|
+
*
|
|
60
|
+
* Padding and margins belong on a child: on this element, or the clipping one inside
|
|
61
|
+
* it, they would hold the row open by that much.
|
|
62
|
+
*/
|
|
63
|
+
export function Collapse({ open, keepMounted = false, children, className, style, ...rest }: CollapseProps) {
|
|
64
|
+
// Whether the children are still on their way out. Adjusted DURING render when
|
|
65
|
+
// `open` flips (React's "storing information from previous renders"), so a close
|
|
66
|
+
// under reduced motion unmounts in the same render that shut it — a test, and a
|
|
67
|
+
// screen reader, see the body go on the click — and a re-open mid-fold simply
|
|
68
|
+
// cancels the linger.
|
|
69
|
+
const [lingering, setLingering] = useState(false);
|
|
70
|
+
const [prevOpen, setPrevOpen] = useState(open);
|
|
71
|
+
if (open !== prevOpen) {
|
|
72
|
+
setPrevOpen(open);
|
|
73
|
+
setLingering(!open && !prefersReducedMotion());
|
|
74
|
+
}
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!lingering) return;
|
|
77
|
+
const timer = setTimeout(() => setLingering(false), COLLAPSE_MS);
|
|
78
|
+
return () => clearTimeout(timer);
|
|
79
|
+
}, [lingering]);
|
|
80
|
+
|
|
81
|
+
const mounted = open || lingering || keepMounted;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
{...rest}
|
|
86
|
+
inert={!open}
|
|
87
|
+
className={cn(
|
|
88
|
+
"grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none",
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
// Inline rather than `grid-rows-[…]` classes: a caller's `className` must not be
|
|
92
|
+
// able to pin the track open, and the two values ARE the state.
|
|
93
|
+
style={{ ...style, gridTemplateRows: open ? "1fr" : "0fr", visibility: open ? "visible" : "hidden" }}
|
|
94
|
+
>
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
"min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
|
98
|
+
open ? "opacity-100" : "opacity-0",
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{mounted && children}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `title` is omitted from the div's own props because this component already owns the
|
|
109
|
+
* name: here it is the header's content (and a ReactNode), not the browser's tooltip.
|
|
110
|
+
* Everything else reaches the outer element.
|
|
111
|
+
*/
|
|
112
|
+
export interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
113
|
+
/** What the header says. A node, so a status chip or a count can sit in it. */
|
|
114
|
+
title: ReactNode;
|
|
115
|
+
/** The smaller line under the title — what is inside, for a reader deciding whether
|
|
116
|
+
* to open it. */
|
|
117
|
+
hint?: ReactNode;
|
|
118
|
+
/**
|
|
119
|
+
* Who decides whether this one is open. Left out, the disclosure decides for itself,
|
|
120
|
+
* which is what one standing on its own wants. Passed, the caller does — a set of
|
|
121
|
+
* which one is open at a time, or an open state kept in the URL so the view can be
|
|
122
|
+
* linked to. There is no accordion component: "one open at a time" is a few lines in
|
|
123
|
+
* the caller and it usually owns a URL parameter, which is app state.
|
|
124
|
+
*/
|
|
125
|
+
open?: boolean;
|
|
126
|
+
/** Whether an UNCONTROLLED disclosure starts open. Ignored when `open` is passed. */
|
|
127
|
+
defaultOpen?: boolean;
|
|
128
|
+
/** Called with the next state on every toggle, controlled or not. */
|
|
129
|
+
onOpenChange?: (open: boolean) => void;
|
|
130
|
+
/**
|
|
131
|
+
* `card` (default) draws the kit's `Card` surface round header and body, with the
|
|
132
|
+
* chevron trailing the header and turning over — the "section that opens" of a
|
|
133
|
+
* settings or analysis page. `bare` draws nothing: a leading chevron that turns
|
|
134
|
+
* down, for an inline "Show 3 hidden accounts" inside something that already has
|
|
135
|
+
* its own surface.
|
|
136
|
+
*/
|
|
137
|
+
variant?: "card" | "bare";
|
|
138
|
+
/**
|
|
139
|
+
* Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern
|
|
140
|
+
* puts the button INSIDE the heading when the disclosure titles a section, so the
|
|
141
|
+
* page's heading outline still lists it. Off by default: an inline "show more" is
|
|
142
|
+
* not a section.
|
|
143
|
+
*/
|
|
144
|
+
headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
|
|
145
|
+
/** See {@link CollapseProps.keepMounted}. */
|
|
146
|
+
keepMounted?: boolean;
|
|
147
|
+
disabled?: boolean;
|
|
148
|
+
/** Extra classes for the header button. */
|
|
149
|
+
headerClassName?: string;
|
|
150
|
+
/** Extra classes for the body's wrapper — where its padding and spacing live. */
|
|
151
|
+
bodyClassName?: string;
|
|
152
|
+
children: ReactNode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A section that opens in place: a header button with `aria-expanded` and
|
|
157
|
+
* `aria-controls`, and a {@link Collapse} under it.
|
|
158
|
+
*
|
|
159
|
+
* Both apps had written this by hand — Lenkbank as a shared `CollapsibleCard` with ten
|
|
160
|
+
* importers, Keksdose three separate times (accounts, twice; the support panel) — and
|
|
161
|
+
* the copies had drifted: none of Keksdose's animated, and all three swapped a
|
|
162
|
+
* `ChevronRight` for a `ChevronDown` rather than turning one. Here the chevron is one
|
|
163
|
+
* icon that rotates, so the change of state is one element moving rather than two
|
|
164
|
+
* trading places.
|
|
165
|
+
*
|
|
166
|
+
* The body is unmounted while shut; see {@link CollapseProps.keepMounted} for why that
|
|
167
|
+
* is the default and when to opt out.
|
|
168
|
+
*/
|
|
169
|
+
export function Disclosure({
|
|
170
|
+
title,
|
|
171
|
+
hint,
|
|
172
|
+
open: controlled,
|
|
173
|
+
defaultOpen = false,
|
|
174
|
+
onOpenChange,
|
|
175
|
+
variant = "card",
|
|
176
|
+
headingAs: Heading,
|
|
177
|
+
keepMounted,
|
|
178
|
+
disabled,
|
|
179
|
+
headerClassName,
|
|
180
|
+
bodyClassName,
|
|
181
|
+
className,
|
|
182
|
+
children,
|
|
183
|
+
...rest
|
|
184
|
+
}: DisclosureProps) {
|
|
185
|
+
const [own, setOwn] = useState(defaultOpen);
|
|
186
|
+
const open = controlled ?? own;
|
|
187
|
+
const bodyId = useId();
|
|
188
|
+
const card = variant === "card";
|
|
189
|
+
|
|
190
|
+
const toggle = () => {
|
|
191
|
+
const next = !open;
|
|
192
|
+
if (controlled === undefined) setOwn(next);
|
|
193
|
+
onOpenChange?.(next);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const button = (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
aria-expanded={open}
|
|
200
|
+
aria-controls={bodyId}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
onClick={toggle}
|
|
203
|
+
className={cn(
|
|
204
|
+
"flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
205
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
206
|
+
card
|
|
207
|
+
? cn(
|
|
208
|
+
"items-center justify-between rounded-lg p-4 hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
|
|
209
|
+
open && "rounded-b-none",
|
|
210
|
+
)
|
|
211
|
+
: "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
|
|
212
|
+
headerClassName,
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{!card && <Chevron open={open} leading />}
|
|
216
|
+
<span className="min-w-0">
|
|
217
|
+
<span className={cn("block", card && "text-sm font-semibold text-[var(--text-primary)]")}>{title}</span>
|
|
218
|
+
{hint !== undefined && (
|
|
219
|
+
<span className="mt-0.5 block text-xs font-normal text-[var(--text-muted)]">{hint}</span>
|
|
220
|
+
)}
|
|
221
|
+
</span>
|
|
222
|
+
{card && <Chevron open={open} />}
|
|
223
|
+
</button>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
{...rest}
|
|
229
|
+
className={cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className)}
|
|
230
|
+
>
|
|
231
|
+
{/* Preflight already makes h1–h6 inherit size and weight, so the heading adds
|
|
232
|
+
structure and nothing visible. */}
|
|
233
|
+
{Heading ? <Heading>{button}</Heading> : button}
|
|
234
|
+
<Collapse id={bodyId} open={open} keepMounted={keepMounted}>
|
|
235
|
+
<div className={cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName)}>{children}</div>
|
|
236
|
+
</Collapse>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One icon for both variants, turned rather than swapped. `leading` (bare) points it
|
|
243
|
+
* along the reading direction while shut — right in LTR, left in RTL — and down when
|
|
244
|
+
* open; trailing (card) points down while shut and up when open, which is the card
|
|
245
|
+
* header's convention. Rotation, not a mirrored glyph, so RTL needs one opposite angle
|
|
246
|
+
* and no `scale` composing with the turn.
|
|
247
|
+
*/
|
|
248
|
+
function Chevron({ open, leading = false }: { open: boolean; leading?: boolean }) {
|
|
249
|
+
return (
|
|
250
|
+
<ChevronDown
|
|
251
|
+
aria-hidden
|
|
252
|
+
className={cn(
|
|
253
|
+
"shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none",
|
|
254
|
+
leading ? "size-3.5" : "size-4",
|
|
255
|
+
leading ? !open && "-rotate-90 rtl:rotate-90" : open && "rotate-180",
|
|
256
|
+
)}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -3,7 +3,12 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
|
3
3
|
import { X } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ComboboxPanel,
|
|
8
|
+
useComboboxCore,
|
|
9
|
+
useComboboxFieldError,
|
|
10
|
+
type ComboOption,
|
|
11
|
+
} from "./combobox-core";
|
|
7
12
|
import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
|
|
8
13
|
|
|
9
14
|
export type { ComboOption } from "./combobox-core";
|
|
@@ -19,8 +24,9 @@ export interface EntityComboboxProps<V extends string | number>
|
|
|
19
24
|
/** Already-loaded options (client-side filtered). Also used to resolve the
|
|
20
25
|
* trigger label for the current `value`. */
|
|
21
26
|
options?: ComboOption<V>[];
|
|
22
|
-
/** Async option source, debounced and called on open + as the query changes
|
|
23
|
-
*
|
|
27
|
+
/** Async option source, debounced and called on open + as the query changes
|
|
28
|
+
* (at `minChars` and up). Stale responses are ignored; a rejection empties the
|
|
29
|
+
* list and says `loadErrorLabel` instead of leaving the last query's rows up. When set, `options` is used only for label
|
|
24
30
|
* resolution, not as the result list. */
|
|
25
31
|
loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
|
|
26
32
|
/** External loading flag (OR-ed with the internal async state). */
|
|
@@ -42,6 +48,19 @@ export interface EntityComboboxProps<V extends string | number>
|
|
|
42
48
|
createLabel?: (query: string) => string;
|
|
43
49
|
/** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
|
|
44
50
|
invalid?: boolean;
|
|
51
|
+
/** What is wrong with the value, as {@link Input}'s `error`: rendered under the
|
|
52
|
+
* field, on the trigger's `aria-describedby`, and implies `invalid`. */
|
|
53
|
+
error?: ReactNode;
|
|
54
|
+
/** Narrow `options` client-side by the query. Default `true`; `false` shows them
|
|
55
|
+
* as given (a server-ranked list). */
|
|
56
|
+
filter?: boolean;
|
|
57
|
+
/** Offer nothing, and call no `loadOptions`, below this many characters. Default
|
|
58
|
+
* `0`, i.e. the list loads as the panel opens. */
|
|
59
|
+
minChars?: number;
|
|
60
|
+
/** `loadOptions` debounce. Default 150 ms. */
|
|
61
|
+
debounceMs?: number;
|
|
62
|
+
/** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
|
|
63
|
+
loadErrorLabel?: string;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -69,10 +88,23 @@ export function EntityCombobox<V extends string | number>({
|
|
|
69
88
|
createLabel,
|
|
70
89
|
className,
|
|
71
90
|
invalid,
|
|
91
|
+
error,
|
|
92
|
+
filter,
|
|
93
|
+
minChars,
|
|
94
|
+
debounceMs,
|
|
95
|
+
loadErrorLabel,
|
|
72
96
|
"aria-label": ariaLabel,
|
|
73
97
|
...rest
|
|
74
98
|
}: EntityComboboxProps<V>) {
|
|
75
|
-
const core = useComboboxCore<V>({
|
|
99
|
+
const core = useComboboxCore<V>({
|
|
100
|
+
options,
|
|
101
|
+
loadOptions,
|
|
102
|
+
loading,
|
|
103
|
+
filter,
|
|
104
|
+
minChars,
|
|
105
|
+
debounceMs,
|
|
106
|
+
});
|
|
107
|
+
const field = useComboboxFieldError(error, invalid);
|
|
76
108
|
// The props are the per-instance overrides, the provider the app-wide ones; a
|
|
77
109
|
// prop left `undefined` falls through to the provider rather than masking it.
|
|
78
110
|
const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
|
|
@@ -138,7 +170,8 @@ export function EntityCombobox<V extends string | number>({
|
|
|
138
170
|
(typeof label === "string" ? common.fieldValue(label, triggerText) : undefined)
|
|
139
171
|
}
|
|
140
172
|
disabled={disabled}
|
|
141
|
-
aria-invalid={
|
|
173
|
+
aria-invalid={field.isInvalid || undefined}
|
|
174
|
+
aria-describedby={field.describedBy}
|
|
142
175
|
onClick={() => !disabled && setOpen((o) => !o)}
|
|
143
176
|
// Down/Up opens the list from the closed trigger, per the APG. Enter and
|
|
144
177
|
// Space already do it through the button's own click.
|
|
@@ -154,7 +187,7 @@ export function EntityCombobox<V extends string | number>({
|
|
|
154
187
|
"pr-9",
|
|
155
188
|
label !== undefined && FIELD_FLOATING_PAD,
|
|
156
189
|
disabled && "cursor-not-allowed opacity-50",
|
|
157
|
-
|
|
190
|
+
field.isInvalid && FIELD_INVALID,
|
|
158
191
|
)}
|
|
159
192
|
>
|
|
160
193
|
<span className="flex min-w-0 items-center gap-2">
|
|
@@ -201,6 +234,7 @@ export function EntityCombobox<V extends string | number>({
|
|
|
201
234
|
searchPlaceholder={labels.search}
|
|
202
235
|
emptyLabel={labels.noResults}
|
|
203
236
|
closeLabel={closeLabel}
|
|
237
|
+
loadErrorLabel={loadErrorLabel}
|
|
204
238
|
isSelected={(v) => v === value}
|
|
205
239
|
onChoose={choose}
|
|
206
240
|
showCreate={showCreate}
|
|
@@ -210,6 +244,7 @@ export function EntityCombobox<V extends string | number>({
|
|
|
210
244
|
}}
|
|
211
245
|
createContent={labels.create(q)}
|
|
212
246
|
/>
|
|
247
|
+
{field.errorEl}
|
|
213
248
|
</div>
|
|
214
249
|
);
|
|
215
250
|
}
|