@eifi1/ui-kit 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +103 -0
- package/dist/components/autocomplete.js +260 -0
- package/dist/components/autocomplete.js.map +1 -0
- package/dist/components/calculator.d.ts +7 -0
- package/dist/components/checkbox.d.ts +9 -0
- package/dist/components/checkbox.js +7 -2
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +3 -2
- package/dist/components/chip.js +14 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +100 -0
- package/dist/components/choice-card.js +170 -0
- package/dist/components/choice-card.js.map +1 -0
- package/dist/components/combobox-core.d.ts +76 -6
- package/dist/components/combobox-core.js +119 -49
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +12 -2
- package/dist/components/combobox.js +42 -17
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +91 -0
- package/dist/components/danger-confirm.js +181 -0
- package/dist/components/danger-confirm.js.map +1 -0
- package/dist/components/dialog-frame.d.ts +84 -0
- package/dist/components/dialog-frame.js +86 -0
- package/dist/components/dialog-frame.js.map +1 -0
- package/dist/components/disclosure.d.ts +108 -0
- package/dist/components/disclosure.js +127 -0
- package/dist/components/disclosure.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +17 -3
- package/dist/components/entity-combobox.js +25 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +161 -0
- package/dist/components/file-button.js +243 -0
- package/dist/components/file-button.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +72 -23
- package/dist/components/file-dropzone.js +219 -94
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/icon-picker.d.ts +72 -0
- package/dist/components/icon-picker.js +104 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +3 -0
- package/dist/components/mini-calendar.js +4 -3
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +8 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +16 -3
- package/dist/components/multi-entity-combobox.js +25 -5
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +41 -1
- package/dist/components/number-field.js +42 -10
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +35 -2
- package/dist/components/number-input.js +35 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -0
- package/dist/components/search-field.d.ts +16 -0
- package/dist/components/search-field.js +29 -7
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/signature-pad.d.ts +43 -1
- package/dist/components/signature-pad.js +74 -2
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +69 -0
- package/dist/components/swatch-picker.js +75 -0
- package/dist/components/swatch-picker.js.map +1 -0
- package/dist/components/switch.d.ts +9 -0
- package/dist/components/switch.js +7 -2
- package/dist/components/switch.js.map +1 -1
- package/dist/components/tile-radio.d.ts +50 -0
- package/dist/components/tile-radio.js +140 -0
- package/dist/components/tile-radio.js.map +1 -0
- package/dist/components/toggle-group.d.ts +27 -5
- package/dist/components/toggle-group.js +22 -15
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +150 -12
- package/dist/components/ui.js +196 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +13 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +38 -5
- package/dist/i18n/kit-labels.js +12 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/table-text.d.ts +127 -0
- package/dist/lib/table-text.js +82 -0
- package/dist/lib/table-text.js.map +1 -0
- package/dist/rhf/form.d.ts +79 -0
- package/dist/rhf/form.js +143 -0
- package/dist/rhf/form.js.map +1 -0
- package/dist/rhf.d.ts +4 -0
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -0
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/table-text.d.ts +1 -0
- package/dist/table-text.js +3 -0
- package/dist/table-text.js.map +1 -0
- package/package.json +14 -1
- package/src/components/autocomplete.tsx +429 -0
- package/src/components/checkbox.tsx +16 -0
- package/src/components/chip.tsx +24 -2
- package/src/components/choice-card.tsx +305 -0
- package/src/components/combobox-core.tsx +228 -58
- package/src/components/combobox.tsx +58 -21
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/dialog-frame.tsx +179 -0
- package/src/components/disclosure.tsx +259 -0
- package/src/components/entity-combobox.tsx +41 -6
- package/src/components/file-button.tsx +458 -0
- package/src/components/file-dropzone.tsx +323 -117
- package/src/components/icon-picker.tsx +181 -0
- package/src/components/mini-calendar.tsx +7 -3
- package/src/components/modal.tsx +10 -2
- package/src/components/multi-entity-combobox.tsx +40 -6
- package/src/components/number-field.tsx +86 -10
- package/src/components/number-input.tsx +79 -2
- package/src/components/search-field.tsx +49 -6
- package/src/components/signature-pad.tsx +112 -0
- package/src/components/swatch-picker.tsx +141 -0
- package/src/components/switch.tsx +16 -0
- package/src/components/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/ui.tsx +400 -24
- package/src/i18n/defaults.ts +12 -1
- package/src/i18n/kit-labels.tsx +45 -5
- package/src/index.ts +19 -0
- package/src/lib/table-text.ts +265 -0
- package/src/rhf/form.tsx +300 -0
- package/src/rhf.ts +9 -0
- package/src/shell/app-shell.tsx +3 -1
- package/src/table-text.ts +8 -0
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,103 @@
|
|
|
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. An Escape that closes an open list is consumed and never reaches the
|
|
91
|
+
* caller; with the list closed it does. For every other key a caller's `onKeyDown`
|
|
92
|
+
* runs first and may `preventDefault()` to
|
|
93
|
+
* keep a key for itself.
|
|
94
|
+
*
|
|
95
|
+
* The list is portalled and anchored under the field on every screen size: the
|
|
96
|
+
* field already carries the keyboard, and `useAnchoredPanel` keeps the list clear
|
|
97
|
+
* of it on a phone. There is no full-screen sheet as {@link Combobox} has.
|
|
98
|
+
|
|
99
|
+
* Generic over the option value (`ComboOption<V>`); the ref is the `<input>`'s.
|
|
100
|
+
*/
|
|
101
|
+
declare const Autocomplete: <V extends string | number = string>(props: AutocompleteProps<V> & RefAttributes<HTMLInputElement>) => ReactElement | null;
|
|
102
|
+
|
|
103
|
+
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
|
+
if (e.key === "Escape" && expanded) {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
e.stopPropagation();
|
|
135
|
+
close();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
onKeyDown?.(e);
|
|
139
|
+
if (e.defaultPrevented) return;
|
|
140
|
+
const last = results.length - 1;
|
|
141
|
+
if (e.key === "ArrowDown") {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
if (!open) setOpen(true);
|
|
144
|
+
else setActive((i) => Math.min(i + 1, last));
|
|
145
|
+
} else if (e.key === "ArrowUp") {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
if (!open) setOpen(true);
|
|
148
|
+
else setActive((i) => i < 0 ? last : Math.max(i - 1, 0));
|
|
149
|
+
} else if (e.key === "Enter") {
|
|
150
|
+
if (expanded && activeId) {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
take(results[active]);
|
|
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 // An Escape that closes the OPEN list is the list's, and is consumed before\n // the caller sees it. A caller whose Escape means \"close the panel\" (keksdose's\n // address search) otherwise closed the whole panel when the user only meant\n // to dismiss the suggestions — the opposite of what the docs promised.\n if (e.key === \"Escape\" && expanded) {\n e.preventDefault();\n e.stopPropagation();\n close();\n return;\n }\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 === \"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. An Escape that closes an open list is consumed and never reaches the\n * caller; with the list closed it does. For every other key a caller's `onKeyDown`\n * 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
|
|
@@ -35,6 +35,15 @@ interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "typ
|
|
|
35
35
|
* the row when there is a label, the box's own wrapper when there is not — which
|
|
36
36
|
* is where layout (`self-end pb-2` to sit on a field's baseline) belongs. */
|
|
37
37
|
inputClassName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Must be ticked to submit — a consent, an acceptance before paying. Reaches the
|
|
40
|
+
* `<input>` as the native `required` (so a `<form>` refuses to submit without it and a
|
|
41
|
+
* screen reader announces "required"), and draws the kit's required mark after the
|
|
42
|
+
* label, the same `aria-hidden` star as {@link Label}'s: the word is announced from
|
|
43
|
+
* the control, so a star inside the name would only be read out as noise. Write the
|
|
44
|
+
* label WITHOUT a literal "*". No mark without a label.
|
|
45
|
+
*/
|
|
46
|
+
required?: boolean;
|
|
38
47
|
}
|
|
39
48
|
/**
|
|
40
49
|
* A native checkbox in the kit's tokens, with an optional label, description and
|
|
@@ -20,6 +20,7 @@ const Checkbox = forwardRef(function Checkbox2({
|
|
|
20
20
|
inputClassName,
|
|
21
21
|
id,
|
|
22
22
|
disabled,
|
|
23
|
+
required,
|
|
23
24
|
...rest
|
|
24
25
|
}, ref) {
|
|
25
26
|
const generated = useId();
|
|
@@ -48,6 +49,7 @@ const Checkbox = forwardRef(function Checkbox2({
|
|
|
48
49
|
ref: setRef,
|
|
49
50
|
id: bare ? id : inputId,
|
|
50
51
|
disabled,
|
|
52
|
+
required,
|
|
51
53
|
...rest,
|
|
52
54
|
type: "checkbox",
|
|
53
55
|
"aria-invalid": isInvalid || rest["aria-invalid"] || void 0,
|
|
@@ -96,7 +98,7 @@ const Checkbox = forwardRef(function Checkbox2({
|
|
|
96
98
|
children: [
|
|
97
99
|
/* @__PURE__ */ jsx("span", { className: "flex h-5 shrink-0 items-center", children: box }),
|
|
98
100
|
/* @__PURE__ */ jsxs("span", { className: "min-w-0", children: [
|
|
99
|
-
label !== void 0 && /* @__PURE__ */
|
|
101
|
+
label !== void 0 && /* @__PURE__ */ jsxs(
|
|
100
102
|
"label",
|
|
101
103
|
{
|
|
102
104
|
htmlFor: inputId,
|
|
@@ -106,7 +108,10 @@ const Checkbox = forwardRef(function Checkbox2({
|
|
|
106
108
|
// words, and a drag that starts on them starts a native text drag.
|
|
107
109
|
disabled ? "cursor-not-allowed" : "cursor-pointer select-none"
|
|
108
110
|
),
|
|
109
|
-
children:
|
|
111
|
+
children: [
|
|
112
|
+
label,
|
|
113
|
+
required && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "ms-0.5 text-[var(--danger)]", children: "*" })
|
|
114
|
+
]
|
|
110
115
|
}
|
|
111
116
|
),
|
|
112
117
|
showDescription && /* @__PURE__ */ jsx("span", { id: descriptionId, className: "mt-0.5 block text-xs text-[var(--text-muted)]", children: description }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/checkbox.tsx"],"sourcesContent":["import { forwardRef, useCallback, useEffect, useId, useRef } from \"react\";\nimport { Check, Minus } from \"lucide-react\";\nimport type { ChangeEvent, InputHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { assignRef, hasMessage, mergeDescribedBy } from \"./choice-parts\";\n\n/**\n * A checkbox that is still `<input type=\"checkbox\">`.\n *\n * The three consuming apps had three answers: ~30 raw inputs in Keksdose leaning on\n * `tokens.css`'s `accent-color`, a label-plus-box `CheckboxField` in Lenkbank, and a\n * Radix `<button role=\"checkbox\">` in Kastlan. The native element is the one that\n * needs no ARIA to be a checkbox — it submits with its form, answers to `required`,\n * resets with the form and takes a `<label for>` click — so it stays underneath, and\n * only its paint is replaced (`appearance-none`, a token-coloured box, and a glyph\n * laid over it that ignores the pointer so the click still lands on the input).\n *\n * `accent-color` alone was not enough: it tints the native box but cannot change its\n * border, so an INVALID checkbox had no way to say so, and the unchecked box kept the\n * browser's own grey outline in both themes.\n */\n\n// The box. 16px, like every raw checkbox it replaces, so a migration moves nothing.\n//\n// `focus-visible`, with an offset in the surface colour: a --brand ring touching a\n// --brand-filled (checked) box would be a ring painted in the colour it sits on —\n// present in the DOM and invisible on screen. The offset is what separates the two.\nconst BOX =\n \"peer size-4 shrink-0 cursor-pointer appearance-none rounded-[4px] border border-[var(--border-strong)] bg-[var(--bg-surface)] shadow-sm transition-colors \" +\n \"checked:border-[var(--brand)] checked:bg-[var(--brand)] \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed\";\n\n// A mixed box wears the checked fill whether or not the underlying input is checked:\n// `indeterminate` is a property the browser keeps separately from `checked`, and the\n// dash means \"some\", which is neither answer.\nconst BOX_MIXED = \"border-[var(--brand)] bg-[var(--brand)]\";\n\n// The same geometry as FIELD_INVALID — a border plus a 1px ring, so the highlight\n// survives fractional display scaling on every edge (see the note in ui.tsx). The\n// focus ring turns danger too, so focusing a wrong box does not repaint it as fine.\nconst BOX_INVALID =\n \"border-[var(--danger-border)] ring-1 ring-[var(--danger-border)] checked:border-[var(--danger-border)] focus-visible:ring-[var(--danger)]\";\n\nconst ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\nexport interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\"> {\n /**\n * The words beside the box. Rendered as a `<label for>` rather than a label that\n * wraps the input, so `description` and `error` can sit under it without becoming\n * part of the accessible NAME — a wrapping label would have the screen reader read\n * the whole paragraph as the checkbox's name.\n */\n label?: ReactNode;\n /** Secondary text under the label (\"Used when no account is chosen\"). Pointed at by\n * `aria-describedby`, merged with any the caller passed. Kastlan's\n * `CheckboxField` `description` variant. */\n description?: ReactNode;\n /**\n * \"Some but not all\" — the header box of a list whose rows are partly selected.\n * A DOM property with no HTML attribute, so it is set on the element after every\n * render; it does not touch `checked`, and a click clears it (the browser's rule,\n * and the caller's `onChange` decides what the click means).\n */\n indeterminate?: boolean;\n /** See {@link Input}'s `invalid`: paints the box and sets `aria-invalid` together,\n * so the two cannot be spelled separately. */\n invalid?: boolean;\n /** What is wrong, in the caller's words (\"Accept the terms to continue\"). Rendered\n * under the label, attached with `aria-describedby`, and implies `invalid`. */\n error?: ReactNode;\n /** The checked state as a boolean — the `(next) => …` shape Lenkbank's\n * `CheckboxField` and Radix's `onCheckedChange` both used, so a migration does not\n * have to unwrap `event.target.checked` at every call site. Fires alongside\n * `onChange`, never instead of it. */\n onCheckedChange?: (checked: boolean) => void;\n /** Classes for the `<input>` itself. `className` styles the OUTERMOST element —\n * the row when there is a label, the box's own wrapper when there is not — which\n * is where layout (`self-end pb-2` to sit on a field's baseline) belongs. */\n inputClassName?: string;\n}\n\n/**\n * A native checkbox in the kit's tokens, with an optional label, description and\n * error.\n *\n * Without `label`, `description` or `error` it renders the box and nothing else, so\n * it drops into a table cell or a caller's own `<label>` exactly as the raw input\n * did — give it an `aria-label` there. Every other prop reaches the `<input>`\n * (`checked`, `defaultChecked`, `name`, `required`, `data-*`, `aria-*`), so it works\n * controlled, uncontrolled, and inside a plain `<form>`.\n */\nexport const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(\n {\n label,\n description,\n indeterminate = false,\n invalid,\n error,\n onCheckedChange,\n onChange,\n className,\n inputClassName,\n id,\n disabled,\n ...rest\n },\n ref,\n) {\n const generated = useId();\n const inputId = id ?? generated;\n const descriptionId = `${inputId}-description`;\n const errorId = `${inputId}-error`;\n const showError = hasMessage(error);\n const showDescription = hasMessage(description);\n const isInvalid = Boolean(invalid) || showError;\n // Nothing to lay out beside the box: render the box alone, as the raw input was.\n const bare = label === undefined && !showDescription && !showError;\n\n const local = useRef<HTMLInputElement | null>(null);\n const setRef = useCallback(\n (node: HTMLInputElement | null) => {\n local.current = node;\n assignRef(ref, node);\n },\n [ref],\n );\n // After EVERY render, not only when the prop changes: a click clears the property\n // in the DOM, and a controlled caller that keeps `indeterminate` true expects the\n // dash back on the next render even though the prop never changed.\n useEffect(() => {\n if (local.current) local.current.indeterminate = indeterminate;\n });\n\n const box = (\n <span className={cn(\"relative inline-flex shrink-0\", bare && className)}>\n <input\n ref={setRef}\n id={bare ? id : inputId}\n disabled={disabled}\n {...rest}\n // After the spread, like Switch: a props object spread at a checkbox must not\n // be able to turn it into something else.\n type=\"checkbox\"\n // OR-ed with the spread so a caller's own `aria-invalid` survives — Input's rule.\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={mergeDescribedBy(\n rest[\"aria-describedby\"],\n showDescription && descriptionId,\n showError && errorId,\n )}\n onChange={(e: ChangeEvent<HTMLInputElement>) => {\n onChange?.(e);\n onCheckedChange?.(e.target.checked);\n }}\n className={cn(BOX, indeterminate && BOX_MIXED, isInvalid && BOX_INVALID, inputClassName)}\n />\n {/* The glyph is drawn over the box, not inside it (an input has no children),\n and ignores the pointer so a click on the tick still toggles the input. The\n tick is shown by `peer-checked`, so an UNCONTROLLED box shows it too; the\n dash replaces it outright while `indeterminate` holds. */}\n {indeterminate ? (\n <Minus\n aria-hidden\n strokeWidth={3}\n className=\"pointer-events-none absolute inset-0 m-auto size-3 text-[var(--brand-contrast)]\"\n />\n ) : (\n <Check\n aria-hidden\n strokeWidth={3}\n className=\"pointer-events-none invisible absolute inset-0 m-auto size-3 text-[var(--brand-contrast)] peer-checked:visible\"\n />\n )}\n </span>\n );\n\n if (bare) return box;\n\n return (\n <div\n className={cn(\n // `items-start` + a 20px cell for the box: the box centres on the label's FIRST\n // line (text-sm is 20px tall), so a label that wraps keeps the box at the top\n // rather than floating to the middle of a paragraph.\n \"flex items-start gap-2\",\n // The whole row fades, the way ToggleGroup and the fields do, so the label does\n // not stay at full strength beside a box you cannot change.\n disabled && \"opacity-60\",\n className,\n )}\n >\n <span className=\"flex h-5 shrink-0 items-center\">{box}</span>\n <span className=\"min-w-0\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className={cn(\n \"block text-sm leading-5 text-[var(--text-primary)]\",\n // `select-none`: a double-click to toggle twice would otherwise select the\n // words, and a drag that starts on them starts a native text drag.\n disabled ? \"cursor-not-allowed\" : \"cursor-pointer select-none\",\n )}\n >\n {label}\n </label>\n )}\n {showDescription && (\n <span id={descriptionId} className=\"mt-0.5 block text-xs text-[var(--text-muted)]\">\n {description}\n </span>\n )}\n {showError && (\n <span id={errorId} className={cn(\"block\", ERROR_CLASS)}>\n {error}\n </span>\n )}\n </span>\n </div>\n );\n});\nCheckbox.displayName = \"Checkbox\";\n"],"mappings":";AAuII,SACE,KADF;AAvIJ,SAAS,YAAY,aAAa,WAAW,OAAO,cAAc;AAClE,SAAS,OAAO,aAAa;AAE7B,SAAS,UAAU;AACnB,SAAS,WAAW,YAAY,wBAAwB;AAuBxD,MAAM,MACJ;AAQF,MAAM,YAAY;AAKlB,MAAM,cACJ;AAEF,MAAM,cAAc;AAgDb,MAAM,WAAW,WAA4C,SAASA,UAC3E;AAAA,EACE;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,YAAY,MAAM;AACxB,QAAM,UAAU,MAAM;AACtB,QAAM,gBAAgB,GAAG,OAAO;AAChC,QAAM,UAAU,GAAG,OAAO;AAC1B,QAAM,YAAY,WAAW,KAAK;AAClC,QAAM,kBAAkB,WAAW,WAAW;AAC9C,QAAM,YAAY,QAAQ,OAAO,KAAK;AAEtC,QAAM,OAAO,UAAU,UAAa,CAAC,mBAAmB,CAAC;AAEzD,QAAM,QAAQ,OAAgC,IAAI;AAClD,QAAM,SAAS;AAAA,IACb,CAAC,SAAkC;AACjC,YAAM,UAAU;AAChB,gBAAU,KAAK,IAAI;AAAA,IACrB;AAAA,IACA,CAAC,GAAG;AAAA,EACN;AAIA,YAAU,MAAM;AACd,QAAI,MAAM,QAAS,OAAM,QAAQ,gBAAgB;AAAA,EACnD,CAAC;AAED,QAAM,MACJ,qBAAC,UAAK,WAAW,GAAG,iCAAiC,QAAQ,SAAS,GACpE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,IAAI,OAAO,KAAK;AAAA,QAChB;AAAA,QACC,GAAG;AAAA,QAGJ,MAAK;AAAA,QAEL,gBAAc,aAAa,KAAK,cAAc,KAAK;AAAA,QACnD,oBAAkB;AAAA,UAChB,KAAK,kBAAkB;AAAA,UACvB,mBAAmB;AAAA,UACnB,aAAa;AAAA,QACf;AAAA,QACA,UAAU,CAAC,MAAqC;AAC9C,qBAAW,CAAC;AACZ,4BAAkB,EAAE,OAAO,OAAO;AAAA,QACpC;AAAA,QACA,WAAW,GAAG,KAAK,iBAAiB,WAAW,aAAa,aAAa,cAAc;AAAA;AAAA,IACzF;AAAA,IAKC,gBACC;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,aAAa;AAAA,QACb,WAAU;AAAA;AAAA,IACZ,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,aAAa;AAAA,QACb,WAAU;AAAA;AAAA,IACZ;AAAA,KAEJ;AAGF,MAAI,KAAM,QAAO;AAEjB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA;AAAA;AAAA;AAAA,QAIT;AAAA;AAAA;AAAA,QAGA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,UAAK,WAAU,kCAAkC,eAAI;AAAA,QACtD,qBAAC,UAAK,WAAU,WACb;AAAA,oBAAU,UACT;AAAA,YAAC;AAAA;AAAA,cACC,SAAS;AAAA,cACT,WAAW;AAAA,gBACT;AAAA;AAAA;AAAA,gBAGA,WAAW,uBAAuB;AAAA,cACpC;AAAA,cAEC;AAAA;AAAA,UACH;AAAA,UAED,mBACC,oBAAC,UAAK,IAAI,eAAe,WAAU,iDAChC,uBACH;AAAA,UAED,aACC,oBAAC,UAAK,IAAI,SAAS,WAAW,GAAG,SAAS,WAAW,GAClD,iBACH;AAAA,WAEJ;AAAA;AAAA;AAAA,EACF;AAEJ,CAAC;AACD,SAAS,cAAc;","names":["Checkbox"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/checkbox.tsx"],"sourcesContent":["import { forwardRef, useCallback, useEffect, useId, useRef } from \"react\";\nimport { Check, Minus } from \"lucide-react\";\nimport type { ChangeEvent, InputHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { assignRef, hasMessage, mergeDescribedBy } from \"./choice-parts\";\n\n/**\n * A checkbox that is still `<input type=\"checkbox\">`.\n *\n * The three consuming apps had three answers: ~30 raw inputs in Keksdose leaning on\n * `tokens.css`'s `accent-color`, a label-plus-box `CheckboxField` in Lenkbank, and a\n * Radix `<button role=\"checkbox\">` in Kastlan. The native element is the one that\n * needs no ARIA to be a checkbox — it submits with its form, answers to `required`,\n * resets with the form and takes a `<label for>` click — so it stays underneath, and\n * only its paint is replaced (`appearance-none`, a token-coloured box, and a glyph\n * laid over it that ignores the pointer so the click still lands on the input).\n *\n * `accent-color` alone was not enough: it tints the native box but cannot change its\n * border, so an INVALID checkbox had no way to say so, and the unchecked box kept the\n * browser's own grey outline in both themes.\n */\n\n// The box. 16px, like every raw checkbox it replaces, so a migration moves nothing.\n//\n// `focus-visible`, with an offset in the surface colour: a --brand ring touching a\n// --brand-filled (checked) box would be a ring painted in the colour it sits on —\n// present in the DOM and invisible on screen. The offset is what separates the two.\nconst BOX =\n \"peer size-4 shrink-0 cursor-pointer appearance-none rounded-[4px] border border-[var(--border-strong)] bg-[var(--bg-surface)] shadow-sm transition-colors \" +\n \"checked:border-[var(--brand)] checked:bg-[var(--brand)] \" +\n \"focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)] \" +\n \"disabled:cursor-not-allowed\";\n\n// A mixed box wears the checked fill whether or not the underlying input is checked:\n// `indeterminate` is a property the browser keeps separately from `checked`, and the\n// dash means \"some\", which is neither answer.\nconst BOX_MIXED = \"border-[var(--brand)] bg-[var(--brand)]\";\n\n// The same geometry as FIELD_INVALID — a border plus a 1px ring, so the highlight\n// survives fractional display scaling on every edge (see the note in ui.tsx). The\n// focus ring turns danger too, so focusing a wrong box does not repaint it as fine.\nconst BOX_INVALID =\n \"border-[var(--danger-border)] ring-1 ring-[var(--danger-border)] checked:border-[var(--danger-border)] focus-visible:ring-[var(--danger)]\";\n\nconst ERROR_CLASS = \"mt-1 text-[11px] leading-tight text-[var(--danger)]\";\n\nexport interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\"> {\n /**\n * The words beside the box. Rendered as a `<label for>` rather than a label that\n * wraps the input, so `description` and `error` can sit under it without becoming\n * part of the accessible NAME — a wrapping label would have the screen reader read\n * the whole paragraph as the checkbox's name.\n */\n label?: ReactNode;\n /** Secondary text under the label (\"Used when no account is chosen\"). Pointed at by\n * `aria-describedby`, merged with any the caller passed. Kastlan's\n * `CheckboxField` `description` variant. */\n description?: ReactNode;\n /**\n * \"Some but not all\" — the header box of a list whose rows are partly selected.\n * A DOM property with no HTML attribute, so it is set on the element after every\n * render; it does not touch `checked`, and a click clears it (the browser's rule,\n * and the caller's `onChange` decides what the click means).\n */\n indeterminate?: boolean;\n /** See {@link Input}'s `invalid`: paints the box and sets `aria-invalid` together,\n * so the two cannot be spelled separately. */\n invalid?: boolean;\n /** What is wrong, in the caller's words (\"Accept the terms to continue\"). Rendered\n * under the label, attached with `aria-describedby`, and implies `invalid`. */\n error?: ReactNode;\n /** The checked state as a boolean — the `(next) => …` shape Lenkbank's\n * `CheckboxField` and Radix's `onCheckedChange` both used, so a migration does not\n * have to unwrap `event.target.checked` at every call site. Fires alongside\n * `onChange`, never instead of it. */\n onCheckedChange?: (checked: boolean) => void;\n /** Classes for the `<input>` itself. `className` styles the OUTERMOST element —\n * the row when there is a label, the box's own wrapper when there is not — which\n * is where layout (`self-end pb-2` to sit on a field's baseline) belongs. */\n inputClassName?: string;\n /**\n * Must be ticked to submit — a consent, an acceptance before paying. Reaches the\n * `<input>` as the native `required` (so a `<form>` refuses to submit without it and a\n * screen reader announces \"required\"), and draws the kit's required mark after the\n * label, the same `aria-hidden` star as {@link Label}'s: the word is announced from\n * the control, so a star inside the name would only be read out as noise. Write the\n * label WITHOUT a literal \"*\". No mark without a label.\n */\n required?: boolean;\n}\n\n/**\n * A native checkbox in the kit's tokens, with an optional label, description and\n * error.\n *\n * Without `label`, `description` or `error` it renders the box and nothing else, so\n * it drops into a table cell or a caller's own `<label>` exactly as the raw input\n * did — give it an `aria-label` there. Every other prop reaches the `<input>`\n * (`checked`, `defaultChecked`, `name`, `required`, `data-*`, `aria-*`), so it works\n * controlled, uncontrolled, and inside a plain `<form>`.\n */\nexport const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(\n {\n label,\n description,\n indeterminate = false,\n invalid,\n error,\n onCheckedChange,\n onChange,\n className,\n inputClassName,\n id,\n disabled,\n required,\n ...rest\n },\n ref,\n) {\n const generated = useId();\n const inputId = id ?? generated;\n const descriptionId = `${inputId}-description`;\n const errorId = `${inputId}-error`;\n const showError = hasMessage(error);\n const showDescription = hasMessage(description);\n const isInvalid = Boolean(invalid) || showError;\n // Nothing to lay out beside the box: render the box alone, as the raw input was.\n const bare = label === undefined && !showDescription && !showError;\n\n const local = useRef<HTMLInputElement | null>(null);\n const setRef = useCallback(\n (node: HTMLInputElement | null) => {\n local.current = node;\n assignRef(ref, node);\n },\n [ref],\n );\n // After EVERY render, not only when the prop changes: a click clears the property\n // in the DOM, and a controlled caller that keeps `indeterminate` true expects the\n // dash back on the next render even though the prop never changed.\n useEffect(() => {\n if (local.current) local.current.indeterminate = indeterminate;\n });\n\n const box = (\n <span className={cn(\"relative inline-flex shrink-0\", bare && className)}>\n <input\n ref={setRef}\n id={bare ? id : inputId}\n disabled={disabled}\n required={required}\n {...rest}\n // After the spread, like Switch: a props object spread at a checkbox must not\n // be able to turn it into something else.\n type=\"checkbox\"\n // OR-ed with the spread so a caller's own `aria-invalid` survives — Input's rule.\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={mergeDescribedBy(\n rest[\"aria-describedby\"],\n showDescription && descriptionId,\n showError && errorId,\n )}\n onChange={(e: ChangeEvent<HTMLInputElement>) => {\n onChange?.(e);\n onCheckedChange?.(e.target.checked);\n }}\n className={cn(BOX, indeterminate && BOX_MIXED, isInvalid && BOX_INVALID, inputClassName)}\n />\n {/* The glyph is drawn over the box, not inside it (an input has no children),\n and ignores the pointer so a click on the tick still toggles the input. The\n tick is shown by `peer-checked`, so an UNCONTROLLED box shows it too; the\n dash replaces it outright while `indeterminate` holds. */}\n {indeterminate ? (\n <Minus\n aria-hidden\n strokeWidth={3}\n className=\"pointer-events-none absolute inset-0 m-auto size-3 text-[var(--brand-contrast)]\"\n />\n ) : (\n <Check\n aria-hidden\n strokeWidth={3}\n className=\"pointer-events-none invisible absolute inset-0 m-auto size-3 text-[var(--brand-contrast)] peer-checked:visible\"\n />\n )}\n </span>\n );\n\n if (bare) return box;\n\n return (\n <div\n className={cn(\n // `items-start` + a 20px cell for the box: the box centres on the label's FIRST\n // line (text-sm is 20px tall), so a label that wraps keeps the box at the top\n // rather than floating to the middle of a paragraph.\n \"flex items-start gap-2\",\n // The whole row fades, the way ToggleGroup and the fields do, so the label does\n // not stay at full strength beside a box you cannot change.\n disabled && \"opacity-60\",\n className,\n )}\n >\n <span className=\"flex h-5 shrink-0 items-center\">{box}</span>\n <span className=\"min-w-0\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className={cn(\n \"block text-sm leading-5 text-[var(--text-primary)]\",\n // `select-none`: a double-click to toggle twice would otherwise select the\n // words, and a drag that starts on them starts a native text drag.\n disabled ? \"cursor-not-allowed\" : \"cursor-pointer select-none\",\n )}\n >\n {label}\n {required && (\n <span aria-hidden className=\"ms-0.5 text-[var(--danger)]\">\n *\n </span>\n )}\n </label>\n )}\n {showDescription && (\n <span id={descriptionId} className=\"mt-0.5 block text-xs text-[var(--text-muted)]\">\n {description}\n </span>\n )}\n {showError && (\n <span id={errorId} className={cn(\"block\", ERROR_CLASS)}>\n {error}\n </span>\n )}\n </span>\n </div>\n );\n});\nCheckbox.displayName = \"Checkbox\";\n"],"mappings":";AAiJI,SACE,KADF;AAjJJ,SAAS,YAAY,aAAa,WAAW,OAAO,cAAc;AAClE,SAAS,OAAO,aAAa;AAE7B,SAAS,UAAU;AACnB,SAAS,WAAW,YAAY,wBAAwB;AAuBxD,MAAM,MACJ;AAQF,MAAM,YAAY;AAKlB,MAAM,cACJ;AAEF,MAAM,cAAc;AAyDb,MAAM,WAAW,WAA4C,SAASA,UAC3E;AAAA,EACE;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,YAAY,MAAM;AACxB,QAAM,UAAU,MAAM;AACtB,QAAM,gBAAgB,GAAG,OAAO;AAChC,QAAM,UAAU,GAAG,OAAO;AAC1B,QAAM,YAAY,WAAW,KAAK;AAClC,QAAM,kBAAkB,WAAW,WAAW;AAC9C,QAAM,YAAY,QAAQ,OAAO,KAAK;AAEtC,QAAM,OAAO,UAAU,UAAa,CAAC,mBAAmB,CAAC;AAEzD,QAAM,QAAQ,OAAgC,IAAI;AAClD,QAAM,SAAS;AAAA,IACb,CAAC,SAAkC;AACjC,YAAM,UAAU;AAChB,gBAAU,KAAK,IAAI;AAAA,IACrB;AAAA,IACA,CAAC,GAAG;AAAA,EACN;AAIA,YAAU,MAAM;AACd,QAAI,MAAM,QAAS,OAAM,QAAQ,gBAAgB;AAAA,EACnD,CAAC;AAED,QAAM,MACJ,qBAAC,UAAK,WAAW,GAAG,iCAAiC,QAAQ,SAAS,GACpE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,IAAI,OAAO,KAAK;AAAA,QAChB;AAAA,QACA;AAAA,QACC,GAAG;AAAA,QAGJ,MAAK;AAAA,QAEL,gBAAc,aAAa,KAAK,cAAc,KAAK;AAAA,QACnD,oBAAkB;AAAA,UAChB,KAAK,kBAAkB;AAAA,UACvB,mBAAmB;AAAA,UACnB,aAAa;AAAA,QACf;AAAA,QACA,UAAU,CAAC,MAAqC;AAC9C,qBAAW,CAAC;AACZ,4BAAkB,EAAE,OAAO,OAAO;AAAA,QACpC;AAAA,QACA,WAAW,GAAG,KAAK,iBAAiB,WAAW,aAAa,aAAa,cAAc;AAAA;AAAA,IACzF;AAAA,IAKC,gBACC;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,aAAa;AAAA,QACb,WAAU;AAAA;AAAA,IACZ,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,aAAa;AAAA,QACb,WAAU;AAAA;AAAA,IACZ;AAAA,KAEJ;AAGF,MAAI,KAAM,QAAO;AAEjB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA;AAAA;AAAA;AAAA,QAIT;AAAA;AAAA;AAAA,QAGA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEA;AAAA,4BAAC,UAAK,WAAU,kCAAkC,eAAI;AAAA,QACtD,qBAAC,UAAK,WAAU,WACb;AAAA,oBAAU,UACT;AAAA,YAAC;AAAA;AAAA,cACC,SAAS;AAAA,cACT,WAAW;AAAA,gBACT;AAAA;AAAA;AAAA,gBAGA,WAAW,uBAAuB;AAAA,cACpC;AAAA,cAEC;AAAA;AAAA,gBACA,YACC,oBAAC,UAAK,eAAW,MAAC,WAAU,+BAA8B,eAE1D;AAAA;AAAA;AAAA,UAEJ;AAAA,UAED,mBACC,oBAAC,UAAK,IAAI,eAAe,WAAU,iDAChC,uBACH;AAAA,UAED,aACC,oBAAC,UAAK,IAAI,SAAS,WAAW,GAAG,SAAS,WAAW,GAClD,iBACH;AAAA,WAEJ;AAAA;AAAA;AAAA,EACF;AAEJ,CAAC;AACD,SAAS,cAAc;","names":["Checkbox"]}
|
|
@@ -16,8 +16,9 @@ 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;
|