@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
@@ -1,9 +1,11 @@
1
- import { useId, useMemo, type ComponentPropsWithoutRef, type ReactNode } from "react";
1
+ import { useCallback, useId, useMemo, type ComponentPropsWithoutRef, type ReactNode, type Ref } from "react";
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
5
  import {
6
6
  ComboboxPanel,
7
+ endHintRowProps,
8
+ offersCreate,
7
9
  useComboboxCore,
8
10
  useComboboxFieldError,
9
11
  type ComboOption,
@@ -14,6 +16,17 @@ import {
14
16
  useKitLabels,
15
17
  useKitLocale,
16
18
  } from "../i18n/kit-labels";
19
+ import {
20
+ EndHintRow,
21
+ FieldCaption,
22
+ LABEL_IN_ROW,
23
+ LockedReason,
24
+ StaticLabelRow,
25
+ useFieldHint,
26
+ useLockReason,
27
+ } from "./field-parts";
28
+ import { assignRef, mergeDescribedBy } from "./choice-parts";
29
+ import { useCommitReason } from "./write-lock";
17
30
 
18
31
  /** `onChange` is the kit's — "a selection was made", carrying values — rather
19
32
  * than the div's form event, so the DOM's spelling of it is omitted. */
@@ -35,6 +48,9 @@ export interface MultiEntityComboboxProps<V extends string | number>
35
48
  /** Trigger summary when 1+ selected (default: the labels as a locale-formatted
36
49
  * list, or `combobox.selectedCount` when a label can't be resolved). */
37
50
  itemLabel?: (count: number) => string;
51
+ /** Offer a clear "×" while anything is chosen; it emits `[]`. A pointer shortcut out
52
+ * of the tab order — the keyboard's way is Delete or Backspace on the CLOSED trigger,
53
+ * or unticking rows in the open list. Not offered while `disabled` or locked. */
38
54
  clearable?: boolean;
39
55
  clearLabel?: string;
40
56
  /** The phone sheet's close button. Its own prop rather than a reuse of
@@ -43,15 +59,37 @@ export interface MultiEntityComboboxProps<V extends string | number>
43
59
  * so it is the one control here that MUST be in the reader's language. */
44
60
  closeLabel?: string;
45
61
  disabled?: boolean;
62
+ /** Why the selection cannot be changed — {@link EntityCombobox}'s `disabledReason`:
63
+ * focusable and `aria-disabled`, no list, no clear, no `onChange`, the reason in the
64
+ * kit {@link Tooltip}. Wins over `disabled`. */
65
+ disabledReason?: ReactNode;
66
+ /** This picker COMMITS — toggling saves. Locked the `disabledReason` way under a
67
+ * locked {@link WriteLockProvider}. See {@link EntityCombobox}'s `commit`. */
68
+ commit?: boolean;
46
69
  /** When set, a "create" row appears for a non-empty query with no exact match.
47
70
  * The panel stays open (adding the new entity to `value` is the caller's job). */
48
71
  onCreate?: (query: string) => void;
49
72
  createLabel?: (query: string) => string;
73
+ /** Offer the create row with nothing typed too, worded as this. See
74
+ * {@link EntityCombobox}'s `createEmptyLabel`. */
75
+ createEmptyLabel?: string;
76
+ /** The create row is a write while toggling is not: locked on its own under a locked
77
+ * {@link WriteLockProvider}. See {@link EntityCombobox}'s `createCommit`. */
78
+ createCommit?: boolean;
50
79
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
51
80
  invalid?: boolean;
52
81
  /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
53
82
  * field, on the trigger's `aria-describedby`, and implies `invalid`. */
54
83
  error?: ReactNode;
84
+ /**
85
+ * Standing advice — the rest of the Combobox family's `hint` since 0.22, which this
86
+ * one was left without (keksdose K4): plain TEXT is a caption under the field on the
87
+ * trigger's `aria-describedby`, before any error; a {@link FieldHint} "?" rides the
88
+ * label line, or with no label sits at the trigger's end edge, outside the box.
89
+ * Passed at all — even `undefined` — the field keeps one box, so a hint coming and
90
+ * going never rebuilds the trigger under the cursor (the 0.22 rule).
91
+ */
92
+ hint?: ReactNode;
55
93
  /** Narrow `options` client-side by the query. Default `true`; `false` shows them
56
94
  * as given (a server-ranked list). */
57
95
  filter?: boolean;
@@ -62,6 +100,10 @@ export interface MultiEntityComboboxProps<V extends string | number>
62
100
  debounceMs?: number;
63
101
  /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
64
102
  loadErrorLabel?: string;
103
+ /** The TRIGGER — the focusable `<button>` — as a React 19 ref prop, as on
104
+ * {@link EntityCombobox} and {@link CountrySelect} (0.24, kastlan: react-hook-form's
105
+ * focus-on-error focuses whatever `field.ref` is handed). Not the wrapper. */
106
+ ref?: Ref<HTMLButtonElement>;
65
107
  }
66
108
 
67
109
  /**
@@ -69,42 +111,49 @@ export interface MultiEntityComboboxProps<V extends string | number>
69
111
  * with the same async `loadOptions`/`onCreate` support. Rows show a checkbox and
70
112
  * toggle without closing the panel; the trigger summarises the selection.
71
113
  */
72
- export function MultiEntityCombobox<V extends string | number>({
73
- value,
74
- onChange,
75
- options,
76
- loadOptions,
77
- loading,
78
- label,
79
- placeholder,
80
- searchPlaceholder,
81
- emptyLabel,
82
- itemLabel,
83
- clearable,
84
- clearLabel,
85
- closeLabel,
86
- disabled,
87
- onCreate,
88
- createLabel,
89
- className,
90
- invalid,
91
- error,
92
- filter,
93
- minChars,
94
- debounceMs,
95
- loadErrorLabel,
96
- "aria-label": ariaLabel,
97
- // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
98
- // the wrapper: `Field`'s render-prop spreads `{ id, aria-describedby, aria-invalid,
99
- // aria-required }`, and on the role-less wrapper div the label's `htmlFor` named
100
- // nothing and the hint and required state described nothing — kastlan's
101
- // communication-page To/Cc/Bcc and lease-tenants-step.tsx read as unnamed pickers.
102
- id,
103
- "aria-describedby": ariaDescribedBy,
104
- "aria-invalid": ariaInvalid,
105
- "aria-required": ariaRequired,
106
- ...rest
107
- }: MultiEntityComboboxProps<V>) {
114
+ export function MultiEntityCombobox<V extends string | number>(props: MultiEntityComboboxProps<V>) {
115
+ const {
116
+ value,
117
+ onChange,
118
+ options,
119
+ loadOptions,
120
+ loading,
121
+ label,
122
+ placeholder,
123
+ searchPlaceholder,
124
+ emptyLabel,
125
+ itemLabel,
126
+ clearable,
127
+ clearLabel,
128
+ closeLabel,
129
+ disabled,
130
+ disabledReason,
131
+ commit,
132
+ onCreate,
133
+ createLabel,
134
+ createEmptyLabel,
135
+ createCommit,
136
+ className,
137
+ invalid,
138
+ error,
139
+ hint,
140
+ filter,
141
+ minChars,
142
+ debounceMs,
143
+ loadErrorLabel,
144
+ ref,
145
+ "aria-label": ariaLabel,
146
+ // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
147
+ // the wrapper: `Field`'s render-prop spreads `{ id, aria-describedby, aria-invalid,
148
+ // aria-required }`, and on the role-less wrapper div the label's `htmlFor` named
149
+ // nothing and the hint and required state described nothing — kastlan's
150
+ // communication-page To/Cc/Bcc and lease-tenants-step.tsx read as unnamed pickers.
151
+ id,
152
+ "aria-describedby": ariaDescribedBy,
153
+ "aria-invalid": ariaInvalid,
154
+ "aria-required": ariaRequired,
155
+ ...rest
156
+ } = props;
108
157
  const core = useComboboxCore<V>({
109
158
  options,
110
159
  loadOptions,
@@ -113,10 +162,11 @@ export function MultiEntityCombobox<V extends string | number>({
113
162
  minChars,
114
163
  debounceMs,
115
164
  });
165
+ const hintParts = useFieldHint(hint, ariaDescribedBy);
116
166
  const field = useComboboxFieldError(
117
167
  error,
118
168
  invalid || ariaInvalid === true || ariaInvalid === "true",
119
- ariaDescribedBy,
169
+ hintParts.describedBy,
120
170
  );
121
171
  // The props are the per-instance overrides, the provider the app-wide ones; a
122
172
  // prop left `undefined` falls through to the provider rather than masking it.
@@ -128,6 +178,19 @@ export function MultiEntityCombobox<V extends string | number>({
128
178
  });
129
179
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
130
180
  const { open, results, resolve, setOpen, query, triggerRef } = core;
181
+ // The core's trigger ref and the caller's, fed by one callback — see EntityCombobox.
182
+ const setTrigger = useCallback(
183
+ (el: HTMLButtonElement | null) => {
184
+ triggerRef.current = el;
185
+ assignRef(ref, el);
186
+ },
187
+ [ref, triggerRef],
188
+ );
189
+ // The lock — see EntityCombobox: the trigger stays reachable and says why.
190
+ const lock = useLockReason(commit, disabledReason);
191
+ const inert = lock.locked || Boolean(disabled);
192
+ if (lock.locked && open) setOpen(false);
193
+ const createReason = useCommitReason(createCommit, undefined);
131
194
  // One id per instance, generated here rather than in the core: `aria-controls` on
132
195
  // the trigger has to name the list while the list is still closed, so the id
133
196
  // belongs to whoever renders both ends of it.
@@ -154,12 +217,27 @@ export function MultiEntityCombobox<V extends string | number>({
154
217
  : labels.selectedCount(value.length);
155
218
  })();
156
219
 
220
+ // While locked the trigger sits in the reason's Tooltip, as on EntityCombobox.
221
+ const withLock = (trigger: ReactNode) =>
222
+ lock.locked ? (
223
+ <LockedReason lock={lock} className="block">
224
+ {trigger}
225
+ </LockedReason>
226
+ ) : (
227
+ trigger
228
+ );
229
+
157
230
  const q = query.trim();
158
- const showCreate =
159
- Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
160
- const showClear = Boolean(clearable && value.length > 0 && !disabled);
231
+ const showCreate = offersCreate(
232
+ onCreate,
233
+ q,
234
+ results.map((o) => o.label),
235
+ createEmptyLabel,
236
+ );
237
+ const showClear = Boolean(clearable && value.length > 0 && !inert);
161
238
 
162
239
  const toggle = (o: ComboOption<V>) => {
240
+ if (lock.locked) return;
163
241
  core.rememberOption(o);
164
242
  if (valueSet.has(o.value)) onChange(value.filter((v) => v !== o.value));
165
243
  else onChange([...value, o.value]);
@@ -172,88 +250,107 @@ export function MultiEntityCombobox<V extends string | number>({
172
250
  // has one. Spread FIRST so the trigger's ARIA and the
173
251
  // handlers that open the panel cannot be clobbered from outside.
174
252
  <div {...rest} className={cn("relative", className)}>
175
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
176
- <button
177
- ref={triggerRef}
178
- id={id}
179
- type="button"
180
- // A combobox, not a button. The distinction is not pedantry: this control
181
- // carried `aria-invalid`, which `button` does not support, so a required
182
- // field left empty painted a rose border and told a reader nothing at all —
183
- // and ESLint flagged it as exactly that (`role-supports-aria-props`). The
184
- // fix the audit asked for is the role that describes what this IS: a closed
185
- // choice that expands into the list named below. `combobox` supports
186
- // `aria-invalid`, so the border and the announcement finally agree.
187
- role="combobox"
188
- aria-expanded={open}
189
- aria-controls={listboxId}
190
- aria-haspopup="listbox"
191
- // The label is a floating <span>, not a <label for>, so without this the
192
- // trigger's accessible name is whatever value happens to be selected —
193
- // "Checking" with nothing saying it is the account. The label AND the
194
- // value, because `aria-label` replaces the content rather than adding to
195
- // it, and a control that announces only its name has lost the answer.
196
- // A caller's own name wins over the composition: two fields labelled
197
- // "Account" on a transfer form are the from and the to, and only the
198
- // caller knows which is which.
199
- aria-label={
200
- ariaLabel ??
201
- (typeof label === "string" ? common.fieldValue(label, summary) : undefined)
202
- }
203
- disabled={disabled}
204
- aria-invalid={field.isInvalid || undefined}
205
- aria-describedby={field.describedBy}
206
- aria-required={ariaRequired}
207
- onClick={() => !disabled && setOpen((o) => !o)}
208
- // Down/Up opens the list from the closed trigger, per the APG. Enter and
209
- // Space already do it through the button's own click.
210
- onKeyDown={(e) => {
211
- if (disabled) return;
212
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
213
- e.preventDefault();
214
- setOpen(true);
215
- }
216
- }}
217
- className={cn(
218
- FIELD_TRIGGER,
219
- "pe-9",
220
- label !== undefined && FIELD_FLOATING_PAD,
221
- disabled && "cursor-not-allowed opacity-50",
222
- field.isInvalid && FIELD_INVALID,
223
- )}
224
- >
225
- <span
226
- className={cn(
227
- "min-w-0 truncate",
228
- // Inherit FIELD_BASE's ink for a value, keep the placeholder tone
229
- // (`--text-placeholder`) — the same rule every field on a form row
230
- // follows (Keksdose dev#477).
231
- !value.length && "text-[var(--text-placeholder)]",
232
- )}
233
- >
234
- {summary}
235
- </span>
236
- {showClear ? (
237
- /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
238
- the trigger <button>, so it never holds focus and a key handler here could
239
- never fire. A pointer shortcut; the keyboard path is unticking the options
240
- in the open list. */
241
- <span
242
- role="button"
243
- tabIndex={-1}
244
- aria-label={labels.clear}
245
- onClick={(e) => {
246
- e.stopPropagation();
247
- onChange([]);
253
+ {label !== undefined && hintParts.labelHint === undefined && <FieldLabel>{label}</FieldLabel>}
254
+ <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
255
+ {withLock(
256
+ <button
257
+ ref={setTrigger}
258
+ id={id}
259
+ type="button"
260
+ // A combobox, not a button. The distinction is not pedantry: this control
261
+ // carried `aria-invalid`, which `button` does not support, so a required
262
+ // field left empty painted a rose border and told a reader nothing at all —
263
+ // and ESLint flagged it as exactly that (`role-supports-aria-props`). The
264
+ // fix the audit asked for is the role that describes what this IS: a closed
265
+ // choice that expands into the list named below. `combobox` supports
266
+ // `aria-invalid`, so the border and the announcement finally agree.
267
+ role="combobox"
268
+ aria-expanded={open}
269
+ aria-controls={listboxId}
270
+ aria-haspopup="listbox"
271
+ // The label is a floating <span>, not a <label for>, so without this the
272
+ // trigger's accessible name is whatever value happens to be selected —
273
+ // "Checking" with nothing saying it is the account. The label AND the
274
+ // value, because `aria-label` replaces the content rather than adding to
275
+ // it, and a control that announces only its name has lost the answer.
276
+ // A caller's own name wins over the composition: two fields labelled
277
+ // "Account" on a transfer form are the from and the to, and only the
278
+ // caller knows which is which.
279
+ aria-label={
280
+ ariaLabel ??
281
+ (typeof label === "string" ? common.fieldValue(label, summary) : undefined)
282
+ }
283
+ // Focusable while locked — see EntityCombobox.
284
+ disabled={lock.locked ? undefined : disabled}
285
+ aria-disabled={lock.locked || undefined}
286
+ aria-invalid={field.isInvalid || undefined}
287
+ aria-describedby={mergeDescribedBy(field.describedBy, lock.locked && lock.reasonId)}
288
+ aria-required={ariaRequired}
289
+ onClick={() => !inert && setOpen((o) => !o)}
290
+ // Down/Up opens the list from the closed trigger, per the APG. Enter and
291
+ // Space already do it through the button's own click.
292
+ onKeyDown={(e) => {
293
+ if (inert) return;
294
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
295
+ e.preventDefault();
296
+ setOpen(true);
297
+ } else if (showClear && !open && (e.key === "Delete" || e.key === "Backspace")) {
298
+ // The keyboard's clear: the "×" is a pointer shortcut out of the tab
299
+ // order, so without this a cleared-by-mouse field was set-only from the
300
+ // keyboard. The closed trigger only — CountrySelect's rule (0.23).
301
+ e.preventDefault();
302
+ onChange([]);
303
+ }
248
304
  }}
249
- className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
305
+ className={cn(
306
+ FIELD_TRIGGER,
307
+ "pe-9",
308
+ label !== undefined && FIELD_FLOATING_PAD,
309
+ inert && "cursor-not-allowed opacity-50",
310
+ field.isInvalid && FIELD_INVALID,
311
+ )}
250
312
  >
251
- <X className="size-4" />
252
- </span>
253
- ) : (
254
- <FieldChevron />
313
+ <span
314
+ className={cn(
315
+ "min-w-0 truncate",
316
+ // Inherit FIELD_BASE's ink for a value, keep the placeholder tone
317
+ // (`--text-placeholder`) — the same rule every field on a form row
318
+ // follows (Keksdose dev#477).
319
+ !value.length && "text-[var(--text-placeholder)]",
320
+ )}
321
+ >
322
+ {summary}
323
+ </span>
324
+ {showClear ? (
325
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
326
+ the trigger <button>, so it never holds focus and a key handler here could
327
+ never fire. A pointer shortcut; the keyboard paths are Delete on the closed
328
+ trigger and unticking the options in the open list. */
329
+ <span
330
+ role="button"
331
+ tabIndex={-1}
332
+ aria-label={labels.clear}
333
+ onClick={(e) => {
334
+ e.stopPropagation();
335
+ onChange([]);
336
+ }}
337
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
338
+ >
339
+ <X className="size-4" />
340
+ </span>
341
+ ) : (
342
+ <FieldChevron />
343
+ )}
344
+ </button>,
255
345
  )}
256
- </button>
346
+ </EndHintRow>
347
+ {/* With a "?" the label shares the top strip with it — after the trigger, so the
348
+ "?" follows the control in the tab order, as on EntityCombobox. */}
349
+ {label !== undefined && hintParts.labelHint !== undefined && (
350
+ <StaticLabelRow hint={hintParts.labelHint}>
351
+ <FieldLabel className={LABEL_IN_ROW}>{label}</FieldLabel>
352
+ </StaticLabelRow>
353
+ )}
257
354
  <ComboboxPanel
258
355
  core={core}
259
356
  listboxId={listboxId}
@@ -269,11 +366,14 @@ export function MultiEntityCombobox<V extends string | number>({
269
366
  onChoose={toggle}
270
367
  showCreate={showCreate}
271
368
  onCreate={() => {
369
+ if (lock.locked) return;
272
370
  onCreate?.(q);
273
371
  core.setQuery("");
274
372
  }}
275
- createContent={labels.create(q)}
373
+ createContent={q ? labels.create(q) : createEmptyLabel}
374
+ createDisabledReason={createReason}
276
375
  />
376
+ <FieldCaption parts={hintParts} />
277
377
  {field.errorEl}
278
378
  </div>
279
379
  );
@@ -2,10 +2,10 @@ import { useContext, useEffect, useId, useRef, useState } from "react";
2
2
  import type { KeyboardEvent, ReactNode } from "react";
3
3
 
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
- import { usePromisePending } from "./danger-confirm";
5
+ import { CurrentPasswordInput, usePromisePending } from "./danger-confirm";
6
6
  import { DialogFrame } from "./dialog-frame";
7
7
  import { ModalCloseContext } from "./modal";
8
- import { Button, Input, Spinner } from "./ui";
8
+ import { Button, Spinner } from "./ui";
9
9
 
10
10
  /**
11
11
  * Every string the dialog renders — the `reauthDialog` namespace of
@@ -85,8 +85,8 @@ export interface ReauthDialogProps {
85
85
  * `requirePassword` tile. Both are the same three facts — the field is the CURRENT
86
86
  * password (the password manager fills it, never generates one), a wrong one stays in
87
87
  * the dialog with its error, and the APP verifies it — so the promise handling is
88
- * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` spelled out
89
- * with an `error`, and this is the modal form of them.
88
+ * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` with an
89
+ * `error`, and this is the modal form of them.
90
90
  *
91
91
  * A component rather than a `useReauth()` promise: the step that follows the check
92
92
  * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error
@@ -201,24 +201,23 @@ export function ReauthDialog({
201
201
  submit();
202
202
  }}
203
203
  >
204
- {/* CurrentPasswordInput's field — the password manager fills it with the CURRENT
205
- password and never offers to generate one; `readOnly`, not `disabled`, while
206
- busy, because disabling the field that has focus drops focus to <body> —
207
- plus `Input`'s own `error`, which that helper does not take. The message
208
- paints the field, sits under it and is its `aria-describedby`; passing the
209
- prop on every render keeps the field's box steady, so the message going away
210
- on the first keystroke of a retry no longer remounts the <input> under the
211
- typist (it did, which is why this used to draw its own message). */}
212
- <Input
204
+ {/* The kit's CurrentPasswordInput (0.23; it drew the same field by hand until
205
+ then): the password manager fills it with the CURRENT password and never
206
+ offers to generate one; `busy` makes it `readOnly`, not `disabled`, because
207
+ disabling the field that has focus drops focus to <body>. Labelled by this
208
+ namespace's `password` ("Current password"), not `dangerConfirm.password`.
209
+ The `error` is Input's own: it paints the field, sits under it and is its
210
+ `aria-describedby`; passing the prop on every render keeps the field's box
211
+ steady, so the message going away on the first keystroke of a retry does not
212
+ remount the <input> under the typist. */}
213
+ <CurrentPasswordInput
213
214
  ref={inputRef}
214
- type="password"
215
- autoComplete="current-password"
216
215
  label={labels.password}
217
216
  value={password}
218
- readOnly={busy}
217
+ busy={busy}
219
218
  error={shownError}
220
- onChange={(e) => {
221
- setPassword(e.target.value);
219
+ onValueChange={(next) => {
220
+ setPassword(next);
222
221
  setEdited(true);
223
222
  }}
224
223
  />
@@ -6,7 +6,7 @@ import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
6
6
  import type { TileItem, TileSize } from "./tile-radio";
7
7
  import { Tooltip } from "./tooltip";
8
8
  import { useCommitReason } from "./write-lock";
9
- import { hasContent, LabelStrip } from "./field-anatomy";
9
+ import { hasContent, LabelStrip } from "./field-parts";
10
10
 
11
11
  export interface SwatchPickerLabels {
12
12
  /** The "no colour" tile's name, when `allowNone` is set. */
@@ -38,7 +38,23 @@ export interface SwatchOption<T extends string> {
38
38
  /** A remark read after the name and shown under it in the bubble ("used by
39
39
  * Groceries"). The tile wears a dot while it has one. */
40
40
  note?: string;
41
+ /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a
42
+ * `disabledReason` the tile stays reachable instead and says why. */
41
43
  disabled?: boolean;
44
+ /**
45
+ * Why THIS colour cannot be chosen right now — the whole picker's `disabledReason`,
46
+ * one tile at a time (the kit's later list after keksdose K3: a colour another
47
+ * category already wears, a flag only an owner may set). {@link Button}'s rule: a tile
48
+ * that is merely `disabled` is a dead square a keyboard never lands on, so nobody
49
+ * learns why it is dimmed.
50
+ *
51
+ * The tile looks disabled but is `aria-disabled`, not `disabled`: Tab and the arrow
52
+ * keys still reach it, its bubble shows the reason under its name, and the reason
53
+ * describes it. Choosing it — a click, Space or Enter, or an arrow under automatic
54
+ * activation — does nothing: the focus moves onto it and the checked colour stays
55
+ * where it was, as under the whole-picker lock. Wins over `disabled`.
56
+ */
57
+ disabledReason?: ReactNode;
42
58
  }
43
59
 
44
60
  /**
@@ -142,16 +158,24 @@ export function SwatchPicker<T extends string>({
142
158
  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
143
159
  const items: TileItem<T>[] = [
144
160
  ...(allowNone ? [{ key: null, label: text.none }] : []),
145
- ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
161
+ ...options.map((o) => ({
162
+ key: o.value,
163
+ label: o.label,
164
+ note: o.note,
165
+ disabled: o.disabled,
166
+ disabledReason: o.disabledReason,
167
+ })),
146
168
  ];
147
169
  const tiles = (
148
170
  <TileRadioGroup
149
171
  items={items}
150
172
  checked={mixed ? undefined : value}
151
173
  // A locked picker saves on change, so the change is swallowed — the arrow keys
152
- // still move the focus (and, automatic, would choose), and nothing is chosen.
174
+ // still move the focus (and, automatic, would choose), and nothing is chosen. A
175
+ // locked TILE is refused by the tiles themselves; asked again here so a reason
176
+ // can never become a change, whatever the tiles do.
153
177
  onSelect={(next) => {
154
- if (!locked) onChange(next);
178
+ if (!locked && !hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);
155
179
  }}
156
180
  activation={activation}
157
181
  // A reason wins over `disabled`: the tiles stay in the tab order.
@@ -235,7 +259,7 @@ export function SwatchPicker<T extends string>({
235
259
  );
236
260
  if (!labelled) return group;
237
261
  return (
238
- <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled || locked} pad="tiles" className={className}>
262
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled || locked} pad="clear" className={className}>
239
263
  {group}
240
264
  </LabelStrip>
241
265
  );