@eifi1/ui-kit 0.7.0 → 0.8.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 (233) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +5 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +5 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +19 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +56 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +26 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +5 -0
  60. package/dist/components/number-input.d.ts +5 -0
  61. package/dist/components/numpad-sheet.d.ts +5 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/settings-fields.d.ts +2 -0
  78. package/dist/components/skeleton.d.ts +36 -0
  79. package/dist/components/skeleton.js +35 -0
  80. package/dist/components/skeleton.js.map +1 -0
  81. package/dist/components/stat-tile.d.ts +60 -6
  82. package/dist/components/stat-tile.js +60 -26
  83. package/dist/components/stat-tile.js.map +1 -1
  84. package/dist/components/table.d.ts +83 -0
  85. package/dist/components/table.js +166 -0
  86. package/dist/components/table.js.map +1 -0
  87. package/dist/components/time-input.d.ts +2 -0
  88. package/dist/components/toggle-legend.d.ts +3 -2
  89. package/dist/components/toggle-legend.js +1 -1
  90. package/dist/components/toggle-legend.js.map +1 -1
  91. package/dist/components/tooltip.d.ts +10 -4
  92. package/dist/components/tooltip.js +23 -6
  93. package/dist/components/tooltip.js.map +1 -1
  94. package/dist/components/tree-view.d.ts +129 -0
  95. package/dist/components/tree-view.js +376 -0
  96. package/dist/components/tree-view.js.map +1 -0
  97. package/dist/components/treemap.d.ts +9 -2
  98. package/dist/components/treemap.js +2 -1
  99. package/dist/components/treemap.js.map +1 -1
  100. package/dist/components/ui.d.ts +92 -14
  101. package/dist/components/ui.js +112 -30
  102. package/dist/components/ui.js.map +1 -1
  103. package/dist/components/use-table-state.d.ts +1 -1
  104. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  105. package/dist/data-table.d.ts +2 -2
  106. package/dist/data-table.js.map +1 -1
  107. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  108. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  109. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  110. package/dist/hooks/use-debounce.d.ts +43 -0
  111. package/dist/hooks/use-debounce.js +56 -0
  112. package/dist/hooks/use-debounce.js.map +1 -0
  113. package/dist/hooks/use-file-drop.d.ts +72 -0
  114. package/dist/hooks/use-file-drop.js +57 -0
  115. package/dist/hooks/use-file-drop.js.map +1 -0
  116. package/dist/i18n/defaults.d.ts +5 -0
  117. package/dist/i18n/defaults.js +7 -1
  118. package/dist/i18n/defaults.js.map +1 -1
  119. package/dist/i18n/kit-labels.d.ts +17 -0
  120. package/dist/i18n/kit-labels.js +6 -2
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  123. package/dist/i18n/locales/de-CH-informal.js +8 -0
  124. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  125. package/dist/i18n/locales/de-CH.d.ts +5 -0
  126. package/dist/i18n/locales/de-CH.js +1 -14
  127. package/dist/i18n/locales/de-CH.js.map +1 -1
  128. package/dist/i18n/locales/de-informal.d.ts +64 -0
  129. package/dist/i18n/locales/de-informal.js +37 -0
  130. package/dist/i18n/locales/de-informal.js.map +1 -0
  131. package/dist/i18n/locales/de.d.ts +5 -0
  132. package/dist/i18n/locales/de.js +25 -0
  133. package/dist/i18n/locales/de.js.map +1 -1
  134. package/dist/i18n/locales/es.d.ts +5 -0
  135. package/dist/i18n/locales/es.js +25 -0
  136. package/dist/i18n/locales/es.js.map +1 -1
  137. package/dist/i18n/locales/fr.d.ts +5 -0
  138. package/dist/i18n/locales/fr.js +25 -0
  139. package/dist/i18n/locales/fr.js.map +1 -1
  140. package/dist/i18n/locales/hu.d.ts +5 -0
  141. package/dist/i18n/locales/hu.js +25 -0
  142. package/dist/i18n/locales/hu.js.map +1 -1
  143. package/dist/i18n/locales/it.d.ts +5 -0
  144. package/dist/i18n/locales/it.js +25 -0
  145. package/dist/i18n/locales/it.js.map +1 -1
  146. package/dist/i18n/locales/zh.d.ts +5 -0
  147. package/dist/i18n/locales/zh.js +25 -0
  148. package/dist/i18n/locales/zh.js.map +1 -1
  149. package/dist/i18n/swiss.d.ts +13 -0
  150. package/dist/i18n/swiss.js +19 -0
  151. package/dist/i18n/swiss.js.map +1 -0
  152. package/dist/index.d.ts +26 -11
  153. package/dist/index.js +22 -0
  154. package/dist/index.js.map +1 -1
  155. package/dist/lib/dates.d.ts +37 -1
  156. package/dist/lib/dates.js +19 -0
  157. package/dist/lib/dates.js.map +1 -1
  158. package/dist/rhf/form.d.ts +2 -0
  159. package/dist/rhf.d.ts +2 -0
  160. package/dist/search/command-palette.d.ts +15 -1
  161. package/dist/search/command-palette.js +10 -2
  162. package/dist/search/command-palette.js.map +1 -1
  163. package/dist/shell/app-shell.js +2 -2
  164. package/dist/shell/app-shell.js.map +1 -1
  165. package/dist/wizard/stepper-nav.d.ts +35 -2
  166. package/dist/wizard/stepper-nav.js +54 -22
  167. package/dist/wizard/stepper-nav.js.map +1 -1
  168. package/dist/wizard/types.d.ts +107 -5
  169. package/dist/wizard/types.js +2 -1
  170. package/dist/wizard/types.js.map +1 -1
  171. package/dist/wizard/use-wizard.js +141 -21
  172. package/dist/wizard/use-wizard.js.map +1 -1
  173. package/dist/wizard/wizard-summary.d.ts +10 -2
  174. package/dist/wizard/wizard-summary.js +3 -1
  175. package/dist/wizard/wizard-summary.js.map +1 -1
  176. package/dist/wizard.d.ts +4 -1
  177. package/dist/wizard.js.map +1 -1
  178. package/package.json +1 -1
  179. package/src/components/alert-banner.tsx +211 -20
  180. package/src/components/button-group.tsx +75 -0
  181. package/src/components/chart-zoom.tsx +158 -20
  182. package/src/components/chip.tsx +160 -9
  183. package/src/components/confirm-dialog.tsx +242 -0
  184. package/src/components/copy-button.tsx +158 -0
  185. package/src/components/data-table-pagination.tsx +19 -6
  186. package/src/components/data-table-sort.ts +49 -10
  187. package/src/components/data-table.tsx +293 -24
  188. package/src/components/date-picker.tsx +262 -57
  189. package/src/components/description-list.tsx +174 -0
  190. package/src/components/disclosure.tsx +58 -5
  191. package/src/components/file-button.tsx +22 -2
  192. package/src/components/file-dropzone.tsx +198 -100
  193. package/src/components/floating-panel.tsx +341 -0
  194. package/src/components/full-bleed-dialog.tsx +48 -5
  195. package/src/components/modal.tsx +9 -2
  196. package/src/components/progress-bar.tsx +163 -0
  197. package/src/components/scroll-area.tsx +129 -0
  198. package/src/components/separator.tsx +37 -0
  199. package/src/components/series-chart-ticks.ts +135 -0
  200. package/src/components/series-chart.tsx +934 -67
  201. package/src/components/skeleton.tsx +65 -0
  202. package/src/components/stat-tile.tsx +160 -27
  203. package/src/components/table.tsx +263 -0
  204. package/src/components/toggle-legend.tsx +4 -3
  205. package/src/components/tooltip.tsx +40 -12
  206. package/src/components/tree-view.tsx +589 -0
  207. package/src/components/treemap.tsx +10 -2
  208. package/src/components/ui.tsx +268 -42
  209. package/src/data-table.ts +2 -0
  210. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  211. package/src/hooks/use-debounce.ts +105 -0
  212. package/src/hooks/use-file-drop.ts +123 -0
  213. package/src/i18n/defaults.ts +6 -0
  214. package/src/i18n/kit-labels.tsx +19 -0
  215. package/src/i18n/locales/de-CH-informal.ts +10 -0
  216. package/src/i18n/locales/de-CH.ts +1 -16
  217. package/src/i18n/locales/de-informal.ts +61 -0
  218. package/src/i18n/locales/de.ts +25 -0
  219. package/src/i18n/locales/es.ts +25 -0
  220. package/src/i18n/locales/fr.ts +25 -0
  221. package/src/i18n/locales/hu.ts +25 -0
  222. package/src/i18n/locales/it.ts +25 -0
  223. package/src/i18n/locales/zh.ts +25 -0
  224. package/src/i18n/swiss.ts +25 -0
  225. package/src/index.ts +29 -0
  226. package/src/lib/dates.ts +48 -0
  227. package/src/search/command-palette.tsx +29 -3
  228. package/src/shell/app-shell.tsx +2 -2
  229. package/src/wizard/stepper-nav.tsx +102 -28
  230. package/src/wizard/types.ts +106 -4
  231. package/src/wizard/use-wizard.ts +211 -31
  232. package/src/wizard/wizard-summary.tsx +24 -12
  233. package/src/wizard.ts +3 -2
@@ -24,13 +24,83 @@ export interface WizardStepConfig {
24
24
  * per-field error messages via `wizard.fieldErrors`.
25
25
  */
26
26
  validate?: () => ValidateResult | Promise<ValidateResult>;
27
+ /**
28
+ * The forward button's label on THIS step, in place of `labels.next` — "Analyse",
29
+ * "Import", "Send code". On the committing step (see {@link commits}) it labels
30
+ * Finish instead of `labels.finish`; `labels.submitting` still replaces it while
31
+ * the commit runs. Like `label`, a node the app has already translated.
32
+ */
33
+ nextLabel?: ReactNode;
34
+ /**
35
+ * This step's forward button COMMITS: it runs `onComplete` (via `finish`) instead
36
+ * of just advancing. Without the flag the last step commits, as it always has.
37
+ *
38
+ * Flag a step before the last and every step after it becomes a post-commit step —
39
+ * an import's "here is what happened, set up recurring rules?" page:
40
+ * - a successful `onComplete` advances onto the next step (a failed one stays, with
41
+ * `wizard.error`, exactly like a last-step Finish);
42
+ * - from there Back, the indicator and `goBack`/`goToStep` cannot cross back over
43
+ * the commit, and Cancel is hidden — there is nothing left to discard;
44
+ * - the last post-commit step's button is Done, which calls `onDone` (and is not
45
+ * shown when there is no `onDone`: the step then carries its own way out).
46
+ *
47
+ * Only the FIRST flagged step counts. Steps after it may still validate, skip and
48
+ * use `nextLabel` like any other.
49
+ */
50
+ commits?: boolean;
27
51
  }
28
52
 
53
+ /**
54
+ * Where the active step lives. `true` (the default) mirrors it to `?step=N` through
55
+ * react-router, as every wizard before 0.8 did; `{ param }` renames that param (two
56
+ * wizards on one page, or a page that already uses `step`); `false` keeps it in
57
+ * memory only and needs no router at all.
58
+ *
59
+ * Read once, at mount: switching it on a mounted wizard has no effect.
60
+ */
61
+ export type WizardUrlSync = boolean | { param?: string };
62
+
29
63
  export interface UseWizardOptions<TData extends Record<string, unknown>> {
30
64
  steps: WizardStepConfig[];
31
65
  initialData?: Partial<TData>;
32
66
  onComplete?: (data: TData) => void | Promise<void>;
33
67
  onCancel?: () => void;
68
+ /**
69
+ * Leave the wizard BACKWARDS: when given, Back is shown on the first step too and
70
+ * calls this — keksdose's import panel, whose first step's Back returns to the
71
+ * dropzone the file came from. No confirm dialog: going back is not discarding.
72
+ */
73
+ onExit?: () => void;
74
+ /**
75
+ * The Done button on the last step after a committing step (see
76
+ * {@link WizardStepConfig.commits}). Without it no Done button is rendered, and the
77
+ * post-commit step is expected to carry its own way out.
78
+ */
79
+ onDone?: () => void;
80
+ /**
81
+ * Whether the chrome offers Cancel at all. Default `true`. `false` hides the button
82
+ * (`wizard.canCancel` is false); `wizard.cancel()` still works if the app calls it.
83
+ */
84
+ cancellable?: boolean;
85
+ /**
86
+ * Whether Cancel asks first ("Discard this form?"). Default `true`. `false` makes
87
+ * `cancel()` call `onCancel` straight away — for a wizard that has nothing to lose
88
+ * yet, or one whose data is kept elsewhere.
89
+ */
90
+ confirmCancel?: boolean;
91
+ /**
92
+ * Mirror the active step to the URL. Default `true` (`?step=N`, needs a router).
93
+ * See {@link WizardUrlSync}.
94
+ *
95
+ * The param is WRITTEN only, never read back (see the note in `use-wizard.ts`). It
96
+ * is removed again when the wizard is left through the kit: a confirmed Cancel,
97
+ * `onExit` from the first step's Back, and Done — each before the app's callback
98
+ * runs, so an app that navigates away navigates from a clean URL. It is NOT removed
99
+ * after `onComplete` on a last step: an app navigating away there would otherwise
100
+ * be pulled back by the kit's own URL write. An app that stays on the page after a
101
+ * final Finish drops the param itself, or uses `urlSync: false`.
102
+ */
103
+ urlSync?: WizardUrlSync;
34
104
  /**
35
105
  * Shown when a step blocks with no per-field detail — an RHF step surfacing
36
106
  * its own inline messages, say — so the user learns why the wizard did not
@@ -55,14 +125,35 @@ export interface UseWizardReturn<TData extends Record<string, unknown>> {
55
125
  steps: WizardStepConfig[];
56
126
  isFirstStep: boolean;
57
127
  isLastStep: boolean;
58
- /** True once every step before the last has been completed or skipped. */
128
+ /** True once every step before the committing one (the last, unless a step is
129
+ * flagged `commits`) has been completed or skipped. */
59
130
  canFinish: boolean;
131
+ /** The step whose forward button commits: the first `commits` step, else the last. */
132
+ commitStepIndex: number;
133
+ /** The active step is {@link commitStepIndex}: the chrome shows Finish, not Next. */
134
+ isCommitStep: boolean;
135
+ /** `onComplete` has resolved. Once the active step is past the committing one, Back
136
+ * cannot cross it and Cancel is hidden. */
137
+ committed: boolean;
138
+ /** Whether Back is offered here: not on the first step unless `onExit` was given,
139
+ * and never back across a commit. (Disabled, not hidden, while `isSubmitting`.) */
140
+ canGoBack: boolean;
141
+ /** Whether Cancel is offered: `cancellable`, and not after a commit. */
142
+ canCancel: boolean;
143
+ /** Whether Done is offered: the last step, past a commit, with an `onDone`. */
144
+ canDone: boolean;
145
+ /** Whether the indicator may jump to `index` — a completed/skipped/active step on
146
+ * the right side of any commit. `goToStep` refuses anything this rejects. */
147
+ canGoToStep: (index: number) => boolean;
60
148
  goNext: () => Promise<void>;
149
+ /** One step back — or, on the first step, `onExit`. Refused while submitting. */
61
150
  goBack: () => void;
62
151
  goToStep: (index: number) => void;
63
152
  skip: () => void;
64
153
  cancel: () => void;
65
154
  finish: () => Promise<void>;
155
+ /** Leave after the post-commit steps: drops the URL param, then calls `onDone`. */
156
+ done: () => void;
66
157
  /** Register a validator for the mounted step (used by useRhfWizardStep). */
67
158
  registerStepValidate: (
68
159
  fn: () => ValidateResult | Promise<ValidateResult>,
@@ -82,6 +173,9 @@ export interface UseWizardReturn<TData extends Record<string, unknown>> {
82
173
 
83
174
  // Loading / error
84
175
  isSubmitting: boolean;
176
+ /** A `goNext`/`finish` is running the step's validators (an async server check,
177
+ * say). The chrome shows Next pending — spinner, `aria-busy`, disabled. */
178
+ isValidating: boolean;
85
179
  error: string | null;
86
180
  clearError: () => void;
87
181
 
@@ -102,8 +196,12 @@ export interface SummaryItem {
102
196
 
103
197
  export interface SummarySection {
104
198
  label: ReactNode;
105
- /** Which step the section's edit button jumps back to. */
106
- stepIndex: number;
199
+ /**
200
+ * Which step the section's edit button jumps back to. Leave it out for a section
201
+ * that belongs to no step — file-level counts, a total — and the card has no edit
202
+ * button, instead of borrowing one from whichever step it was parked under.
203
+ */
204
+ stepIndex?: number;
107
205
  items: SummaryItem[];
108
206
  }
109
207
 
@@ -149,12 +247,15 @@ export interface WizardLabels {
149
247
  /** `wizard.error` when `onComplete` rejects with nothing readable — a thrown
150
248
  * non-Error, or an Error whose message is empty. Optional: see above. */
151
249
  genericError?: string;
250
+ /** The button on the last step after a committing step (`onDone`). Optional: see
251
+ * `missingRequired`. */
252
+ done?: string;
152
253
  }
153
254
 
154
255
  /** Typed with the two optional keys made required, so code reading the defaults
155
256
  * directly (the fallback in `useWizard`) needs no `!`. */
156
257
  export const DEFAULT_WIZARD_LABELS: WizardLabels &
157
- Required<Pick<WizardLabels, "missingRequired" | "genericError">> = {
258
+ Required<Pick<WizardLabels, "missingRequired" | "genericError" | "done">> = {
158
259
  cancel: "Cancel",
159
260
  back: "Back",
160
261
  next: "Next",
@@ -171,6 +272,7 @@ export const DEFAULT_WIZARD_LABELS: WizardLabels &
171
272
  edit: "Edit",
172
273
  missingRequired: "Please fill in all required fields.",
173
274
  genericError: "An error occurred",
275
+ done: "Done",
174
276
  };
175
277
 
176
278
  export function resolveWizardLabels(labels?: Partial<WizardLabels>): WizardLabels {
@@ -3,10 +3,16 @@
3
3
  * per-step async validators, collected data, field errors, submit and the
4
4
  * cancel-confirm gate.
5
5
  *
6
- * Domain-free, but NOT router-free: it mirrors the active step to `?step=N`, so
7
- * it must be mounted inside a react-router context — the same requirement
8
- * `DataTable` already carries. It only ever WRITES that param (see the note on
9
- * `initialStep` below for why it deliberately does not read it back).
6
+ * Domain-free, and router-free on request. By default it mirrors the active step
7
+ * to `?step=N`, so it must be mounted inside a react-router context — the same
8
+ * requirement `DataTable` already carries. `urlSync: false` keeps the step in
9
+ * memory and needs no router; `urlSync: { param }` renames the param. It only ever
10
+ * WRITES that param (see the note on `initialStep` below for why it deliberately
11
+ * does not read it back), and removes it when the wizard is left through the kit
12
+ * (see `UseWizardOptions.urlSync`).
13
+ *
14
+ * The committing step is the last one unless a step says `commits: true`; the
15
+ * steps after that one are post-commit steps (see `WizardStepConfig.commits`).
10
16
  */
11
17
  import { useCallback, useEffect, useReducer, useRef, useState } from "react";
12
18
  import { useSearchParams } from "react-router";
@@ -27,9 +33,26 @@ interface WizardState<TData> {
27
33
  skippedSteps: Set<number>;
28
34
  data: TData;
29
35
  isSubmitting: boolean;
36
+ isValidating: boolean;
30
37
  error: string | null;
31
38
  fieldErrors: FieldErrors;
32
39
  showCancelDialog: boolean;
40
+ /** The step whose `onComplete` resolved, or null before any commit. */
41
+ committedStepIndex: number | null;
42
+ }
43
+
44
+ /** Past the commit: the active step comes after the step that committed. Moves to
45
+ * that step or any before it are refused from here. */
46
+ function isAfterCommit(state: { currentStepIndex: number; committedStepIndex: number | null }) {
47
+ return state.committedStepIndex !== null && state.currentStepIndex > state.committedStepIndex;
48
+ }
49
+
50
+ /** Whether `index` is on the far side of a commit the wizard has already moved past. */
51
+ function crossesCommit(
52
+ state: { currentStepIndex: number; committedStepIndex: number | null },
53
+ index: number,
54
+ ) {
55
+ return isAfterCommit(state) && index <= (state.committedStepIndex as number);
33
56
  }
34
57
 
35
58
  type WizardAction<TData> =
@@ -41,7 +64,9 @@ type WizardAction<TData> =
41
64
  | { type: "GO_TO_STEP"; index: number }
42
65
  | { type: "SKIP"; from: number }
43
66
  | { type: "UPDATE_DATA"; partial: Partial<TData> }
67
+ | { type: "COMMITTED"; from: number }
44
68
  | { type: "SET_SUBMITTING"; value: boolean }
69
+ | { type: "SET_VALIDATING"; value: boolean }
45
70
  | { type: "SET_ERROR"; error: string | null }
46
71
  | { type: "SET_FIELD_ERRORS"; errors: FieldErrors }
47
72
  | { type: "SHOW_CANCEL_DIALOG"; show: boolean };
@@ -61,10 +86,12 @@ function createReducer<TData>(stepCount: number) {
61
86
  }
62
87
  case "GO_BACK": {
63
88
  const prevIndex = Math.max(state.currentStepIndex - 1, 0);
89
+ if (crossesCommit(state, prevIndex)) return state;
64
90
  return { ...state, currentStepIndex: prevIndex, fieldErrors: {} };
65
91
  }
66
92
  case "GO_TO_STEP": {
67
93
  if (action.index < 0 || action.index >= stepCount) return state;
94
+ if (crossesCommit(state, action.index)) return state;
68
95
  if (!state.completedSteps.has(action.index) && action.index > state.currentStepIndex) {
69
96
  return state;
70
97
  }
@@ -86,8 +113,27 @@ function createReducer<TData>(stepCount: number) {
86
113
  }
87
114
  return { ...state, data: { ...state.data, ...action.partial }, fieldErrors: nextErrors };
88
115
  }
116
+ case "COMMITTED": {
117
+ // Recorded whatever step committed, so `wizard.committed` reads true after any
118
+ // successful Finish. Only a commit with steps after it MOVES: the default
119
+ // last-step commit stays where it is and looks exactly as it did before 0.8.
120
+ const committed = { ...state, committedStepIndex: action.from };
121
+ if (action.from !== state.currentStepIndex || action.from >= stepCount - 1) {
122
+ return committed;
123
+ }
124
+ const completed = new Set(state.completedSteps);
125
+ completed.add(action.from);
126
+ return {
127
+ ...committed,
128
+ currentStepIndex: action.from + 1,
129
+ completedSteps: completed,
130
+ fieldErrors: {},
131
+ };
132
+ }
89
133
  case "SET_SUBMITTING":
90
134
  return { ...state, isSubmitting: action.value };
135
+ case "SET_VALIDATING":
136
+ return { ...state, isValidating: action.value };
91
137
  case "SET_ERROR":
92
138
  return { ...state, error: action.error };
93
139
  case "SET_FIELD_ERRORS":
@@ -103,8 +149,19 @@ function createReducer<TData>(stepCount: number) {
103
149
  export function useWizard<TData extends Record<string, unknown>>(
104
150
  options: UseWizardOptions<TData>,
105
151
  ): UseWizardReturn<TData> {
106
- const { steps, initialData, onComplete, onCancel, missingRequiredMessage, onValidationFailed } =
107
- options;
152
+ const {
153
+ steps,
154
+ initialData,
155
+ onComplete,
156
+ onCancel,
157
+ onExit,
158
+ onDone,
159
+ cancellable = true,
160
+ confirmCancel: askBeforeCancel = true,
161
+ urlSync = true,
162
+ missingRequiredMessage,
163
+ onValidationFailed,
164
+ } = options;
108
165
  const stepsRef = useRef<WizardStepConfig[]>(steps);
109
166
  stepsRef.current = steps;
110
167
  // A hook, not a component, but it produces two sentences of its own — the
@@ -138,7 +195,13 @@ export function useWizard<TData extends Record<string, unknown>>(
138
195
  [],
139
196
  );
140
197
 
141
- const [, setUrlParams] = useSearchParams();
198
+ // Chosen once, at mount: a hook may not be called conditionally, but it may be
199
+ // chosen once and then called unconditionally on every render — which is what
200
+ // makes `urlSync: false` work with no router above the wizard at all.
201
+ const [useStepUrl] = useState(() => (urlSync === false ? useNoStepUrl : useSearchParamStepUrl));
202
+ const [urlParam] = useState(() =>
203
+ typeof urlSync === "object" && urlSync.param ? urlSync.param : "step",
204
+ );
142
205
  // The wizard's entered `data` and `completedSteps` are NOT persisted across a
143
206
  // reload / deep-link (the reducer re-inits to empty defaults on every mount).
144
207
  // Restoring a later step index from `?step=N` would therefore leave the wizard
@@ -154,9 +217,11 @@ export function useWizard<TData extends Record<string, unknown>>(
154
217
  skippedSteps: new Set<number>(),
155
218
  data: (initialData ?? {}) as TData,
156
219
  isSubmitting: false,
220
+ isValidating: false,
157
221
  error: null,
158
222
  fieldErrors: {},
159
223
  showCancelDialog: false,
224
+ committedStepIndex: null,
160
225
  });
161
226
 
162
227
  // A mounted step can DISABLE forward navigation (Next/Skip) while its own
@@ -168,26 +233,36 @@ export function useWizard<TData extends Record<string, unknown>>(
168
233
  [],
169
234
  );
170
235
 
171
- // Sync step index to URL
172
- useEffect(() => {
173
- setUrlParams((prev) => {
174
- prev.set("step", String(state.currentStepIndex));
175
- return prev;
176
- }, { replace: true });
177
- }, [state.currentStepIndex, setUrlParams]);
236
+ // Sync step index to URL (a no-op under `urlSync: false`). `clearStepUrl` drops
237
+ // the param when the wizard is left through the kit, and stops the sync writing
238
+ // it back.
239
+ const clearStepUrl = useStepUrl(urlParam, state.currentStepIndex);
178
240
 
179
- const currentStep = stepsRef.current[state.currentStepIndex];
241
+ // `steps`, not `stepsRef.current`: during render the two are the same array, and
242
+ // reading a ref in render is what the hooks linter (rightly) objects to.
243
+ const currentStep = steps[state.currentStepIndex];
180
244
  const isFirstStep = state.currentStepIndex === 0;
181
- const isLastStep = state.currentStepIndex === stepsRef.current.length - 1;
182
- // Finish is only valid once every step before the last has actually been
183
- // walked through (completed or skipped). Guards against a restored/last-step
245
+ const isLastStep = state.currentStepIndex === steps.length - 1;
246
+ // The step whose forward button commits: the first one flagged `commits`, else the
247
+ // last — which is every wizard written before the flag existed.
248
+ const flagged = steps.findIndex((s) => s.commits);
249
+ const commitStepIndex = flagged === -1 ? steps.length - 1 : flagged;
250
+ const isCommitStep = state.currentStepIndex === commitStepIndex;
251
+ const afterCommit = isAfterCommit(state);
252
+ // Finish is only valid once every step before the committing one has actually
253
+ // been walked through (completed or skipped). Guards against a restored/last-step
184
254
  // index submitting a payload built from empty defaults.
185
- const canFinish = stepsRef.current.every(
255
+ const canFinish = steps.every(
186
256
  (_, index) =>
187
- index === stepsRef.current.length - 1 ||
257
+ index >= commitStepIndex ||
188
258
  state.completedSteps.has(index) ||
189
259
  state.skippedSteps.has(index),
190
260
  );
261
+ const canGoBack =
262
+ (!isFirstStep || onExit !== undefined) &&
263
+ !crossesCommit(state, state.currentStepIndex - 1);
264
+ const canCancel = cancellable && !afterCommit;
265
+ const canDone = isLastStep && afterCommit && onDone !== undefined;
191
266
 
192
267
  // Runs the active step's config `validate` plus every validator its mounted
193
268
  // components registered (RHF steps via useRhfWizardStep), AND-combining the
@@ -266,23 +341,50 @@ export function useWizard<TData extends Record<string, unknown>>(
266
341
  if (advancingRef.current) return;
267
342
  advancingRef.current = true;
268
343
  const from = state.currentStepIndex;
344
+ // Pending state for the chrome (spinner, aria-busy). The ref above is still what
345
+ // refuses the second click: state would only be seen after a re-render.
346
+ dispatch({ type: "SET_VALIDATING", value: true });
269
347
  try {
270
348
  if (await runStepValidators()) {
271
349
  dispatch({ type: "GO_NEXT", from });
272
350
  }
273
351
  } finally {
352
+ dispatch({ type: "SET_VALIDATING", value: false });
274
353
  advancingRef.current = false;
275
354
  }
276
355
  }, [runStepValidators, state.currentStepIndex]);
277
356
 
357
+ // Read by `goBack`, which must not move while `onComplete` runs: the commit was
358
+ // decided on the data of the step the user is on, and Back mid-commit would show a
359
+ // step whose edits can no longer reach it. The chrome disables the button too; this
360
+ // covers an app calling `goBack` itself.
361
+ const submittingRef = useRef(false);
362
+
278
363
  const goBack = useCallback(() => {
364
+ if (submittingRef.current) return;
365
+ if (state.currentStepIndex === 0 && onExit) {
366
+ clearStepUrl();
367
+ onExit();
368
+ return;
369
+ }
370
+ // On step 0 with no `onExit` this stays the no-op move (clearing field errors) it
371
+ // always was.
279
372
  dispatch({ type: "GO_BACK" });
280
- }, []);
373
+ }, [state.currentStepIndex, onExit, clearStepUrl]);
281
374
 
282
375
  const goToStep = useCallback((index: number) => {
283
376
  dispatch({ type: "GO_TO_STEP", index });
284
377
  }, []);
285
378
 
379
+ const canGoToStep = useCallback(
380
+ (index: number) =>
381
+ index >= 0 &&
382
+ index < steps.length &&
383
+ (index <= state.currentStepIndex || state.completedSteps.has(index)) &&
384
+ !crossesCommit(state, index),
385
+ [steps.length, state],
386
+ );
387
+
286
388
  const skip = useCallback(() => {
287
389
  // Not while Next is validating: the skip would land first and the pending GO_NEXT
288
390
  // would then be stale anyway — but the user asked for one move, not a race.
@@ -290,14 +392,24 @@ export function useWizard<TData extends Record<string, unknown>>(
290
392
  dispatch({ type: "SKIP", from: state.currentStepIndex });
291
393
  }, [state.currentStepIndex]);
292
394
 
293
- const cancel = useCallback(() => {
294
- dispatch({ type: "SHOW_CANCEL_DIALOG", show: true });
295
- }, []);
296
-
297
395
  const confirmCancel = useCallback(() => {
298
396
  dispatch({ type: "SHOW_CANCEL_DIALOG", show: false });
397
+ clearStepUrl();
299
398
  onCancel?.();
300
- }, [onCancel]);
399
+ }, [onCancel, clearStepUrl]);
400
+
401
+ const cancel = useCallback(() => {
402
+ if (!askBeforeCancel) {
403
+ confirmCancel();
404
+ return;
405
+ }
406
+ dispatch({ type: "SHOW_CANCEL_DIALOG", show: true });
407
+ }, [askBeforeCancel, confirmCancel]);
408
+
409
+ const done = useCallback(() => {
410
+ clearStepUrl();
411
+ onDone?.();
412
+ }, [onDone, clearStepUrl]);
301
413
 
302
414
  const dismissCancel = useCallback(() => {
303
415
  dispatch({ type: "SHOW_CANCEL_DIALOG", show: false });
@@ -317,29 +429,55 @@ export function useWizard<TData extends Record<string, unknown>>(
317
429
 
318
430
  const finish = useCallback(async () => {
319
431
  if (!onComplete || !canFinish) return;
432
+ // A commit with steps after it happens once: those steps exist because the
433
+ // data has been written. (A last-step commit keeps its old behaviour — the app
434
+ // may retry Finish after `onComplete` resolved, as it always could.)
435
+ if (commitStepIndex < steps.length - 1 && state.committedStepIndex !== null) return;
320
436
  // The same re-entry guard as `goNext`: a double-click on Finish would otherwise
321
437
  // validate twice and call `onComplete` twice — `isSubmitting` is only set once
322
438
  // validation has passed, too late to stop the second click.
323
439
  if (advancingRef.current) return;
324
440
  advancingRef.current = true;
441
+ const from = state.currentStepIndex;
325
442
  try {
326
- // Validate the (possibly form-bearing) last step before submitting, matching
327
- // the Next gate — review-only steps register no validators and pass through.
328
- if (!(await runStepValidators())) return;
443
+ // Validate the (possibly form-bearing) committing step before submitting,
444
+ // matching the Next gate — review-only steps register no validators and pass
445
+ // through.
446
+ dispatch({ type: "SET_VALIDATING", value: true });
447
+ let valid: boolean;
448
+ try {
449
+ valid = await runStepValidators();
450
+ } finally {
451
+ dispatch({ type: "SET_VALIDATING", value: false });
452
+ }
453
+ if (!valid) return;
454
+ submittingRef.current = true;
329
455
  dispatch({ type: "SET_SUBMITTING", value: true });
330
456
  dispatch({ type: "SET_ERROR", error: null });
331
457
  try {
332
458
  await onComplete(state.data);
459
+ dispatch({ type: "COMMITTED", from });
333
460
  } catch (err) {
334
461
  const message = err instanceof Error && err.message ? err.message : genericError;
335
462
  dispatch({ type: "SET_ERROR", error: message });
336
463
  } finally {
464
+ submittingRef.current = false;
337
465
  dispatch({ type: "SET_SUBMITTING", value: false });
338
466
  }
339
467
  } finally {
340
468
  advancingRef.current = false;
341
469
  }
342
- }, [onComplete, canFinish, state.data, runStepValidators, genericError]);
470
+ }, [
471
+ onComplete,
472
+ canFinish,
473
+ commitStepIndex,
474
+ steps.length,
475
+ state.committedStepIndex,
476
+ state.currentStepIndex,
477
+ state.data,
478
+ runStepValidators,
479
+ genericError,
480
+ ]);
343
481
 
344
482
  const stepStatus = useCallback(
345
483
  (index: number): StepStatus => {
@@ -354,16 +492,24 @@ export function useWizard<TData extends Record<string, unknown>>(
354
492
  return {
355
493
  currentStepIndex: state.currentStepIndex,
356
494
  currentStep,
357
- steps: stepsRef.current,
495
+ steps,
358
496
  isFirstStep,
359
497
  isLastStep,
360
498
  canFinish,
499
+ commitStepIndex,
500
+ isCommitStep,
501
+ committed: state.committedStepIndex !== null,
502
+ canGoBack,
503
+ canCancel,
504
+ canDone,
505
+ canGoToStep,
361
506
  goNext,
362
507
  goBack,
363
508
  goToStep,
364
509
  skip,
365
510
  cancel,
366
511
  finish,
512
+ done,
367
513
  registerStepValidate,
368
514
  nextBlocked,
369
515
  setNextBlocked,
@@ -372,6 +518,7 @@ export function useWizard<TData extends Record<string, unknown>>(
372
518
  data: state.data,
373
519
  updateData,
374
520
  isSubmitting: state.isSubmitting,
521
+ isValidating: state.isValidating,
375
522
  error: state.error,
376
523
  clearError,
377
524
  fieldErrors: state.fieldErrors,
@@ -381,3 +528,36 @@ export function useWizard<TData extends Record<string, unknown>>(
381
528
  dismissCancel,
382
529
  };
383
530
  }
531
+
532
+ /**
533
+ * `urlSync: true` / `{ param }`: mirror the active step to `?<param>=N`, and hand back
534
+ * the function that removes it again. Once removed it stays removed — the sync effect
535
+ * re-runs whenever react-router hands out a new setter (every search change), and
536
+ * without the latch it would write the param straight back after the exit cleared it.
537
+ */
538
+ function useSearchParamStepUrl(param: string, index: number): () => void {
539
+ const [, setUrlParams] = useSearchParams();
540
+ const leftRef = useRef(false);
541
+ useEffect(() => {
542
+ if (leftRef.current) return;
543
+ setUrlParams((prev) => {
544
+ prev.set(param, String(index));
545
+ return prev;
546
+ }, { replace: true });
547
+ }, [param, index, setUrlParams]);
548
+ return useCallback(() => {
549
+ if (leftRef.current) return;
550
+ leftRef.current = true;
551
+ setUrlParams((prev) => {
552
+ prev.delete(param);
553
+ return prev;
554
+ }, { replace: true });
555
+ }, [param, setUrlParams]);
556
+ }
557
+
558
+ /** `urlSync: false`: the step lives in the reducer only. Touches no router context. */
559
+ function useNoStepUrl(_param: string, _index: number): () => void {
560
+ return noop;
561
+ }
562
+
563
+ function noop() {}
@@ -11,14 +11,23 @@ import { useKitLabels } from "../i18n/kit-labels";
11
11
  * Sections are data, not children, because "jump back to step N" is the whole
12
12
  * point of the component and a caller assembling its own cards invariably drops
13
13
  * it. Labels and values are `ReactNode`, so a value can be a badge or a link.
14
+ *
15
+ * A section with no `stepIndex` (file-level counts, a total) is a card without an
16
+ * edit button. `disabled` greys every edit button at once — while the commit runs,
17
+ * when jumping back would show a step whose edits can no longer reach the request.
14
18
  */
15
19
  export function WizardSummary({
16
20
  sections,
17
21
  onEditStep,
22
+ disabled = false,
18
23
  labels,
19
24
  }: {
20
25
  sections: SummarySection[];
21
- onEditStep: (stepIndex: number) => void;
26
+ /** Called with the section's `stepIndex`. Optional only for a summary whose
27
+ * sections name no step at all. */
28
+ onEditStep?: (stepIndex: number) => void;
29
+ /** Disable every edit button (e.g. `wizard.isSubmitting`). */
30
+ disabled?: boolean;
22
31
  labels?: Partial<WizardLabels>;
23
32
  }) {
24
33
  const l = useKitLabels("wizard", DEFAULT_WIZARD_LABELS, labels);
@@ -33,17 +42,20 @@ export function WizardSummary({
33
42
  the title instead of sitting beside it. */}
34
43
  <CardHeader className="pb-2">
35
44
  <CardTitle className="text-base">{section.label}</CardTitle>
36
- <CardAction>
37
- <IconButton
38
- size="sm"
39
- variant="ghost"
40
- aria-label={l.edit}
41
- title={l.edit}
42
- onClick={() => onEditStep(section.stepIndex)}
43
- >
44
- <Pencil />
45
- </IconButton>
46
- </CardAction>
45
+ {section.stepIndex !== undefined && onEditStep && (
46
+ <CardAction>
47
+ <IconButton
48
+ size="sm"
49
+ variant="ghost"
50
+ aria-label={l.edit}
51
+ title={l.edit}
52
+ disabled={disabled}
53
+ onClick={() => onEditStep(section.stepIndex as number)}
54
+ >
55
+ <Pencil />
56
+ </IconButton>
57
+ </CardAction>
58
+ )}
47
59
  </CardHeader>
48
60
  <CardContent>
49
61
  <dl className="grid gap-2 text-sm">
package/src/wizard.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // `@eifi1/ui-kit/wizard` — the multi-step engine, its chrome and its review step.
2
2
  //
3
- // A re-slicing of the main barrel, not a new API. This entry point requires
4
- // `react-router`: useWizard syncs the active step to `?step=` via useSearchParams.
3
+ // A re-slicing of the main barrel, not a new API. By default useWizard syncs the
4
+ // active step to `?step=` via react-router's useSearchParams, so it needs a router;
5
+ // `urlSync: false` keeps the step in memory and needs none.
5
6
  //
6
7
  // NOT the same thing as `WizardStepper` in components/wizard-stepper, which is a bare
7
8
  // two-step indicator with no engine and stays on the main barrel.