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