@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.
Files changed (136) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +103 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. 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__ */ jsx(
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: label
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":"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","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
- type ChipSize = "sm" | "md";
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;