@eifi1/ui-kit 0.12.0 → 0.13.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 (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +17 -1
  3. package/dist/components/amount-input.js +46 -11
  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 +211 -20
  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 +6 -2
  46. package/dist/components/numpad-sheet.js +3 -2
  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 +3 -1
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +57 -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 +97 -10
  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 +453 -29
  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 +6 -1
  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 +46 -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 +29 -6
  190. package/src/rhf/fields.tsx +97 -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,13 @@ 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). See {@link AmountInput}'s `digits`. */
549
+ digits?: number;
550
+ /** Clamp the settled amount (a release capped at the deposit's balance). Validate
551
+ * with `rules` as well if a clamp needs explaining. */
552
+ min?: number;
553
+ max?: number;
479
554
  };
480
555
 
481
556
  /** The amount a text reads as, or `undefined` for a draft that is not one yet. */
@@ -573,12 +648,13 @@ export function RhfMoneyField<
573
648
  hint,
574
649
  required,
575
650
  disabled,
651
+ excludeWhenDisabled,
576
652
  className,
577
653
  ...amountProps
578
654
  }: RhfMoneyFieldProps<TFieldValues, TName, TTransformed>) {
579
655
  return (
580
656
  <RhfField
581
- {...{ name, control, rules, label, hint, required, disabled, className }}
657
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
582
658
  asControl={false}
583
659
  render={({ field, invalid, id, describedBy }) => (
584
660
  <MoneyControl
@@ -662,6 +738,7 @@ export function RhfDateField<
662
738
  hint,
663
739
  required,
664
740
  disabled,
741
+ excludeWhenDisabled,
665
742
  className,
666
743
  inputClassName,
667
744
  emptyValue = "",
@@ -669,7 +746,7 @@ export function RhfDateField<
669
746
  }: RhfDateFieldProps<TFieldValues, TName, TTransformed>) {
670
747
  return (
671
748
  <RhfField
672
- {...{ name, control, rules, label, hint, required, disabled, className }}
749
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
673
750
  render={({ field, invalid, id }) => (
674
751
  // FormControl clones this element with `aria-describedby` / `aria-invalid`,
675
752
  // which DateControl hands on to the picker's trigger.
@@ -726,6 +803,7 @@ export function RhfSelect<
726
803
  hint,
727
804
  required,
728
805
  disabled,
806
+ excludeWhenDisabled,
729
807
  className,
730
808
  options,
731
809
  children,
@@ -736,7 +814,7 @@ export function RhfSelect<
736
814
  }: RhfSelectProps<TFieldValues, TName, TTransformed>) {
737
815
  return (
738
816
  <RhfField
739
- {...{ name, control, rules, label, hint, required, disabled, className }}
817
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
740
818
  render={({ field, invalid }) => (
741
819
  <Select
742
820
  aria-required={required || undefined}
@@ -793,12 +871,13 @@ export function RhfCheckbox<
793
871
  hint,
794
872
  required,
795
873
  disabled,
874
+ excludeWhenDisabled,
796
875
  className,
797
876
  ...boxProps
798
877
  }: RhfCheckboxProps<TFieldValues, TName, TTransformed>) {
799
878
  return (
800
879
  <RhfField
801
- {...{ name, control, rules, required, disabled, className }}
880
+ {...{ name, control, rules, required, disabled, excludeWhenDisabled, className }}
802
881
  showLabel={false}
803
882
  render={({ field, invalid }) => (
804
883
  <Checkbox
@@ -923,6 +1002,7 @@ export function RhfCombobox<
923
1002
  hint,
924
1003
  required,
925
1004
  disabled,
1005
+ excludeWhenDisabled,
926
1006
  className,
927
1007
  clearValue = null,
928
1008
  comboClassName,
@@ -930,7 +1010,7 @@ export function RhfCombobox<
930
1010
  }: RhfComboboxProps<TFieldValues, TName, V, TTransformed>) {
931
1011
  return (
932
1012
  <RhfField
933
- {...{ name, control, rules, label, hint, required, disabled, className }}
1013
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
934
1014
  asControl={false}
935
1015
  message={false}
936
1016
  render={({ field, invalid, error, id, describedBy }) => (
@@ -987,13 +1067,14 @@ export function RhfTextCombobox<
987
1067
  hint,
988
1068
  required,
989
1069
  disabled,
1070
+ excludeWhenDisabled,
990
1071
  className,
991
1072
  comboClassName,
992
1073
  ...comboProps
993
1074
  }: RhfTextComboboxProps<TFieldValues, TName, TTransformed>) {
994
1075
  return (
995
1076
  <RhfField
996
- {...{ name, control, rules, label, hint, required, disabled, className }}
1077
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
997
1078
  asControl={false}
998
1079
  message={false}
999
1080
  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>