@eifi1/ui-kit 0.5.0 → 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 (144) 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 +22 -7
  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/currency-select.js +10 -2
  20. package/dist/components/currency-select.js.map +1 -1
  21. package/dist/components/danger-confirm.d.ts +91 -0
  22. package/dist/components/danger-confirm.js +181 -0
  23. package/dist/components/danger-confirm.js.map +1 -0
  24. package/dist/components/date-picker.js +19 -7
  25. package/dist/components/date-picker.js.map +1 -1
  26. package/dist/components/dialog-frame.d.ts +84 -0
  27. package/dist/components/dialog-frame.js +86 -0
  28. package/dist/components/dialog-frame.js.map +1 -0
  29. package/dist/components/disclosure.d.ts +108 -0
  30. package/dist/components/disclosure.js +127 -0
  31. package/dist/components/disclosure.js.map +1 -0
  32. package/dist/components/entity-combobox.d.ts +17 -3
  33. package/dist/components/entity-combobox.js +25 -5
  34. package/dist/components/entity-combobox.js.map +1 -1
  35. package/dist/components/file-button.d.ts +161 -0
  36. package/dist/components/file-button.js +225 -0
  37. package/dist/components/file-button.js.map +1 -0
  38. package/dist/components/file-dropzone.d.ts +72 -23
  39. package/dist/components/file-dropzone.js +219 -94
  40. package/dist/components/file-dropzone.js.map +1 -1
  41. package/dist/components/icon-picker.d.ts +72 -0
  42. package/dist/components/icon-picker.js +104 -0
  43. package/dist/components/icon-picker.js.map +1 -0
  44. package/dist/components/mini-calendar.d.ts +3 -0
  45. package/dist/components/mini-calendar.js +4 -3
  46. package/dist/components/mini-calendar.js.map +1 -1
  47. package/dist/components/modal.d.ts +8 -1
  48. package/dist/components/modal.js +4 -2
  49. package/dist/components/modal.js.map +1 -1
  50. package/dist/components/month-picker.js +16 -5
  51. package/dist/components/month-picker.js.map +1 -1
  52. package/dist/components/multi-entity-combobox.d.ts +16 -3
  53. package/dist/components/multi-entity-combobox.js +25 -5
  54. package/dist/components/multi-entity-combobox.js.map +1 -1
  55. package/dist/components/number-field.d.ts +41 -1
  56. package/dist/components/number-field.js +42 -10
  57. package/dist/components/number-field.js.map +1 -1
  58. package/dist/components/number-input.d.ts +35 -2
  59. package/dist/components/number-input.js +35 -4
  60. package/dist/components/number-input.js.map +1 -1
  61. package/dist/components/numpad-sheet.d.ts +7 -0
  62. package/dist/components/popover.js +4 -1
  63. package/dist/components/popover.js.map +1 -1
  64. package/dist/components/search-field.d.ts +16 -0
  65. package/dist/components/search-field.js +29 -7
  66. package/dist/components/search-field.js.map +1 -1
  67. package/dist/components/signature-pad.d.ts +43 -1
  68. package/dist/components/signature-pad.js +74 -2
  69. package/dist/components/signature-pad.js.map +1 -1
  70. package/dist/components/swatch-picker.d.ts +69 -0
  71. package/dist/components/swatch-picker.js +75 -0
  72. package/dist/components/swatch-picker.js.map +1 -0
  73. package/dist/components/tile-radio.d.ts +50 -0
  74. package/dist/components/tile-radio.js +140 -0
  75. package/dist/components/tile-radio.js.map +1 -0
  76. package/dist/components/toggle-group.d.ts +27 -5
  77. package/dist/components/toggle-group.js +22 -15
  78. package/dist/components/toggle-group.js.map +1 -1
  79. package/dist/components/trigger-aria.d.ts +18 -0
  80. package/dist/components/trigger-aria.js +25 -0
  81. package/dist/components/trigger-aria.js.map +1 -0
  82. package/dist/components/ui.d.ts +164 -18
  83. package/dist/components/ui.js +207 -34
  84. package/dist/components/ui.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +7 -0
  86. package/dist/i18n/defaults.js +13 -2
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +38 -5
  89. package/dist/i18n/kit-labels.js +12 -4
  90. package/dist/i18n/kit-labels.js.map +1 -1
  91. package/dist/index.d.ts +16 -7
  92. package/dist/index.js +12 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/table-text.d.ts +127 -0
  95. package/dist/lib/table-text.js +82 -0
  96. package/dist/lib/table-text.js.map +1 -0
  97. package/dist/rhf/form.d.ts +79 -0
  98. package/dist/rhf/form.js +143 -0
  99. package/dist/rhf/form.js.map +1 -0
  100. package/dist/rhf.d.ts +4 -0
  101. package/dist/rhf.js +3 -0
  102. package/dist/rhf.js.map +1 -0
  103. package/dist/shell/app-shell.js +3 -1
  104. package/dist/shell/app-shell.js.map +1 -1
  105. package/dist/table-text.d.ts +1 -0
  106. package/dist/table-text.js +3 -0
  107. package/dist/table-text.js.map +1 -0
  108. package/package.json +14 -1
  109. package/src/components/autocomplete.tsx +425 -0
  110. package/src/components/chip.tsx +43 -7
  111. package/src/components/choice-card.tsx +305 -0
  112. package/src/components/combobox-core.tsx +228 -58
  113. package/src/components/combobox.tsx +58 -21
  114. package/src/components/currency-select.tsx +16 -2
  115. package/src/components/danger-confirm.tsx +286 -0
  116. package/src/components/date-picker.tsx +31 -6
  117. package/src/components/dialog-frame.tsx +179 -0
  118. package/src/components/disclosure.tsx +259 -0
  119. package/src/components/entity-combobox.tsx +41 -6
  120. package/src/components/file-button.tsx +431 -0
  121. package/src/components/file-dropzone.tsx +323 -117
  122. package/src/components/icon-picker.tsx +181 -0
  123. package/src/components/mini-calendar.tsx +7 -3
  124. package/src/components/modal.tsx +10 -2
  125. package/src/components/month-picker.tsx +22 -4
  126. package/src/components/multi-entity-combobox.tsx +40 -6
  127. package/src/components/number-field.tsx +86 -10
  128. package/src/components/number-input.tsx +79 -2
  129. package/src/components/popover.tsx +4 -1
  130. package/src/components/search-field.tsx +49 -6
  131. package/src/components/signature-pad.tsx +112 -0
  132. package/src/components/swatch-picker.tsx +141 -0
  133. package/src/components/tile-radio.tsx +228 -0
  134. package/src/components/toggle-group.tsx +54 -18
  135. package/src/components/trigger-aria.ts +42 -0
  136. package/src/components/ui.tsx +427 -40
  137. package/src/i18n/defaults.ts +12 -1
  138. package/src/i18n/kit-labels.tsx +45 -5
  139. package/src/index.ts +19 -0
  140. package/src/lib/table-text.ts +265 -0
  141. package/src/rhf/form.tsx +300 -0
  142. package/src/rhf.ts +9 -0
  143. package/src/shell/app-shell.tsx +3 -1
  144. package/src/table-text.ts +8 -0
@@ -0,0 +1,181 @@
1
+ import { useId, useMemo, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ComponentType } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
+ import { SearchField } from "./search-field";
6
+ import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
7
+ import type { TileItem, TileSize } from "./tile-radio";
8
+
9
+ export interface IconPickerLabels {
10
+ /** The "no icon" tile's name, when `allowNone` is set. */
11
+ none: string;
12
+ /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */
13
+ mixed: string;
14
+ /** The search box's accessible name and placeholder, when `searchable` is set. */
15
+ search: string;
16
+ /** Shown in place of the tiles when the search matches nothing. */
17
+ noResults: string;
18
+ /** Announced as the search narrows the grid. Receives the number of matches. */
19
+ resultCount: (count: number) => string;
20
+ }
21
+
22
+ export const DEFAULT_ICON_PICKER_LABELS: IconPickerLabels = {
23
+ none: "No icon",
24
+ mixed: "Mixed: the selected items have different icons",
25
+ search: "Search icons",
26
+ noResults: "No icons match",
27
+ resultCount: (count) => (count === 1 ? "1 icon" : `${count} icons`),
28
+ };
29
+
30
+ export interface IconOption<T extends string> {
31
+ value: T;
32
+ /** A Lucide icon, or any component that takes a `className`. Rendered `aria-hidden`
33
+ * — the tile's name is `label`. */
34
+ icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
35
+ /** What the symbol IS, in the user's language ("Game controller") — not the icon
36
+ * library's identifier (`Gamepad2`), which is nobody's word for anything. */
37
+ label: string;
38
+ /** More words the search matches ("games", "play"), besides `label`. */
39
+ keywords?: string[];
40
+ /** A remark read after the name and shown under it in the bubble ("used by
41
+ * Leisure"). The tile wears a dot while it has one. */
42
+ note?: string;
43
+ disabled?: boolean;
44
+ }
45
+
46
+ export interface IconPickerProps<T extends string>
47
+ extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children" | "defaultValue"> {
48
+ options: IconOption<T>[];
49
+ /** The chosen icon, or `null` for none. */
50
+ value: T | null;
51
+ onChange: (value: T | null) => void;
52
+ /** Lead with a "no icon" tile whose value is `null`. */
53
+ allowNone?: boolean;
54
+ /** See {@link SwatchPickerProps.mixed}: nothing is checked, and the group is
55
+ * described as mixed. */
56
+ mixed?: boolean;
57
+ /**
58
+ * "automatic" (default): an arrow key moves AND chooses, the APG radio group.
59
+ * "manual": arrows move focus only; Space or Enter chooses. For a picker whose every
60
+ * change is expensive — a bulk edit that saves as it goes.
61
+ */
62
+ activation?: "automatic" | "manual";
63
+ /** A search box above the grid, matching `label` and `keywords`. For a set large
64
+ * enough that scanning it is slower than typing. */
65
+ searchable?: boolean;
66
+ /** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
67
+ size?: TileSize;
68
+ disabled?: boolean;
69
+ labels?: Partial<IconPickerLabels>;
70
+ }
71
+
72
+ /** Case- and accent-insensitive, so "cafe" finds "Café". */
73
+ function fold(text: string, locale: string | undefined): string {
74
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase(locale);
75
+ }
76
+
77
+ /**
78
+ * A grid of symbols that is one radio group, with an optional search.
79
+ *
80
+ * Name the group with `aria-label` or `aria-labelledby`. The name lands on the
81
+ * radiogroup itself, not on the outer box: with `searchable` the outer box also holds
82
+ * the search field, which has a name of its own.
83
+ *
84
+ * Keyboard is {@link SwatchPicker}'s: one tab stop, arrows to move (flipped in a
85
+ * right-to-left page), Home and End.
86
+ */
87
+ export function IconPicker<T extends string>({
88
+ options,
89
+ value,
90
+ onChange,
91
+ allowNone = false,
92
+ mixed = false,
93
+ activation = "automatic",
94
+ searchable = false,
95
+ size = "md",
96
+ disabled = false,
97
+ labels,
98
+ className,
99
+ "aria-label": ariaLabel,
100
+ "aria-labelledby": ariaLabelledBy,
101
+ "aria-describedby": ariaDescribedBy,
102
+ ...rest
103
+ }: IconPickerProps<T>) {
104
+ const text = useKitLabels("iconPicker", DEFAULT_ICON_PICKER_LABELS, labels);
105
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
106
+ const locale = useKitLocale();
107
+ const mixedId = useId();
108
+ const [query, setQuery] = useState("");
109
+ const needle = fold(query.trim(), locale);
110
+
111
+ const visible = useMemo(
112
+ () =>
113
+ needle
114
+ ? options.filter((o) =>
115
+ [o.label, ...(o.keywords ?? [])].some((w) => fold(w, locale).includes(needle)),
116
+ )
117
+ : options,
118
+ [options, needle, locale],
119
+ );
120
+ const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
121
+ const items: TileItem<T>[] = [
122
+ // "None" stays through a search: it is the way back to no icon, and it matches
123
+ // no word anybody would type for it.
124
+ ...(allowNone ? [{ key: null, label: text.none }] : []),
125
+ ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
126
+ ];
127
+
128
+ return (
129
+ <div {...rest} className={cn("space-y-2", className)}>
130
+ {searchable && (
131
+ <SearchField
132
+ value={query}
133
+ onChange={setQuery}
134
+ aria-label={text.search}
135
+ clearLabel={common.clear}
136
+ disabled={disabled}
137
+ />
138
+ )}
139
+ <div
140
+ role="radiogroup"
141
+ aria-label={ariaLabel}
142
+ aria-labelledby={ariaLabelledBy}
143
+ aria-describedby={[ariaDescribedBy, mixed && mixedId].filter(Boolean).join(" ") || undefined}
144
+ aria-disabled={disabled || undefined}
145
+ className="flex flex-wrap items-center gap-1.5"
146
+ >
147
+ <TileRadioGroup
148
+ items={items}
149
+ checked={mixed ? undefined : value}
150
+ onSelect={onChange}
151
+ activation={activation}
152
+ disabled={disabled}
153
+ size={size}
154
+ renderTile={(item) => {
155
+ const Icon = item.key === null ? undefined : byValue.get(item.key)?.icon;
156
+ return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;
157
+ }}
158
+ tileClassName={(_item, selected) =>
159
+ selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
160
+ }
161
+ />
162
+ </div>
163
+ {needle && visible.length === 0 && (
164
+ <p className="text-xs text-[var(--text-muted)]">{text.noResults}</p>
165
+ )}
166
+ {mixed && (
167
+ <span id={mixedId} hidden>
168
+ {text.mixed}
169
+ </span>
170
+ )}
171
+ {searchable && (
172
+ // Typing moves no focus and the grid changes silently, so the count is said.
173
+ // `sr-only-fixed`: this sits inside a consumer's markup and must not extend
174
+ // the document height (see tokens.css).
175
+ <span role="status" aria-live="polite" aria-atomic className="sr-only-fixed">
176
+ {needle ? text.resultCount(visible.length) : ""}
177
+ </span>
178
+ )}
179
+ </div>
180
+ );
181
+ }
@@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { useAnnounce } from "../hooks/use-announce";
6
6
  import { parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
7
- import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
7
+ import { useKitLabels, useKitLocale, useKitWeekStart } from "../i18n/kit-labels";
8
8
 
9
9
  /**
10
10
  * Every string this calendar can speak — the `miniCalendar` namespace of
@@ -95,6 +95,9 @@ export interface MiniCalendarProps extends Omit<ComponentPropsWithoutRef<"div">,
95
95
  * Saturday in much of the Arab world — which is what the user's own wall calendar
96
96
  * does. It was hard-wired to Monday before, which is wrong for most of the people
97
97
  * who use a Sunday-first calendar and invisible to the people who wrote this one.
98
+ *
99
+ * Resolved as: this prop, else `<UiKitProvider weekStartsOn>`, else the locale's
100
+ * week info, else Monday.
98
101
  */
99
102
  weekStartsOn?: WeekDay;
100
103
  onSelect: (from: string, to: string) => void;
@@ -198,9 +201,10 @@ export function MiniCalendar({
198
201
  }: MiniCalendarProps) {
199
202
  const labels = useKitLabels("miniCalendar", DEFAULT_MINI_CALENDAR_LABELS, labelsProp);
200
203
  const locale = useKitLocale(localeProp);
204
+ const providerWeekStart = useKitWeekStart();
201
205
  const weekStart = useMemo(
202
- () => weekStartsOn ?? localeWeekStart(locale),
203
- [weekStartsOn, locale],
206
+ () => weekStartsOn ?? providerWeekStart ?? localeWeekStart(locale),
207
+ [weekStartsOn, providerWeekStart, locale],
204
208
  );
205
209
  // Day numbers through `Intl` too: `getDate()` is always ASCII digits, and a locale
206
210
  // that writes its own (Arabic, Persian, Bengali …) would get a grid of foreign
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from "react";
1
+ import { createContext, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  ComponentPropsWithoutRef,
@@ -79,6 +79,14 @@ function useDragOffset(enabled: boolean) {
79
79
  };
80
80
  }
81
81
 
82
+ /**
83
+ * The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X
84
+ * and its `actions(close)` read this, so a Cancel or an X lowers the panel exactly the
85
+ * way Escape does — calling `onClose` from inside unmounts with no exit, which is the
86
+ * documented limit of {@link useCloseTransition}. `null` outside a `Modal`.
87
+ */
88
+ export const ModalCloseContext = createContext<(() => void) | null>(null);
89
+
82
90
  /**
83
91
  * Backdrop handlers that close only when a press starts AND ends on the
84
92
  * backdrop itself. A plain onClick fires on mouseup, so dragging a text
@@ -279,7 +287,7 @@ export function Modal({
279
287
  className,
280
288
  )}
281
289
  >
282
- {children}
290
+ <ModalCloseContext.Provider value={requestClose}>{children}</ModalCloseContext.Provider>
283
291
  </div>
284
292
  </div>,
285
293
  document.body,
@@ -5,6 +5,8 @@ import { cn } from "../lib/cn";
5
5
  import { monthKey, pad } from "../lib/dates";
6
6
  import { FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER } from "./ui";
7
7
  import { Popover } from "./popover";
8
+ import { splitTriggerAria } from "./trigger-aria";
9
+ import type { TriggerAria } from "./trigger-aria";
8
10
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
9
11
 
10
12
  /**
@@ -156,6 +158,7 @@ function MonthFieldTrigger({
156
158
  disabled,
157
159
  invalid,
158
160
  className,
161
+ aria,
159
162
  }: {
160
163
  open: boolean;
161
164
  toggle: () => void;
@@ -163,6 +166,8 @@ function MonthFieldTrigger({
163
166
  triggerText: string;
164
167
  hasValue: boolean;
165
168
  labelledBy: string;
169
+ /** The caller's naming attributes — see `trigger-aria.ts`. */
170
+ aria: TriggerAria;
166
171
  valueId: string;
167
172
  panelId: string;
168
173
  padded: boolean;
@@ -191,8 +196,11 @@ function MonthFieldTrigger({
191
196
  aria-haspopup="dialog"
192
197
  aria-controls={panelId}
193
198
  aria-expanded={open}
194
- aria-invalid={invalid || undefined}
195
- aria-labelledby={labelledBy}
199
+ id={aria.id}
200
+ aria-invalid={invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined}
201
+ aria-labelledby={aria["aria-label"] && !aria["aria-labelledby"] ? undefined : labelledBy}
202
+ aria-label={aria["aria-label"]}
203
+ aria-describedby={aria["aria-describedby"]}
196
204
  className={cn(
197
205
  FIELD_TRIGGER,
198
206
  "pe-9",
@@ -488,6 +496,7 @@ export function MonthPicker({
488
496
  : (placeholder ?? "");
489
497
 
490
498
  const id = useId();
499
+ const [aria, wrapperRest] = splitTriggerAria(rest);
491
500
  const labelId = `${id}-label`;
492
501
  const valueId = `${id}-value`;
493
502
  const panelId = `${id}-panel`;
@@ -495,7 +504,7 @@ export function MonthPicker({
495
504
  const named = typeof label === "string";
496
505
 
497
506
  return (
498
- <div {...rest} className={cn("relative", className)}>
507
+ <div {...wrapperRest} className={cn("relative", className)}>
499
508
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
500
509
  {/* The hidden twin the trigger is named by — see DateField. `sr-only-fixed`,
501
510
  and inside this `relative` root either way. */}
@@ -515,7 +524,16 @@ export function MonthPicker({
515
524
  triggerRef={ref}
516
525
  triggerText={triggerText}
517
526
  hasValue={selected != null}
518
- labelledBy={named ? `${labelId} ${valueId}` : valueId}
527
+ // As DatePicker: a caller's reference first; an `id` without a label of our
528
+ // own references the trigger itself, so an external <label htmlFor> names it.
529
+ labelledBy={[
530
+ aria["aria-labelledby"],
531
+ named ? labelId : !aria["aria-labelledby"] && aria.id ? aria.id : undefined,
532
+ valueId,
533
+ ]
534
+ .filter(Boolean)
535
+ .join(" ")}
536
+ aria={aria}
519
537
  valueId={valueId}
520
538
  panelId={panelId}
521
539
  padded={label !== undefined}
@@ -2,7 +2,12 @@ import { useId, useMemo, type ComponentPropsWithoutRef, type ReactNode } from "r
2
2
  import { X } from "lucide-react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
5
- import { ComboboxPanel, useComboboxCore, type ComboOption } from "./combobox-core";
5
+ import {
6
+ ComboboxPanel,
7
+ useComboboxCore,
8
+ useComboboxFieldError,
9
+ type ComboOption,
10
+ } from "./combobox-core";
6
11
  import {
7
12
  DEFAULT_COMBOBOX_LABELS,
8
13
  DEFAULT_COMMON_LABELS,
@@ -19,8 +24,8 @@ export interface MultiEntityComboboxProps<V extends string | number>
19
24
  onChange: (value: V[]) => void;
20
25
  /** Already-loaded options (client-side filtered); also resolves selected labels. */
21
26
  options?: ComboOption<V>[];
22
- /** Async option source, debounced + race-safe. When set, `options` resolves
23
- * labels only. */
27
+ /** Async option source, debounced + race-safe; a rejection shows
28
+ * `loadErrorLabel`. When set, `options` resolves labels only. */
24
29
  loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
25
30
  loading?: boolean;
26
31
  label?: ReactNode;
@@ -44,6 +49,19 @@ export interface MultiEntityComboboxProps<V extends string | number>
44
49
  createLabel?: (query: string) => string;
45
50
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
46
51
  invalid?: boolean;
52
+ /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
53
+ * field, on the trigger's `aria-describedby`, and implies `invalid`. */
54
+ error?: ReactNode;
55
+ /** Narrow `options` client-side by the query. Default `true`; `false` shows them
56
+ * as given (a server-ranked list). */
57
+ filter?: boolean;
58
+ /** Offer nothing, and call no `loadOptions`, below this many characters. Default
59
+ * `0`, i.e. the list loads as the panel opens. */
60
+ minChars?: number;
61
+ /** `loadOptions` debounce. Default 150 ms. */
62
+ debounceMs?: number;
63
+ /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
64
+ loadErrorLabel?: string;
47
65
  }
48
66
 
49
67
  /**
@@ -70,10 +88,23 @@ export function MultiEntityCombobox<V extends string | number>({
70
88
  createLabel,
71
89
  className,
72
90
  invalid,
91
+ error,
92
+ filter,
93
+ minChars,
94
+ debounceMs,
95
+ loadErrorLabel,
73
96
  "aria-label": ariaLabel,
74
97
  ...rest
75
98
  }: MultiEntityComboboxProps<V>) {
76
- const core = useComboboxCore<V>({ options, loadOptions, loading });
99
+ const core = useComboboxCore<V>({
100
+ options,
101
+ loadOptions,
102
+ loading,
103
+ filter,
104
+ minChars,
105
+ debounceMs,
106
+ });
107
+ const field = useComboboxFieldError(error, invalid);
77
108
  // The props are the per-instance overrides, the provider the app-wide ones; a
78
109
  // prop left `undefined` falls through to the provider rather than masking it.
79
110
  const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
@@ -155,7 +186,8 @@ export function MultiEntityCombobox<V extends string | number>({
155
186
  (typeof label === "string" ? common.fieldValue(label, summary) : undefined)
156
187
  }
157
188
  disabled={disabled}
158
- aria-invalid={invalid || undefined}
189
+ aria-invalid={field.isInvalid || undefined}
190
+ aria-describedby={field.describedBy}
159
191
  onClick={() => !disabled && setOpen((o) => !o)}
160
192
  // Down/Up opens the list from the closed trigger, per the APG. Enter and
161
193
  // Space already do it through the button's own click.
@@ -171,7 +203,7 @@ export function MultiEntityCombobox<V extends string | number>({
171
203
  "pr-9",
172
204
  label !== undefined && FIELD_FLOATING_PAD,
173
205
  disabled && "cursor-not-allowed opacity-50",
174
- invalid && FIELD_INVALID,
206
+ field.isInvalid && FIELD_INVALID,
175
207
  )}
176
208
  >
177
209
  <span
@@ -212,6 +244,7 @@ export function MultiEntityCombobox<V extends string | number>({
212
244
  searchPlaceholder={labels.search}
213
245
  emptyLabel={labels.noResults}
214
246
  closeLabel={closeLabel}
247
+ loadErrorLabel={loadErrorLabel}
215
248
  isSelected={(v) => valueSet.has(v)}
216
249
  onChoose={toggle}
217
250
  showCreate={showCreate}
@@ -221,6 +254,7 @@ export function MultiEntityCombobox<V extends string | number>({
221
254
  }}
222
255
  createContent={labels.create(q)}
223
256
  />
257
+ {field.errorEl}
224
258
  </div>
225
259
  );
226
260
  }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useId, useState } from "react";
2
2
  import type { ComponentProps, ReactNode } from "react";
3
3
  import { NumberInput } from "./number-input";
4
- import { formatResult } from "../lib/calc";
4
+ import { evaluateExpression, formatResult } from "../lib/calc";
5
5
  import { useKitLocale } from "../i18n/kit-labels";
6
6
 
7
7
  type NumberInputProps = ComponentProps<typeof NumberInput>;
@@ -16,6 +16,25 @@ export interface NumberFieldProps
16
16
  * into `[min, max]`. `null` only when `nullable` and the field was emptied.
17
17
  */
18
18
  onCommit: (value: number | null) => void;
19
+ /**
20
+ * LIVE MODE: the number the text reads as, on every keystroke (and every step
21
+ * key) — for a calculator that recomputes as the user types, while `onCommit`
22
+ * stays the one to save on.
23
+ *
24
+ * What it is NOT, until the commit: rounded to `digits` or clamped into
25
+ * `[min, max]` — a half-typed "1" on the way to "150" must not read as the
26
+ * minimum of 100. A calculation reads as its result ("12+5" → 17); an empty field
27
+ * as `null` (whether or not `nullable`); a draft that is no number yet ("-", "12+")
28
+ * fires nothing, so the last number stands. On blur/Enter it fires once more with
29
+ * the committed value if that differs from the last one it reported — the rounded,
30
+ * clamped or snapped-back number — so a live consumer always ends where `onCommit`
31
+ * does.
32
+ *
33
+ * Feeding it back into `value` is safe: a `value` equal to the number this field
34
+ * last reported does not re-render the text, so "1," stays "1," while the rest of
35
+ * the form already shows 1. Not called when `value` changes from outside.
36
+ */
37
+ onValueChange?: (value: number | null) => void;
19
38
  /** Round to this many decimals on commit, and show at most this many (trailing
20
39
  * zeros are trimmed: `digits={2}` shows 12.5, not 12.50). Omit for no rounding. */
21
40
  digits?: number;
@@ -59,6 +78,20 @@ function decimalMark(locale: string | undefined): "," | "." {
59
78
  }
60
79
  }
61
80
 
81
+ /** What the draft reads as while it is being typed — see `onValueChange`. `undefined`
82
+ * is "not a number yet", distinct from `null` (empty). */
83
+ function parseLive(text: string): number | null | undefined {
84
+ const t = text.trim().replace(/,/g, ".");
85
+ if (t === "") return null;
86
+ if (/^-?\d*\.?\d*$/.test(t)) {
87
+ // "-", "." and "-." pass the pattern and are no number yet.
88
+ if (!/\d/.test(t)) return undefined;
89
+ const n = Number(t);
90
+ return Number.isFinite(n) ? n : undefined;
91
+ }
92
+ return evaluateExpression(t) ?? undefined;
93
+ }
94
+
62
95
  /** `toFixed` rounding rather than `Math.round(n * 10^d)`, which turns 1.005 into
63
96
  * 1.00 by way of 100.49999999999999 — the same choice the lenkbank field made. */
64
97
  function roundTo(n: number, digits: number | undefined): number {
@@ -92,6 +125,20 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
92
125
  * The draft follows `value` when it changes from outside (a reset, a loaded record);
93
126
  * a change that came from here arrives back identical and is a no-op.
94
127
  *
128
+ * LIVE MODE. A calculator that recomputes per keystroke used to have to pick: this
129
+ * field (limits, rounding, but a number only on blur) or a bare NumberInput (every
130
+ * keystroke, but a string and no limits). `onValueChange` is the per-keystroke
131
+ * channel here, next to the commit rather than instead of it: live numbers for the
132
+ * read-out, `onCommit` for the save. It lives on this field rather than as
133
+ * `min`/`max`/`digits` on NumberInput because parsing, the locale's mark and the
134
+ * limits are what this field already is — a second copy on the string field would be
135
+ * a second, subtly different NumberField.
136
+ *
137
+ * STEPS. `step` (with `min`/`max` as bounds) turns on ArrowUp/ArrowDown and PageUp/
138
+ * PageDown (×10) — see NumberInput's `step`. A step is a draft change like a
139
+ * keystroke: `onValueChange` hears it at once, `onCommit` on blur/Enter. Pick a
140
+ * `step` that `digits` can show (0.25 needs `digits={2}`), or the commit rounds it.
141
+ *
95
142
  * LOCALE. The decimal mark is the locale's: a German user sees and types "1,5".
96
143
  * NumberInput normalises every keystroke to a dot (so its evaluator reads one
97
144
  * alphabet); this field maps the dot back before the draft is shown, which also
@@ -102,6 +149,7 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
102
149
  export function NumberField({
103
150
  value,
104
151
  onCommit,
152
+ onValueChange,
105
153
  digits,
106
154
  min,
107
155
  max,
@@ -128,34 +176,59 @@ export function NumberField({
128
176
  const [draft, setDraft] = useState(() => format(value));
129
177
  // Everything the displayed text depends on, not just `value`: switching the
130
178
  // provider's language or a field's `digits` has to re-render the draft too.
131
- const shownFor = `${value}|${mark}|${digits}`;
132
- const [seen, setSeen] = useState(shownFor);
179
+ const [seen, setSeen] = useState({ value, mark, digits });
133
180
  // The last number handed to `onCommit`, so Enter-then-blur (both commit) and a
134
181
  // parent that does not echo the value back cannot commit the same number twice.
135
182
  // State rather than a ref because the external-change branch below resets it
136
183
  // during render, and a ref may not be written there.
137
184
  const [committed, setCommitted] = useState(value);
185
+ // The last number handed to `onValueChange` — state for the same reason.
186
+ const [reported, setReported] = useState(value);
138
187
 
139
188
  // Adjust state during render rather than in an effect: React re-renders at once
140
189
  // with the new draft, before anything is painted, so there is no frame showing
141
190
  // the old text.
142
- if (shownFor !== seen) {
143
- setSeen(shownFor);
144
- setDraft(format(value));
145
- setCommitted(value);
191
+ if (seen.value !== value || seen.mark !== mark || seen.digits !== digits) {
192
+ setSeen({ value, mark, digits });
193
+ // Live mode echoing back what it was just told: the draft already says it, in
194
+ // the typist's own spelling ("1," or "12+5"), and reformatting would fight them.
195
+ // Only a changed `value` qualifies — a new mark or `digits` still re-renders.
196
+ const echo =
197
+ onValueChange !== undefined && value === reported && seen.mark === mark && seen.digits === digits;
198
+ if (!echo) {
199
+ setDraft(format(value));
200
+ setCommitted(value);
201
+ setReported(value);
202
+ }
146
203
  }
147
204
 
205
+ const report = (next: number | null) => {
206
+ if (next === reported) return;
207
+ setReported(next);
208
+ onValueChange?.(next);
209
+ };
210
+
211
+ const onText = (text: string) => {
212
+ setDraft(localize(text));
213
+ const live = parseLive(text);
214
+ if (live !== undefined) report(live);
215
+ };
216
+
148
217
  const commit = (text: string) => {
149
218
  const trimmed = text.trim();
150
219
  let next: number | null;
220
+ const snapBack = () => {
221
+ setDraft(format(committed));
222
+ report(committed);
223
+ };
151
224
  if (trimmed === "") {
152
- if (!nullable) return setDraft(format(committed));
225
+ if (!nullable) return snapBack();
153
226
  next = null;
154
227
  } else {
155
228
  // `text` is NumberInput's committed form: an evaluated calculation, dot-decimal.
156
229
  // What survives its digits-only fallback can still be "-" or "." alone.
157
230
  const parsed = Number(trimmed.replace(/,/g, "."));
158
- if (!Number.isFinite(parsed)) return setDraft(format(committed));
231
+ if (!Number.isFinite(parsed)) return snapBack();
159
232
  next = roundTo(parsed, digits);
160
233
  if (min !== undefined) next = Math.max(min, next);
161
234
  if (max !== undefined) next = Math.min(max, next);
@@ -163,6 +236,7 @@ export function NumberField({
163
236
  // Always re-render the draft from the result — "007" becomes "7", a clamped 500
164
237
  // shows the 100 it was clamped to — even when there is nothing new to commit.
165
238
  setDraft(format(next));
239
+ report(next);
166
240
  if (next !== committed) {
167
241
  setCommitted(next);
168
242
  onCommit(next);
@@ -205,8 +279,10 @@ export function NumberField({
205
279
  id={fieldId}
206
280
  label={labelWithUnit}
207
281
  value={draft}
208
- onChange={(text) => setDraft(localize(text))}
282
+ onChange={onText}
209
283
  onCommit={commit}
284
+ min={min}
285
+ max={max}
210
286
  suffix={unitPlacement === "suffix" ? unit : undefined}
211
287
  invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === "true"}
212
288
  />