@daltonr/pathwrite-react 0.9.0 → 0.10.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.
package/src/index.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  PathEvent,
18
18
  PathSnapshot,
19
19
  ProgressLayout,
20
- RootProgress
20
+ RootProgress,
21
+ formatFieldKey,
22
+ errorPhaseMessage,
21
23
  } from "@daltonr/pathwrite-core";
22
24
 
23
25
  // ---------------------------------------------------------------------------
@@ -67,11 +69,29 @@ export interface UsePathReturn<TData extends PathData = PathData> {
67
69
  * Use for "Start over" / retry flows without remounting the component.
68
70
  */
69
71
  restart: () => void;
72
+ /**
73
+ * Re-runs the operation that set `snapshot.error`. Increments `snapshot.error.retryCount`
74
+ * on repeated failure so shells can escalate from "Try again" to "Come back later".
75
+ * No-op when there is no pending error.
76
+ */
77
+ retry: () => void;
78
+ /**
79
+ * Pauses the path with intent to return. Emits a `suspended` event that the application
80
+ * listens for to dismiss the wizard UI. All state and data are preserved.
81
+ */
82
+ suspend: () => void;
70
83
  }
71
84
 
72
85
  export type PathProviderProps = PropsWithChildren<{
73
86
  /** Forwarded to the internal usePath hook. */
74
87
  onEvent?: (event: PathEvent) => void;
88
+ /**
89
+ * Services object passed through context to all step components.
90
+ * Step components access it via `usePathContext<TData, TServices>()`.
91
+ * Typed as `unknown` here so `PathProvider` stays non-generic — provide a
92
+ * typed services interface via the `TServices` type parameter on `usePathContext`.
93
+ */
94
+ services?: unknown;
75
95
  }>;
76
96
 
77
97
  // ---------------------------------------------------------------------------
@@ -160,37 +180,58 @@ export function usePath<TData extends PathData = PathData>(options?: UsePathOpti
160
180
 
161
181
  const restart = useCallback(() => engine.restart(), [engine]);
162
182
 
163
- return { snapshot, start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart };
183
+ const retry = useCallback(() => engine.retry(), [engine]);
184
+
185
+ const suspend = useCallback(() => engine.suspend(), [engine]);
186
+
187
+ return { snapshot, start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart, retry, suspend };
164
188
  }
165
189
 
166
190
  // ---------------------------------------------------------------------------
167
191
  // Context + Provider
168
192
  // ---------------------------------------------------------------------------
169
193
 
170
- const PathContext = createContext<UsePathReturn | null>(null);
194
+ interface PathContextValue {
195
+ path: UsePathReturn;
196
+ services: unknown;
197
+ }
198
+
199
+ const PathContext = createContext<PathContextValue | null>(null);
171
200
 
172
201
  /**
173
202
  * Provides a single `usePath` instance to all descendants.
174
203
  * Consume with `usePathContext()`.
175
204
  */
176
- export function PathProvider({ children, onEvent }: PathProviderProps): ReactElement {
205
+ export function PathProvider({ children, onEvent, services }: PathProviderProps): ReactElement {
177
206
  const path = usePath({ onEvent });
178
- return createElement(PathContext.Provider, { value: path }, children);
207
+ return createElement(PathContext.Provider, { value: { path, services: services ?? null } }, children);
179
208
  }
180
209
 
181
210
  /**
182
- * Access the nearest `PathProvider`'s path instance.
183
- * Throws if used outside of a `<PathProvider>`.
211
+ * Access the nearest `PathProvider`'s path instance and optional services object.
212
+ * Throws if used outside of a `<PathProvider>` or `<PathShell>`.
213
+ *
214
+ * Both generics are type-level assertions, not runtime guarantees:
215
+ * - `TData` narrows `snapshot.data`
216
+ * - `TServices` types the `services` value — must match what was passed to `PathShell` or `PathProvider`
184
217
  *
185
- * The optional generic narrows `snapshot.data` for convenience — it is a
186
- * **type-level assertion**, not a runtime guarantee.
218
+ * @example
219
+ * ```tsx
220
+ * function OfficeStep() {
221
+ * const { snapshot, services } = usePathContext<HiringData, HiringServices>();
222
+ * // services is typed as HiringServices
223
+ * }
224
+ * ```
187
225
  */
188
- export function usePathContext<TData extends PathData = PathData>(): Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: PathSnapshot<TData> } {
226
+ export function usePathContext<TData extends PathData = PathData, TServices = unknown>(): Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: PathSnapshot<TData>; services: TServices } {
189
227
  const ctx = useContext(PathContext);
190
228
  if (ctx === null) {
191
229
  throw new Error("usePathContext must be used within a <PathProvider>.");
192
230
  }
193
- return ctx as Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: PathSnapshot<TData> };
231
+ return {
232
+ ...(ctx.path as unknown as Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: PathSnapshot<TData> }),
233
+ services: ctx.services as TServices
234
+ };
194
235
  }
195
236
 
196
237
  // ---------------------------------------------------------------------------
@@ -238,11 +279,6 @@ export function useField<TData extends PathData, K extends string & keyof TData>
238
279
  // Helpers
239
280
  // ---------------------------------------------------------------------------
240
281
 
241
- /** Converts a camelCase or lowercase field key to a display label.
242
- * e.g. "firstName" → "First Name", "email" → "Email" */
243
- function formatFieldKey(key: string): string {
244
- return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
245
- }
246
282
 
247
283
  // ---------------------------------------------------------------------------
248
284
  // Default UI — PathShell
@@ -275,6 +311,8 @@ export interface PathShellProps {
275
311
  nextLabel?: string;
276
312
  /** Label for the Complete button (shown on the last step). Defaults to `"Complete"`. */
277
313
  completeLabel?: string;
314
+ /** Label shown on the Next/Complete button while an async operation is in progress. Defaults to `undefined` (button shows a CSS spinner but keeps its label). */
315
+ loadingLabel?: string;
278
316
  /** Label for the Cancel button. Defaults to `"Cancel"`. */
279
317
  cancelLabel?: string;
280
318
  /** If true, hide the Cancel button. Defaults to `false`. */
@@ -309,6 +347,22 @@ export interface PathShellProps {
309
347
  * - `"activeOnly"`: Only the active (sub-path) bar — root bar hidden.
310
348
  */
311
349
  progressLayout?: ProgressLayout;
350
+ /**
351
+ * Services object passed through context to all step components.
352
+ * Step components access it via `usePathContext<TData, TServices>()`.
353
+ *
354
+ * The same services object that was passed to your path factory function
355
+ * should be passed here so step components can call service methods directly
356
+ * (e.g. parameterised queries that depend on mid-step user input).
357
+ *
358
+ * @example
359
+ * ```tsx
360
+ * const svc = new LiveHiringServices();
361
+ * const path = createHiringPath(svc);
362
+ * <PathShell path={path} services={svc} steps={{ ... }} />
363
+ * ```
364
+ */
365
+ services?: unknown;
312
366
  }
313
367
 
314
368
  export interface PathShellHandle {
@@ -325,6 +379,10 @@ export interface PathShellActions {
325
379
  setData: (key: string, value: unknown) => void;
326
380
  /** Restart the shell's current path with its current `initialData`. */
327
381
  restart: () => void;
382
+ /** Re-run the operation that set `snapshot.error`. See `PathEngine.retry()`. */
383
+ retry: () => void;
384
+ /** Pause with intent to return, preserving all state. Emits `suspended`. */
385
+ suspend: () => void;
328
386
  }
329
387
 
330
388
  /**
@@ -355,6 +413,7 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
355
413
  backLabel = "Previous",
356
414
  nextLabel = "Next",
357
415
  completeLabel = "Complete",
416
+ loadingLabel,
358
417
  cancelLabel = "Cancel",
359
418
  hideCancel = false,
360
419
  hideProgress = false,
@@ -364,6 +423,7 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
364
423
  renderFooter,
365
424
  validationDisplay = "summary",
366
425
  progressLayout = "merged",
426
+ services,
367
427
  }: PathShellProps, ref): ReactElement {
368
428
  const pathReturn = usePath({
369
429
  engine: externalEngine,
@@ -374,7 +434,7 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
374
434
  }
375
435
  });
376
436
 
377
- const { snapshot, start, next, previous, cancel, goToStep, goToStepChecked, setData, restart } = pathReturn;
437
+ const { snapshot, start, next, previous, cancel, goToStep, goToStepChecked, setData, restart, retry, suspend } = pathReturn;
378
438
 
379
439
  useImperativeHandle(ref, () => ({
380
440
  restart: () => restart(),
@@ -399,8 +459,10 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
399
459
  ? ((snapshot.formId ? steps[snapshot.formId] : undefined) ?? steps[snapshot.stepId] ?? null)
400
460
  : null;
401
461
 
462
+ const contextValue: PathContextValue = { path: pathReturn, services: services ?? null };
463
+
402
464
  if (!snapshot) {
403
- return createElement(PathContext.Provider, { value: pathReturn },
465
+ return createElement(PathContext.Provider, { value: contextValue },
404
466
  createElement("div", { className: cls("pw-shell", className) },
405
467
  createElement("div", { className: "pw-shell__empty" },
406
468
  createElement("p", null, "No active path."),
@@ -416,7 +478,9 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
416
478
 
417
479
  const actions: PathShellActions = {
418
480
  next, previous, cancel, goToStep, goToStepChecked, setData,
419
- restart: () => restart()
481
+ restart: () => restart(),
482
+ retry: () => retry(),
483
+ suspend: () => suspend(),
420
484
  };
421
485
 
422
486
  const showRoot = !hideProgress && !!snapshot.rootProgress && progressLayout !== "activeOnly";
@@ -424,7 +488,7 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
424
488
  ? true
425
489
  : (snapshot.stepCount > 1 || snapshot.nestingLevel > 0) && progressLayout !== "rootOnly");
426
490
 
427
- return createElement(PathContext.Provider, { value: pathReturn },
491
+ return createElement(PathContext.Provider, { value: contextValue },
428
492
  createElement("div", { className: cls("pw-shell", progressLayout !== "merged" && `pw-shell--progress-${progressLayout}`, className) },
429
493
  // Root progress — persistent top-level bar visible during sub-paths
430
494
  showRoot && defaultRootProgress(snapshot.rootProgress!),
@@ -452,12 +516,18 @@ export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function Pa
452
516
  )
453
517
  )
454
518
  ),
455
- // Footernavigation buttons
456
- renderFooter
457
- ? renderFooter(snapshot, actions)
458
- : defaultFooter(snapshot, actions, {
459
- backLabel, nextLabel, completeLabel, cancelLabel, hideCancel, footerLayout
460
- })
519
+ // Blocking error guard returned { allowed: false, reason }
520
+ validationDisplay !== "inline" && snapshot.hasAttemptedNext && snapshot.blockingError &&
521
+ createElement("p", { className: "pw-shell__blocking-error" }, snapshot.blockingError),
522
+ // Error panel — replaces footer when an async operation has failed
523
+ snapshot.status === "error" && snapshot.error
524
+ ? defaultErrorPanel(snapshot, actions)
525
+ // Footer — navigation buttons
526
+ : renderFooter
527
+ ? renderFooter(snapshot, actions)
528
+ : defaultFooter(snapshot, actions, {
529
+ backLabel, nextLabel, completeLabel, loadingLabel, cancelLabel, hideCancel, footerLayout
530
+ })
461
531
  )
462
532
  );
463
533
  });
@@ -522,6 +592,47 @@ function defaultHeader(snapshot: PathSnapshot): ReactElement {
522
592
  );
523
593
  }
524
594
 
595
+ // ---------------------------------------------------------------------------
596
+ // Default error panel
597
+ // ---------------------------------------------------------------------------
598
+
599
+
600
+ function defaultErrorPanel(
601
+ snapshot: PathSnapshot,
602
+ actions: PathShellActions
603
+ ): ReactElement {
604
+ const { error, hasPersistence } = snapshot;
605
+ if (!error) return createElement("div", null);
606
+ const escalated = error.retryCount >= 2;
607
+ const title = escalated ? "Still having trouble." : "Something went wrong.";
608
+ const phaseMsg = errorPhaseMessage(error.phase);
609
+
610
+ return createElement("div", { className: "pw-shell__error" },
611
+ createElement("div", { className: "pw-shell__error-title" }, title),
612
+ createElement("div", { className: "pw-shell__error-message" },
613
+ phaseMsg,
614
+ error.message && ` ${error.message}`
615
+ ),
616
+ createElement("div", { className: "pw-shell__error-actions" },
617
+ !escalated && createElement("button", {
618
+ type: "button",
619
+ className: "pw-shell__btn pw-shell__btn--retry",
620
+ onClick: actions.retry
621
+ }, "Try again"),
622
+ hasPersistence && createElement("button", {
623
+ type: "button",
624
+ className: cls("pw-shell__btn", escalated ? "pw-shell__btn--retry" : "pw-shell__btn--suspend"),
625
+ onClick: actions.suspend
626
+ }, "Save and come back later"),
627
+ escalated && !hasPersistence && createElement("button", {
628
+ type: "button",
629
+ className: "pw-shell__btn pw-shell__btn--retry",
630
+ onClick: actions.retry
631
+ }, "Try again")
632
+ )
633
+ );
634
+ }
635
+
525
636
  // ---------------------------------------------------------------------------
526
637
  // Default footer (navigation buttons)
527
638
  // ---------------------------------------------------------------------------
@@ -530,6 +641,7 @@ interface FooterLabels {
530
641
  backLabel: string;
531
642
  nextLabel: string;
532
643
  completeLabel: string;
644
+ loadingLabel?: string;
533
645
  cancelLabel: string;
534
646
  hideCancel: boolean;
535
647
  footerLayout: "wizard" | "form" | "auto";
@@ -553,14 +665,14 @@ function defaultFooter(
553
665
  isFormMode && !labels.hideCancel && createElement("button", {
554
666
  type: "button",
555
667
  className: "pw-shell__btn pw-shell__btn--cancel",
556
- disabled: snapshot.isNavigating,
668
+ disabled: snapshot.status !== "idle",
557
669
  onClick: actions.cancel
558
670
  }, labels.cancelLabel),
559
671
  // Wizard mode: Back on the left
560
672
  !isFormMode && !snapshot.isFirstStep && createElement("button", {
561
673
  type: "button",
562
674
  className: "pw-shell__btn pw-shell__btn--back",
563
- disabled: snapshot.isNavigating || !snapshot.canMovePrevious,
675
+ disabled: snapshot.status !== "idle" || !snapshot.canMovePrevious,
564
676
  onClick: actions.previous
565
677
  }, labels.backLabel)
566
678
  ),
@@ -569,16 +681,18 @@ function defaultFooter(
569
681
  !isFormMode && !labels.hideCancel && createElement("button", {
570
682
  type: "button",
571
683
  className: "pw-shell__btn pw-shell__btn--cancel",
572
- disabled: snapshot.isNavigating,
684
+ disabled: snapshot.status !== "idle",
573
685
  onClick: actions.cancel
574
686
  }, labels.cancelLabel),
575
687
  // Both modes: Submit on the right
576
688
  createElement("button", {
577
689
  type: "button",
578
- className: "pw-shell__btn pw-shell__btn--next",
579
- disabled: snapshot.isNavigating,
690
+ className: cls("pw-shell__btn pw-shell__btn--next", snapshot.status !== "idle" && "pw-shell__btn--loading"),
691
+ disabled: snapshot.status !== "idle",
580
692
  onClick: actions.next
581
- }, snapshot.isLastStep ? labels.completeLabel : labels.nextLabel)
693
+ }, snapshot.status !== "idle" && labels.loadingLabel
694
+ ? labels.loadingLabel
695
+ : snapshot.isLastStep ? labels.completeLabel : labels.nextLabel)
582
696
  )
583
697
  );
584
698
  }