@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/README.md +72 -571
- package/dist/index.css +86 -0
- package/dist/index.d.ts +56 -6
- package/dist/index.js +75 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +146 -32
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
186
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
693
|
+
}, snapshot.status !== "idle" && labels.loadingLabel
|
|
694
|
+
? labels.loadingLabel
|
|
695
|
+
: snapshot.isLastStep ? labels.completeLabel : labels.nextLabel)
|
|
582
696
|
)
|
|
583
697
|
);
|
|
584
698
|
}
|