@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
@@ -0,0 +1,429 @@
1
+ import { Fragment, forwardRef, useId, useRef, useState } from "react";
2
+ import type {
3
+ ComponentPropsWithoutRef,
4
+ ForwardedRef,
5
+ ReactElement,
6
+ ReactNode,
7
+ RefAttributes,
8
+ } from "react";
9
+ import { createPortal } from "react-dom";
10
+ import { cn } from "../lib/cn";
11
+ import { FieldLabel, FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, Spinner } from "./ui";
12
+ import { useAnchoredPanel } from "../hooks/use-anchored-panel";
13
+ import {
14
+ useActiveOptionScroll,
15
+ useComboboxFieldError,
16
+ useOptionSource,
17
+ type ComboOption,
18
+ } from "./combobox-core";
19
+ import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
20
+
21
+ /**
22
+ * `value`/`onChange` are the TEXT's, and `onSelect` is "a suggestion was taken" —
23
+ * not the DOM's text-selection event — so the input's own spellings of those three
24
+ * are omitted. Everything else reaches the `<input>`, the way it does on {@link Input}.
25
+ */
26
+ export interface AutocompleteProps<V extends string | number = string>
27
+ extends Omit<
28
+ ComponentPropsWithoutRef<"input">,
29
+ "value" | "defaultValue" | "onChange" | "onSelect" | "children" | "type"
30
+ > {
31
+ /** What is in the field. Controlled, and never reset by the component: opening,
32
+ * closing and a failed lookup all leave it exactly as typed. */
33
+ value: string;
34
+ /** Every keystroke — and, with `fillOnSelect`, the label of a taken suggestion. */
35
+ onChange: (text: string) => void;
36
+ /** Suggestions the caller already has (e.g. from its own query hook). Narrowed by
37
+ * the text unless `filter={false}`. */
38
+ options?: ComboOption<V>[];
39
+ /** Or: fetch them. Debounced (`debounceMs`), race-safe, only at `minChars` and up,
40
+ * never while the field is idle; a rejection shows `loadErrorLabel` and empties
41
+ * the list rather than leaving the previous text's rows under it. */
42
+ loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
43
+ /** Narrow `options` by label/sublabel substring. Default `true`. Pass `false` for
44
+ * a list a server has already searched and ranked — it is shown as given. */
45
+ filter?: boolean;
46
+ /** External loading flag (a caller-side fetch), OR-ed with `loadOptions`'. */
47
+ loading?: boolean;
48
+ /** Below this many characters nothing is offered or fetched. Default 1: an empty
49
+ * field asks nothing — focusing it is not a search. */
50
+ minChars?: number;
51
+ /** Quiet time before `loadOptions` runs. Default 300 ms — an ordinary typist's
52
+ * inter-key interval is 150–250 ms, so the pickers' 150 fires on nearly every
53
+ * letter, which a rate-limited geocoder cannot afford. */
54
+ debounceMs?: number;
55
+ /** A suggestion was taken (click, or Enter on the highlighted row). */
56
+ onSelect?: (option: ComboOption<V>) => void;
57
+ /**
58
+ * Put the taken suggestion's label into the field (`onChange(label)`). Default
59
+ * `true` — the free-text case, where the text IS the value and a suggestion only
60
+ * completes it (an address field). Pass `false` where taking a row is an ACTION
61
+ * and the field is only the search that found it; the text then stays as typed.
62
+ */
63
+ fillOnSelect?: boolean;
64
+ /** A floating label, as on {@link Combobox}. Names the field (`aria-labelledby`)
65
+ * unless an `aria-label` is given. */
66
+ label?: ReactNode;
67
+ /** Leading decoration inside the field (a pin, a magnifier). Decorative: the
68
+ * label names the field. */
69
+ icon?: ReactNode;
70
+ /** Required and unanswered — {@link FIELD_INVALID}. */
71
+ invalid?: boolean;
72
+ /** What is wrong with the VALUE, as {@link Input}'s `error`: under the field, on
73
+ * its `aria-describedby`, implies `invalid`. Not a failed lookup — that is
74
+ * `loadErrorLabel`, shown in the list. */
75
+ error?: ReactNode;
76
+ /**
77
+ * The caller's own line in the list, replacing the automatic one (loading / failed
78
+ * / no results). For what only the caller knows: "offline", "the address service
79
+ * is switched off", a privacy note, the hint a caller-fetched list needs below its
80
+ * own minimum. While set, the list opens even with no rows. Announced politely.
81
+ */
82
+ status?: ReactNode;
83
+ /** Default: `combobox.noResults` from the {@link UiKitProvider}, else English. */
84
+ emptyLabel?: string;
85
+ /** Default: `combobox.loadError`. */
86
+ loadErrorLabel?: string;
87
+ /** Classes for the `<input>` itself; `className` styles the wrapper. */
88
+ inputClassName?: string;
89
+ }
90
+
91
+ function AutocompleteInner<V extends string | number = string>(
92
+ {
93
+ value,
94
+ onChange,
95
+ options,
96
+ loadOptions,
97
+ filter,
98
+ loading,
99
+ minChars = 1,
100
+ debounceMs = 300,
101
+ onSelect,
102
+ fillOnSelect = true,
103
+ label,
104
+ icon,
105
+ invalid,
106
+ error,
107
+ status,
108
+ emptyLabel,
109
+ loadErrorLabel,
110
+ className,
111
+ inputClassName,
112
+ id,
113
+ disabled,
114
+ onKeyDown,
115
+ onFocus,
116
+ onBlur,
117
+ "aria-label": ariaLabel,
118
+ "aria-labelledby": ariaLabelledBy,
119
+ "aria-describedby": ariaDescribedBy,
120
+ ...rest
121
+ }: AutocompleteProps<V>,
122
+ ref: ForwardedRef<HTMLInputElement>,
123
+ ) {
124
+ const generated = useId();
125
+ const fieldId = id ?? generated;
126
+ // Off the GENERATED id: a caller's `id` is theirs to collide with.
127
+ const listboxId = `${generated}-listbox`;
128
+ const labelId = `${generated}-label`;
129
+ const optionId = (index: number) => `${generated}-option-${index}`;
130
+ const fieldRef = useRef<HTMLDivElement>(null);
131
+ const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
132
+ noResults: emptyLabel,
133
+ loadError: loadErrorLabel,
134
+ });
135
+ const field = useComboboxFieldError(error, invalid, ariaDescribedBy);
136
+
137
+ /** The user's intent: the field is focused and the list has not been dismissed.
138
+ * Whether anything SHOWS is decided below, from what there is to show. */
139
+ const [open, setOpen] = useState(false);
140
+ const [active, setActive] = useState(-1);
141
+ const live = open && !disabled;
142
+ const { results, busy, failed, tooShort } = useOptionSource<V>({
143
+ options,
144
+ loadOptions,
145
+ loading,
146
+ filter,
147
+ minChars,
148
+ debounceMs,
149
+ query: value,
150
+ active: live,
151
+ });
152
+
153
+ const hasStatus = status !== undefined && status !== null && status !== false && status !== "";
154
+ // The visible line under the rows. Nothing below `minChars` unless the caller has
155
+ // something to say — an empty field opening a box that says "type more" is noise.
156
+ const statusLine: ReactNode = hasStatus
157
+ ? status
158
+ : tooShort
159
+ ? null
160
+ : busy && results.length === 0
161
+ ? labels.loading
162
+ : failed
163
+ ? labels.loadError
164
+ : !busy && results.length === 0
165
+ ? labels.noResults
166
+ : null;
167
+ const expanded = live && (results.length > 0 || statusLine !== null);
168
+ const activeId =
169
+ expanded && active >= 0 && active < results.length ? optionId(active) : undefined;
170
+ useActiveOptionScroll(activeId);
171
+ const { rect, top, maxHeight } = useAnchoredPanel(fieldRef, expanded, { preferredHeight: 256 });
172
+
173
+ const close = () => {
174
+ setOpen(false);
175
+ setActive(-1);
176
+ };
177
+ const take = (o: ComboOption<V>) => {
178
+ if (fillOnSelect) onChange(o.label);
179
+ onSelect?.(o);
180
+ close();
181
+ };
182
+
183
+ const hasLabel = label !== undefined;
184
+ // The caller's name wins; else the floating label, by reference so a non-string
185
+ // label still names the field.
186
+ const labelledBy =
187
+ ariaLabel === undefined ? (ariaLabelledBy ?? (hasLabel ? labelId : undefined)) : undefined;
188
+
189
+ return (
190
+ // `relative`: the floating label and the live region's `sr-only` both need a
191
+ // local containing block.
192
+ <div className={cn("relative", className)}>
193
+ {hasLabel && <FieldLabel id={labelId}>{label}</FieldLabel>}
194
+ <div ref={fieldRef} className="relative">
195
+ {icon && (
196
+ <span
197
+ aria-hidden
198
+ className="pointer-events-none absolute start-2.5 top-1/2 flex -translate-y-1/2 text-[var(--text-muted)] [&>svg]:size-4"
199
+ >
200
+ {icon}
201
+ </span>
202
+ )}
203
+ <input
204
+ {...rest}
205
+ ref={ref}
206
+ id={fieldId}
207
+ type="text"
208
+ value={value}
209
+ disabled={disabled}
210
+ role="combobox"
211
+ aria-autocomplete="list"
212
+ aria-expanded={expanded}
213
+ // Required by the role, and set while closed too, as on the siblings.
214
+ aria-controls={listboxId}
215
+ aria-activedescendant={activeId}
216
+ aria-label={ariaLabel}
217
+ aria-labelledby={labelledBy}
218
+ aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
219
+ aria-describedby={field.describedBy}
220
+ autoComplete="off"
221
+ onFocus={(e) => {
222
+ onFocus?.(e);
223
+ // A seeded field (keksdose's `initialQuery`) is looked up as it is
224
+ // focused; an empty one asks nothing, because of `minChars`.
225
+ setOpen(true);
226
+ }}
227
+ onBlur={(e) => {
228
+ onBlur?.(e);
229
+ close();
230
+ }}
231
+ onChange={(e) => {
232
+ onChange(e.target.value);
233
+ setOpen(true);
234
+ setActive(-1);
235
+ }}
236
+ onKeyDown={(e) => {
237
+ // An Escape that closes the OPEN list is the list's, and is consumed before
238
+ // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
239
+ // address search) otherwise closed the whole panel when the user only meant
240
+ // to dismiss the suggestions — the opposite of what the docs promised.
241
+ if (e.key === "Escape" && expanded) {
242
+ e.preventDefault();
243
+ e.stopPropagation();
244
+ close();
245
+ return;
246
+ }
247
+ onKeyDown?.(e);
248
+ if (e.defaultPrevented) return;
249
+ const last = results.length - 1;
250
+ if (e.key === "ArrowDown") {
251
+ e.preventDefault();
252
+ if (!open) setOpen(true);
253
+ else setActive((i) => Math.min(i + 1, last));
254
+ } else if (e.key === "ArrowUp") {
255
+ e.preventDefault();
256
+ if (!open) setOpen(true);
257
+ // From "nothing highlighted", Up goes to the bottom, as the APG has it.
258
+ else setActive((i) => (i < 0 ? last : Math.max(i - 1, 0)));
259
+ } else if (e.key === "Enter") {
260
+ // No row highlighted: the text is the answer, and a form's own submit
261
+ // is left alone.
262
+ if (expanded && activeId) {
263
+ e.preventDefault();
264
+ take(results[active]);
265
+ }
266
+ } else if (e.key === "Tab") {
267
+ close();
268
+ }
269
+ }}
270
+ className={cn(
271
+ FIELD_BASE,
272
+ hasLabel && FIELD_FLOATING_PAD,
273
+ icon ? "ps-8" : undefined,
274
+ busy && "pe-9",
275
+ field.isInvalid && FIELD_INVALID,
276
+ inputClassName,
277
+ )}
278
+ />
279
+ {busy && live && (
280
+ // Decorative: the live region below already says "Loading…".
281
+ <span className="pointer-events-none absolute end-2.5 top-1/2 flex -translate-y-1/2">
282
+ <Spinner label={null} className="h-4 w-4" />
283
+ </span>
284
+ )}
285
+ </div>
286
+ {field.errorEl}
287
+ {/* The live region, kept mounted so a change IS an announcement: focus stays
288
+ in the field, and without it a reader typed into a geocoder and heard only
289
+ their own letters. The panel's visible line is aria-hidden in favour of
290
+ this, which also carries the row count the eye reads off the list. */}
291
+ <div role="status" aria-live="polite" className="sr-only">
292
+ {!live
293
+ ? null
294
+ : statusLine !== null
295
+ ? statusLine
296
+ : results.length > 0
297
+ ? labels.resultCount(results.length)
298
+ : null}
299
+ </div>
300
+ {expanded &&
301
+ rect &&
302
+ typeof document !== "undefined" &&
303
+ createPortal(
304
+ <div
305
+ // Every press inside the list keeps focus in the field — rows, the status
306
+ // line, the scrollbar — or the input's blur would close the list first.
307
+ role="presentation"
308
+ onMouseDown={(e) => e.preventDefault()}
309
+ className="fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg"
310
+ style={{ top, left: rect.left, width: rect.width, maxHeight }}
311
+ >
312
+ {results.length > 0 && (
313
+ <ul
314
+ id={listboxId}
315
+ role="listbox"
316
+ aria-label={ariaLabel}
317
+ aria-labelledby={labelledBy}
318
+ className="min-h-0 flex-1 overflow-y-auto py-1"
319
+ >
320
+ {results.map((o, i) => {
321
+ const startsGroup = o.group != null && o.group !== results[i - 1]?.group;
322
+ return (
323
+ // Keyed by group AND value, as in the core panel: an option may
324
+ // appear once per group.
325
+ <Fragment key={`${o.group ?? ""}|${String(o.value)}`}>
326
+ {startsGroup && (
327
+ <li
328
+ role="presentation"
329
+ className="px-3 pb-0.5 pt-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] first:pt-1"
330
+ >
331
+ {o.group}
332
+ </li>
333
+ )}
334
+ <li role="presentation">
335
+ <button
336
+ type="button"
337
+ id={optionId(i)}
338
+ role="option"
339
+ // CHOSEN, never "highlighted" — the family's rule. This
340
+ // field holds no value, so the only row that can be chosen
341
+ // is the one whose label the text already is.
342
+ aria-selected={fillOnSelect && o.label === value}
343
+ tabIndex={-1}
344
+ onClick={() => take(o)}
345
+ onMouseEnter={() => setActive(i)}
346
+ className={cn(
347
+ "flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm",
348
+ i === active ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
349
+ o.group != null && "ps-6",
350
+ )}
351
+ >
352
+ {o.icon && (
353
+ <span aria-hidden className="shrink-0 text-[var(--text-muted)]">
354
+ {o.icon}
355
+ </span>
356
+ )}
357
+ <span className="min-w-0 flex-1">
358
+ <span className="block truncate text-[var(--text-primary)]">
359
+ {o.label}
360
+ </span>
361
+ {o.sublabel && (
362
+ <span className="block truncate text-xs text-[var(--text-placeholder)]">
363
+ {o.sublabel}
364
+ </span>
365
+ )}
366
+ </span>
367
+ </button>
368
+ </li>
369
+ </Fragment>
370
+ );
371
+ })}
372
+ </ul>
373
+ )}
374
+ {statusLine !== null && (
375
+ // Visible, not live — the region above speaks for it.
376
+ <div
377
+ aria-hidden
378
+ className={cn(
379
+ "px-3 py-2 text-sm",
380
+ failed && !hasStatus ? "text-[var(--danger)]" : "text-[var(--text-muted)]",
381
+ results.length > 0 && "border-t border-[var(--border)] text-xs",
382
+ )}
383
+ >
384
+ {statusLine}
385
+ </div>
386
+ )}
387
+ </div>,
388
+ document.body,
389
+ )}
390
+ </div>
391
+ );
392
+ }
393
+
394
+ const AutocompleteBase = forwardRef(AutocompleteInner);
395
+ AutocompleteBase.displayName = "Autocomplete";
396
+
397
+ /**
398
+ * An inline, async-capable text field with a suggestion list under it — the
399
+ * WAI-ARIA APG "editable combobox with list autocomplete".
400
+ *
401
+ * Two jobs, one control:
402
+ * - **free text** (kastlan's address field): the text is the value, the list only
403
+ * offers completions, and taking one fills the field (`fillOnSelect`, default);
404
+ * - **search-then-act** (keksdose's address search): the text is a query, taking a
405
+ * row calls `onSelect` and nothing is held (`fillOnSelect={false}`).
406
+ *
407
+ * How it differs from its siblings: {@link Combobox} is free text over a STATIC
408
+ * string pool and opens on focus; {@link EntityCombobox} is a trigger holding an id.
409
+ * This one keeps no value of its own, never resets the text, asks nothing below
410
+ * `minChars`, and reports a failed lookup instead of showing stale rows.
411
+ *
412
+ * Focus never leaves the `<input>`: `aria-activedescendant` names the highlighted
413
+ * row, `aria-controls` the list, and a polite live region says what the list now
414
+ * holds. Keyboard: ↓/↑ move (↓ opens), Enter takes the highlighted row (with none,
415
+ * Enter is left to the form), Escape closes, Tab closes and moves on; Home/End stay
416
+ * with the caret. An Escape that closes an open list is consumed and never reaches the
417
+ * caller; with the list closed it does. For every other key a caller's `onKeyDown`
418
+ * runs first and may `preventDefault()` to
419
+ * keep a key for itself.
420
+ *
421
+ * The list is portalled and anchored under the field on every screen size: the
422
+ * field already carries the keyboard, and `useAnchoredPanel` keeps the list clear
423
+ * of it on a phone. There is no full-screen sheet as {@link Combobox} has.
424
+
425
+ * Generic over the option value (`ComboOption<V>`); the ref is the `<input>`'s.
426
+ */
427
+ export const Autocomplete = AutocompleteBase as <V extends string | number = string>(
428
+ props: AutocompleteProps<V> & RefAttributes<HTMLInputElement>,
429
+ ) => ReactElement | null;
@@ -78,6 +78,15 @@ export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement
78
78
  * the row when there is a label, the box's own wrapper when there is not — which
79
79
  * is where layout (`self-end pb-2` to sit on a field's baseline) belongs. */
80
80
  inputClassName?: string;
81
+ /**
82
+ * Must be ticked to submit — a consent, an acceptance before paying. Reaches the
83
+ * `<input>` as the native `required` (so a `<form>` refuses to submit without it and a
84
+ * screen reader announces "required"), and draws the kit's required mark after the
85
+ * label, the same `aria-hidden` star as {@link Label}'s: the word is announced from
86
+ * the control, so a star inside the name would only be read out as noise. Write the
87
+ * label WITHOUT a literal "*". No mark without a label.
88
+ */
89
+ required?: boolean;
81
90
  }
82
91
 
83
92
  /**
@@ -103,6 +112,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Che
103
112
  inputClassName,
104
113
  id,
105
114
  disabled,
115
+ required,
106
116
  ...rest
107
117
  },
108
118
  ref,
@@ -138,6 +148,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Che
138
148
  ref={setRef}
139
149
  id={bare ? id : inputId}
140
150
  disabled={disabled}
151
+ required={required}
141
152
  {...rest}
142
153
  // After the spread, like Switch: a props object spread at a checkbox must not
143
154
  // be able to turn it into something else.
@@ -203,6 +214,11 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Che
203
214
  )}
204
215
  >
205
216
  {label}
217
+ {required && (
218
+ <span aria-hidden className="ms-0.5 text-[var(--danger)]">
219
+ *
220
+ </span>
221
+ )}
206
222
  </label>
207
223
  )}
208
224
  {showDescription && (
@@ -21,8 +21,17 @@ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-l
21
21
  * things". Using `Button` for both is the mistake this exists to prevent.
22
22
  */
23
23
 
24
- export type ChipTone = "neutral" | "brand" | "danger" | "warning" | "success" | "info";
25
- export type ChipSize = "sm" | "md";
24
+ export type ChipTone =
25
+ | "neutral"
26
+ | "brand"
27
+ | "danger"
28
+ | "warning"
29
+ | "success"
30
+ | "info"
31
+ | "income"
32
+ | "expense";
33
+ /** `lg` is the 44px touch target (`min-h-11`) a phone surface wants; `md` is ~28px. */
34
+ export type ChipSize = "sm" | "md" | "lg";
26
35
 
27
36
  const TONE: Record<ChipTone, { idle: string; selected: string }> = {
28
37
  neutral: {
@@ -49,11 +58,24 @@ const TONE: Record<ChipTone, { idle: string; selected: string }> = {
49
58
  idle: "border-[var(--info-border)] bg-[var(--info-bg)] text-[var(--info)]",
50
59
  selected: "border-[var(--info)] bg-[var(--info-bg)] text-[var(--info)]",
51
60
  },
61
+ // The money pair (Keksdose's direction toggle). Only the TEXT and the BORDER carry
62
+ // the tint, on the neutral chip's own surfaces: the money tokens are chosen for text
63
+ // contrast, and a filled amber pill beside an amber figure reads as one smear.
64
+ // `border-current` takes its hue from the text, so the two cannot drift apart.
65
+ income: {
66
+ idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-income)]",
67
+ selected: "border-current bg-[var(--bg-active)] text-[var(--money-income)]",
68
+ },
69
+ expense: {
70
+ idle: "border-current/40 bg-[var(--bg-surface-2)] text-[var(--money-expense)]",
71
+ selected: "border-current bg-[var(--bg-active)] text-[var(--money-expense)]",
72
+ },
52
73
  };
53
74
 
54
75
  const SIZE: Record<ChipSize, { body: string; icon: string; remove: string }> = {
55
76
  sm: { body: "gap-1 px-2 py-0.5 text-xs", icon: "size-3", remove: "size-3" },
56
77
  md: { body: "gap-1.5 px-2.5 py-1 text-sm", icon: "size-3.5", remove: "size-3.5" },
78
+ lg: { body: "min-h-11 gap-2 px-4 py-2 text-sm", icon: "size-4", remove: "size-4" },
57
79
  };
58
80
 
59
81
  const CHIP_BASE =