@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
@@ -2,10 +2,10 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
2
2
  import type { ReactNode } from "react";
3
3
 
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
- import { typedMatches } from "./danger-confirm";
5
+ import { TypedConfirmField, typedMatches } from "./danger-confirm";
6
6
  import type { TypedMatch } from "./danger-confirm";
7
7
  import { DialogFrame } from "./dialog-frame";
8
- import { Button, Input } from "./ui";
8
+ import { Button } from "./ui";
9
9
 
10
10
  /**
11
11
  * The `confirmDialog` namespace of `<UiKitProvider labels>`: the two buttons' fallback
@@ -300,15 +300,19 @@ function ConfirmDialogView({
300
300
  confirmRef.current?.click();
301
301
  }}
302
302
  >
303
- <Input
303
+ {/* The kit's one typed-confirmation field (0.23), DangerConfirm's too: the
304
+ keyboard told to leave the text alone, `data-matched` on the input. The
305
+ label is this namespace's (`confirmDialog.typed`), and `placeholder=""`
306
+ keeps out the `dangerConfirm.phrasePlaceholder` a provider may set for
307
+ the tile — this field never had one. */}
308
+ <TypedConfirmField
304
309
  ref={typedRef}
310
+ target={target}
311
+ match={request.typedMatch ?? "caseless"}
305
312
  label={typedLabel}
313
+ placeholder=""
306
314
  value={typed}
307
- autoComplete="off"
308
- autoCapitalize="off"
309
- autoCorrect="off"
310
- spellCheck={false}
311
- onChange={(e) => setTyped(e.target.value)}
315
+ onValueChange={setTyped}
312
316
  />
313
317
  </form>
314
318
  )}
@@ -1,5 +1,6 @@
1
- import { useId, useMemo } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import { useCallback, useId, useMemo } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
+ import { X } from "lucide-react";
3
4
  import { cn } from "../lib/cn";
4
5
  import { COUNTRY_CODES, countryName, normalizeCountryCode } from "../lib/countries";
5
6
  import { FieldChevron, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, FloatingField } from "./ui";
@@ -11,9 +12,14 @@ import {
11
12
  useComboboxFieldError,
12
13
  type ComboOption,
13
14
  } from "./combobox-core";
14
- import { hasMessage, mergeDescribedBy } from "./choice-parts";
15
+ import { assignRef, hasMessage, mergeDescribedBy } from "./choice-parts";
15
16
  import { useCommitReason } from "./write-lock";
16
- import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
17
+ import {
18
+ DEFAULT_COMBOBOX_LABELS,
19
+ DEFAULT_COMMON_LABELS,
20
+ useKitLabels,
21
+ useKitLocale,
22
+ } from "../i18n/kit-labels";
17
23
 
18
24
  // ── Labels ────────────────────────────────────────────────────────────────────
19
25
 
@@ -44,15 +50,46 @@ export const DEFAULT_COUNTRY_SELECT_LABELS: CountrySelectLabels = {
44
50
  * `onChange` is the kit's — "a country was picked", carrying its code — rather than
45
51
  * the div's form event. `id` and the `aria-*` wiring below go to the TRIGGER, which is
46
52
  * the control a reader meets; everything else in `rest` dresses the wrapper.
53
+ *
54
+ * `Clearable` follows `clearable`, the way {@link EntityCombobox}'s `C` follows its
55
+ * `clearValue`: it is inferred from the prop, so `onChange` is `(code: string) => void`
56
+ * for the field as it always was (`onChange={setCountry}` over a `string` state still
57
+ * type-checks), and `(code: string | null) => void` once `clearable` is set — the
58
+ * `null` of a clear cannot reach a handler that was never told about it.
47
59
  */
48
- export interface CountrySelectProps
60
+ export interface CountrySelectProps<Clearable extends boolean = false>
49
61
  extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> {
50
62
  /** The chosen country as an ISO 3166-1 alpha-2 code ("CH"), in either case;
51
63
  * `null`, `undefined` or `""` when none is. A code outside the list still shows
52
64
  * its name — the list restricts what can be PICKED, not what can be shown. */
53
65
  value: string | null | undefined;
54
- /** A country was picked: its alpha-2 code, always upper-case. */
55
- onChange: (code: string) => void;
66
+ /** A country was picked: its alpha-2 code, always upper-case. With `clearable`,
67
+ * also `null`: the "×" (or Delete on the trigger) emptied the field. */
68
+ onChange: (code: Clearable extends true ? string | null : string) => void;
69
+ /**
70
+ * Offer a clear "×" in place of the chevron while a country is chosen; it hands
71
+ * `onChange` `null` (the kit's later list: an optional country — a contact's
72
+ * nationality, a bank filter's "any country" — could be set but never unset again).
73
+ *
74
+ * The combobox family's clear, as {@link EntityCombobox} draws it: a pointer
75
+ * shortcut inside the trigger, out of the tab order so it is not a second stop
76
+ * between this field and the next, named by `clearLabel` (default: the
77
+ * `combobox.clear` label every picker in the family shares). The keyboard way is
78
+ * Delete or Backspace on the closed trigger. Not offered while `disabled` or locked
79
+ * (`commit` / `disabledReason`): a clear is a change like any other.
80
+ */
81
+ clearable?: Clearable;
82
+ /** The clear "×"'s accessible name. Wins over `combobox.clear` from the
83
+ * {@link UiKitProvider}, as {@link EntityCombobox}'s `clearLabel` does. */
84
+ clearLabel?: string;
85
+ /**
86
+ * The TRIGGER — the focusable `<button>` a reader meets — as a React 19 ref prop,
87
+ * the way {@link MoneyField} takes its input's (kastlan: react-hook-form's
88
+ * focus-on-error calls `focus()` on whatever `field.ref` was handed, and without a
89
+ * ref the first field in error on an address form was never focused). See
90
+ * `RhfCountrySelect`, which passes it.
91
+ */
92
+ ref?: Ref<HTMLButtonElement>;
56
93
  /**
57
94
  * Offer only these codes (any case; duplicates and malformed entries dropped).
58
95
  * Default: every ISO 3166-1 country, {@link COUNTRY_CODES}. Sorted by name either
@@ -92,8 +129,8 @@ export interface CountrySelectProps
92
129
  error?: ReactNode;
93
130
  /** Required and unanswered — {@link FIELD_INVALID}, as on every field. */
94
131
  invalid?: boolean;
95
- /** The empty trigger's text. Default: nothing when the field has a `label` (the
96
- * label already says what goes here), else `labels.country`. */
132
+ /** The empty trigger's text. Default: nothing when the field has a `label` or an
133
+ * `aria-labelledby` (the label already says what goes here), else `labels.country`. */
97
134
  placeholder?: string;
98
135
  /** As on the other pickers: no focus, no list, the settled look. */
99
136
  disabled?: boolean;
@@ -117,6 +154,15 @@ export interface CountrySelectProps
117
154
  /** Per-instance overrides of {@link CountrySelectLabels}, over the provider's
118
155
  * `countrySelect` namespace, over English. */
119
156
  labels?: Partial<CountrySelectLabels>;
157
+ /**
158
+ * Name the trigger by an element of the caller's — a form's own label above the
159
+ * field, which is how `RhfCountrySelect` names it. The trigger is then named by
160
+ * that element AND the chosen country (two references, as {@link DatePicker}'s
161
+ * trigger is named, so "Country Switzerland" needs no punctuation to translate),
162
+ * instead of the composed "label: country". An `aria-label` still wins. Before
163
+ * 0.23 this landed on the wrapper `<div>`, which has no role to be named.
164
+ */
165
+ "aria-labelledby"?: string;
120
166
  }
121
167
 
122
168
  /** Fold a name for search: no case, no accents — "osterreich" finds Österreich, "cote"
@@ -192,8 +238,11 @@ function collator(locale: string): Intl.Collator {
192
238
  *
193
239
  * Field anatomy as on every field: floating `label`, `hint`, `error`, `invalid`; the
194
240
  * trigger is named "label: value" as the other trigger pickers are.
241
+ *
242
+ * Since 0.23 it takes a `ref` to the trigger (react-hook-form's focus-on-error, kastlan)
243
+ * and `clearable`, which hands `onChange` `null`.
195
244
  */
196
- export function CountrySelect({
245
+ export function CountrySelect<Clearable extends boolean = false>({
197
246
  value,
198
247
  onChange,
199
248
  countries,
@@ -207,18 +256,25 @@ export function CountrySelect({
207
256
  disabled,
208
257
  disabledReason: ownReason,
209
258
  commit,
259
+ clearable,
260
+ clearLabel,
210
261
  locale: localeProp,
211
262
  labels: labelsProp,
212
263
  className,
213
264
  id,
265
+ ref,
214
266
  "aria-label": ariaLabel,
267
+ "aria-labelledby": ariaLabelledBy,
215
268
  "aria-describedby": ariaDescribedBy,
216
269
  "aria-invalid": ariaInvalid,
217
270
  "aria-required": ariaRequired,
218
271
  ...rest
219
- }: CountrySelectProps) {
272
+ }: CountrySelectProps<Clearable>) {
220
273
  const text = useKitLabels("countrySelect", DEFAULT_COUNTRY_SELECT_LABELS, labelsProp);
221
274
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
275
+ // The clear's name is the family's (`combobox.clear`), as on EntityCombobox; a prop
276
+ // left `undefined` falls through to the provider rather than masking it.
277
+ const comboText = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, { clear: clearLabel });
222
278
  // English without a locale — see `countryName`.
223
279
  const locale = useKitLocale(localeProp) ?? "en";
224
280
 
@@ -268,6 +324,15 @@ export function CountrySelect({
268
324
  );
269
325
  const core = useComboboxCore<string>({ options: allOptions, filter: false });
270
326
  const { open, setOpen, query, triggerRef, closeToTrigger } = core;
327
+ // The core anchors the panel to the trigger and hands focus back to it; the caller's
328
+ // `ref` wants the same element. One callback feeds both.
329
+ const setTrigger = useCallback(
330
+ (el: HTMLButtonElement | null) => {
331
+ triggerRef.current = el;
332
+ assignRef(ref, el);
333
+ },
334
+ [ref, triggerRef],
335
+ );
271
336
 
272
337
  const q = fold(query.trim());
273
338
  const results = useMemo(() => {
@@ -306,19 +371,40 @@ export function CountrySelect({
306
371
 
307
372
  const code = normalizeCountryCode(value);
308
373
  const selectedName = code ? countryName(code, locale) : undefined;
309
- const emptyText = placeholder ?? (label === undefined ? text.country : "");
374
+ // A field named by a label of the caller's (`aria-labelledby`) is a labelled field too:
375
+ // the label already says what goes here.
376
+ const emptyText = placeholder ?? (label === undefined && !ariaLabelledBy ? text.country : "");
310
377
  const fieldName = typeof label === "string" ? label : text.country;
378
+ const valueId = `${generated}-value`;
379
+ // Named by the caller's element (a form's label) — then the shown text is the second
380
+ // reference, as the composed name below has the value second; but only when it IS a
381
+ // value or the caller's placeholder, never the `country` fallback word.
382
+ const labelledBy =
383
+ ariaLabel === undefined && ariaLabelledBy
384
+ ? mergeDescribedBy(ariaLabelledBy, (selectedName !== undefined || Boolean(placeholder)) && valueId)
385
+ : undefined;
311
386
  const name =
312
- ariaLabel ??
313
- (selectedName !== undefined
314
- ? common.fieldValue(fieldName, selectedName)
315
- : placeholder
316
- ? common.fieldValue(fieldName, placeholder)
317
- : fieldName);
387
+ labelledBy !== undefined
388
+ ? undefined
389
+ : (ariaLabel ??
390
+ (selectedName !== undefined
391
+ ? common.fieldValue(fieldName, selectedName)
392
+ : placeholder
393
+ ? common.fieldValue(fieldName, placeholder)
394
+ : fieldName));
395
+
396
+ const showClear = Boolean(clearable && code && !inert);
397
+ // `onChange` widened to take the clear's `null` — its type says so once `clearable`
398
+ // is set, and the clear is only ever offered then.
399
+ const emit = onChange as (code: string | null) => void;
400
+ const clear = () => {
401
+ if (!showClear) return;
402
+ emit(null);
403
+ };
318
404
 
319
405
  const choose = (o: ComboOption<string>) => {
320
406
  if (inert) return;
321
- onChange(o.value);
407
+ emit(o.value);
322
408
  // Back to the trigger: the panel holding focus is about to unmount, and a keyboard
323
409
  // user who has answered this field should stand on it, ready to Tab on.
324
410
  closeToTrigger();
@@ -326,7 +412,7 @@ export function CountrySelect({
326
412
 
327
413
  const trigger = (
328
414
  <button
329
- ref={triggerRef}
415
+ ref={setTrigger}
330
416
  id={triggerId}
331
417
  type="button"
332
418
  // A closed choice that expands into the list below — the role every trigger
@@ -337,6 +423,7 @@ export function CountrySelect({
337
423
  aria-controls={listboxId}
338
424
  aria-expanded={open}
339
425
  aria-label={name}
426
+ aria-labelledby={labelledBy}
340
427
  aria-invalid={field.isInvalid || undefined}
341
428
  aria-required={ariaRequired}
342
429
  aria-describedby={mergeDescribedBy(field.describedBy, locked && reasonId)}
@@ -358,6 +445,10 @@ export function CountrySelect({
358
445
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
359
446
  e.preventDefault();
360
447
  setOpen(true);
448
+ } else if (showClear && !open && (e.key === "Delete" || e.key === "Backspace")) {
449
+ // The keyboard's clear: the "×" is out of the tab order (see `clearable`).
450
+ e.preventDefault();
451
+ clear();
361
452
  }
362
453
  }}
363
454
  className={cn(
@@ -373,11 +464,33 @@ export function CountrySelect({
373
464
  {flags && code && <CurrencyFlag country={code.toLowerCase()} />}
374
465
  {/* A long name ("Saint Vincent and the Grenadines") truncates rather than
375
466
  widening the field: the kit is checked at 390px. */}
376
- <span className={cn("truncate", selectedName === undefined && "text-[var(--text-placeholder)]")}>
467
+ <span
468
+ id={valueId}
469
+ className={cn("truncate", selectedName === undefined && "text-[var(--text-placeholder)]")}
470
+ >
377
471
  {selectedName ?? emptyText}
378
472
  </span>
379
473
  </span>
380
- <FieldChevron />
474
+ {showClear ? (
475
+ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events -- tabIndex -1 inside
476
+ the trigger <button>, so it never holds focus and a key handler here could never
477
+ fire; the keys go to the trigger, where Delete and Backspace clear. A pointer
478
+ shortcut, as on EntityCombobox. */
479
+ <span
480
+ role="button"
481
+ tabIndex={-1}
482
+ aria-label={comboText.clear}
483
+ onClick={(e) => {
484
+ e.stopPropagation();
485
+ clear();
486
+ }}
487
+ className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
488
+ >
489
+ <X aria-hidden className="size-4" />
490
+ </span>
491
+ ) : (
492
+ <FieldChevron />
493
+ )}
381
494
  </button>
382
495
  );
383
496
 
@@ -324,6 +324,18 @@ export interface DangerConfirmLabels {
324
324
  /** 0.22: the checkbox `requireAcknowledge={true}` shows — "I have read what this does
325
325
  * and want to continue" (keksdose's `admin.users.confirm_ack`). */
326
326
  acknowledge: string;
327
+ /**
328
+ * 0.23: why the armed confirm is held while the typed phrase does not match yet — in
329
+ * the confirm's tooltip and its description (keksdose G4a). A FUNCTION of the phrase
330
+ * by default ("Type “DELETE” to confirm"), or a finished STRING, as `phrase`.
331
+ */
332
+ needsPhrase: string | ((phrase: string) => string);
333
+ /** 0.23: why the armed confirm is held while the `requireAcknowledge` box is unticked
334
+ * — "Tick the box to confirm". */
335
+ needsAcknowledge: string;
336
+ /** 0.23: why the armed confirm is held while the `requirePassword` field is empty —
337
+ * "Enter your password to confirm". */
338
+ needsPassword: string;
327
339
  }
328
340
 
329
341
  /** `satisfies` rather than a type annotation, so `DEFAULT_DANGER_CONFIRM_LABELS.phrase`
@@ -336,8 +348,33 @@ export const DEFAULT_DANGER_CONFIRM_LABELS = {
336
348
  password: "Password",
337
349
  phrase: (phrase: string) => `Type “${phrase}” to confirm`,
338
350
  acknowledge: "I have read what this does and want to continue.",
351
+ needsPhrase: (phrase: string) => `Type “${phrase}” to confirm`,
352
+ needsAcknowledge: "Tick the box to confirm",
353
+ needsPassword: "Enter your password to confirm",
339
354
  } satisfies DangerConfirmLabels;
340
355
 
356
+ /**
357
+ * What the guards were answered with — the second argument of
358
+ * {@link DangerConfirmProps.onConfirm} (0.23, keksdose G4b). A key is there only when its
359
+ * guard was asked for, so the object can go into a request body as it is.
360
+ */
361
+ export interface DangerConfirmValues {
362
+ /**
363
+ * The text typed into the `phrase` field — as the match rule compared it: trimmed
364
+ * under `phraseMatch` `"trim"` and `"caseless"` (the spaces a phone keyboard adds are
365
+ * not part of it), exactly as typed under `"exact"`. The case is the user's, even
366
+ * under `"caseless"`: a server that re-checks applies its own rule to it. Absent
367
+ * without a `phrase`.
368
+ */
369
+ typed?: string;
370
+ /** The entered password — the same value as the first argument. Absent without
371
+ * `requirePassword`. */
372
+ password?: string;
373
+ /** `true` when the `requireAcknowledge` box was shown — it was ticked, since the
374
+ * confirm is held until it is. Absent without one. */
375
+ acknowledged?: true;
376
+ }
377
+
341
378
  /**
342
379
  * One line of {@link DangerConfirmProps.consequences}: what the action will do. A plain
343
380
  * string, or this, for a key of its own and the `severe` mark.
@@ -360,12 +397,25 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
360
397
  * (fields wiped) when it resolves, still armed when it rejects — so the user can
361
398
  * correct a wrong password and retry. The rejection is not swallowed for you to
362
399
  * miss: handle it (and show why) in the caller, as with any mutation.
400
+ *
401
+ * 0.23: the second argument is what every guard was answered with — the typed phrase,
402
+ * the password, the tick ({@link DangerConfirmValues}). keksdose G4b: its admin
403
+ * actions send the address the admin TYPED, and the server re-checks it against the
404
+ * account (`assert_confirmed`), so a client that put the wrong row's id in the request
405
+ * is refused rather than obeyed; the tile kept the text to itself.
406
+ *
407
+ * onConfirm={(_password, { typed }) =>
408
+ * reset.mutateAsync({ id, confirm: { acknowledged: true, confirm_email: typed ?? null } })}
409
+ *
410
+ * A second argument rather than one object in place of the password: every
411
+ * `(password) => …` and `() => …` handler, and a `mutation.mutate` passed as it is,
412
+ * keeps working unchanged, and a handler that needs one guard's answer names it.
363
413
  */
364
- onConfirm: (password?: string) => void | Promise<unknown>;
365
- /** Show a password field; confirm stays disabled until it is filled. */
414
+ onConfirm: (password: string | undefined, values: DangerConfirmValues) => void | Promise<unknown>;
415
+ /** Show a password field; confirm is held until it is filled. */
366
416
  requirePassword?: boolean;
367
417
  /**
368
- * Show an "I understand" checkbox; confirm stays disabled until it is ticked
418
+ * Show an "I understand" checkbox; confirm is held until it is ticked
369
419
  * (keksdose K7). `true` words it with `labels.acknowledge`; any other node IS the
370
420
  * checkbox's label ("I understand Anna will be signed out everywhere").
371
421
  *
@@ -386,8 +436,8 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
386
436
  * who reads the same paragraph on every row stops reading it (keksdose dev#488).
387
437
  */
388
438
  consequences?: readonly (string | DangerConsequence)[];
389
- /** Show a "type <phrase> to confirm" field; confirm stays disabled until the field
390
- * matches (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
439
+ /** Show a "type <phrase> to confirm" field; confirm is held until the field matches
440
+ * (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
391
441
  phrase?: string;
392
442
  /**
393
443
  * How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
@@ -446,8 +496,17 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
446
496
  /**
447
497
  * An "arm → confirm" tile for destructive actions: one button, which expands into a
448
498
  * warning, an optional list of consequences, an optional "I understand" tick, an
449
- * optional type-to-confirm field, an optional password field and a confirm that stays
450
- * disabled until every guard is satisfied.
499
+ * optional type-to-confirm field, an optional password field and a confirm that is held
500
+ * until every guard is satisfied.
501
+ *
502
+ * A held confirm SAYS which guard is still open (0.23, keksdose G4a): it is
503
+ * `aria-disabled` rather than `disabled` — still focusable, so a keyboard user can land
504
+ * on it — with the reason in the kit {@link Tooltip} and its description ("Type
505
+ * “DELETE” to confirm", "Tick the box to confirm", "Enter your password to confirm";
506
+ * `labels.needs*`), the first open guard in the order they are drawn. FormActions'
507
+ * `submitDisabledReason` does the same for a form's Save. Pressing it, or Enter in a
508
+ * field, does nothing. A lock's reason wins over a guard's; while the action runs the
509
+ * confirm is plainly disabled, its spinner saying why.
451
510
  *
452
511
  * Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
453
512
  * budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
@@ -549,14 +608,33 @@ export function DangerConfirm({
549
608
  const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
550
609
  const acknowledgeOk = !asksAcknowledge || acknowledged;
551
610
  const canConfirm = passwordOk && phraseOk && acknowledgeOk && !busy;
611
+ // The first guard still open, in the order the fields are drawn — the one the user
612
+ // meets next, so the sentence points at it.
613
+ const guardReason: string | undefined = !acknowledgeOk
614
+ ? labels.needsAcknowledge
615
+ : !phraseOk
616
+ ? typeof labels.needsPhrase === "function"
617
+ ? labels.needsPhrase(phrase ?? "")
618
+ : labels.needsPhrase
619
+ : !passwordOk
620
+ ? labels.needsPassword
621
+ : undefined;
622
+ // The lock first (no guard can lift it); none while busy, when the guards were met and
623
+ // the spinner is the state.
624
+ const confirmReason: ReactNode = locked ? lockedReason : busy ? undefined : guardReason;
552
625
 
553
626
  const submit = (e: FormEvent) => {
554
627
  e.preventDefault();
555
628
  // `locked` too: Enter in a field submits the form without the button's say.
556
629
  if (!canConfirm || locked) return;
630
+ const values: DangerConfirmValues = {
631
+ ...(phrase !== undefined && { typed: phraseMatch === "exact" ? typed : typed.trim() }),
632
+ ...(requirePassword && { password }),
633
+ ...(asksAcknowledge && { acknowledged: true as const }),
634
+ };
557
635
  // Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
558
636
  // shows why, the user retries.
559
- run(onConfirm(requirePassword ? password : undefined), () => setArmed(false));
637
+ run(onConfirm(values.password, values), () => setArmed(false));
560
638
  };
561
639
 
562
640
  // The first field takes focus on arm: the tick, then the phrase, then the password —
@@ -676,10 +754,11 @@ export function DangerConfirm({
676
754
  type="submit"
677
755
  variant={tone === "warning" ? "primary" : "danger"}
678
756
  disabled={!canConfirm}
679
- // Armed, then locked (or rendered armed under a lock): the confirm says why
680
- // the way Button does — focusable, `aria-disabled`, the reason in its
681
- // tooltip and description — and the fields stay as typed.
682
- disabledReason={locked ? lockedReason : undefined}
757
+ // Held by a guard, or armed and then locked (or rendered armed under a lock):
758
+ // the confirm says why the way Button does — focusable, `aria-disabled`, the
759
+ // reason in its tooltip and description, a press swallowed (the submit with
760
+ // it) — and the fields stay as typed. Only busy is the native `disabled`.
761
+ disabledReason={confirmReason}
683
762
  aria-busy={busy || undefined}
684
763
  >
685
764
  {busy && <Spinner label={null} className="h-4 w-4" />}