@eifi1/ui-kit 0.22.0 → 0.23.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 (205) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +11 -8
  3. package/dist/components/amount-input.d.ts +28 -8
  4. package/dist/components/amount-input.js +3 -1
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +21 -0
  7. package/dist/components/autocomplete.js +128 -103
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +11 -8
  10. package/dist/components/calculator.d.ts +11 -8
  11. package/dist/components/column-mapper.d.ts +182 -0
  12. package/dist/components/column-mapper.js +375 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +80 -2
  18. package/dist/components/combobox.js +448 -311
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +11 -8
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +8 -5
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +11 -8
  28. package/dist/components/danger-confirm.js +13 -3
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +35 -1
  34. package/dist/components/entity-combobox.js +148 -112
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +11 -8
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +11 -8
  44. package/dist/components/file-dropzone.d.ts +11 -8
  45. package/dist/components/form-actions.d.ts +10 -7
  46. package/dist/components/form-actions.js +8 -2
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +11 -8
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +11 -8
  53. package/dist/components/money-field.d.ts +14 -8
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +26 -1
  58. package/dist/components/multi-entity-combobox.js +143 -102
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +11 -8
  61. package/dist/components/number-input.d.ts +11 -8
  62. package/dist/components/numpad-sheet.d.ts +11 -8
  63. package/dist/components/phone-input.d.ts +11 -8
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +11 -8
  68. package/dist/components/settings-fields.d.ts +11 -8
  69. package/dist/components/share-card.d.ts +10 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +10 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +11 -8
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +8 -5
  84. package/dist/components/ui.js +5 -11
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +1 -1
  87. package/dist/feedback/feedback-attachment.js +156 -56
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +1 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +3 -2
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +68 -287
  94. package/dist/feedback/feedback-thread.js +8 -1
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-BxeQVzwq.d.ts} +405 -22
  97. package/dist/{feedback-attachment-NhUm7Zga.d.ts → feedback-attachment-fGAzZPf0.d.ts} +112 -10
  98. package/dist/feedback.d.ts +66 -2
  99. package/dist/hooks/use-file-drop.d.ts +11 -8
  100. package/dist/i18n/defaults.d.ts +11 -8
  101. package/dist/i18n/defaults.js +3 -1
  102. package/dist/i18n/defaults.js.map +1 -1
  103. package/dist/i18n/german.d.ts +11 -8
  104. package/dist/i18n/german.js +36 -2
  105. package/dist/i18n/german.js.map +1 -1
  106. package/dist/i18n/kit-labels.d.ts +7 -4
  107. package/dist/i18n/kit-labels.js.map +1 -1
  108. package/dist/i18n/languages.d.ts +11 -8
  109. package/dist/i18n/locales/de-CH.d.ts +11 -8
  110. package/dist/i18n/locales/en.d.ts +11 -8
  111. package/dist/i18n/locales/en.js +7 -0
  112. package/dist/i18n/locales/en.js.map +1 -1
  113. package/dist/i18n/locales/es.d.ts +11 -8
  114. package/dist/i18n/locales/es.js +37 -2
  115. package/dist/i18n/locales/es.js.map +1 -1
  116. package/dist/i18n/locales/fr.d.ts +11 -8
  117. package/dist/i18n/locales/fr.js +36 -2
  118. package/dist/i18n/locales/fr.js.map +1 -1
  119. package/dist/i18n/locales/hu.d.ts +11 -8
  120. package/dist/i18n/locales/hu.js +38 -2
  121. package/dist/i18n/locales/hu.js.map +1 -1
  122. package/dist/i18n/locales/it.d.ts +11 -8
  123. package/dist/i18n/locales/it.js +37 -2
  124. package/dist/i18n/locales/it.js.map +1 -1
  125. package/dist/i18n/locales/zh.d.ts +11 -8
  126. package/dist/i18n/locales/zh.js +34 -2
  127. package/dist/i18n/locales/zh.js.map +1 -1
  128. package/dist/i18n/review.d.ts +11 -8
  129. package/dist/i18n/review.js +20 -1
  130. package/dist/i18n/review.js.map +1 -1
  131. package/dist/index.d.ts +7 -3
  132. package/dist/index.js +16 -0
  133. package/dist/index.js.map +1 -1
  134. package/dist/lib/column-mapping.d.ts +81 -0
  135. package/dist/lib/column-mapping.js +108 -0
  136. package/dist/lib/column-mapping.js.map +1 -0
  137. package/dist/lib/table-text.d.ts +109 -1
  138. package/dist/lib/table-text.js +122 -1
  139. package/dist/lib/table-text.js.map +1 -1
  140. package/dist/rhf/fields.d.ts +89 -11
  141. package/dist/rhf/fields.js +124 -0
  142. package/dist/rhf/fields.js.map +1 -1
  143. package/dist/rhf/form.d.ts +11 -8
  144. package/dist/rhf.d.ts +12 -9
  145. package/dist/rhf.js.map +1 -1
  146. package/dist/shell/app-shell.d.ts +10 -7
  147. package/dist/shell/top-bar-brand.d.ts +11 -8
  148. package/dist/shell/topbar-action-menu.d.ts +36 -3
  149. package/dist/shell/topbar-action-menu.js +74 -33
  150. package/dist/shell/topbar-action-menu.js.map +1 -1
  151. package/dist/shell.d.ts +10 -7
  152. package/dist/table-text.d.ts +1 -1
  153. package/dist/wizard/stepper-nav.d.ts +41 -10
  154. package/dist/wizard/stepper-nav.js +4 -0
  155. package/dist/wizard/stepper-nav.js.map +1 -1
  156. package/dist/wizard.d.ts +11 -8
  157. package/package.json +1 -1
  158. package/src/components/amount-input.tsx +20 -1
  159. package/src/components/autocomplete.tsx +172 -111
  160. package/src/components/column-mapper.tsx +666 -0
  161. package/src/components/combobox-core.tsx +82 -9
  162. package/src/components/combobox.tsx +614 -332
  163. package/src/components/confirm-dialog.tsx +12 -8
  164. package/src/components/country-select.tsx +135 -22
  165. package/src/components/danger-confirm.tsx +91 -12
  166. package/src/components/date-picker.tsx +423 -10
  167. package/src/components/entity-combobox.tsx +217 -126
  168. package/src/components/field-parts.tsx +224 -5
  169. package/src/components/field-strip.tsx +149 -0
  170. package/src/components/form-actions.tsx +32 -4
  171. package/src/components/icon-picker.tsx +23 -4
  172. package/src/components/money-field.tsx +3 -0
  173. package/src/components/month-picker.tsx +2 -1
  174. package/src/components/multi-entity-combobox.tsx +207 -120
  175. package/src/components/reauth-dialog.tsx +17 -18
  176. package/src/components/swatch-picker.tsx +29 -5
  177. package/src/components/tile-radio.tsx +74 -13
  178. package/src/components/toggle-group.tsx +2 -2
  179. package/src/components/trigger-aria.ts +5 -0
  180. package/src/components/ui.tsx +7 -33
  181. package/src/feedback/feedback-attachment.tsx +308 -61
  182. package/src/feedback/feedback-dialog.tsx +7 -3
  183. package/src/feedback/feedback-inbox.tsx +3 -2
  184. package/src/feedback/feedback-thread.tsx +47 -4
  185. package/src/i18n/defaults.ts +2 -0
  186. package/src/i18n/german.ts +42 -0
  187. package/src/i18n/kit-labels.tsx +4 -0
  188. package/src/i18n/locales/en.ts +27 -5
  189. package/src/i18n/locales/es.ts +41 -0
  190. package/src/i18n/locales/fr.ts +42 -0
  191. package/src/i18n/locales/hu.ts +37 -0
  192. package/src/i18n/locales/it.ts +41 -0
  193. package/src/i18n/locales/zh.ts +32 -0
  194. package/src/i18n/review.ts +19 -0
  195. package/src/index.ts +18 -0
  196. package/src/lib/column-mapping.ts +234 -0
  197. package/src/lib/table-text.ts +271 -0
  198. package/src/rhf/fields.tsx +254 -0
  199. package/src/rhf.ts +2 -1
  200. package/src/shell/topbar-action-menu.tsx +134 -38
  201. package/src/wizard/stepper-nav.tsx +35 -1
  202. package/dist/components/field-anatomy.d.ts +0 -95
  203. package/dist/components/field-anatomy.js +0 -84
  204. package/dist/components/field-anatomy.js.map +0 -1
  205. package/src/components/field-anatomy.tsx +0 -190
package/dist/wizard.d.ts CHANGED
@@ -6,7 +6,13 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
6
6
  export { WizardSummary } from './wizard/wizard-summary.js';
7
7
  export { StepperNav } from './wizard/stepper-nav.js';
8
8
  import 'react';
9
- import './kit-labels-R8mIAc9N.js';
9
+ import './feedback-BxeQVzwq.js';
10
+ import './feedback-attachment-fGAzZPf0.js';
11
+ import './feedback/feedback-inbox.js';
12
+ import 'lucide-react';
13
+ import './components/tooltip.js';
14
+ import './hooks/use-anchored-rect.js';
15
+ import './lib/clipping.js';
10
16
  import './data-table-labels-B7OdnM0S.js';
11
17
  import './components/data-table-sort.js';
12
18
  import './components/swipeable-row.js';
@@ -14,11 +20,7 @@ import './components/mini-calendar.js';
14
20
  import './components/calendar-heatmap.js';
15
21
  import './components/popover.js';
16
22
  import './components/chip.js';
17
- import 'lucide-react';
18
23
  import './components/field-sync.js';
19
- import './components/tooltip.js';
20
- import './hooks/use-anchored-rect.js';
21
- import './lib/clipping.js';
22
24
  import './tour/tour.js';
23
25
  import './search/command-palette.js';
24
26
  import './search/global-search.js';
@@ -38,9 +40,6 @@ import './components/dialog-frame.js';
38
40
  import './components/modal.js';
39
41
  import './hooks/use-search-param-state.js';
40
42
  import './components/measured-grid.js';
41
- import './feedback-attachment-NhUm7Zga.js';
42
- import './feedback/feedback-thread.js';
43
- import './feedback/feedback-inbox.js';
44
43
  import './components/account-settings-labels.js';
45
44
  import './components/floating-panel.js';
46
45
  import './components/bulk-action-bar.js';
@@ -70,3 +69,7 @@ import './components/inline-edit-field.js';
70
69
  import './lib/iban.js';
71
70
  import './lib/phone.js';
72
71
  import './components/sign-chip.js';
72
+ import './components/column-mapper.js';
73
+ import './lib/column-mapping.js';
74
+ import './lib/table-text.js';
75
+ import './components/field-parts.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -55,7 +55,24 @@ interface AmountInputProps {
55
55
  * a phone keeps its numpad, exactly as {@link NumberInput}'s `calculator={false}`.
56
56
  */
57
57
  calculator?: boolean;
58
+ /** The field's WRAPPER — width, a margin, a flex share. The `<input>` is
59
+ * `inputClassName`'s. */
58
60
  className?: string;
61
+ /**
62
+ * Classes for the `<input>` itself — {@link NumberInput}'s and {@link Input}'s prop of
63
+ * the same name, merged the same way: after the field's own base classes, so it can
64
+ * restyle the figure (`text-end` for a money column whose decimal points line up,
65
+ * `font-medium`, a tighter `py-1` in a table cell), and before the end padding the
66
+ * calculator and the currency chip reserve and before the invalid border, so neither
67
+ * can be taken away by accident.
68
+ *
69
+ * keksdose G7: the invoice's VAT cell right-aligns its figure, and with only
70
+ * `className` (the wrapper) it reached the element through `[&_input]:text-end` — a
71
+ * descendant selector that depends on the field's inner markup, which the kit is free
72
+ * to change, and that a reader of the call site has to decode. {@link MoneyField}
73
+ * passes it through, and `RhfMoneyField` takes it too.
74
+ */
75
+ inputClassName?: string;
59
76
  id?: string;
60
77
  ariaLabel?: string;
61
78
  /**
@@ -291,7 +308,7 @@ function isResultOf(previous: string, text: string, settle: (text: string) => st
291
308
  }
292
309
 
293
310
  export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
294
- ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, error, calculator = true, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, hint, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max, onCommit }, ref) => {
311
+ ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, error, calculator = true, className, inputClassName, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, hint, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max, onCommit }, ref) => {
295
312
  const generatedId = useId();
296
313
  const fieldId = id ?? generatedId;
297
314
  const hintId = useId();
@@ -504,6 +521,8 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
504
521
  // (feedback #314). FLOATING_INPUT_CLASS already bundles the peer +
505
522
  // transparent-placeholder bits.
506
523
  asDisplay ? DISPLAY_INPUT_CLASS : label !== undefined ? FLOATING_INPUT_CLASS : FIELD_BASE,
524
+ // The caller's, over the base and under everything below (see `inputClassName`).
525
+ inputClassName,
507
526
  // Room for the trailing controls. showCalc is always false on a phone,
508
527
  // so the display shape only ever has to clear the currency chip — and
509
528
  // it clears it by the chip's actual width (a text-sm code plus a
@@ -22,7 +22,16 @@ import {
22
22
  type ComboOption,
23
23
  } from "./combobox-core";
24
24
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
25
- import { EndHintRow, FieldCaption, LABEL_IN_ROW, StaticLabelRow, useFieldHint } from "./field-parts";
25
+ import {
26
+ EndHintRow,
27
+ FieldCaption,
28
+ LABEL_IN_ROW,
29
+ LockedReason,
30
+ StaticLabelRow,
31
+ useFieldHint,
32
+ useLockReason,
33
+ } from "./field-parts";
34
+ import { mergeDescribedBy } from "./choice-parts";
26
35
 
27
36
  /**
28
37
  * `value`/`onChange` are the TEXT's, and `onSelect` is "a suggestion was taken" —
@@ -126,6 +135,27 @@ export interface AutocompleteProps<V extends string | number = string>
126
135
  loadErrorLabel?: string;
127
136
  /** Classes for the `<input>` itself; `className` styles the wrapper. */
128
137
  inputClassName?: string;
138
+ /**
139
+ * Why the field cannot be changed — {@link Button}'s `disabledReason`, the Combobox
140
+ * family's write lock (keksdose G2, 0.23), for a field whose pick SAVES: an address
141
+ * search whose taken row writes the address, a lookup that links a record.
142
+ *
143
+ * With a reason the input stays focusable and `aria-disabled`, is `readOnly` (the
144
+ * settled grey FIELD_BASE gives a `[readonly]` field), opens no list and asks no
145
+ * `loadOptions` — whatever `open` says — and nothing reaches `onChange`, `onSelect` or
146
+ * `onOpenChange(true)`. Enter is swallowed, so a locked field does not submit the form
147
+ * around it, and a caller's `onKeyDown` does not hear it; every other key still
148
+ * reaches the caller (an Escape that closes its panel) and the caret. The reason is in
149
+ * the kit {@link Tooltip} and on the input's `aria-describedby`. Wins over `disabled`.
150
+ */
151
+ disabledReason?: ReactNode;
152
+ /**
153
+ * This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the
154
+ * `disabledReason` way with the lock's reason (which wins over its own). A search
155
+ * whose result is only draft state — the form's Save writes it — leaves this off and
156
+ * stays live under the lock. No provider, or an unlocked one: no effect.
157
+ */
158
+ commit?: boolean;
129
159
  }
130
160
 
131
161
  function AutocompleteInner<V extends string | number = string>(
@@ -155,6 +185,8 @@ function AutocompleteInner<V extends string | number = string>(
155
185
  inputClassName,
156
186
  id,
157
187
  disabled,
188
+ disabledReason,
189
+ commit,
158
190
  onKeyDown,
159
191
  onFocus,
160
192
  onBlur,
@@ -191,7 +223,11 @@ function AutocompleteInner<V extends string | number = string>(
191
223
  if (next !== open) onOpenChange?.(next);
192
224
  };
193
225
  const [active, setActive] = useState(-1);
194
- const live = open && !disabled;
226
+ // The lock: focusable, `readOnly`, no list — see `disabledReason`. Folded into `live`,
227
+ // so a locked field asks nothing and shows nothing even with `open` held true.
228
+ const lock = useLockReason(commit, disabledReason);
229
+ const locked = lock.locked;
230
+ const live = open && !disabled && !locked;
195
231
  const { results, busy, failed, tooShort } = useOptionSource<V>({
196
232
  options,
197
233
  loadOptions,
@@ -230,7 +266,7 @@ function AutocompleteInner<V extends string | number = string>(
230
266
  setActive(-1);
231
267
  };
232
268
  const take = (o: ComboOption<V>) => {
233
- if (o.disabled) return;
269
+ if (o.disabled || locked) return;
234
270
  if (fillOnSelect) onChange(o.label);
235
271
  onSelect?.(o);
236
272
  close();
@@ -245,6 +281,16 @@ function AutocompleteInner<V extends string | number = string>(
245
281
  // label still names the field.
246
282
  const labelledBy =
247
283
  ariaLabel === undefined ? (ariaLabelledBy ?? (hasLabel ? labelId : undefined)) : undefined;
284
+ // While locked the field sits in the reason's Tooltip — in a fragment with a hidden
285
+ // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.
286
+ const withLock = (box: ReactNode) =>
287
+ locked ? (
288
+ <LockedReason lock={lock} className="block">
289
+ {box}
290
+ </LockedReason>
291
+ ) : (
292
+ box
293
+ );
248
294
 
249
295
  return (
250
296
  // `relative`: the floating label and the live region's `sr-only` both need a
@@ -258,119 +304,134 @@ function AutocompleteInner<V extends string | number = string>(
258
304
  </ComboboxFieldLabel>
259
305
  )}
260
306
  <EndHintRow hint={hasLabel ? undefined : hintParts.labelHint}>
261
- <div ref={fieldRef} className="relative">
262
- {icon && (
263
- <span
264
- aria-hidden
265
- className={cn(
266
- "pointer-events-none absolute flex text-[var(--text-muted)]",
267
- small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
268
- // Labelled, the icon sits on the VALUE's line — the one line box below
269
- // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
270
- // the box. Centred on the box it rose into the top strip and sat on the
271
- // label's first letters, which float at the same start inset. On the
272
- // value line it is beside what it describes, and the label keeps the
273
- // start edge every other label in the form's column shares.
274
- hasLabel
275
- ? "top-[calc(1rem+1px)] h-5 items-center"
276
- : "top-1/2 -translate-y-1/2",
277
- )}
278
- >
279
- {icon}
280
- </span>
281
- )}
282
- <input
283
- {...rest}
284
- ref={ref}
285
- id={fieldId}
286
- size={nativeSize}
287
- type="text"
288
- value={value}
289
- disabled={disabled}
290
- role="combobox"
291
- aria-autocomplete="list"
292
- aria-expanded={expanded}
293
- // Required by the role, and set while closed too, as on the siblings.
294
- aria-controls={listboxId}
295
- aria-activedescendant={activeId}
296
- aria-label={ariaLabel}
297
- aria-labelledby={labelledBy}
298
- aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
299
- aria-describedby={field.describedBy}
300
- autoComplete="off"
301
- onFocus={(e) => {
302
- onFocus?.(e);
303
- // A seeded field (keksdose's `initialQuery`) is looked up as it is
304
- // focused; an empty one asks nothing, because of `minChars`.
305
- setOpen(true);
306
- }}
307
- onBlur={(e) => {
308
- onBlur?.(e);
309
- close();
310
- }}
311
- onChange={(e) => {
312
- onChange(e.target.value);
313
- setOpen(true);
314
- setActive(-1);
315
- }}
316
- onKeyDown={(e) => {
317
- // An Escape that closes the OPEN list is the list's, and is consumed before
318
- // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
319
- // address search) otherwise closed the whole panel when the user only meant
320
- // to dismiss the suggestions — the opposite of what the docs promised.
321
- if (e.key === "Escape" && expanded) {
322
- e.preventDefault();
323
- e.stopPropagation();
307
+ {withLock(
308
+ <div ref={fieldRef} className={cn("relative", locked && "cursor-not-allowed")}>
309
+ {icon && (
310
+ <span
311
+ aria-hidden
312
+ className={cn(
313
+ "pointer-events-none absolute flex text-[var(--text-muted)]",
314
+ small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
315
+ // Labelled, the icon sits on the VALUE's line — the one line box below
316
+ // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
317
+ // the box. Centred on the box it rose into the top strip and sat on the
318
+ // label's first letters, which float at the same start inset. On the
319
+ // value line it is beside what it describes, and the label keeps the
320
+ // start edge every other label in the form's column shares.
321
+ hasLabel
322
+ ? "top-[calc(1rem+1px)] h-5 items-center"
323
+ : "top-1/2 -translate-y-1/2",
324
+ )}
325
+ >
326
+ {icon}
327
+ </span>
328
+ )}
329
+ <input
330
+ {...rest}
331
+ ref={ref}
332
+ id={fieldId}
333
+ size={nativeSize}
334
+ type="text"
335
+ value={value}
336
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `disabledReason`.
337
+ disabled={locked ? undefined : disabled}
338
+ readOnly={locked || rest.readOnly}
339
+ aria-disabled={locked || rest["aria-disabled"]}
340
+ role="combobox"
341
+ aria-autocomplete="list"
342
+ aria-expanded={expanded}
343
+ // Required by the role, and set while closed too, as on the siblings.
344
+ aria-controls={listboxId}
345
+ aria-activedescendant={activeId}
346
+ aria-label={ariaLabel}
347
+ aria-labelledby={labelledBy}
348
+ aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
349
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
350
+ autoComplete="off"
351
+ onFocus={(e) => {
352
+ onFocus?.(e);
353
+ // A locked field takes focus to say why, and opens nothing.
354
+ if (locked) return;
355
+ // A seeded field (keksdose's `initialQuery`) is looked up as it is
356
+ // focused; an empty one asks nothing, because of `minChars`.
357
+ setOpen(true);
358
+ }}
359
+ onBlur={(e) => {
360
+ onBlur?.(e);
324
361
  close();
325
- return;
326
- }
327
- onKeyDown?.(e);
328
- if (e.defaultPrevented) return;
329
- // Disabled rows are passed over; at either end the highlight stays put.
330
- if (e.key === "ArrowDown") {
331
- e.preventDefault();
332
- if (!open) setOpen(true);
333
- else setActive((i) => stepEnabled(results, i, 1));
334
- } else if (e.key === "ArrowUp") {
335
- e.preventDefault();
336
- if (!open) setOpen(true);
337
- // From "nothing highlighted", Up goes to the bottom, as the APG has it.
338
- else setActive((i) => stepEnabled(results, i < 0 ? results.length : i, -1));
339
- } else if (e.key === "Enter") {
340
- // No row highlighted: the text is the answer, and a form's own submit
341
- // is left alone.
342
- if (expanded && activeId) {
362
+ }}
363
+ onChange={(e) => {
364
+ if (locked) return;
365
+ onChange(e.target.value);
366
+ setOpen(true);
367
+ setActive(-1);
368
+ }}
369
+ onKeyDown={(e) => {
370
+ // Locked: Enter is swallowed (no form submit, no caller's "search now");
371
+ // every other key is the caller's and the caret's. No list to drive.
372
+ if (locked) {
373
+ if (e.key === "Enter") e.preventDefault();
374
+ else onKeyDown?.(e);
375
+ return;
376
+ }
377
+ // An Escape that closes the OPEN list is the list's, and is consumed before
378
+ // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
379
+ // address search) otherwise closed the whole panel when the user only meant
380
+ // to dismiss the suggestions — the opposite of what the docs promised.
381
+ if (e.key === "Escape" && expanded) {
343
382
  e.preventDefault();
344
- take(results[active]);
383
+ e.stopPropagation();
384
+ close();
385
+ return;
345
386
  }
346
- } else if (e.key === "Tab") {
347
- close();
348
- }
349
- }}
350
- className={cn(
351
- FIELD_BASE,
352
- hasLabel && FIELD_FLOATING_PAD,
353
- // Select's `SELECT_SM` box: a fixed 28px, so the caller's line height
354
- // cannot grow it.
355
- small && "h-7 py-0 pe-2 ps-2 text-xs",
356
- icon ? (small ? "ps-7" : "ps-8") : undefined,
357
- busy && (small ? "pe-7" : "pe-9"),
358
- field.isInvalid && FIELD_INVALID,
359
- inputClassName,
360
- )}
361
- />
362
- {busy && live && (
363
- // Decorative: the live region below already says "Loading…".
364
- <span
387
+ onKeyDown?.(e);
388
+ if (e.defaultPrevented) return;
389
+ // Disabled rows are passed over; at either end the highlight stays put.
390
+ if (e.key === "ArrowDown") {
391
+ e.preventDefault();
392
+ if (!open) setOpen(true);
393
+ else setActive((i) => stepEnabled(results, i, 1));
394
+ } else if (e.key === "ArrowUp") {
395
+ e.preventDefault();
396
+ if (!open) setOpen(true);
397
+ // From "nothing highlighted", Up goes to the bottom, as the APG has it.
398
+ else setActive((i) => stepEnabled(results, i < 0 ? results.length : i, -1));
399
+ } else if (e.key === "Enter") {
400
+ // No row highlighted: the text is the answer, and a form's own submit
401
+ // is left alone.
402
+ if (expanded && activeId) {
403
+ e.preventDefault();
404
+ take(results[active]);
405
+ }
406
+ } else if (e.key === "Tab") {
407
+ close();
408
+ }
409
+ }}
365
410
  className={cn(
366
- "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
367
- small ? "end-2" : "end-2.5",
411
+ FIELD_BASE,
412
+ hasLabel && FIELD_FLOATING_PAD,
413
+ // Select's `SELECT_SM` box: a fixed 28px, so the caller's line height
414
+ // cannot grow it.
415
+ small && "h-7 py-0 pe-2 ps-2 text-xs",
416
+ icon ? (small ? "ps-7" : "ps-8") : undefined,
417
+ busy && (small ? "pe-7" : "pe-9"),
418
+ field.isInvalid && FIELD_INVALID,
419
+ inputClassName,
368
420
  )}
369
- >
370
- <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
371
- </span>
372
- )}
373
- </div>
421
+ />
422
+ {busy && live && (
423
+ // Decorative: the live region below already says "Loading…".
424
+ <span
425
+ className={cn(
426
+ "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
427
+ small ? "end-2" : "end-2.5",
428
+ )}
429
+ >
430
+ <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
431
+ </span>
432
+ )}
433
+ </div>,
434
+ )}
374
435
  </EndHintRow>
375
436
  {/* With a "?" the label shares the top strip with it, after the field so the "?"
376
437
  follows the control in the tab order (as on Input and Select). */}