@eifi1/ui-kit 0.12.0 → 0.13.1

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 (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +23 -1
  3. package/dist/components/amount-input.js +78 -13
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +225 -21
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -2
  46. package/dist/components/numpad-sheet.js +6 -4
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +4 -2
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +59 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +165 -12
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +470 -30
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +17 -3
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +47 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +32 -7
  190. package/src/rhf/fields.tsx +99 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -34,7 +34,9 @@
34
34
  * omitted, none — then give the field an `aria-label`), `hint` (standing advice under
35
35
  * the field, attached to it with `aria-describedby`), `required` (the label's mark,
36
36
  * and `aria-required` where the control takes it — validation is `rules`' or the
37
- * resolver's), `rules`, `disabled`, and `className` for the item's box.
37
+ * resolver's), `rules`, `disabled` (the control only — the value is still submitted;
38
+ * `excludeWhenDisabled` for react-hook-form's drop-the-value semantics), and
39
+ * `className` for the item's box.
38
40
  *
39
41
  * {@link RhfField} is the shell they are all built on, for a control the kit does not
40
42
  * ship (an address autocomplete): it takes a `render` and wires the rest.
@@ -80,9 +82,17 @@ export interface RhfFieldBaseProps<
80
82
  /** Draws the label's required mark and sets `aria-required` where the control
81
83
  * takes it. Whether the field IS required is `rules`' or the resolver's to say. */
82
84
  required?: boolean;
83
- /** Disables the field — through react-hook-form, so its value is left out of the
84
- * submitted values as a disabled input's would be. */
85
+ /** Disables the control and KEEPS the value: a field locked to a preset (a lease's
86
+ * preselected unit, a fixed period) still submits it, and `rules` still run.
87
+ * react-hook-form's own Controller `disabled` drops the value from `handleSubmit`'s
88
+ * data instead, which a locked preset must never do (kastlan, 0.12). To get that,
89
+ * add `excludeWhenDisabled`. A form-wide `useForm({ disabled })` still disables
90
+ * every field the RHF way. */
85
91
  disabled?: boolean;
92
+ /** With `disabled`: disable through react-hook-form, so the value is left out of
93
+ * the submitted data and validation skips it, like a disabled native input in a
94
+ * plain form post. Default `false`. */
95
+ excludeWhenDisabled?: boolean;
86
96
  /** Classes for the item's box (a `grid gap-1`). */
87
97
  className?: string;
88
98
  }
@@ -187,6 +197,7 @@ export function RhfField<
187
197
  hint,
188
198
  required,
189
199
  disabled,
200
+ excludeWhenDisabled,
190
201
  className,
191
202
  render,
192
203
  asControl = true,
@@ -198,11 +209,16 @@ export function RhfField<
198
209
  control={control}
199
210
  name={name}
200
211
  rules={rules}
201
- disabled={disabled}
212
+ disabled={excludeWhenDisabled ? disabled : undefined}
202
213
  render={({ field, fieldState }) => (
203
214
  <FormItem className={className}>
204
215
  {showLabel && hasContent(label) && <RhfLabel required={required}>{label}</RhfLabel>}
205
- <ControlSlot field={field} fieldState={fieldState} render={render} asControl={asControl} />
216
+ <ControlSlot
217
+ field={disabled && !field.disabled ? { ...field, disabled: true } : field}
218
+ fieldState={fieldState}
219
+ render={render}
220
+ asControl={asControl}
221
+ />
206
222
  {hasContent(hint) && <FormDescription>{hint}</FormDescription>}
207
223
  {message && <FormMessage />}
208
224
  </FormItem>
@@ -230,8 +246,17 @@ function useFocusHandle(ref: ControllerRenderProps["ref"], find: () => HTMLEleme
230
246
 
231
247
  type OwnInputProps = Omit<
232
248
  InputProps,
233
- "name" | "value" | "defaultValue" | "onChange" | "onBlur" | "ref" | "label" | "error" | "invalid" | "className" | "disabled" | "required"
234
- >;
249
+ "name" | "value" | "defaultValue" | "onChange" | "onBlur" | "ref" | "label" | "error" | "invalid" | "className" | "disabled" | "required" | "inputClassName"
250
+ > & {
251
+ /**
252
+ * Classes for the `<input>` — `className` is the item's box (kastlan 42: a
253
+ * `font-mono` IBAN, a `tabular-nums` reference number). {@link RhfTextarea} and
254
+ * {@link RhfNumberField} had it; this one only reached `Input`'s prop of the same
255
+ * name by accident of the rest-spread, undocumented and one refactor from being
256
+ * dropped. Declared, it is part of the contract the three share.
257
+ */
258
+ inputClassName?: string;
259
+ };
235
260
 
236
261
  export type RhfTextFieldProps<
237
262
  TFieldValues extends FieldValues = FieldValues,
@@ -252,12 +277,13 @@ export function RhfTextField<
252
277
  hint,
253
278
  required,
254
279
  disabled,
280
+ excludeWhenDisabled,
255
281
  className,
256
282
  ...inputProps
257
283
  }: RhfTextFieldProps<TFieldValues, TName, TTransformed>) {
258
284
  return (
259
285
  <RhfField
260
- {...{ name, control, rules, label, hint, required, disabled, className }}
286
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
261
287
  render={({ field, invalid }) => (
262
288
  <Input
263
289
  aria-required={required || undefined}
@@ -302,13 +328,14 @@ export function RhfTextarea<
302
328
  hint,
303
329
  required,
304
330
  disabled,
331
+ excludeWhenDisabled,
305
332
  className,
306
333
  inputClassName,
307
334
  ...areaProps
308
335
  }: RhfTextareaProps<TFieldValues, TName, TTransformed>) {
309
336
  return (
310
337
  <RhfField
311
- {...{ name, control, rules, label, hint, required, disabled, className }}
338
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
312
339
  render={({ field, invalid }) => (
313
340
  <Textarea
314
341
  aria-required={required || undefined}
@@ -382,6 +409,7 @@ export function RhfNumberField<
382
409
  hint,
383
410
  required,
384
411
  disabled,
412
+ excludeWhenDisabled,
385
413
  className,
386
414
  emptyValue = null,
387
415
  nullable = true,
@@ -389,7 +417,7 @@ export function RhfNumberField<
389
417
  }: RhfNumberFieldProps<TFieldValues, TName, TTransformed>) {
390
418
  return (
391
419
  <RhfField
392
- {...{ name, control, rules, label, hint, required, disabled, className }}
420
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
393
421
  render={({ field, invalid, id }) => (
394
422
  // FormControl clones this element with `id` / `aria-describedby` /
395
423
  // `aria-invalid`, which NumberControl hands on to the field.
@@ -449,6 +477,46 @@ function NumberControl({
449
477
  );
450
478
  }
451
479
 
480
+ export type RhfIntegerFieldProps<
481
+ TFieldValues extends FieldValues = FieldValues,
482
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
483
+ TTransformed = TFieldValues,
484
+ > = RhfNumberFieldProps<TFieldValues, TName, TTransformed>;
485
+
486
+ /**
487
+ * {@link RhfNumberField} for a whole number — a floor, a room count, a year, a
488
+ * notice period in months. Every such call site in kastlan (item 41) spelled out the
489
+ * same three props, `digits={0} calculator={false} emptyValue=""`, and a preset
490
+ * written thirty times is one that is eventually written wrong once:
491
+ *
492
+ * - `digits={0}`: rounds on commit, so "2.5" rooms settles to 3 instead of reaching
493
+ * an `Integer` column and failing server-side;
494
+ * - `calculator={false}`: a count is typed, not worked out — the trigger is noise
495
+ * beside a two-digit field;
496
+ * - `emptyValue=""`: kastlan's integer schemas are `z.coerce.number()` over `""`,
497
+ * written against the native number inputs these fields replaced.
498
+ *
499
+ * Each is only a default: pass the prop to override it (`emptyValue={null}` for a
500
+ * nullable column). Everything else is {@link RhfNumberField}'s.
501
+ */
502
+ export function RhfIntegerField<
503
+ TFieldValues extends FieldValues = FieldValues,
504
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
505
+ TTransformed = TFieldValues,
506
+ >(props: RhfIntegerFieldProps<TFieldValues, TName, TTransformed>) {
507
+ // `??` / `=== undefined` rather than a spread over the defaults, so a caller
508
+ // forwarding its own optional prop (`digits={props.digits}`) still gets the preset,
509
+ // while an explicit `emptyValue={null}` keeps its null.
510
+ return (
511
+ <RhfNumberField
512
+ {...props}
513
+ digits={props.digits ?? 0}
514
+ calculator={props.calculator ?? false}
515
+ emptyValue={props.emptyValue === undefined ? "" : props.emptyValue}
516
+ />
517
+ );
518
+ }
519
+
452
520
  // ── money ────────────────────────────────────────────────────────────────────
453
521
 
454
522
  export type RhfMoneyFieldProps<
@@ -476,6 +544,15 @@ export type RhfMoneyFieldProps<
476
544
  valueAs?: "number" | "string";
477
545
  /** What an emptied field stores when `valueAs` is `"number"`. Default `null`. */
478
546
  emptyValue?: null | "";
547
+ /** Decimals the amount settles to on blur, Enter or a calculator result. Default:
548
+ * the currency's minor unit (CHF 2, JPY 0). ⚠️ A unit price or a rate needs its
549
+ * column's scale here (`digits={4}` for 1.789 €/l), or it is stored rounded to
550
+ * cents without a word (keksdose G2). See {@link AmountInput}'s `digits`. */
551
+ digits?: number;
552
+ /** Clamp the settled amount (a release capped at the deposit's balance). Validate
553
+ * with `rules` as well if a clamp needs explaining. */
554
+ min?: number;
555
+ max?: number;
479
556
  };
480
557
 
481
558
  /** The amount a text reads as, or `undefined` for a draft that is not one yet. */
@@ -573,12 +650,13 @@ export function RhfMoneyField<
573
650
  hint,
574
651
  required,
575
652
  disabled,
653
+ excludeWhenDisabled,
576
654
  className,
577
655
  ...amountProps
578
656
  }: RhfMoneyFieldProps<TFieldValues, TName, TTransformed>) {
579
657
  return (
580
658
  <RhfField
581
- {...{ name, control, rules, label, hint, required, disabled, className }}
659
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
582
660
  asControl={false}
583
661
  render={({ field, invalid, id, describedBy }) => (
584
662
  <MoneyControl
@@ -662,6 +740,7 @@ export function RhfDateField<
662
740
  hint,
663
741
  required,
664
742
  disabled,
743
+ excludeWhenDisabled,
665
744
  className,
666
745
  inputClassName,
667
746
  emptyValue = "",
@@ -669,7 +748,7 @@ export function RhfDateField<
669
748
  }: RhfDateFieldProps<TFieldValues, TName, TTransformed>) {
670
749
  return (
671
750
  <RhfField
672
- {...{ name, control, rules, label, hint, required, disabled, className }}
751
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
673
752
  render={({ field, invalid, id }) => (
674
753
  // FormControl clones this element with `aria-describedby` / `aria-invalid`,
675
754
  // which DateControl hands on to the picker's trigger.
@@ -726,6 +805,7 @@ export function RhfSelect<
726
805
  hint,
727
806
  required,
728
807
  disabled,
808
+ excludeWhenDisabled,
729
809
  className,
730
810
  options,
731
811
  children,
@@ -736,7 +816,7 @@ export function RhfSelect<
736
816
  }: RhfSelectProps<TFieldValues, TName, TTransformed>) {
737
817
  return (
738
818
  <RhfField
739
- {...{ name, control, rules, label, hint, required, disabled, className }}
819
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
740
820
  render={({ field, invalid }) => (
741
821
  <Select
742
822
  aria-required={required || undefined}
@@ -793,12 +873,13 @@ export function RhfCheckbox<
793
873
  hint,
794
874
  required,
795
875
  disabled,
876
+ excludeWhenDisabled,
796
877
  className,
797
878
  ...boxProps
798
879
  }: RhfCheckboxProps<TFieldValues, TName, TTransformed>) {
799
880
  return (
800
881
  <RhfField
801
- {...{ name, control, rules, required, disabled, className }}
882
+ {...{ name, control, rules, required, disabled, excludeWhenDisabled, className }}
802
883
  showLabel={false}
803
884
  render={({ field, invalid }) => (
804
885
  <Checkbox
@@ -923,6 +1004,7 @@ export function RhfCombobox<
923
1004
  hint,
924
1005
  required,
925
1006
  disabled,
1007
+ excludeWhenDisabled,
926
1008
  className,
927
1009
  clearValue = null,
928
1010
  comboClassName,
@@ -930,7 +1012,7 @@ export function RhfCombobox<
930
1012
  }: RhfComboboxProps<TFieldValues, TName, V, TTransformed>) {
931
1013
  return (
932
1014
  <RhfField
933
- {...{ name, control, rules, label, hint, required, disabled, className }}
1015
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
934
1016
  asControl={false}
935
1017
  message={false}
936
1018
  render={({ field, invalid, error, id, describedBy }) => (
@@ -987,13 +1069,14 @@ export function RhfTextCombobox<
987
1069
  hint,
988
1070
  required,
989
1071
  disabled,
1072
+ excludeWhenDisabled,
990
1073
  className,
991
1074
  comboClassName,
992
1075
  ...comboProps
993
1076
  }: RhfTextComboboxProps<TFieldValues, TName, TTransformed>) {
994
1077
  return (
995
1078
  <RhfField
996
- {...{ name, control, rules, label, hint, required, disabled, className }}
1079
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
997
1080
  asControl={false}
998
1081
  message={false}
999
1082
  render={({ field, invalid, error, id, describedBy }) => (
package/src/rhf.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  export * from "./rhf/form";
10
10
  // The react-hook-form bridge to the wizard's Next gate (useWizard lives in /wizard).
11
11
  export * from "./rhf/use-rhf-wizard-step";
12
- // Bound fields — one line per field (RhfTextField, RhfNumberField, RhfMoneyField,
12
+ // Bound fields — one line per field (RhfTextField, RhfNumberField, RhfIntegerField, RhfMoneyField,
13
13
  // RhfDateField, RhfTextarea, RhfSelect, RhfCheckbox, RhfCombobox, RhfTextCombobox)
14
14
  // and RhfField, the shell they are built on.
15
15
  export * from "./rhf/fields";
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useMemo, useRef, useState } from "react";
1
+ import { useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { CornerDownLeft, Search, X } from "lucide-react";
@@ -54,9 +54,12 @@ function groupItems(items: CommandItem[]) {
54
54
  }
55
55
  byGroup.get(item.group)!.push(item);
56
56
  }
57
+ const flat = order.flatMap((g) => byGroup.get(g)!).filter((item) => item.kind !== "status");
57
58
  return {
58
59
  groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),
59
- flat: order.flatMap((g) => byGroup.get(g)!).filter((item) => item.kind !== "status"),
60
+ flat,
61
+ /** Each selectable item's position in `flat` — its `data-index` and keyboard slot. */
62
+ flatIndex: new Map(flat.map((item, index) => [item, index])),
60
63
  };
61
64
  }
62
65
 
@@ -279,8 +282,9 @@ export function CommandPalette({
279
282
  const dialogRef = useRef<HTMLDivElement>(null);
280
283
  const inputRef = useRef<HTMLInputElement>(null);
281
284
  const listRef = useRef<HTMLUListElement>(null);
282
- const searchRef = useRef(search);
283
- searchRef.current = search;
285
+ // The latest `search`, callable from the debounced effect without being one of its
286
+ // dependencies — `revision` is what re-runs it (see the prop's docstring).
287
+ const runSearch = useEffectEvent((q: string) => search(q));
284
288
  const reqId = useRef(0);
285
289
  /** What the provider's last answer was: a promise (`false`), an array (`true`), or
286
290
  * not known yet. Only a provider known to be async shows "Searching…" while the
@@ -305,17 +309,24 @@ export function CommandPalette({
305
309
  useBodyScrollLock(open);
306
310
 
307
311
  // Reset when opened — the palette's OWN text only; a controlled query belongs to
308
- // its owner (see the prop).
312
+ // its owner (see the prop). Adjusted during render on the closed-to-open transition
313
+ // (starting from "closed", so a palette mounted open resets too). `controlled` is
314
+ // read at the open, not watched: a caller switching modes while the palette is open
315
+ // is not a reason to wipe what is typed.
316
+ const [wasOpen, setWasOpen] = useState(false);
317
+ if (open !== wasOpen) {
318
+ setWasOpen(open);
319
+ if (open) {
320
+ if (!controlled) setOwnQuery("");
321
+ // An uncommitted draft does not outlive the open it was typed in.
322
+ setDraft(controlled ? query : "");
323
+ setActive(0);
324
+ }
325
+ }
326
+ // The ref half of that reset: results from a previous open do not carry a
327
+ // highlight into this one.
309
328
  useEffect(() => {
310
- if (!open) return;
311
- if (!controlled) setOwnQuery("");
312
- // An uncommitted draft does not outlive the open it was typed in.
313
- setDraft(controlled ? query : "");
314
- setActive(0);
315
- shownQuery.current = null;
316
- // `controlled` is read at the open, not watched: a caller switching modes while
317
- // the palette is open is not a reason to wipe what is typed.
318
- // eslint-disable-next-line react-hooks/exhaustive-deps
329
+ if (open) shownQuery.current = null;
319
330
  }, [open]);
320
331
 
321
332
  // Debounced, race-safe search.
@@ -325,7 +336,7 @@ export function CommandPalette({
325
336
  if (syncRef.current === false) setLoading(true);
326
337
  const run = async () => {
327
338
  try {
328
- const answer = searchRef.current(query);
339
+ const answer = runSearch(query);
329
340
  syncRef.current = !isThenable(answer);
330
341
  if (!syncRef.current) setLoading(true);
331
342
  const r = await answer;
@@ -359,7 +370,7 @@ export function CommandPalette({
359
370
  }, [query, open, revision]);
360
371
 
361
372
  // Group results, preserving first-seen group order; keep a flat list for nav.
362
- const { groups, flat } = useMemo(() => groupItems(results), [results]);
373
+ const { groups, flat, flatIndex } = useMemo(() => groupItems(results), [results]);
363
374
  useEffect(() => {
364
375
  activeIdRef.current = flat[active]?.id;
365
376
  });
@@ -404,8 +415,8 @@ export function CommandPalette({
404
415
  if (!open) return null;
405
416
 
406
417
  const comfortable = density === "comfortable";
407
- let flatIndex = -1;
408
418
  return createPortal(
419
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only backdrop dismiss; Escape is the keyboard path
409
420
  <div
410
421
  className={cn(
411
422
  "fixed inset-0 z-[70] flex items-start justify-center bg-black/40",
@@ -546,8 +557,7 @@ export function CommandPalette({
546
557
  </li>
547
558
  );
548
559
  }
549
- flatIndex += 1;
550
- const idx = flatIndex;
560
+ const idx = flatIndex.get(item) ?? -1;
551
561
  const isActive = idx === active;
552
562
  const redact = item.redact ?? redactLabels;
553
563
  // Written once and worn by either tag below, so the anchor and the
@@ -765,7 +765,6 @@ function SidebarGroup({
765
765
  // group UNFOLDS into view on load rather than appearing already open.
766
766
  const here = subActive || onOwnPage;
767
767
  useEffect(() => {
768
- // eslint-disable-next-line react-hooks/set-state-in-effect -- follows the route, see above
769
768
  if (here) setOpenGroup(item.to);
770
769
  }, [here, item.to, setOpenGroup]);
771
770
  const expanded = inline && open;
@@ -931,6 +930,9 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
931
930
  );
932
931
 
933
932
  return (
933
+ // Not a control: hover and focus-within on the nav link inside. Focus (the
934
+ // keyboard path) opens it exactly as hover does, Escape closes it.
935
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- hover/focus-within wrapper around a real link
934
936
  <div
935
937
  ref={wrapperRef}
936
938
  className="relative"
@@ -943,6 +945,9 @@ function SidebarFlyout({ item, children }: { item: AppShellNavItem; children: Re
943
945
  {open &&
944
946
  pos &&
945
947
  createPortal(
948
+ // Keeps the flyout open while the pointer crosses into it; keyboard users
949
+ // reach it by focus, which the wrapper above already handles.
950
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only hover bridge
946
951
  <div
947
952
  dir={dir}
948
953
  style={{ position: "fixed", ...pos }}
@@ -58,9 +58,15 @@ export interface TopBarMenuHeader {
58
58
  title: ReactNode;
59
59
  /** The muted second line — an email, a role. */
60
60
  subtitle?: ReactNode;
61
- /** Anything below — a role chip. A link or button here stays out of the arrow keys
62
- * and in the Tab order, like the footer's. */
63
- extra?: ReactNode;
61
+ /**
62
+ * Anything below — a role chip. A link or button here stays out of the arrow keys
63
+ * and in the Tab order, like the footer's.
64
+ *
65
+ * Pass a function to get `close`, as `footer` does — keksdose F7: its admin role
66
+ * chip links to the admin area, and a router link does not unmount the menu, so
67
+ * without `close` the panel stayed open over the page it had just navigated to.
68
+ */
69
+ extra?: ReactNode | ((close: () => void) => ReactNode);
64
70
  }
65
71
 
66
72
  /** What a control is, as far as `HoverMenu` is concerned, when it lives in the header
@@ -225,6 +231,7 @@ export function TopBarActionMenu({
225
231
  >
226
232
  {(close) => {
227
233
  const footerBody = typeof footer === "function" ? footer(close) : footer;
234
+ const headerExtra = typeof header?.extra === "function" ? header.extra(close) : header?.extra;
228
235
  const footerClass =
229
236
  "flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--text-muted)] [&_a:hover]:text-[var(--text-primary)] [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-[var(--brand)]";
230
237
  return (
@@ -236,7 +243,7 @@ export function TopBarActionMenu({
236
243
  {header.subtitle != null && (
237
244
  <div className="truncate text-xs text-[var(--text-muted)]">{header.subtitle}</div>
238
245
  )}
239
- {header.extra != null && <div className="mt-1">{header.extra}</div>}
246
+ {headerExtra != null && headerExtra !== false && <div className="mt-1">{headerExtra}</div>}
240
247
  </MenuSection>
241
248
  )}
242
249
  {heading && (
package/src/tour/tour.tsx CHANGED
@@ -157,11 +157,15 @@ export function TourProvider({
157
157
  const [activeId, setActiveId] = useState<string | null>(null);
158
158
  const optsRef = useRef<StartOptions>({});
159
159
  // Live mirrors so the step-control callbacks can stay stable (identity-safe for
160
- // consumers) while still reading the current steps/index.
160
+ // consumers) while still reading the current steps/index. Synced in a layout
161
+ // effect — after the commit, before paint and before any passive effect or event
162
+ // can call them — rather than written during render.
161
163
  const stepsRef = useRef<TourStep[] | null>(null);
162
- stepsRef.current = steps;
163
164
  const indexRef = useRef(0);
164
- indexRef.current = index;
165
+ useLayoutEffect(() => {
166
+ stepsRef.current = steps;
167
+ indexRef.current = index;
168
+ }, [steps, index]);
165
169
  const active = !!steps && steps.length > 0;
166
170
  const total = steps?.length ?? 0;
167
171
 
@@ -277,6 +281,15 @@ function TourOverlay({
277
281
  const isLast = index === steps.length - 1;
278
282
  const [rect, setRect] = useState<Rect | null>(null);
279
283
  const [ready, setReady] = useState(false);
284
+ // A new step starts unlocated: reset during render when the step changes, so the
285
+ // previous step's spotlight never paints over the new one. The effect below then
286
+ // locates it.
287
+ const [locating, setLocating] = useState({ index, step });
288
+ if (locating.index !== index || locating.step !== step) {
289
+ setLocating({ index, step });
290
+ setReady(false);
291
+ setRect(null);
292
+ }
280
293
  // The overlay is portalled to <body>, out of whatever `dir` subtree the app set, so it
281
294
  // takes the direction of the element it spotlights (the document's for a centered
282
295
  // step). Arrow keys and start/end placement read it.
@@ -287,8 +300,6 @@ function TourOverlay({
287
300
  useEffect(() => {
288
301
  let cancelled = false;
289
302
  let raf = 0;
290
- setReady(false);
291
- setRect(null);
292
303
  void (async () => {
293
304
  await step.beforeStep?.();
294
305
  if (cancelled) return;
@@ -14,7 +14,7 @@
14
14
  * The committing step is the last one unless a step says `commits: true`; the
15
15
  * steps after that one are post-commit steps (see `WizardStepConfig.commits`).
16
16
  */
17
- import { useCallback, useEffect, useReducer, useRef, useState } from "react";
17
+ import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
18
18
  import { useSearchParams } from "react-router";
19
19
  import { useKitLabels } from "../i18n/kit-labels";
20
20
  import { DEFAULT_WIZARD_LABELS } from "./types";
@@ -163,8 +163,12 @@ export function useWizard<TData extends Record<string, unknown>>(
163
163
  missingRequiredMessage,
164
164
  onValidationFailed,
165
165
  } = options;
166
+ // The latest `steps`, for the stable callbacks below. Synced after commit (before
167
+ // paint, and before any handler can run) rather than written during render.
166
168
  const stepsRef = useRef<WizardStepConfig[]>(steps);
167
- stepsRef.current = steps;
169
+ useLayoutEffect(() => {
170
+ stepsRef.current = steps;
171
+ }, [steps]);
168
172
  // A hook, not a component, but it produces two sentences of its own — the
169
173
  // "fill in the required fields" toast and the fallback submit error — and both
170
174
  // reach the user. So it reads the provider like any component does; only the two
@@ -239,8 +243,8 @@ export function useWizard<TData extends Record<string, unknown>>(
239
243
  // it back.
240
244
  const clearStepUrl = useStepUrl(urlParam, state.currentStepIndex);
241
245
 
242
- // `steps`, not `stepsRef.current`: during render the two are the same array, and
243
- // reading a ref in render is what the hooks linter (rightly) objects to.
246
+ // `steps`, not `stepsRef.current`: reading a ref in render is what the hooks
247
+ // linter (rightly) objects to, and the ref only catches up after the commit.
244
248
  const currentStep = steps[state.currentStepIndex];
245
249
  const isFirstStep = state.currentStepIndex === 0;
246
250
  const isLastStep = state.currentStepIndex === steps.length - 1;
@@ -14,6 +14,16 @@ export interface WizardStepProps {
14
14
  /** The line under the title: what the step asks for, or why. Rendered only with a
15
15
  * `title` or on its own when there is none. */
16
16
  description?: ReactNode;
17
+ /**
18
+ * Controls for the step as a whole, at the end of the header row beside the title —
19
+ * kastlan 43: the buildings step's "Add building" had nowhere to go but UNDER the
20
+ * list it adds to, where it falls below the fold once there are three buildings.
21
+ * A header action is where every other kit surface (`PageHeader`, `Card`) puts it.
22
+ *
23
+ * Wraps under the title on a narrow screen rather than squeezing it. Renders the
24
+ * header on its own if there is no `title` either.
25
+ */
26
+ actions?: ReactNode;
17
27
  /** The heading's level. Default 3: the page has its `h1` and the wizard's own
18
28
  * chrome sits at `h2` (the review step, the cancel dialog), so a step is under it. */
19
29
  headingLevel?: 2 | 3 | 4;
@@ -30,20 +40,42 @@ export function WizardStep({
30
40
  title,
31
41
  description,
32
42
  headingLevel = 3,
43
+ actions,
33
44
  }: WizardStepProps) {
34
45
  const hasTitle = title !== undefined && title !== null && title !== false && title !== "";
35
46
  const hasDescription =
36
47
  description !== undefined && description !== null && description !== false && description !== "";
48
+ const hasActions = actions !== undefined && actions !== null && actions !== false && actions !== "";
37
49
  const Heading = HEADING[headingLevel];
50
+ const heading = (
51
+ <>
52
+ {hasTitle && <Heading className="text-lg font-semibold text-[var(--text-primary)]">{title}</Heading>}
53
+ {hasDescription && <p className="text-sm text-[var(--text-muted)]">{description}</p>}
54
+ </>
55
+ );
38
56
  return (
39
57
  <div className={cn("space-y-6", className)}>
40
- {(hasTitle || hasDescription) && (
41
- <div data-slot="wizard-step-header" className="space-y-1">
42
- {hasTitle && (
43
- <Heading className="text-lg font-semibold text-[var(--text-primary)]">{title}</Heading>
44
- )}
45
- {hasDescription && <p className="text-sm text-[var(--text-muted)]">{description}</p>}
58
+ {hasActions ? (
59
+ // One row: the text takes the room, the actions keep their width at the end
60
+ // and wrap below it when there is not enough — `basis-64` is the width the
61
+ // title asks for before that happens (a zero basis would never wrap and
62
+ // squeeze the title to a word a line instead). `items-start` so a two-line
63
+ // description does not pull the button down to its middle; `ms-auto` keeps
64
+ // the actions at the end with no title, and when they wrap.
65
+ <div data-slot="wizard-step-header" className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
66
+ {(hasTitle || hasDescription) && <div className="min-w-0 grow basis-64 space-y-1">{heading}</div>}
67
+ <div data-slot="wizard-step-actions" className="ms-auto flex shrink-0 flex-wrap items-center gap-2">
68
+ {actions}
69
+ </div>
46
70
  </div>
71
+ ) : (
72
+ // Without actions the header is exactly what it was, so a step that passes
73
+ // none renders as before.
74
+ (hasTitle || hasDescription) && (
75
+ <div data-slot="wizard-step-header" className="space-y-1">
76
+ {heading}
77
+ </div>
78
+ )
47
79
  )}
48
80
  {children}
49
81
  </div>