@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
package/README.md
CHANGED
|
@@ -521,7 +521,56 @@ re-slicing of it, never a second API.
|
|
|
521
521
|
`Input`, `Select`, `Textarea` and `AmountInput` forward their `ref` to the
|
|
522
522
|
underlying DOM element, so they work directly with react-hook-form:
|
|
523
523
|
`<Input {...field} />` (from `Controller`/`register`) attaches RHF's ref, giving
|
|
524
|
-
focus-and-scroll-to-error for free.
|
|
524
|
+
focus-and-scroll-to-error for free. For the full `FormField`/`FormControl` scaffolding,
|
|
525
|
+
see `@eifi1/ui-kit/rhf` below.
|
|
526
|
+
|
|
527
|
+
### react-hook-form — `@eifi1/ui-kit/rhf` (optional)
|
|
528
|
+
|
|
529
|
+
A thin adapter in shadcn's `form.tsx` shape, and the only entry that needs
|
|
530
|
+
`react-hook-form` (an **optional** peer, `^7.55.0`). The main barrel does not import it,
|
|
531
|
+
so apps without a form library install nothing.
|
|
532
|
+
|
|
533
|
+
```tsx
|
|
534
|
+
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "@eifi1/ui-kit/rhf";
|
|
535
|
+
|
|
536
|
+
<Form {...form}>
|
|
537
|
+
<FormField control={form.control} name="amount" render={({ field }) => (
|
|
538
|
+
<FormItem>
|
|
539
|
+
<FormLabel required>Amount</FormLabel>
|
|
540
|
+
<FormControl>
|
|
541
|
+
<NumberField value={field.value} onCommit={field.onChange} />
|
|
542
|
+
</FormControl>
|
|
543
|
+
<FormMessage />
|
|
544
|
+
</FormItem>
|
|
545
|
+
)} />
|
|
546
|
+
</Form>
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
`FormControl` gives its one child `id`, `aria-describedby` (only the description and
|
|
550
|
+
message actually rendered, merged after the child's own) and `aria-invalid` — which every
|
|
551
|
+
kit field now also PAINTS from, so no extra `invalid` prop is needed. `FormMessage` shows
|
|
552
|
+
the form's own message; the kit adds no text. `useFormField()` exposes the ids and field
|
|
553
|
+
state for custom parts.
|
|
554
|
+
|
|
555
|
+
### Pasted and imported tables — `@eifi1/ui-kit/table-text`
|
|
556
|
+
|
|
557
|
+
Pure functions over strings (no React, nothing to install): one lexer for a table pasted
|
|
558
|
+
out of a spreadsheet or read from a CSV, with the comma decided by a named rule rather
|
|
559
|
+
than a guess.
|
|
560
|
+
|
|
561
|
+
- `parseTable(text, { decimal, columns?, headerLines? })` → `{ rows, header, decimalComma, skipped }`
|
|
562
|
+
- `decimal: "whole-text"` — a **file**: separator and decimal mark decided once for the
|
|
563
|
+
whole text, and reported back as `decimalComma`.
|
|
564
|
+
- `decimal: "per-line"` — a **paste**: each line read on its own evidence.
|
|
565
|
+
- `columns` slices extra columns off and reports shorter lines; `skipped` holds 1-based
|
|
566
|
+
line numbers as they stand in the text.
|
|
567
|
+
- `parseRows(text, width)` — the paste door: `{ rows }`, or `{ error: line }` naming the
|
|
568
|
+
first unreadable line (never a silent empty table).
|
|
569
|
+
- `splitRow(line)`, `cellNumber(cell)`, `isCellNumber(cell)` — a single cell's comma is
|
|
570
|
+
always a decimal mark.
|
|
571
|
+
|
|
572
|
+
Not handled, on purpose: thousands separators (reported, not guessed), quoted fields,
|
|
573
|
+
empty cells held open as holes.
|
|
525
574
|
|
|
526
575
|
## i18n
|
|
527
576
|
|
|
@@ -20,6 +20,13 @@ import './sparkline.js';
|
|
|
20
20
|
import './stat-tile.js';
|
|
21
21
|
import './signature-pad.js';
|
|
22
22
|
import './password-strength.js';
|
|
23
|
+
import './danger-confirm.js';
|
|
24
|
+
import './swatch-picker.js';
|
|
25
|
+
import './tile-radio.js';
|
|
26
|
+
import './icon-picker.js';
|
|
27
|
+
import './dialog-frame.js';
|
|
28
|
+
import './modal.js';
|
|
29
|
+
import './file-button.js';
|
|
23
30
|
|
|
24
31
|
interface AmountInputProps {
|
|
25
32
|
value: string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, ReactNode, RefAttributes, ReactElement } from 'react';
|
|
2
|
+
import { ComboOption } from './combobox-core.js';
|
|
3
|
+
import '../hooks/use-anchored-rect.js';
|
|
4
|
+
import '../hooks/use-anchored-panel.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `value`/`onChange` are the TEXT's, and `onSelect` is "a suggestion was taken" —
|
|
8
|
+
* not the DOM's text-selection event — so the input's own spellings of those three
|
|
9
|
+
* are omitted. Everything else reaches the `<input>`, the way it does on {@link Input}.
|
|
10
|
+
*/
|
|
11
|
+
interface AutocompleteProps<V extends string | number = string> extends Omit<ComponentPropsWithoutRef<"input">, "value" | "defaultValue" | "onChange" | "onSelect" | "children" | "type"> {
|
|
12
|
+
/** What is in the field. Controlled, and never reset by the component: opening,
|
|
13
|
+
* closing and a failed lookup all leave it exactly as typed. */
|
|
14
|
+
value: string;
|
|
15
|
+
/** Every keystroke — and, with `fillOnSelect`, the label of a taken suggestion. */
|
|
16
|
+
onChange: (text: string) => void;
|
|
17
|
+
/** Suggestions the caller already has (e.g. from its own query hook). Narrowed by
|
|
18
|
+
* the text unless `filter={false}`. */
|
|
19
|
+
options?: ComboOption<V>[];
|
|
20
|
+
/** Or: fetch them. Debounced (`debounceMs`), race-safe, only at `minChars` and up,
|
|
21
|
+
* never while the field is idle; a rejection shows `loadErrorLabel` and empties
|
|
22
|
+
* the list rather than leaving the previous text's rows under it. */
|
|
23
|
+
loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
|
|
24
|
+
/** Narrow `options` by label/sublabel substring. Default `true`. Pass `false` for
|
|
25
|
+
* a list a server has already searched and ranked — it is shown as given. */
|
|
26
|
+
filter?: boolean;
|
|
27
|
+
/** External loading flag (a caller-side fetch), OR-ed with `loadOptions`'. */
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
/** Below this many characters nothing is offered or fetched. Default 1: an empty
|
|
30
|
+
* field asks nothing — focusing it is not a search. */
|
|
31
|
+
minChars?: number;
|
|
32
|
+
/** Quiet time before `loadOptions` runs. Default 300 ms — an ordinary typist's
|
|
33
|
+
* inter-key interval is 150–250 ms, so the pickers' 150 fires on nearly every
|
|
34
|
+
* letter, which a rate-limited geocoder cannot afford. */
|
|
35
|
+
debounceMs?: number;
|
|
36
|
+
/** A suggestion was taken (click, or Enter on the highlighted row). */
|
|
37
|
+
onSelect?: (option: ComboOption<V>) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Put the taken suggestion's label into the field (`onChange(label)`). Default
|
|
40
|
+
* `true` — the free-text case, where the text IS the value and a suggestion only
|
|
41
|
+
* completes it (an address field). Pass `false` where taking a row is an ACTION
|
|
42
|
+
* and the field is only the search that found it; the text then stays as typed.
|
|
43
|
+
*/
|
|
44
|
+
fillOnSelect?: boolean;
|
|
45
|
+
/** A floating label, as on {@link Combobox}. Names the field (`aria-labelledby`)
|
|
46
|
+
* unless an `aria-label` is given. */
|
|
47
|
+
label?: ReactNode;
|
|
48
|
+
/** Leading decoration inside the field (a pin, a magnifier). Decorative: the
|
|
49
|
+
* label names the field. */
|
|
50
|
+
icon?: ReactNode;
|
|
51
|
+
/** Required and unanswered — {@link FIELD_INVALID}. */
|
|
52
|
+
invalid?: boolean;
|
|
53
|
+
/** What is wrong with the VALUE, as {@link Input}'s `error`: under the field, on
|
|
54
|
+
* its `aria-describedby`, implies `invalid`. Not a failed lookup — that is
|
|
55
|
+
* `loadErrorLabel`, shown in the list. */
|
|
56
|
+
error?: ReactNode;
|
|
57
|
+
/**
|
|
58
|
+
* The caller's own line in the list, replacing the automatic one (loading / failed
|
|
59
|
+
* / no results). For what only the caller knows: "offline", "the address service
|
|
60
|
+
* is switched off", a privacy note, the hint a caller-fetched list needs below its
|
|
61
|
+
* own minimum. While set, the list opens even with no rows. Announced politely.
|
|
62
|
+
*/
|
|
63
|
+
status?: ReactNode;
|
|
64
|
+
/** Default: `combobox.noResults` from the {@link UiKitProvider}, else English. */
|
|
65
|
+
emptyLabel?: string;
|
|
66
|
+
/** Default: `combobox.loadError`. */
|
|
67
|
+
loadErrorLabel?: string;
|
|
68
|
+
/** Classes for the `<input>` itself; `className` styles the wrapper. */
|
|
69
|
+
inputClassName?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An inline, async-capable text field with a suggestion list under it — the
|
|
73
|
+
* WAI-ARIA APG "editable combobox with list autocomplete".
|
|
74
|
+
*
|
|
75
|
+
* Two jobs, one control:
|
|
76
|
+
* - **free text** (kastlan's address field): the text is the value, the list only
|
|
77
|
+
* offers completions, and taking one fills the field (`fillOnSelect`, default);
|
|
78
|
+
* - **search-then-act** (keksdose's address search): the text is a query, taking a
|
|
79
|
+
* row calls `onSelect` and nothing is held (`fillOnSelect={false}`).
|
|
80
|
+
*
|
|
81
|
+
* How it differs from its siblings: {@link Combobox} is free text over a STATIC
|
|
82
|
+
* string pool and opens on focus; {@link EntityCombobox} is a trigger holding an id.
|
|
83
|
+
* This one keeps no value of its own, never resets the text, asks nothing below
|
|
84
|
+
* `minChars`, and reports a failed lookup instead of showing stale rows.
|
|
85
|
+
*
|
|
86
|
+
* Focus never leaves the `<input>`: `aria-activedescendant` names the highlighted
|
|
87
|
+
* row, `aria-controls` the list, and a polite live region says what the list now
|
|
88
|
+
* holds. Keyboard: ↓/↑ move (↓ opens), Enter takes the highlighted row (with none,
|
|
89
|
+
* Enter is left to the form), Escape closes, Tab closes and moves on; Home/End stay
|
|
90
|
+
* with the caret. A caller's `onKeyDown` runs first and may `preventDefault()` to
|
|
91
|
+
* keep a key for itself.
|
|
92
|
+
*
|
|
93
|
+
* The list is portalled and anchored under the field on every screen size: the
|
|
94
|
+
* field already carries the keyboard, and `useAnchoredPanel` keeps the list clear
|
|
95
|
+
* of it on a phone. There is no full-screen sheet as {@link Combobox} has.
|
|
96
|
+
|
|
97
|
+
* Generic over the option value (`ComboOption<V>`); the ref is the `<input>`'s.
|
|
98
|
+
*/
|
|
99
|
+
declare const Autocomplete: <V extends string | number = string>(props: AutocompleteProps<V> & RefAttributes<HTMLInputElement>) => ReactElement | null;
|
|
100
|
+
|
|
101
|
+
export { Autocomplete, type AutocompleteProps };
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Fragment, forwardRef, useId, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { FieldLabel, FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, Spinner } from "./ui.js";
|
|
7
|
+
import { useAnchoredPanel } from "../hooks/use-anchored-panel.js";
|
|
8
|
+
import {
|
|
9
|
+
useActiveOptionScroll,
|
|
10
|
+
useComboboxFieldError,
|
|
11
|
+
useOptionSource
|
|
12
|
+
} from "./combobox-core.js";
|
|
13
|
+
import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels.js";
|
|
14
|
+
function AutocompleteInner({
|
|
15
|
+
value,
|
|
16
|
+
onChange,
|
|
17
|
+
options,
|
|
18
|
+
loadOptions,
|
|
19
|
+
filter,
|
|
20
|
+
loading,
|
|
21
|
+
minChars = 1,
|
|
22
|
+
debounceMs = 300,
|
|
23
|
+
onSelect,
|
|
24
|
+
fillOnSelect = true,
|
|
25
|
+
label,
|
|
26
|
+
icon,
|
|
27
|
+
invalid,
|
|
28
|
+
error,
|
|
29
|
+
status,
|
|
30
|
+
emptyLabel,
|
|
31
|
+
loadErrorLabel,
|
|
32
|
+
className,
|
|
33
|
+
inputClassName,
|
|
34
|
+
id,
|
|
35
|
+
disabled,
|
|
36
|
+
onKeyDown,
|
|
37
|
+
onFocus,
|
|
38
|
+
onBlur,
|
|
39
|
+
"aria-label": ariaLabel,
|
|
40
|
+
"aria-labelledby": ariaLabelledBy,
|
|
41
|
+
"aria-describedby": ariaDescribedBy,
|
|
42
|
+
...rest
|
|
43
|
+
}, ref) {
|
|
44
|
+
const generated = useId();
|
|
45
|
+
const fieldId = id ?? generated;
|
|
46
|
+
const listboxId = `${generated}-listbox`;
|
|
47
|
+
const labelId = `${generated}-label`;
|
|
48
|
+
const optionId = (index) => `${generated}-option-${index}`;
|
|
49
|
+
const fieldRef = useRef(null);
|
|
50
|
+
const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
|
|
51
|
+
noResults: emptyLabel,
|
|
52
|
+
loadError: loadErrorLabel
|
|
53
|
+
});
|
|
54
|
+
const field = useComboboxFieldError(error, invalid, ariaDescribedBy);
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
const [active, setActive] = useState(-1);
|
|
57
|
+
const live = open && !disabled;
|
|
58
|
+
const { results, busy, failed, tooShort } = useOptionSource({
|
|
59
|
+
options,
|
|
60
|
+
loadOptions,
|
|
61
|
+
loading,
|
|
62
|
+
filter,
|
|
63
|
+
minChars,
|
|
64
|
+
debounceMs,
|
|
65
|
+
query: value,
|
|
66
|
+
active: live
|
|
67
|
+
});
|
|
68
|
+
const hasStatus = status !== void 0 && status !== null && status !== false && status !== "";
|
|
69
|
+
const statusLine = hasStatus ? status : tooShort ? null : busy && results.length === 0 ? labels.loading : failed ? labels.loadError : !busy && results.length === 0 ? labels.noResults : null;
|
|
70
|
+
const expanded = live && (results.length > 0 || statusLine !== null);
|
|
71
|
+
const activeId = expanded && active >= 0 && active < results.length ? optionId(active) : void 0;
|
|
72
|
+
useActiveOptionScroll(activeId);
|
|
73
|
+
const { rect, top, maxHeight } = useAnchoredPanel(fieldRef, expanded, { preferredHeight: 256 });
|
|
74
|
+
const close = () => {
|
|
75
|
+
setOpen(false);
|
|
76
|
+
setActive(-1);
|
|
77
|
+
};
|
|
78
|
+
const take = (o) => {
|
|
79
|
+
if (fillOnSelect) onChange(o.label);
|
|
80
|
+
onSelect?.(o);
|
|
81
|
+
close();
|
|
82
|
+
};
|
|
83
|
+
const hasLabel = label !== void 0;
|
|
84
|
+
const labelledBy = ariaLabel === void 0 ? ariaLabelledBy ?? (hasLabel ? labelId : void 0) : void 0;
|
|
85
|
+
return (
|
|
86
|
+
// `relative`: the floating label and the live region's `sr-only` both need a
|
|
87
|
+
// local containing block.
|
|
88
|
+
/* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
|
|
89
|
+
hasLabel && /* @__PURE__ */ jsx(FieldLabel, { id: labelId, children: label }),
|
|
90
|
+
/* @__PURE__ */ jsxs("div", { ref: fieldRef, className: "relative", children: [
|
|
91
|
+
icon && /* @__PURE__ */ jsx(
|
|
92
|
+
"span",
|
|
93
|
+
{
|
|
94
|
+
"aria-hidden": true,
|
|
95
|
+
className: "pointer-events-none absolute start-2.5 top-1/2 flex -translate-y-1/2 text-[var(--text-muted)] [&>svg]:size-4",
|
|
96
|
+
children: icon
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
/* @__PURE__ */ jsx(
|
|
100
|
+
"input",
|
|
101
|
+
{
|
|
102
|
+
...rest,
|
|
103
|
+
ref,
|
|
104
|
+
id: fieldId,
|
|
105
|
+
type: "text",
|
|
106
|
+
value,
|
|
107
|
+
disabled,
|
|
108
|
+
role: "combobox",
|
|
109
|
+
"aria-autocomplete": "list",
|
|
110
|
+
"aria-expanded": expanded,
|
|
111
|
+
"aria-controls": listboxId,
|
|
112
|
+
"aria-activedescendant": activeId,
|
|
113
|
+
"aria-label": ariaLabel,
|
|
114
|
+
"aria-labelledby": labelledBy,
|
|
115
|
+
"aria-invalid": field.isInvalid || rest["aria-invalid"] || void 0,
|
|
116
|
+
"aria-describedby": field.describedBy,
|
|
117
|
+
autoComplete: "off",
|
|
118
|
+
onFocus: (e) => {
|
|
119
|
+
onFocus?.(e);
|
|
120
|
+
setOpen(true);
|
|
121
|
+
},
|
|
122
|
+
onBlur: (e) => {
|
|
123
|
+
onBlur?.(e);
|
|
124
|
+
close();
|
|
125
|
+
},
|
|
126
|
+
onChange: (e) => {
|
|
127
|
+
onChange(e.target.value);
|
|
128
|
+
setOpen(true);
|
|
129
|
+
setActive(-1);
|
|
130
|
+
},
|
|
131
|
+
onKeyDown: (e) => {
|
|
132
|
+
onKeyDown?.(e);
|
|
133
|
+
if (e.defaultPrevented) return;
|
|
134
|
+
const last = results.length - 1;
|
|
135
|
+
if (e.key === "ArrowDown") {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
if (!open) setOpen(true);
|
|
138
|
+
else setActive((i) => Math.min(i + 1, last));
|
|
139
|
+
} else if (e.key === "ArrowUp") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
if (!open) setOpen(true);
|
|
142
|
+
else setActive((i) => i < 0 ? last : Math.max(i - 1, 0));
|
|
143
|
+
} else if (e.key === "Enter") {
|
|
144
|
+
if (expanded && activeId) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
take(results[active]);
|
|
147
|
+
}
|
|
148
|
+
} else if (e.key === "Escape") {
|
|
149
|
+
if (expanded) {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
close();
|
|
153
|
+
}
|
|
154
|
+
} else if (e.key === "Tab") {
|
|
155
|
+
close();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
className: cn(
|
|
159
|
+
FIELD_BASE,
|
|
160
|
+
hasLabel && FIELD_FLOATING_PAD,
|
|
161
|
+
icon ? "ps-8" : void 0,
|
|
162
|
+
busy && "pe-9",
|
|
163
|
+
field.isInvalid && FIELD_INVALID,
|
|
164
|
+
inputClassName
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
),
|
|
168
|
+
busy && live && // Decorative: the live region below already says "Loading…".
|
|
169
|
+
/* @__PURE__ */ jsx("span", { className: "pointer-events-none absolute end-2.5 top-1/2 flex -translate-y-1/2", children: /* @__PURE__ */ jsx(Spinner, { label: null, className: "h-4 w-4" }) })
|
|
170
|
+
] }),
|
|
171
|
+
field.errorEl,
|
|
172
|
+
/* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: !live ? null : statusLine !== null ? statusLine : results.length > 0 ? labels.resultCount(results.length) : null }),
|
|
173
|
+
expanded && rect && typeof document !== "undefined" && createPortal(
|
|
174
|
+
/* @__PURE__ */ jsxs(
|
|
175
|
+
"div",
|
|
176
|
+
{
|
|
177
|
+
role: "presentation",
|
|
178
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
179
|
+
className: "fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
|
|
180
|
+
style: { top, left: rect.left, width: rect.width, maxHeight },
|
|
181
|
+
children: [
|
|
182
|
+
results.length > 0 && /* @__PURE__ */ jsx(
|
|
183
|
+
"ul",
|
|
184
|
+
{
|
|
185
|
+
id: listboxId,
|
|
186
|
+
role: "listbox",
|
|
187
|
+
"aria-label": ariaLabel,
|
|
188
|
+
"aria-labelledby": labelledBy,
|
|
189
|
+
className: "min-h-0 flex-1 overflow-y-auto py-1",
|
|
190
|
+
children: results.map((o, i) => {
|
|
191
|
+
const startsGroup = o.group != null && o.group !== results[i - 1]?.group;
|
|
192
|
+
return (
|
|
193
|
+
// Keyed by group AND value, as in the core panel: an option may
|
|
194
|
+
// appear once per group.
|
|
195
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
196
|
+
startsGroup && /* @__PURE__ */ jsx(
|
|
197
|
+
"li",
|
|
198
|
+
{
|
|
199
|
+
role: "presentation",
|
|
200
|
+
className: "px-3 pb-0.5 pt-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] first:pt-1",
|
|
201
|
+
children: o.group
|
|
202
|
+
}
|
|
203
|
+
),
|
|
204
|
+
/* @__PURE__ */ jsx("li", { role: "presentation", children: /* @__PURE__ */ jsxs(
|
|
205
|
+
"button",
|
|
206
|
+
{
|
|
207
|
+
type: "button",
|
|
208
|
+
id: optionId(i),
|
|
209
|
+
role: "option",
|
|
210
|
+
"aria-selected": fillOnSelect && o.label === value,
|
|
211
|
+
tabIndex: -1,
|
|
212
|
+
onClick: () => take(o),
|
|
213
|
+
onMouseEnter: () => setActive(i),
|
|
214
|
+
className: cn(
|
|
215
|
+
"flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm",
|
|
216
|
+
i === active ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
|
|
217
|
+
o.group != null && "ps-6"
|
|
218
|
+
),
|
|
219
|
+
children: [
|
|
220
|
+
o.icon && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "shrink-0 text-[var(--text-muted)]", children: o.icon }),
|
|
221
|
+
/* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
|
|
222
|
+
/* @__PURE__ */ jsx("span", { className: "block truncate text-[var(--text-primary)]", children: o.label }),
|
|
223
|
+
o.sublabel && /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-[var(--text-placeholder)]", children: o.sublabel })
|
|
224
|
+
] })
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
) })
|
|
228
|
+
] }, `${o.group ?? ""}|${String(o.value)}`)
|
|
229
|
+
);
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
),
|
|
233
|
+
statusLine !== null && // Visible, not live — the region above speaks for it.
|
|
234
|
+
/* @__PURE__ */ jsx(
|
|
235
|
+
"div",
|
|
236
|
+
{
|
|
237
|
+
"aria-hidden": true,
|
|
238
|
+
className: cn(
|
|
239
|
+
"px-3 py-2 text-sm",
|
|
240
|
+
failed && !hasStatus ? "text-[var(--danger)]" : "text-[var(--text-muted)]",
|
|
241
|
+
results.length > 0 && "border-t border-[var(--border)] text-xs"
|
|
242
|
+
),
|
|
243
|
+
children: statusLine
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
]
|
|
247
|
+
}
|
|
248
|
+
),
|
|
249
|
+
document.body
|
|
250
|
+
)
|
|
251
|
+
] })
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const AutocompleteBase = forwardRef(AutocompleteInner);
|
|
255
|
+
AutocompleteBase.displayName = "Autocomplete";
|
|
256
|
+
const Autocomplete = AutocompleteBase;
|
|
257
|
+
export {
|
|
258
|
+
Autocomplete
|
|
259
|
+
};
|
|
260
|
+
//# sourceMappingURL=autocomplete.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/autocomplete.tsx"],"sourcesContent":["import { Fragment, forwardRef, useId, useRef, useState } from \"react\";\nimport type {\n ComponentPropsWithoutRef,\n ForwardedRef,\n ReactElement,\n ReactNode,\n RefAttributes,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/cn\";\nimport { FieldLabel, FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, Spinner } from \"./ui\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport {\n useActiveOptionScroll,\n useComboboxFieldError,\n useOptionSource,\n type ComboOption,\n} from \"./combobox-core\";\nimport { DEFAULT_COMBOBOX_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * `value`/`onChange` are the TEXT's, and `onSelect` is \"a suggestion was taken\" —\n * not the DOM's text-selection event — so the input's own spellings of those three\n * are omitted. Everything else reaches the `<input>`, the way it does on {@link Input}.\n */\nexport interface AutocompleteProps<V extends string | number = string>\n extends Omit<\n ComponentPropsWithoutRef<\"input\">,\n \"value\" | \"defaultValue\" | \"onChange\" | \"onSelect\" | \"children\" | \"type\"\n > {\n /** What is in the field. Controlled, and never reset by the component: opening,\n * closing and a failed lookup all leave it exactly as typed. */\n value: string;\n /** Every keystroke — and, with `fillOnSelect`, the label of a taken suggestion. */\n onChange: (text: string) => void;\n /** Suggestions the caller already has (e.g. from its own query hook). Narrowed by\n * the text unless `filter={false}`. */\n options?: ComboOption<V>[];\n /** Or: fetch them. Debounced (`debounceMs`), race-safe, only at `minChars` and up,\n * never while the field is idle; a rejection shows `loadErrorLabel` and empties\n * the list rather than leaving the previous text's rows under it. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** Narrow `options` by label/sublabel substring. Default `true`. Pass `false` for\n * a list a server has already searched and ranked — it is shown as given. */\n filter?: boolean;\n /** External loading flag (a caller-side fetch), OR-ed with `loadOptions`'. */\n loading?: boolean;\n /** Below this many characters nothing is offered or fetched. Default 1: an empty\n * field asks nothing — focusing it is not a search. */\n minChars?: number;\n /** Quiet time before `loadOptions` runs. Default 300 ms — an ordinary typist's\n * inter-key interval is 150–250 ms, so the pickers' 150 fires on nearly every\n * letter, which a rate-limited geocoder cannot afford. */\n debounceMs?: number;\n /** A suggestion was taken (click, or Enter on the highlighted row). */\n onSelect?: (option: ComboOption<V>) => void;\n /**\n * Put the taken suggestion's label into the field (`onChange(label)`). Default\n * `true` — the free-text case, where the text IS the value and a suggestion only\n * completes it (an address field). Pass `false` where taking a row is an ACTION\n * and the field is only the search that found it; the text then stays as typed.\n */\n fillOnSelect?: boolean;\n /** A floating label, as on {@link Combobox}. Names the field (`aria-labelledby`)\n * unless an `aria-label` is given. */\n label?: ReactNode;\n /** Leading decoration inside the field (a pin, a magnifier). Decorative: the\n * label names the field. */\n icon?: ReactNode;\n /** Required and unanswered — {@link FIELD_INVALID}. */\n invalid?: boolean;\n /** What is wrong with the VALUE, as {@link Input}'s `error`: under the field, on\n * its `aria-describedby`, implies `invalid`. Not a failed lookup — that is\n * `loadErrorLabel`, shown in the list. */\n error?: ReactNode;\n /**\n * The caller's own line in the list, replacing the automatic one (loading / failed\n * / no results). For what only the caller knows: \"offline\", \"the address service\n * is switched off\", a privacy note, the hint a caller-fetched list needs below its\n * own minimum. While set, the list opens even with no rows. Announced politely.\n */\n status?: ReactNode;\n /** Default: `combobox.noResults` from the {@link UiKitProvider}, else English. */\n emptyLabel?: string;\n /** Default: `combobox.loadError`. */\n loadErrorLabel?: string;\n /** Classes for the `<input>` itself; `className` styles the wrapper. */\n inputClassName?: string;\n}\n\nfunction AutocompleteInner<V extends string | number = string>(\n {\n value,\n onChange,\n options,\n loadOptions,\n filter,\n loading,\n minChars = 1,\n debounceMs = 300,\n onSelect,\n fillOnSelect = true,\n label,\n icon,\n invalid,\n error,\n status,\n emptyLabel,\n loadErrorLabel,\n className,\n inputClassName,\n id,\n disabled,\n onKeyDown,\n onFocus,\n onBlur,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledBy,\n \"aria-describedby\": ariaDescribedBy,\n ...rest\n }: AutocompleteProps<V>,\n ref: ForwardedRef<HTMLInputElement>,\n) {\n const generated = useId();\n const fieldId = id ?? generated;\n // Off the GENERATED id: a caller's `id` is theirs to collide with.\n const listboxId = `${generated}-listbox`;\n const labelId = `${generated}-label`;\n const optionId = (index: number) => `${generated}-option-${index}`;\n const fieldRef = useRef<HTMLDivElement>(null);\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS, {\n noResults: emptyLabel,\n loadError: loadErrorLabel,\n });\n const field = useComboboxFieldError(error, invalid, ariaDescribedBy);\n\n /** The user's intent: the field is focused and the list has not been dismissed.\n * Whether anything SHOWS is decided below, from what there is to show. */\n const [open, setOpen] = useState(false);\n const [active, setActive] = useState(-1);\n const live = open && !disabled;\n const { results, busy, failed, tooShort } = useOptionSource<V>({\n options,\n loadOptions,\n loading,\n filter,\n minChars,\n debounceMs,\n query: value,\n active: live,\n });\n\n const hasStatus = status !== undefined && status !== null && status !== false && status !== \"\";\n // The visible line under the rows. Nothing below `minChars` unless the caller has\n // something to say — an empty field opening a box that says \"type more\" is noise.\n const statusLine: ReactNode = hasStatus\n ? status\n : tooShort\n ? null\n : busy && results.length === 0\n ? labels.loading\n : failed\n ? labels.loadError\n : !busy && results.length === 0\n ? labels.noResults\n : null;\n const expanded = live && (results.length > 0 || statusLine !== null);\n const activeId =\n expanded && active >= 0 && active < results.length ? optionId(active) : undefined;\n useActiveOptionScroll(activeId);\n const { rect, top, maxHeight } = useAnchoredPanel(fieldRef, expanded, { preferredHeight: 256 });\n\n const close = () => {\n setOpen(false);\n setActive(-1);\n };\n const take = (o: ComboOption<V>) => {\n if (fillOnSelect) onChange(o.label);\n onSelect?.(o);\n close();\n };\n\n const hasLabel = label !== undefined;\n // The caller's name wins; else the floating label, by reference so a non-string\n // label still names the field.\n const labelledBy =\n ariaLabel === undefined ? (ariaLabelledBy ?? (hasLabel ? labelId : undefined)) : undefined;\n\n return (\n // `relative`: the floating label and the live region's `sr-only` both need a\n // local containing block.\n <div className={cn(\"relative\", className)}>\n {hasLabel && <FieldLabel id={labelId}>{label}</FieldLabel>}\n <div ref={fieldRef} className=\"relative\">\n {icon && (\n <span\n aria-hidden\n className=\"pointer-events-none absolute start-2.5 top-1/2 flex -translate-y-1/2 text-[var(--text-muted)] [&>svg]:size-4\"\n >\n {icon}\n </span>\n )}\n <input\n {...rest}\n ref={ref}\n id={fieldId}\n type=\"text\"\n value={value}\n disabled={disabled}\n role=\"combobox\"\n aria-autocomplete=\"list\"\n aria-expanded={expanded}\n // Required by the role, and set while closed too, as on the siblings.\n aria-controls={listboxId}\n aria-activedescendant={activeId}\n aria-label={ariaLabel}\n aria-labelledby={labelledBy}\n aria-invalid={field.isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={field.describedBy}\n autoComplete=\"off\"\n onFocus={(e) => {\n onFocus?.(e);\n // A seeded field (keksdose's `initialQuery`) is looked up as it is\n // focused; an empty one asks nothing, because of `minChars`.\n setOpen(true);\n }}\n onBlur={(e) => {\n onBlur?.(e);\n close();\n }}\n onChange={(e) => {\n onChange(e.target.value);\n setOpen(true);\n setActive(-1);\n }}\n onKeyDown={(e) => {\n onKeyDown?.(e);\n if (e.defaultPrevented) return;\n const last = results.length - 1;\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n if (!open) setOpen(true);\n else setActive((i) => Math.min(i + 1, last));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n if (!open) setOpen(true);\n // From \"nothing highlighted\", Up goes to the bottom, as the APG has it.\n else setActive((i) => (i < 0 ? last : Math.max(i - 1, 0)));\n } else if (e.key === \"Enter\") {\n // No row highlighted: the text is the answer, and a form's own submit\n // is left alone.\n if (expanded && activeId) {\n e.preventDefault();\n take(results[active]);\n }\n } else if (e.key === \"Escape\") {\n // Only when there is a list to close — otherwise the Escape belongs to\n // whatever this field sits in (a dialog), and must reach it.\n if (expanded) {\n e.preventDefault();\n e.stopPropagation();\n close();\n }\n } else if (e.key === \"Tab\") {\n close();\n }\n }}\n className={cn(\n FIELD_BASE,\n hasLabel && FIELD_FLOATING_PAD,\n icon ? \"ps-8\" : undefined,\n busy && \"pe-9\",\n field.isInvalid && FIELD_INVALID,\n inputClassName,\n )}\n />\n {busy && live && (\n // Decorative: the live region below already says \"Loading…\".\n <span className=\"pointer-events-none absolute end-2.5 top-1/2 flex -translate-y-1/2\">\n <Spinner label={null} className=\"h-4 w-4\" />\n </span>\n )}\n </div>\n {field.errorEl}\n {/* The live region, kept mounted so a change IS an announcement: focus stays\n in the field, and without it a reader typed into a geocoder and heard only\n their own letters. The panel's visible line is aria-hidden in favour of\n this, which also carries the row count the eye reads off the list. */}\n <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {!live\n ? null\n : statusLine !== null\n ? statusLine\n : results.length > 0\n ? labels.resultCount(results.length)\n : null}\n </div>\n {expanded &&\n rect &&\n typeof document !== \"undefined\" &&\n createPortal(\n <div\n // Every press inside the list keeps focus in the field — rows, the status\n // line, the scrollbar — or the input's blur would close the list first.\n role=\"presentation\"\n onMouseDown={(e) => e.preventDefault()}\n className=\"fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\"\n style={{ top, left: rect.left, width: rect.width, maxHeight }}\n >\n {results.length > 0 && (\n <ul\n id={listboxId}\n role=\"listbox\"\n aria-label={ariaLabel}\n aria-labelledby={labelledBy}\n className=\"min-h-0 flex-1 overflow-y-auto py-1\"\n >\n {results.map((o, i) => {\n const startsGroup = o.group != null && o.group !== results[i - 1]?.group;\n return (\n // Keyed by group AND value, as in the core panel: an option may\n // appear once per group.\n <Fragment key={`${o.group ?? \"\"}|${String(o.value)}`}>\n {startsGroup && (\n <li\n role=\"presentation\"\n className=\"px-3 pb-0.5 pt-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] first:pt-1\"\n >\n {o.group}\n </li>\n )}\n <li role=\"presentation\">\n <button\n type=\"button\"\n id={optionId(i)}\n role=\"option\"\n // CHOSEN, never \"highlighted\" — the family's rule. This\n // field holds no value, so the only row that can be chosen\n // is the one whose label the text already is.\n aria-selected={fillOnSelect && o.label === value}\n tabIndex={-1}\n onClick={() => take(o)}\n onMouseEnter={() => setActive(i)}\n className={cn(\n \"flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm\",\n i === active ? \"bg-[var(--bg-active)]\" : \"hover:bg-[var(--bg-hover)]\",\n o.group != null && \"ps-6\",\n )}\n >\n {o.icon && (\n <span aria-hidden className=\"shrink-0 text-[var(--text-muted)]\">\n {o.icon}\n </span>\n )}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate text-[var(--text-primary)]\">\n {o.label}\n </span>\n {o.sublabel && (\n <span className=\"block truncate text-xs text-[var(--text-placeholder)]\">\n {o.sublabel}\n </span>\n )}\n </span>\n </button>\n </li>\n </Fragment>\n );\n })}\n </ul>\n )}\n {statusLine !== null && (\n // Visible, not live — the region above speaks for it.\n <div\n aria-hidden\n className={cn(\n \"px-3 py-2 text-sm\",\n failed && !hasStatus ? \"text-[var(--danger)]\" : \"text-[var(--text-muted)]\",\n results.length > 0 && \"border-t border-[var(--border)] text-xs\",\n )}\n >\n {statusLine}\n </div>\n )}\n </div>,\n document.body,\n )}\n </div>\n );\n}\n\nconst AutocompleteBase = forwardRef(AutocompleteInner);\nAutocompleteBase.displayName = \"Autocomplete\";\n\n/**\n * An inline, async-capable text field with a suggestion list under it — the\n * WAI-ARIA APG \"editable combobox with list autocomplete\".\n *\n * Two jobs, one control:\n * - **free text** (kastlan's address field): the text is the value, the list only\n * offers completions, and taking one fills the field (`fillOnSelect`, default);\n * - **search-then-act** (keksdose's address search): the text is a query, taking a\n * row calls `onSelect` and nothing is held (`fillOnSelect={false}`).\n *\n * How it differs from its siblings: {@link Combobox} is free text over a STATIC\n * string pool and opens on focus; {@link EntityCombobox} is a trigger holding an id.\n * This one keeps no value of its own, never resets the text, asks nothing below\n * `minChars`, and reports a failed lookup instead of showing stale rows.\n *\n * Focus never leaves the `<input>`: `aria-activedescendant` names the highlighted\n * row, `aria-controls` the list, and a polite live region says what the list now\n * holds. Keyboard: ↓/↑ move (↓ opens), Enter takes the highlighted row (with none,\n * Enter is left to the form), Escape closes, Tab closes and moves on; Home/End stay\n * with the caret. A caller's `onKeyDown` runs first and may `preventDefault()` to\n * keep a key for itself.\n *\n * The list is portalled and anchored under the field on every screen size: the\n * field already carries the keyboard, and `useAnchoredPanel` keeps the list clear\n * of it on a phone. There is no full-screen sheet as {@link Combobox} has.\n\n * Generic over the option value (`ComboOption<V>`); the ref is the `<input>`'s.\n */\nexport const Autocomplete = AutocompleteBase as <V extends string | number = string>(\n props: AutocompleteProps<V> & RefAttributes<HTMLInputElement>,\n) => ReactElement | null;\n"],"mappings":"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","names":[]}
|
|
@@ -17,6 +17,13 @@ import './sparkline.js';
|
|
|
17
17
|
import './stat-tile.js';
|
|
18
18
|
import './signature-pad.js';
|
|
19
19
|
import './password-strength.js';
|
|
20
|
+
import './danger-confirm.js';
|
|
21
|
+
import './swatch-picker.js';
|
|
22
|
+
import './tile-radio.js';
|
|
23
|
+
import './icon-picker.js';
|
|
24
|
+
import './dialog-frame.js';
|
|
25
|
+
import './modal.js';
|
|
26
|
+
import './file-button.js';
|
|
20
27
|
|
|
21
28
|
/** Screen-reader names for the calculator panel's own controls — any part of the
|
|
22
29
|
* kit's `calculator` namespace. Once three optional keys of its own; widened, not
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode, KeyboardEvent, ComponentPropsWithoutRef } from 'react';
|
|
2
|
+
import { ReactNode, MouseEvent, KeyboardEvent, ComponentPropsWithoutRef } from 'react';
|
|
3
3
|
import { LucideIcon } from 'lucide-react';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -16,19 +16,28 @@ import { LucideIcon } from 'lucide-react';
|
|
|
16
16
|
* "choose one of eight things to do" and a row of eight chips says "here are eight
|
|
17
17
|
* things". Using `Button` for both is the mistake this exists to prevent.
|
|
18
18
|
*/
|
|
19
|
-
type ChipTone = "neutral" | "brand" | "danger" | "warning" | "success" | "info";
|
|
20
|
-
|
|
19
|
+
type ChipTone = "neutral" | "brand" | "danger" | "warning" | "success" | "info" | "income" | "expense";
|
|
20
|
+
/** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px. */
|
|
21
|
+
type ChipSize = "sm" | "md" | "lg";
|
|
21
22
|
interface ChipProps {
|
|
22
23
|
children: ReactNode;
|
|
23
24
|
tone?: ChipTone;
|
|
24
25
|
size?: ChipSize;
|
|
25
26
|
icon?: LucideIcon;
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Marks the chip as the current one — `aria-current` on a link, `aria-pressed` on a
|
|
29
|
+
* toggle.
|
|
30
|
+
*
|
|
31
|
+
* On a toggle, keep the LABEL the same in both states and let `selected` carry the
|
|
32
|
+
* state. A label that flips with it ("Skip this month" / "Ask again this month") is
|
|
33
|
+
* announced together with "pressed", and then says the opposite of what it does.
|
|
34
|
+
*/
|
|
27
35
|
selected?: boolean;
|
|
28
36
|
/** Renders the chip as a link. Mutually exclusive with `onClick`. */
|
|
29
37
|
href?: string;
|
|
30
|
-
/** Renders the chip as a toggle button. Mutually exclusive with `href`.
|
|
31
|
-
|
|
38
|
+
/** Renders the chip as a toggle button. Mutually exclusive with `href`. Receives the
|
|
39
|
+
* click, so a chip inside a clickable row can `stopPropagation()`. */
|
|
40
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
32
41
|
/** Renders a dismiss affordance. Works alongside `href`/`onClick` — see the note below. */
|
|
33
42
|
onRemove?: () => void;
|
|
34
43
|
/** Accessible name for the dismiss button. Default: `common.remove` from the
|
|
@@ -47,8 +56,14 @@ interface ChipProps {
|
|
|
47
56
|
onFocus?: () => void;
|
|
48
57
|
/** Accessible name, when the visible text is not the whole story. */
|
|
49
58
|
"aria-label"?: string;
|
|
50
|
-
/**
|
|
59
|
+
/** Any other ARIA attribute — `aria-describedby`, `aria-expanded`, `aria-controls`,
|
|
60
|
+
* `aria-haspopup` — reaches the chip's interactive element. */
|
|
61
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
62
|
+
/** A test id, a `data-tour` anchor, or any other data attribute. */
|
|
51
63
|
[key: `data-${string}`]: unknown;
|
|
64
|
+
id?: string;
|
|
65
|
+
/** A native tooltip. For anything a user must read, prefer the kit's `Tooltip`. */
|
|
66
|
+
title?: string;
|
|
52
67
|
}
|
|
53
68
|
/**
|
|
54
69
|
* Note on `onRemove` with `href`/`onClick`: a button cannot be nested inside a button or
|
package/dist/components/chip.js
CHANGED
|
@@ -29,11 +29,24 @@ const TONE = {
|
|
|
29
29
|
info: {
|
|
30
30
|
idle: "border-[var(--info-border)] bg-[var(--info-bg)] text-[var(--info)]",
|
|
31
31
|
selected: "border-[var(--info)] bg-[var(--info-bg)] text-[var(--info)]"
|
|
32
|
+
},
|
|
33
|
+
// The money pair (Keksdose's direction toggle). Only the TEXT and the BORDER carry
|
|
34
|
+
// the tint, on the neutral chip's own surfaces: the money tokens are chosen for text
|
|
35
|
+
// contrast, and a filled amber pill beside an amber figure reads as one smear.
|
|
36
|
+
// `border-current` takes its hue from the text, so the two cannot drift apart.
|
|
37
|
+
income: {
|
|
38
|
+
idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-income)]",
|
|
39
|
+
selected: "border-current bg-[var(--bg-active)] text-[var(--money-income)]"
|
|
40
|
+
},
|
|
41
|
+
expense: {
|
|
42
|
+
idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-expense)]",
|
|
43
|
+
selected: "border-current bg-[var(--bg-active)] text-[var(--money-expense)]"
|
|
32
44
|
}
|
|
33
45
|
};
|
|
34
46
|
const SIZE = {
|
|
35
47
|
sm: { body: "gap-1 px-2 py-0.5 text-xs", icon: "size-3", remove: "size-3" },
|
|
36
|
-
md: { body: "gap-1.5 px-2.5 py-1 text-sm", icon: "size-3.5", remove: "size-3.5" }
|
|
48
|
+
md: { body: "gap-1.5 px-2.5 py-1 text-sm", icon: "size-3.5", remove: "size-3.5" },
|
|
49
|
+
lg: { body: "min-h-11 gap-2 px-4 py-2 text-sm", icon: "size-4", remove: "size-4" }
|
|
37
50
|
};
|
|
38
51
|
const CHIP_BASE = "inline-flex max-w-full items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)]";
|
|
39
52
|
const Chip = forwardRef(function Chip2({
|