@eifi1/ui-kit 0.22.0 → 0.24.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 (208) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +8 -7
  3. package/dist/components/amount-input.d.ts +34 -7
  4. package/dist/components/amount-input.js +14 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +26 -0
  7. package/dist/components/autocomplete.js +164 -137
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +8 -7
  10. package/dist/components/calculator.d.ts +8 -7
  11. package/dist/components/column-mapper.d.ts +235 -0
  12. package/dist/components/column-mapper.js +411 -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 +113 -3
  18. package/dist/components/combobox.js +456 -313
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +8 -7
  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 +5 -4
  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 +8 -7
  28. package/dist/components/danger-confirm.js +64 -7
  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 +48 -2
  34. package/dist/components/entity-combobox.js +159 -114
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +8 -7
  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 +8 -7
  44. package/dist/components/file-dropzone.d.ts +8 -7
  45. package/dist/components/form-actions.d.ts +8 -7
  46. package/dist/components/form-actions.js +23 -3
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +8 -7
  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 +8 -7
  53. package/dist/components/money-field.d.ts +11 -7
  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 +31 -2
  58. package/dist/components/multi-entity-combobox.js +152 -103
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +8 -7
  61. package/dist/components/number-input.d.ts +8 -7
  62. package/dist/components/numpad-sheet.d.ts +8 -7
  63. package/dist/components/phone-input.d.ts +8 -7
  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 +8 -7
  68. package/dist/components/settings-fields.d.ts +8 -7
  69. package/dist/components/share-card.d.ts +8 -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 +8 -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 +8 -7
  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 +5 -4
  84. package/dist/components/ui.js +60 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +66 -1
  87. package/dist/feedback/feedback-attachment.js +185 -65
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +66 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +67 -252
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +66 -287
  94. package/dist/feedback/feedback-thread.js +16 -3
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
  97. package/dist/feedback.d.ts +65 -3
  98. package/dist/hooks/use-file-drop.d.ts +8 -7
  99. package/dist/i18n/defaults.d.ts +8 -7
  100. package/dist/i18n/defaults.js +3 -1
  101. package/dist/i18n/defaults.js.map +1 -1
  102. package/dist/i18n/german.d.ts +8 -7
  103. package/dist/i18n/german.js +36 -2
  104. package/dist/i18n/german.js.map +1 -1
  105. package/dist/i18n/kit-labels.d.ts +5 -4
  106. package/dist/i18n/kit-labels.js.map +1 -1
  107. package/dist/i18n/languages.d.ts +8 -7
  108. package/dist/i18n/locales/de-CH.d.ts +8 -7
  109. package/dist/i18n/locales/en.d.ts +8 -7
  110. package/dist/i18n/locales/en.js +7 -0
  111. package/dist/i18n/locales/en.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +8 -7
  113. package/dist/i18n/locales/es.js +37 -2
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +8 -7
  116. package/dist/i18n/locales/fr.js +36 -2
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +8 -7
  119. package/dist/i18n/locales/hu.js +38 -2
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +8 -7
  122. package/dist/i18n/locales/it.js +37 -2
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +8 -7
  125. package/dist/i18n/locales/zh.js +34 -2
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/i18n/review.d.ts +8 -7
  128. package/dist/i18n/review.js +20 -1
  129. package/dist/i18n/review.js.map +1 -1
  130. package/dist/index.d.ts +6 -4
  131. package/dist/index.js +16 -0
  132. package/dist/index.js.map +1 -1
  133. package/dist/lib/column-mapping.d.ts +81 -0
  134. package/dist/lib/column-mapping.js +108 -0
  135. package/dist/lib/column-mapping.js.map +1 -0
  136. package/dist/lib/strip-fade.d.ts +2 -2
  137. package/dist/lib/strip-fade.js.map +1 -1
  138. package/dist/lib/table-text.d.ts +109 -1
  139. package/dist/lib/table-text.js +122 -1
  140. package/dist/lib/table-text.js.map +1 -1
  141. package/dist/rhf/fields.d.ts +142 -13
  142. package/dist/rhf/fields.js +185 -3
  143. package/dist/rhf/fields.js.map +1 -1
  144. package/dist/rhf/form.d.ts +8 -7
  145. package/dist/rhf.d.ts +9 -8
  146. package/dist/rhf.js.map +1 -1
  147. package/dist/shell/app-shell.d.ts +8 -7
  148. package/dist/shell/top-bar-brand.d.ts +8 -7
  149. package/dist/shell/topbar-action-menu.d.ts +36 -3
  150. package/dist/shell/topbar-action-menu.js +74 -33
  151. package/dist/shell/topbar-action-menu.js.map +1 -1
  152. package/dist/shell.d.ts +8 -7
  153. package/dist/table-text.d.ts +1 -1
  154. package/dist/wizard/stepper-nav.d.ts +38 -9
  155. package/dist/wizard/stepper-nav.js +4 -0
  156. package/dist/wizard/stepper-nav.js.map +1 -1
  157. package/dist/wizard.d.ts +8 -7
  158. package/package.json +1 -1
  159. package/src/components/amount-input.tsx +50 -14
  160. package/src/components/autocomplete.tsx +188 -116
  161. package/src/components/column-mapper.tsx +768 -0
  162. package/src/components/combobox-core.tsx +82 -9
  163. package/src/components/combobox.tsx +655 -335
  164. package/src/components/confirm-dialog.tsx +12 -8
  165. package/src/components/country-select.tsx +135 -22
  166. package/src/components/danger-confirm.tsx +274 -24
  167. package/src/components/date-picker.tsx +423 -10
  168. package/src/components/entity-combobox.tsx +243 -129
  169. package/src/components/field-parts.tsx +224 -5
  170. package/src/components/field-strip.tsx +149 -0
  171. package/src/components/form-actions.tsx +52 -5
  172. package/src/components/icon-picker.tsx +23 -4
  173. package/src/components/money-field.tsx +3 -0
  174. package/src/components/month-picker.tsx +2 -1
  175. package/src/components/multi-entity-combobox.tsx +221 -121
  176. package/src/components/reauth-dialog.tsx +17 -18
  177. package/src/components/swatch-picker.tsx +29 -5
  178. package/src/components/tile-radio.tsx +74 -13
  179. package/src/components/toggle-group.tsx +2 -2
  180. package/src/components/trigger-aria.ts +5 -0
  181. package/src/components/ui.tsx +114 -33
  182. package/src/feedback/feedback-attachment.tsx +372 -72
  183. package/src/feedback/feedback-dialog.tsx +7 -3
  184. package/src/feedback/feedback-inbox.tsx +3 -2
  185. package/src/feedback/feedback-thread.tsx +112 -9
  186. package/src/i18n/defaults.ts +2 -0
  187. package/src/i18n/german.ts +42 -0
  188. package/src/i18n/kit-labels.tsx +4 -0
  189. package/src/i18n/locales/en.ts +27 -5
  190. package/src/i18n/locales/es.ts +41 -0
  191. package/src/i18n/locales/fr.ts +42 -0
  192. package/src/i18n/locales/hu.ts +37 -0
  193. package/src/i18n/locales/it.ts +41 -0
  194. package/src/i18n/locales/zh.ts +32 -0
  195. package/src/i18n/review.ts +19 -0
  196. package/src/index.ts +18 -0
  197. package/src/lib/column-mapping.ts +234 -0
  198. package/src/lib/strip-fade.ts +2 -2
  199. package/src/lib/table-text.ts +271 -0
  200. package/src/rhf/fields.tsx +421 -6
  201. package/src/rhf.ts +2 -1
  202. package/src/shell/topbar-action-menu.tsx +134 -38
  203. package/src/wizard/stepper-nav.tsx +35 -1
  204. package/dist/components/field-anatomy.d.ts +0 -95
  205. package/dist/components/field-anatomy.js +0 -84
  206. package/dist/components/field-anatomy.js.map +0 -1
  207. package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
  208. package/src/components/field-anatomy.tsx +0 -190
package/dist/wizard.d.ts CHANGED
@@ -6,7 +6,10 @@ 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-DOwPu-Il.js';
10
+ import './components/tooltip.js';
11
+ import './hooks/use-anchored-rect.js';
12
+ import './lib/clipping.js';
10
13
  import './data-table-labels-B7OdnM0S.js';
11
14
  import './components/data-table-sort.js';
12
15
  import './components/swipeable-row.js';
@@ -16,9 +19,6 @@ import './components/popover.js';
16
19
  import './components/chip.js';
17
20
  import 'lucide-react';
18
21
  import './components/field-sync.js';
19
- import './components/tooltip.js';
20
- import './hooks/use-anchored-rect.js';
21
- import './lib/clipping.js';
22
22
  import './tour/tour.js';
23
23
  import './search/command-palette.js';
24
24
  import './search/global-search.js';
@@ -38,9 +38,6 @@ import './components/dialog-frame.js';
38
38
  import './components/modal.js';
39
39
  import './hooks/use-search-param-state.js';
40
40
  import './components/measured-grid.js';
41
- import './feedback-attachment-NhUm7Zga.js';
42
- import './feedback/feedback-thread.js';
43
- import './feedback/feedback-inbox.js';
44
41
  import './components/account-settings-labels.js';
45
42
  import './components/floating-panel.js';
46
43
  import './components/bulk-action-bar.js';
@@ -70,3 +67,7 @@ import './components/inline-edit-field.js';
70
67
  import './lib/iban.js';
71
68
  import './lib/phone.js';
72
69
  import './components/sign-chip.js';
70
+ import './components/column-mapper.js';
71
+ import './lib/column-mapping.js';
72
+ import './lib/table-text.js';
73
+ 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.24.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,33 @@ 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 everything the field sets for LOOKS — the
64
+ * base classes, the plain end padding, the display shape's centring and the `tone`
65
+ * colour — so it can restyle the figure (`text-end` for a money column whose decimal
66
+ * points line up, `font-medium`, a tighter `py-1` or a symmetric `px-2` in a table
67
+ * cell, `text-transparent` while a figure is being re-fetched), and before what the
68
+ * field needs to WORK: the end padding the calculator and the currency chip reserve,
69
+ * and the invalid border. Neither of those can be taken away by accident — a caller's
70
+ * `px-2` keeps its start edge and the digits still stop short of the chip, and a
71
+ * borderless cell's `border-transparent` still turns red when the value is wrong.
72
+ *
73
+ * keksdose G7: the invoice's VAT cell right-aligns its figure, and with only
74
+ * `className` (the wrapper) it reached the element through `[&_input]:text-end` — a
75
+ * descendant selector that depends on the field's inner markup, which the kit is free
76
+ * to change, and that a reader of the call site has to decode. {@link MoneyField}
77
+ * passes it through, and `RhfMoneyField` takes it too.
78
+ *
79
+ * keksdose 0.24: until then it sat BEFORE the plain end padding (`pe-3`) and the tone,
80
+ * so the budget's assigned cell could not make its padding symmetric (`px-2` came out
81
+ * `px-2 pe-3`) and the holdings price could not hide an outflow-toned figure while
82
+ * re-fetching — both were back on `[&_input]:` selectors on the wrapper.
83
+ */
84
+ inputClassName?: string;
59
85
  id?: string;
60
86
  ariaLabel?: string;
61
87
  /**
@@ -291,7 +317,7 @@ function isResultOf(previous: string, text: string, settle: (text: string) => st
291
317
  }
292
318
 
293
319
  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) => {
320
+ ({ 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
321
  const generatedId = useId();
296
322
  const fieldId = id ?? generatedId;
297
323
  const hintId = useId();
@@ -320,6 +346,17 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
320
346
  // fighting itself through responsive overrides. `useMediaQuery` reads
321
347
  // synchronously on mount, so there is no field→figure flash.
322
348
  const asDisplay = variant === "display" && isMobile;
349
+ // Room for the trailing controls — the calculator and the currency chip — or
350
+ // nothing when neither trails. showCalc is always false on a phone, so the display
351
+ // shape only ever has to clear the currency chip — and it clears it by the chip's
352
+ // actual width (a text-sm code plus a chevron, ~58px) rather than the 5rem the
353
+ // boxed shape reserves. On a 375px screen those 16px are the difference between a
354
+ // readable figure and a clipped one (feedback #430 rework).
355
+ const trailingRoom = asDisplay
356
+ ? currency ? (editable ? "pe-16" : "pe-12") : undefined
357
+ : showCalc
358
+ ? currency ? (editable ? "pe-24" : "pe-16") : "pe-10"
359
+ : currency ? (editable ? "pe-20" : "pe-14") : undefined;
323
360
  const innerRef = useRef<HTMLInputElement>(null);
324
361
  const setRefs = useCallback(
325
362
  (el: HTMLInputElement | null) => {
@@ -504,25 +541,24 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
504
541
  // (feedback #314). FLOATING_INPUT_CLASS already bundles the peer +
505
542
  // transparent-placeholder bits.
506
543
  asDisplay ? DISPLAY_INPUT_CLASS : label !== undefined ? FLOATING_INPUT_CLASS : FIELD_BASE,
507
- // Room for the trailing controls. showCalc is always false on a phone,
508
- // so the display shape only ever has to clear the currency chip — and
509
- // it clears it by the chip's actual width (a text-sm code plus a
510
- // chevron, ~58px) rather than the 5rem the boxed shape reserves. On a
511
- // 375px screen those 16px are the difference between a readable figure
512
- // and a clipped one (feedback #430 rework).
513
- asDisplay
514
- ? currency ? (editable ? "pe-16" : "pe-12") : "pe-0"
515
- : showCalc
516
- ? currency ? (editable ? "pe-24" : "pe-16") : "pe-10"
517
- : currency ? (editable ? "pe-20" : "pe-14") : "pe-3",
544
+ // The plain end padding, with nothing trailing: cosmetic, so the caller's
545
+ // to change (keksdose 0.24 — a symmetric `px-2` in a budget cell).
546
+ !trailingRoom && (asDisplay ? "pe-0" : "pe-3"),
518
547
  // Centred display shape: centre in what is LEFT of the currency chip.
519
548
  // `text-center` alone would centre the figure in the whole box, i.e.
520
- // partly underneath the chip; the padding above is what takes the chip
549
+ // partly underneath the chip; the padding below is what takes the chip
521
550
  // out of the centring, and nothing is added at the start because a
522
551
  // mirrored reservation would spend the width twice (see `align`).
523
552
  asDisplay && align === "center" && "text-center",
524
- // Last, so it wins over the base class's own text colour.
553
+ // Over the base class's own text colour.
525
554
  TONE_CLASS[tone],
555
+ // The caller's, over all of the above — base, plain padding, alignment and
556
+ // tone — and under only what the field needs to work (see `inputClassName`).
557
+ inputClassName,
558
+ // Room for the trailing controls, after the caller's so a `px-2` cannot run
559
+ // the digits under the chip (NumberInput's order, which reserves its unit
560
+ // and calculator the same way).
561
+ trailingRoom,
526
562
  invalid && FIELD_INVALID,
527
563
  )}
528
564
  aria-invalid={invalid || undefined}
@@ -14,6 +14,7 @@ import { useAnchorDir } from "./use-anchor-dir";
14
14
  import {
15
15
  ComboboxFieldLabel,
16
16
  DISABLED_ROW_CLASS,
17
+ endHintRowProps,
17
18
  isOptionEnabled,
18
19
  stepEnabled,
19
20
  useActiveOptionScroll,
@@ -22,7 +23,16 @@ import {
22
23
  type ComboOption,
23
24
  } from "./combobox-core";
24
25
  import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
25
- import { EndHintRow, FieldCaption, LABEL_IN_ROW, StaticLabelRow, useFieldHint } from "./field-parts";
26
+ import {
27
+ EndHintRow,
28
+ FieldCaption,
29
+ LABEL_IN_ROW,
30
+ LockedReason,
31
+ StaticLabelRow,
32
+ useFieldHint,
33
+ useLockReason,
34
+ } from "./field-parts";
35
+ import { mergeDescribedBy } from "./choice-parts";
26
36
 
27
37
  /**
28
38
  * `value`/`onChange` are the TEXT's, and `onSelect` is "a suggestion was taken" —
@@ -111,6 +121,11 @@ export interface AutocompleteProps<V extends string | number = string>
111
121
  * `aria-describedby`, before any error; a {@link FieldHint} "?" rides the label line,
112
122
  * or with no label sits at the field's end edge, outside the box. Not the list's
113
123
  * `status` line, which talks about the lookup, not the field.
124
+ *
125
+ * Pass it — even as `undefined` — on a field whose hint comes and goes (0.24): the
126
+ * unlabelled field then keeps its end-hint row whether or not a "?" is in it, so the
127
+ * `<input>` is never re-parented, and focus and caret survive the toggle. The rule
128
+ * the rest of the family has had since 0.23 (and the fields since 0.22).
114
129
  */
115
130
  hint?: ReactNode;
116
131
  /**
@@ -126,10 +141,34 @@ export interface AutocompleteProps<V extends string | number = string>
126
141
  loadErrorLabel?: string;
127
142
  /** Classes for the `<input>` itself; `className` styles the wrapper. */
128
143
  inputClassName?: string;
144
+ /**
145
+ * Why the field cannot be changed — {@link Button}'s `disabledReason`, the Combobox
146
+ * family's write lock (keksdose G2, 0.23), for a field whose pick SAVES: an address
147
+ * search whose taken row writes the address, a lookup that links a record.
148
+ *
149
+ * With a reason the input stays focusable and `aria-disabled`, is `readOnly` (the
150
+ * settled grey FIELD_BASE gives a `[readonly]` field), opens no list and asks no
151
+ * `loadOptions` — whatever `open` says — and nothing reaches `onChange`, `onSelect` or
152
+ * `onOpenChange(true)`. Enter is swallowed, so a locked field does not submit the form
153
+ * around it, and a caller's `onKeyDown` does not hear it; every other key still
154
+ * reaches the caller (an Escape that closes its panel) and the caret. The reason is in
155
+ * the kit {@link Tooltip} and on the input's `aria-describedby`. Wins over `disabled`.
156
+ */
157
+ disabledReason?: ReactNode;
158
+ /**
159
+ * This field COMMITS. Under a locked {@link WriteLockProvider} it is locked the
160
+ * `disabledReason` way with the lock's reason (which wins over its own). A search
161
+ * whose result is only draft state — the form's Save writes it — leaves this off and
162
+ * stays live under the lock. No provider, or an unlocked one: no effect.
163
+ */
164
+ commit?: boolean;
129
165
  }
130
166
 
131
167
  function AutocompleteInner<V extends string | number = string>(
132
- {
168
+ props: AutocompleteProps<V>,
169
+ ref: ForwardedRef<HTMLInputElement>,
170
+ ) {
171
+ const {
133
172
  value,
134
173
  onChange,
135
174
  options,
@@ -155,6 +194,8 @@ function AutocompleteInner<V extends string | number = string>(
155
194
  inputClassName,
156
195
  id,
157
196
  disabled,
197
+ disabledReason,
198
+ commit,
158
199
  onKeyDown,
159
200
  onFocus,
160
201
  onBlur,
@@ -162,9 +203,7 @@ function AutocompleteInner<V extends string | number = string>(
162
203
  "aria-labelledby": ariaLabelledBy,
163
204
  "aria-describedby": ariaDescribedBy,
164
205
  ...rest
165
- }: AutocompleteProps<V>,
166
- ref: ForwardedRef<HTMLInputElement>,
167
- ) {
206
+ } = props;
168
207
  const generated = useId();
169
208
  const fieldId = id ?? generated;
170
209
  // Off the GENERATED id: a caller's `id` is theirs to collide with.
@@ -191,7 +230,11 @@ function AutocompleteInner<V extends string | number = string>(
191
230
  if (next !== open) onOpenChange?.(next);
192
231
  };
193
232
  const [active, setActive] = useState(-1);
194
- const live = open && !disabled;
233
+ // The lock: focusable, `readOnly`, no list — see `disabledReason`. Folded into `live`,
234
+ // so a locked field asks nothing and shows nothing even with `open` held true.
235
+ const lock = useLockReason(commit, disabledReason);
236
+ const locked = lock.locked;
237
+ const live = open && !disabled && !locked;
195
238
  const { results, busy, failed, tooShort } = useOptionSource<V>({
196
239
  options,
197
240
  loadOptions,
@@ -230,7 +273,7 @@ function AutocompleteInner<V extends string | number = string>(
230
273
  setActive(-1);
231
274
  };
232
275
  const take = (o: ComboOption<V>) => {
233
- if (o.disabled) return;
276
+ if (o.disabled || locked) return;
234
277
  if (fillOnSelect) onChange(o.label);
235
278
  onSelect?.(o);
236
279
  close();
@@ -245,6 +288,16 @@ function AutocompleteInner<V extends string | number = string>(
245
288
  // label still names the field.
246
289
  const labelledBy =
247
290
  ariaLabel === undefined ? (ariaLabelledBy ?? (hasLabel ? labelId : undefined)) : undefined;
291
+ // While locked the field sits in the reason's Tooltip — in a fragment with a hidden
292
+ // copy of the reason, Button's anatomy; `block` so a full-width field stays full width.
293
+ const withLock = (box: ReactNode) =>
294
+ locked ? (
295
+ <LockedReason lock={lock} className="block">
296
+ {box}
297
+ </LockedReason>
298
+ ) : (
299
+ box
300
+ );
248
301
 
249
302
  return (
250
303
  // `relative`: the floating label and the live region's `sr-only` both need a
@@ -257,120 +310,139 @@ function AutocompleteInner<V extends string | number = string>(
257
310
  {label}
258
311
  </ComboboxFieldLabel>
259
312
  )}
260
- <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();
313
+ {/* `"hint" in props`: the family's 0.22 rule (see `endHintRowProps`) — passed at
314
+ all, even as `undefined`, a hint keeps the unlabelled field's end-hint row, so
315
+ a "?" coming and going never re-parents the <input> under the caret (0.24, the
316
+ last of the family to get it; the siblings had it in 0.23). */}
317
+ <EndHintRow {...endHintRowProps("hint" in props, hasLabel, hintParts.labelHint)}>
318
+ {withLock(
319
+ <div ref={fieldRef} className={cn("relative", locked && "cursor-not-allowed")}>
320
+ {icon && (
321
+ <span
322
+ aria-hidden
323
+ className={cn(
324
+ "pointer-events-none absolute flex text-[var(--text-muted)]",
325
+ small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
326
+ // Labelled, the icon sits on the VALUE's line — the one line box below
327
+ // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
328
+ // the box. Centred on the box it rose into the top strip and sat on the
329
+ // label's first letters, which float at the same start inset. On the
330
+ // value line it is beside what it describes, and the label keeps the
331
+ // start edge every other label in the form's column shares.
332
+ hasLabel
333
+ ? "top-[calc(1rem+1px)] h-5 items-center"
334
+ : "top-1/2 -translate-y-1/2",
335
+ )}
336
+ >
337
+ {icon}
338
+ </span>
339
+ )}
340
+ <input
341
+ {...rest}
342
+ ref={ref}
343
+ id={fieldId}
344
+ size={nativeSize}
345
+ type="text"
346
+ value={value}
347
+ // Locked: focusable, `readOnly`, `aria-disabled` — see `disabledReason`.
348
+ disabled={locked ? undefined : disabled}
349
+ readOnly={locked || rest.readOnly}
350
+ aria-disabled={locked || rest["aria-disabled"]}
351
+ role="combobox"
352
+ aria-autocomplete="list"
353
+ aria-expanded={expanded}
354
+ // Required by the role, and set while closed too, as on the siblings.
355
+ aria-controls={listboxId}
356
+ aria-activedescendant={activeId}
357
+ aria-label={ariaLabel}
358
+ aria-labelledby={labelledBy}
359
+ aria-invalid={field.isInvalid || rest["aria-invalid"] || undefined}
360
+ aria-describedby={mergeDescribedBy(field.describedBy, locked && lock.reasonId)}
361
+ autoComplete="off"
362
+ onFocus={(e) => {
363
+ onFocus?.(e);
364
+ // A locked field takes focus to say why, and opens nothing.
365
+ if (locked) return;
366
+ // A seeded field (keksdose's `initialQuery`) is looked up as it is
367
+ // focused; an empty one asks nothing, because of `minChars`.
368
+ setOpen(true);
369
+ }}
370
+ onBlur={(e) => {
371
+ onBlur?.(e);
324
372
  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) {
373
+ }}
374
+ onChange={(e) => {
375
+ if (locked) return;
376
+ onChange(e.target.value);
377
+ setOpen(true);
378
+ setActive(-1);
379
+ }}
380
+ onKeyDown={(e) => {
381
+ // Locked: Enter is swallowed (no form submit, no caller's "search now");
382
+ // every other key is the caller's and the caret's. No list to drive.
383
+ if (locked) {
384
+ if (e.key === "Enter") e.preventDefault();
385
+ else onKeyDown?.(e);
386
+ return;
387
+ }
388
+ // An Escape that closes the OPEN list is the list's, and is consumed before
389
+ // the caller sees it. A caller whose Escape means "close the panel" (keksdose's
390
+ // address search) otherwise closed the whole panel when the user only meant
391
+ // to dismiss the suggestions — the opposite of what the docs promised.
392
+ if (e.key === "Escape" && expanded) {
343
393
  e.preventDefault();
344
- take(results[active]);
394
+ e.stopPropagation();
395
+ close();
396
+ return;
345
397
  }
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
398
+ onKeyDown?.(e);
399
+ if (e.defaultPrevented) return;
400
+ // Disabled rows are passed over; at either end the highlight stays put.
401
+ if (e.key === "ArrowDown") {
402
+ e.preventDefault();
403
+ if (!open) setOpen(true);
404
+ else setActive((i) => stepEnabled(results, i, 1));
405
+ } else if (e.key === "ArrowUp") {
406
+ e.preventDefault();
407
+ if (!open) setOpen(true);
408
+ // From "nothing highlighted", Up goes to the bottom, as the APG has it.
409
+ else setActive((i) => stepEnabled(results, i < 0 ? results.length : i, -1));
410
+ } else if (e.key === "Enter") {
411
+ // No row highlighted: the text is the answer, and a form's own submit
412
+ // is left alone.
413
+ if (expanded && activeId) {
414
+ e.preventDefault();
415
+ take(results[active]);
416
+ }
417
+ } else if (e.key === "Tab") {
418
+ close();
419
+ }
420
+ }}
365
421
  className={cn(
366
- "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
367
- small ? "end-2" : "end-2.5",
422
+ FIELD_BASE,
423
+ hasLabel && FIELD_FLOATING_PAD,
424
+ // Select's `SELECT_SM` box: a fixed 28px, so the caller's line height
425
+ // cannot grow it.
426
+ small && "h-7 py-0 pe-2 ps-2 text-xs",
427
+ icon ? (small ? "ps-7" : "ps-8") : undefined,
428
+ busy && (small ? "pe-7" : "pe-9"),
429
+ field.isInvalid && FIELD_INVALID,
430
+ inputClassName,
368
431
  )}
369
- >
370
- <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
371
- </span>
372
- )}
373
- </div>
432
+ />
433
+ {busy && live && (
434
+ // Decorative: the live region below already says "Loading…".
435
+ <span
436
+ className={cn(
437
+ "pointer-events-none absolute top-1/2 flex -translate-y-1/2",
438
+ small ? "end-2" : "end-2.5",
439
+ )}
440
+ >
441
+ <Spinner label={null} className={small ? "h-3.5 w-3.5" : "h-4 w-4"} />
442
+ </span>
443
+ )}
444
+ </div>,
445
+ )}
374
446
  </EndHintRow>
375
447
  {/* With a "?" the label shares the top strip with it, after the field so the "?"
376
448
  follows the control in the tab order (as on Input and Select). */}