@eifi1/ui-kit 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) 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 +101 -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/chip.d.ts +3 -2
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/danger-confirm.d.ts +91 -0
  20. package/dist/components/danger-confirm.js +181 -0
  21. package/dist/components/danger-confirm.js.map +1 -0
  22. package/dist/components/dialog-frame.d.ts +84 -0
  23. package/dist/components/dialog-frame.js +86 -0
  24. package/dist/components/dialog-frame.js.map +1 -0
  25. package/dist/components/disclosure.d.ts +108 -0
  26. package/dist/components/disclosure.js +127 -0
  27. package/dist/components/disclosure.js.map +1 -0
  28. package/dist/components/entity-combobox.d.ts +17 -3
  29. package/dist/components/entity-combobox.js +25 -5
  30. package/dist/components/entity-combobox.js.map +1 -1
  31. package/dist/components/file-button.d.ts +161 -0
  32. package/dist/components/file-button.js +225 -0
  33. package/dist/components/file-button.js.map +1 -0
  34. package/dist/components/file-dropzone.d.ts +72 -23
  35. package/dist/components/file-dropzone.js +219 -94
  36. package/dist/components/file-dropzone.js.map +1 -1
  37. package/dist/components/icon-picker.d.ts +72 -0
  38. package/dist/components/icon-picker.js +104 -0
  39. package/dist/components/icon-picker.js.map +1 -0
  40. package/dist/components/mini-calendar.d.ts +3 -0
  41. package/dist/components/mini-calendar.js +4 -3
  42. package/dist/components/mini-calendar.js.map +1 -1
  43. package/dist/components/modal.d.ts +8 -1
  44. package/dist/components/modal.js +4 -2
  45. package/dist/components/modal.js.map +1 -1
  46. package/dist/components/multi-entity-combobox.d.ts +16 -3
  47. package/dist/components/multi-entity-combobox.js +25 -5
  48. package/dist/components/multi-entity-combobox.js.map +1 -1
  49. package/dist/components/number-field.d.ts +41 -1
  50. package/dist/components/number-field.js +42 -10
  51. package/dist/components/number-field.js.map +1 -1
  52. package/dist/components/number-input.d.ts +35 -2
  53. package/dist/components/number-input.js +35 -4
  54. package/dist/components/number-input.js.map +1 -1
  55. package/dist/components/numpad-sheet.d.ts +7 -0
  56. package/dist/components/search-field.d.ts +16 -0
  57. package/dist/components/search-field.js +29 -7
  58. package/dist/components/search-field.js.map +1 -1
  59. package/dist/components/signature-pad.d.ts +43 -1
  60. package/dist/components/signature-pad.js +74 -2
  61. package/dist/components/signature-pad.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +69 -0
  63. package/dist/components/swatch-picker.js +75 -0
  64. package/dist/components/swatch-picker.js.map +1 -0
  65. package/dist/components/tile-radio.d.ts +50 -0
  66. package/dist/components/tile-radio.js +140 -0
  67. package/dist/components/tile-radio.js.map +1 -0
  68. package/dist/components/toggle-group.d.ts +27 -5
  69. package/dist/components/toggle-group.js +22 -15
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/ui.d.ts +150 -12
  72. package/dist/components/ui.js +196 -22
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/i18n/defaults.d.ts +7 -0
  75. package/dist/i18n/defaults.js +13 -2
  76. package/dist/i18n/defaults.js.map +1 -1
  77. package/dist/i18n/kit-labels.d.ts +38 -5
  78. package/dist/i18n/kit-labels.js +12 -4
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +12 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/lib/table-text.d.ts +127 -0
  84. package/dist/lib/table-text.js +82 -0
  85. package/dist/lib/table-text.js.map +1 -0
  86. package/dist/rhf/form.d.ts +79 -0
  87. package/dist/rhf/form.js +143 -0
  88. package/dist/rhf/form.js.map +1 -0
  89. package/dist/rhf.d.ts +4 -0
  90. package/dist/rhf.js +3 -0
  91. package/dist/rhf.js.map +1 -0
  92. package/dist/shell/app-shell.js +3 -1
  93. package/dist/shell/app-shell.js.map +1 -1
  94. package/dist/table-text.d.ts +1 -0
  95. package/dist/table-text.js +3 -0
  96. package/dist/table-text.js.map +1 -0
  97. package/package.json +14 -1
  98. package/src/components/autocomplete.tsx +425 -0
  99. package/src/components/chip.tsx +24 -2
  100. package/src/components/choice-card.tsx +305 -0
  101. package/src/components/combobox-core.tsx +228 -58
  102. package/src/components/combobox.tsx +58 -21
  103. package/src/components/danger-confirm.tsx +286 -0
  104. package/src/components/dialog-frame.tsx +179 -0
  105. package/src/components/disclosure.tsx +259 -0
  106. package/src/components/entity-combobox.tsx +41 -6
  107. package/src/components/file-button.tsx +431 -0
  108. package/src/components/file-dropzone.tsx +323 -117
  109. package/src/components/icon-picker.tsx +181 -0
  110. package/src/components/mini-calendar.tsx +7 -3
  111. package/src/components/modal.tsx +10 -2
  112. package/src/components/multi-entity-combobox.tsx +40 -6
  113. package/src/components/number-field.tsx +86 -10
  114. package/src/components/number-input.tsx +79 -2
  115. package/src/components/search-field.tsx +49 -6
  116. package/src/components/signature-pad.tsx +112 -0
  117. package/src/components/swatch-picker.tsx +141 -0
  118. package/src/components/tile-radio.tsx +228 -0
  119. package/src/components/toggle-group.tsx +54 -18
  120. package/src/components/ui.tsx +400 -24
  121. package/src/i18n/defaults.ts +12 -1
  122. package/src/i18n/kit-labels.tsx +45 -5
  123. package/src/index.ts +19 -0
  124. package/src/lib/table-text.ts +265 -0
  125. package/src/rhf/form.tsx +300 -0
  126. package/src/rhf.ts +9 -0
  127. package/src/shell/app-shell.tsx +3 -1
  128. package/src/table-text.ts +8 -0
@@ -0,0 +1,425 @@
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
+ onKeyDown?.(e);
238
+ if (e.defaultPrevented) return;
239
+ const last = results.length - 1;
240
+ if (e.key === "ArrowDown") {
241
+ e.preventDefault();
242
+ if (!open) setOpen(true);
243
+ else setActive((i) => Math.min(i + 1, last));
244
+ } else if (e.key === "ArrowUp") {
245
+ e.preventDefault();
246
+ if (!open) setOpen(true);
247
+ // From "nothing highlighted", Up goes to the bottom, as the APG has it.
248
+ else setActive((i) => (i < 0 ? last : Math.max(i - 1, 0)));
249
+ } else if (e.key === "Enter") {
250
+ // No row highlighted: the text is the answer, and a form's own submit
251
+ // is left alone.
252
+ if (expanded && activeId) {
253
+ e.preventDefault();
254
+ take(results[active]);
255
+ }
256
+ } else if (e.key === "Escape") {
257
+ // Only when there is a list to close — otherwise the Escape belongs to
258
+ // whatever this field sits in (a dialog), and must reach it.
259
+ if (expanded) {
260
+ e.preventDefault();
261
+ e.stopPropagation();
262
+ close();
263
+ }
264
+ } else if (e.key === "Tab") {
265
+ close();
266
+ }
267
+ }}
268
+ className={cn(
269
+ FIELD_BASE,
270
+ hasLabel && FIELD_FLOATING_PAD,
271
+ icon ? "ps-8" : undefined,
272
+ busy && "pe-9",
273
+ field.isInvalid && FIELD_INVALID,
274
+ inputClassName,
275
+ )}
276
+ />
277
+ {busy && live && (
278
+ // Decorative: the live region below already says "Loading…".
279
+ <span className="pointer-events-none absolute end-2.5 top-1/2 flex -translate-y-1/2">
280
+ <Spinner label={null} className="h-4 w-4" />
281
+ </span>
282
+ )}
283
+ </div>
284
+ {field.errorEl}
285
+ {/* The live region, kept mounted so a change IS an announcement: focus stays
286
+ in the field, and without it a reader typed into a geocoder and heard only
287
+ their own letters. The panel's visible line is aria-hidden in favour of
288
+ this, which also carries the row count the eye reads off the list. */}
289
+ <div role="status" aria-live="polite" className="sr-only">
290
+ {!live
291
+ ? null
292
+ : statusLine !== null
293
+ ? statusLine
294
+ : results.length > 0
295
+ ? labels.resultCount(results.length)
296
+ : null}
297
+ </div>
298
+ {expanded &&
299
+ rect &&
300
+ typeof document !== "undefined" &&
301
+ createPortal(
302
+ <div
303
+ // Every press inside the list keeps focus in the field — rows, the status
304
+ // line, the scrollbar — or the input's blur would close the list first.
305
+ role="presentation"
306
+ onMouseDown={(e) => e.preventDefault()}
307
+ className="fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg"
308
+ style={{ top, left: rect.left, width: rect.width, maxHeight }}
309
+ >
310
+ {results.length > 0 && (
311
+ <ul
312
+ id={listboxId}
313
+ role="listbox"
314
+ aria-label={ariaLabel}
315
+ aria-labelledby={labelledBy}
316
+ className="min-h-0 flex-1 overflow-y-auto py-1"
317
+ >
318
+ {results.map((o, i) => {
319
+ const startsGroup = o.group != null && o.group !== results[i - 1]?.group;
320
+ return (
321
+ // Keyed by group AND value, as in the core panel: an option may
322
+ // appear once per group.
323
+ <Fragment key={`${o.group ?? ""}|${String(o.value)}`}>
324
+ {startsGroup && (
325
+ <li
326
+ role="presentation"
327
+ className="px-3 pb-0.5 pt-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] first:pt-1"
328
+ >
329
+ {o.group}
330
+ </li>
331
+ )}
332
+ <li role="presentation">
333
+ <button
334
+ type="button"
335
+ id={optionId(i)}
336
+ role="option"
337
+ // CHOSEN, never "highlighted" — the family's rule. This
338
+ // field holds no value, so the only row that can be chosen
339
+ // is the one whose label the text already is.
340
+ aria-selected={fillOnSelect && o.label === value}
341
+ tabIndex={-1}
342
+ onClick={() => take(o)}
343
+ onMouseEnter={() => setActive(i)}
344
+ className={cn(
345
+ "flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm",
346
+ i === active ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
347
+ o.group != null && "ps-6",
348
+ )}
349
+ >
350
+ {o.icon && (
351
+ <span aria-hidden className="shrink-0 text-[var(--text-muted)]">
352
+ {o.icon}
353
+ </span>
354
+ )}
355
+ <span className="min-w-0 flex-1">
356
+ <span className="block truncate text-[var(--text-primary)]">
357
+ {o.label}
358
+ </span>
359
+ {o.sublabel && (
360
+ <span className="block truncate text-xs text-[var(--text-placeholder)]">
361
+ {o.sublabel}
362
+ </span>
363
+ )}
364
+ </span>
365
+ </button>
366
+ </li>
367
+ </Fragment>
368
+ );
369
+ })}
370
+ </ul>
371
+ )}
372
+ {statusLine !== null && (
373
+ // Visible, not live — the region above speaks for it.
374
+ <div
375
+ aria-hidden
376
+ className={cn(
377
+ "px-3 py-2 text-sm",
378
+ failed && !hasStatus ? "text-[var(--danger)]" : "text-[var(--text-muted)]",
379
+ results.length > 0 && "border-t border-[var(--border)] text-xs",
380
+ )}
381
+ >
382
+ {statusLine}
383
+ </div>
384
+ )}
385
+ </div>,
386
+ document.body,
387
+ )}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ const AutocompleteBase = forwardRef(AutocompleteInner);
393
+ AutocompleteBase.displayName = "Autocomplete";
394
+
395
+ /**
396
+ * An inline, async-capable text field with a suggestion list under it — the
397
+ * WAI-ARIA APG "editable combobox with list autocomplete".
398
+ *
399
+ * Two jobs, one control:
400
+ * - **free text** (kastlan's address field): the text is the value, the list only
401
+ * offers completions, and taking one fills the field (`fillOnSelect`, default);
402
+ * - **search-then-act** (keksdose's address search): the text is a query, taking a
403
+ * row calls `onSelect` and nothing is held (`fillOnSelect={false}`).
404
+ *
405
+ * How it differs from its siblings: {@link Combobox} is free text over a STATIC
406
+ * string pool and opens on focus; {@link EntityCombobox} is a trigger holding an id.
407
+ * This one keeps no value of its own, never resets the text, asks nothing below
408
+ * `minChars`, and reports a failed lookup instead of showing stale rows.
409
+ *
410
+ * Focus never leaves the `<input>`: `aria-activedescendant` names the highlighted
411
+ * row, `aria-controls` the list, and a polite live region says what the list now
412
+ * holds. Keyboard: ↓/↑ move (↓ opens), Enter takes the highlighted row (with none,
413
+ * Enter is left to the form), Escape closes, Tab closes and moves on; Home/End stay
414
+ * with the caret. A caller's `onKeyDown` runs first and may `preventDefault()` to
415
+ * keep a key for itself.
416
+ *
417
+ * The list is portalled and anchored under the field on every screen size: the
418
+ * field already carries the keyboard, and `useAnchoredPanel` keeps the list clear
419
+ * of it on a phone. There is no full-screen sheet as {@link Combobox} has.
420
+
421
+ * Generic over the option value (`ComboOption<V>`); the ref is the `<input>`'s.
422
+ */
423
+ export const Autocomplete = AutocompleteBase as <V extends string | number = string>(
424
+ props: AutocompleteProps<V> & RefAttributes<HTMLInputElement>,
425
+ ) => ReactElement | null;
@@ -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 =