@eifi1/ui-kit 0.7.1 → 0.8.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 (222) 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 +3 -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 +3 -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 +17 -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 +54 -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 +28 -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 +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -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/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -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.