@daltonr/pathwrite-react 0.10.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.
Files changed (2) hide show
  1. package/README.md +72 -571
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @daltonr/pathwrite-react
2
2
 
3
- React hooks over `@daltonr/pathwrite-core`. Exposes path state as reactive React state via `useSyncExternalStore`, with stable action callbacks and an optional context provider.
3
+ React adapter for Pathwrite exposes path engine state as React state via `useSyncExternalStore`, with stable action callbacks and an optional context provider.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,622 +8,123 @@ React hooks over `@daltonr/pathwrite-core`. Exposes path state as reactive React
8
8
  npm install @daltonr/pathwrite-core @daltonr/pathwrite-react
9
9
  ```
10
10
 
11
- ## Exported Types
12
-
13
- For convenience, this package re-exports core types so you don't need to import from `@daltonr/pathwrite-core`:
14
-
15
- ```typescript
16
- import {
17
- PathShell, // React-specific
18
- usePath, // React-specific
19
- usePathContext, // React-specific
20
- PathProvider, // React-specific
21
- PathEngine, // Re-exported from core (value + type)
22
- PathData, // Re-exported from core
23
- PathDefinition, // Re-exported from core
24
- PathEvent, // Re-exported from core
25
- PathSnapshot, // Re-exported from core
26
- PathStep, // Re-exported from core
27
- PathStepContext, // Re-exported from core
28
- SerializedPathState // Re-exported from core
29
- } from "@daltonr/pathwrite-react";
30
- ```
11
+ Peer dependencies: React 18+
31
12
 
32
13
  ---
33
14
 
34
- ## Setup
35
-
36
- ### Option A — `usePath` hook (component-scoped)
37
-
38
- Each call to `usePath` creates an isolated path engine instance.
39
-
40
- ```tsx
41
- import { usePath } from "@daltonr/pathwrite-react";
42
-
43
- function MyPathHost() {
44
- const { snapshot, start, next, previous, cancel, setData } = usePath({
45
- onEvent(event) {
46
- console.log(event);
47
- }
48
- });
49
-
50
- return (
51
- <div>
52
- {snapshot ? (
53
- <>
54
- <h2>{snapshot.stepTitle ?? snapshot.stepId}</h2>
55
- <p>Step {snapshot.stepIndex + 1} of {snapshot.stepCount}</p>
56
- <button onClick={previous} disabled={snapshot.isNavigating}>Back</button>
57
- <button onClick={next} disabled={snapshot.isNavigating}>
58
- {snapshot.isLastStep ? "Complete" : "Next"}
59
- </button>
60
- <button onClick={cancel}>Cancel</button>
61
- </>
62
- ) : (
63
- <button onClick={() => start(myPath)}>Start Path</button>
64
- )}
65
- </div>
66
- );
67
- }
68
- ```
69
-
70
- ### Option B — `PathProvider` + `usePathContext` (shared across components)
71
-
72
- Wrap a subtree so that multiple components can read and drive the same path instance.
15
+ ## Quick start
73
16
 
74
17
  ```tsx
75
- import { PathProvider, usePathContext } from "@daltonr/pathwrite-react";
18
+ import { PathShell, usePathContext } from "@daltonr/pathwrite-react";
19
+ import type { PathDefinition, PathData } from "@daltonr/pathwrite-core";
76
20
 
77
- function App() {
78
- return (
79
- <PathProvider onEvent={(e) => console.log(e)}>
80
- <StepDisplay />
81
- <NavButtons />
82
- </PathProvider>
83
- );
21
+ interface SignupData extends PathData {
22
+ name: string;
23
+ email: string;
84
24
  }
85
25
 
86
- function StepDisplay() {
87
- const { snapshot } = usePathContext();
88
- if (!snapshot) return <p>No path running.</p>;
89
- return <h2>{snapshot.stepTitle ?? snapshot.stepId}</h2>;
90
- }
26
+ const signupPath: PathDefinition<SignupData> = {
27
+ id: "signup",
28
+ steps: [
29
+ { id: "details", title: "Your Details" },
30
+ { id: "review", title: "Review" },
31
+ ],
32
+ };
91
33
 
92
- function NavButtons() {
93
- const { snapshot, next, previous } = usePathContext();
34
+ function DetailsStep() {
35
+ const { snapshot, setData } = usePathContext<SignupData>();
94
36
  if (!snapshot) return null;
95
37
  return (
96
- <>
97
- <button onClick={previous}>Back</button>
98
- <button onClick={next}>Next</button>
99
- </>
100
- );
101
- }
102
- ```
103
-
104
- ## `usePath` API
105
-
106
- ### Options
107
-
108
- | Option | Type | Description |
109
- |--------|------|-------------|
110
- | `engine` | `PathEngine` | An externally-managed engine (e.g. from `createPersistedEngine()`). When provided, `usePath` subscribes to it instead of creating a new one; snapshot is seeded immediately from the engine's current state. The caller is responsible for the engine's lifecycle. Must be a stable reference. |
111
- | `onEvent` | `(event: PathEvent) => void` | Called for every engine event. The callback ref is kept current — changing it does **not** re-subscribe to the engine. |
112
-
113
- ### Return value
114
-
115
- | Property | Type | Description |
116
- |----------|------|-------------|
117
- | `snapshot` | `PathSnapshot \| null` | Current snapshot. `null` when no path is active. Triggers a React re-render on change. |
118
- | `start(definition, data?)` | `function` | Start or re-start a path. |
119
- | `startSubPath(definition, data?, meta?)` | `function` | Push a sub-path. Requires an active path. `meta` is returned unchanged to `onSubPathComplete` / `onSubPathCancel`. |
120
- | `next()` | `function` | Advance one step. Completes the path on the last step. |
121
- | `previous()` | `function` | Go back one step. No-op when already on the first step of a top-level path. |
122
- | `cancel()` | `function` | Cancel the active path (or sub-path). |
123
- | `goToStep(stepId)` | `function` | Jump directly to a step by ID. Calls `onLeave` / `onEnter` but bypasses guards and `shouldSkip`. |
124
- | `goToStepChecked(stepId)` | `function` | Jump to a step by ID, checking `canMoveNext` (forward) or `canMovePrevious` (backward) first. Navigation is blocked if the guard returns false. |
125
- | `setData(key, value)` | `function` | Update a single data value; triggers re-render via `stateChanged`. When `TData` is specified, `key` and `value` are type-checked against your data shape. |
126
- | `restart(definition, data?)` | `function` | Tear down any active path (without firing hooks) and start the given path fresh. Safe to call at any time. Use for "Start over" / retry flows. |
127
-
128
- All action callbacks are **referentially stable** — safe to pass as props or include in dependency arrays without causing unnecessary re-renders.
129
-
130
- ### Typed snapshot data
131
-
132
- `usePath` and `usePathContext` accept an optional generic so that `snapshot.data` is typed:
133
-
134
- ```tsx
135
- interface FormData extends PathData {
136
- name: string;
137
- age: number;
138
- }
139
-
140
- const { snapshot } = usePath<FormData>();
141
- snapshot?.data.name; // string
142
- snapshot?.data.age; // number
143
- ```
144
-
145
- The generic is a **type-level assertion** — it narrows `snapshot.data` for convenience but is not enforced at runtime. Define your data shape once in a `PathDefinition<FormData>` and the types will stay consistent throughout.
146
-
147
- `setData` is also typed against `TData` — passing a wrong key or mismatched value type is a compile-time error:
148
-
149
- ```tsx
150
- setData("name", 42); // ✗ TS error: number is not assignable to string
151
- setData("typo", "x"); // ✗ TS error: "typo" is not a key of FormData
152
- setData("name", "Alice"); // ✓
153
- ```
154
-
155
- ### Snapshot guard booleans
156
-
157
- The snapshot includes `canMoveNext` and `canMovePrevious` — the evaluated results of the current step's navigation guards. Use them to proactively disable buttons:
158
-
159
- ```tsx
160
- <button onClick={previous} disabled={snapshot.isNavigating || !snapshot.canMovePrevious}>Back</button>
161
- <button onClick={next} disabled={snapshot.isNavigating || !snapshot.canMoveNext}>Next</button>
162
- ```
163
-
164
- These update automatically when data changes (e.g. after `setData`). Async guards default to `true` optimistically.
165
-
166
- ## Context sharing
167
-
168
- ### `PathProvider` + `usePathContext`
169
-
170
- Wrap a subtree in `<PathProvider>` so multiple components share the same engine instance. Consume with `usePathContext()`.
171
-
172
- ---
173
-
174
- ## Default UI — `PathShell`
175
-
176
- `<PathShell>` is a ready-made shell component that renders a progress indicator, step content, and navigation buttons. Pass a `steps` map to define per-step content.
177
-
178
- ```tsx
179
- import { PathShell } from "@daltonr/pathwrite-react";
180
-
181
- <PathShell
182
- path={myPath}
183
- initialData={{ name: "" }}
184
- onComplete={(data) => console.log("Done!", data)}
185
- steps={{
186
- details: <DetailsForm />,
187
- review: <ReviewPanel />,
188
- }}
189
- />
190
- ```
191
-
192
- > **⚠️ Important: `steps` Keys Must Match Step IDs**
193
- >
194
- > The keys in the `steps` object **must exactly match** the step IDs from your path definition:
195
- >
196
- > ```typescript
197
- > const myPath: PathDefinition = {
198
- > id: 'signup',
199
- > steps: [
200
- > { id: 'details' }, // ← Step ID
201
- > { id: 'review' } // ← Step ID
202
- > ]
203
- > };
204
- > ```
205
- >
206
- > ```tsx
207
- > <PathShell
208
- > path={myPath}
209
- > steps={{
210
- > details: <DetailsForm />, // ✅ Matches "details" step
211
- > review: <ReviewPanel />, // ✅ Matches "review" step
212
- > foo: <FooPanel /> // ❌ No step with id "foo"
213
- > }}
214
- > />
215
- > ```
216
- >
217
- > If a key doesn't match any step ID, PathShell will render:
218
- > **`No content for step "foo"`**
219
- >
220
- > **💡 Tip:** Use your IDE's "Go to Definition" on the step ID in your path definition, then copy-paste the exact string when creating the `steps` object. This ensures perfect matching and avoids typos.
221
-
222
- ### Props
223
-
224
- | Prop | Type | Default | Description |
225
- |------|------|---------|-------------|
226
- | `path` | `PathDefinition` | *required* | The path to run. |
227
- | `engine` | `PathEngine` | — | An externally-managed engine. When provided, `PathShell` skips its own `start()` and drives the UI from this engine. |
228
- | `steps` | `Record<string, ReactNode>` | *required* | Map of step ID → content to render. |
229
- | `initialData` | `PathData` | `{}` | Initial data passed to `engine.start()`. |
230
- | `autoStart` | `boolean` | `true` | Start the path automatically on mount. |
231
- | `onComplete` | `(data: PathData) => void` | — | Called when the path completes. |
232
- | `onCancel` | `(data: PathData) => void` | — | Called when the path is cancelled. |
233
- | `onEvent` | `(event: PathEvent) => void` | — | Called for every engine event. |
234
- | `backLabel` | `string` | `"Previous"` | Previous button label. |
235
- | `nextLabel` | `string` | `"Next"` | Next button label. |
236
- | `completeLabel` | `string` | `"Complete"` | Complete button label (last step). |
237
- | `cancelLabel` | `string` | `"Cancel"` | Cancel button label. |
238
- | `hideCancel` | `boolean` | `false` | Hide the Cancel button. |
239
- | `hideProgress` | `boolean` | `false` | Hide the progress indicator. Also hidden automatically for single-step top-level paths. |
240
- | `footerLayout` | `"wizard" \| "form" \| "auto"` | `"auto"` | Footer button layout. `"auto"` uses `"form"` for single-step top-level paths, `"wizard"` otherwise. `"wizard"`: Back on left, Cancel+Submit on right. `"form"`: Cancel on left, Submit on right, no Back button. |
241
- | `className` | `string` | — | Extra CSS class on the root element. |
242
- | `renderHeader` | `(snapshot) => ReactNode` | — | Render prop to replace the progress header. |
243
- | `renderFooter` | `(snapshot, actions) => ReactNode` | — | Render prop to replace the navigation footer. |
244
-
245
- ### Eager JSX evaluation
246
-
247
- The `steps` prop is a plain `Record<string, ReactNode>`. React evaluates every JSX
248
- expression in the map when `<PathShell>` renders — all step content is instantiated
249
- up-front, even though only one step is visible at a time.
250
-
251
- For most step components this is negligible: the components are not mounted (no
252
- `useEffect` or lifecycle code runs) for off-screen steps. The cost is JSX object
253
- creation only.
254
-
255
- If a step's JSX expression itself is expensive (e.g. it calls a function inline on
256
- every render), move that work inside the component:
257
-
258
- ```tsx
259
- // ❌ buildList() runs on every PathShell render, even when "review" is not current
260
- <PathShell steps={{ review: <ReviewStep items={buildList()} /> }} />
261
-
262
- // ✅ buildList() only runs when ReviewStep mounts
263
- <PathShell steps={{ review: <ReviewStep /> }} />
264
- ```
265
-
266
- > If you need deferred module loading (code splitting), wrap the component with
267
- > `React.lazy` and a `<Suspense>` boundary inside the step — not around the `steps`
268
- > map entry.
269
-
270
- ### Customising the header and footer
271
-
272
- Use `renderHeader` and `renderFooter` to replace the built-in progress bar or navigation buttons with your own UI. Both receive the current `PathSnapshot`; `renderFooter` also receives a `PathShellActions` object with all navigation callbacks.
273
-
274
- ```tsx
275
- <PathShell
276
- path={myPath}
277
- steps={{ details: <DetailsForm />, review: <ReviewPanel /> }}
278
- renderHeader={(snapshot) => (
279
- <p>{snapshot.stepIndex + 1} / {snapshot.stepCount} — {snapshot.stepTitle}</p>
280
- )}
281
- renderFooter={(snapshot, actions) => (
282
38
  <div>
283
- <button onClick={actions.previous} disabled={snapshot.isFirstStep}>Back</button>
284
- <button onClick={actions.next} disabled={!snapshot.canMoveNext}>
285
- {snapshot.isLastStep ? "Complete" : "Next"}
286
- </button>
39
+ <input value={snapshot.data.name} onChange={(e) => setData("name", e.target.value)} placeholder="Name" />
40
+ <input value={snapshot.data.email} onChange={(e) => setData("email", e.target.value)} placeholder="Email" />
287
41
  </div>
288
- )}
289
- />
290
- ```
291
-
292
- `PathShellActions` contains: `next`, `previous`, `cancel`, `goToStep`, `goToStepChecked`, `setData`, `restart`.
293
-
294
- ### Resetting the path
295
-
296
- Use the `key` prop to reset `<PathShell>` to step 1. Changing `key` forces React to discard the old component and mount a fresh one — this is idiomatic React and requires no new API:
297
-
298
- ```tsx
299
- const [formKey, setFormKey] = useState(0);
300
-
301
- <PathShell
302
- key={formKey}
303
- path={myPath}
304
- initialData={{ name: "" }}
305
- onComplete={handleDone}
306
- steps={{ details: <DetailsForm /> }}
307
- />
308
-
309
- <button onClick={() => setFormKey(k => k + 1)}>Try Again</button>
310
- ```
311
-
312
- Incrementing `formKey` discards the old shell and mounts a completely fresh one — path engine, child component state, and DOM are all reset.
313
-
314
- If your "Try Again" button is inside the success/cancelled panel you conditionally render after completion, the pattern is even simpler:
315
-
316
- ```tsx
317
- const [isActive, setIsActive] = useState(true);
318
-
319
- {isActive
320
- ? <PathShell path={myPath} onComplete={() => setIsActive(false)} steps={...} />
321
- : <SuccessPanel onRetry={() => setIsActive(true)} />
322
- }
323
- ```
324
-
325
- React function components have no instance, so there is no `ref.restart()` method. The `key` prop achieves the same result and is the React-idiomatic way to reset any component tree.
326
-
327
- ### Context sharing
328
-
329
- `<PathShell>` provides a path context automatically — step components rendered inside it can call `usePathContext()` without a separate `<PathProvider>`:
330
-
331
- ```tsx
332
- function DetailsForm() {
333
- const { snapshot, setData } = usePathContext();
334
- return (
335
- <input
336
- value={String(snapshot?.data.name ?? "")}
337
- onChange={(e) => setData("name", e.target.value)}
338
- />
339
42
  );
340
43
  }
341
44
 
342
- <PathShell
343
- path={myPath}
344
- initialData={{ name: "" }}
345
- onComplete={handleDone}
346
- steps={{ details: <DetailsForm />, review: <ReviewPanel /> }}
347
- />
348
- ```
349
-
350
- ---
351
-
352
- ## Styling
353
-
354
- `<PathShell>` renders structural HTML with BEM-style `pw-shell__*` CSS classes but ships with no embedded styles. Import the optional stylesheet for sensible defaults:
355
-
356
- ```ts
357
- import "@daltonr/pathwrite-react/styles.css";
358
- ```
359
-
360
- All visual values are CSS custom properties (`--pw-*`), so you can theme without overriding selectors:
361
-
362
- ```css
363
- :root {
364
- --pw-color-primary: #8b5cf6;
365
- --pw-shell-radius: 12px;
366
- }
367
- ```
368
-
369
- ### Available CSS Custom Properties
370
-
371
- **Layout:**
372
- - `--pw-shell-max-width` — Maximum width of the shell (default: `720px`)
373
- - `--pw-shell-padding` — Internal padding (default: `24px`)
374
- - `--pw-shell-gap` — Gap between header, body, footer (default: `20px`)
375
- - `--pw-shell-radius` — Border radius for cards (default: `10px`)
376
-
377
- **Colors:**
378
- - `--pw-color-bg` — Background color (default: `#ffffff`)
379
- - `--pw-color-border` — Border color (default: `#dbe4f0`)
380
- - `--pw-color-text` — Primary text color (default: `#1f2937`)
381
- - `--pw-color-muted` — Muted text color (default: `#5b677a`)
382
- - `--pw-color-primary` — Primary/accent color (default: `#2563eb`)
383
- - `--pw-color-primary-light` — Light primary for backgrounds (default: `rgba(37, 99, 235, 0.12)`)
384
- - `--pw-color-btn-bg` — Button background (default: `#f8fbff`)
385
- - `--pw-color-btn-border` — Button border (default: `#c2d0e5`)
386
-
387
- **Validation:**
388
- - `--pw-color-error` — Error text color (default: `#dc2626`)
389
- - `--pw-color-error-bg` — Error background (default: `#fef2f2`)
390
- - `--pw-color-error-border` — Error border (default: `#fecaca`)
391
-
392
- **Progress Indicator:**
393
- - `--pw-dot-size` — Step dot size (default: `32px`)
394
- - `--pw-dot-font-size` — Font size inside dots (default: `13px`)
395
- - `--pw-track-height` — Progress track height (default: `4px`)
396
-
397
- **Buttons:**
398
- - `--pw-btn-padding` — Button padding (default: `8px 16px`)
399
- - `--pw-btn-radius` — Button border radius (default: `6px`)
400
-
401
- ---
402
-
403
- ## Sub-Paths
404
-
405
- Sub-paths allow you to nest multi-step workflows. Common use cases include:
406
- - Running a child workflow per collection item (e.g., approve each document)
407
- - Conditional drill-down flows (e.g., "Add payment method" modal)
408
- - Reusable wizard components
409
-
410
- ### Basic Sub-Path Flow
411
-
412
- When a sub-path is active:
413
- - The shell switches to show the sub-path's steps
414
- - The progress bar displays sub-path steps (not main path steps)
415
- - Pressing Back on the first sub-path step **cancels** the sub-path and returns to the parent
416
- - `usePathContext()` returns the **sub-path** snapshot, not the parent's
417
-
418
- ### Complete Example: Approver Collection
419
-
420
- ```tsx
421
- import type { PathData, PathDefinition } from "@daltonr/pathwrite-core";
422
-
423
- // Sub-path data shape
424
- interface ApproverReviewData extends PathData {
425
- decision: "approve" | "reject" | "";
426
- comments: string;
427
- }
428
-
429
- // Main path data shape
430
- interface ApprovalWorkflowData extends PathData {
431
- documentTitle: string;
432
- approvers: string[];
433
- approvals: Array<{ approver: string; decision: string; comments: string }>;
45
+ function ReviewStep() {
46
+ const { snapshot } = usePathContext<SignupData>();
47
+ if (!snapshot) return null;
48
+ return <p>Signing up as {snapshot.data.name} ({snapshot.data.email})</p>;
434
49
  }
435
50
 
436
- // Define the sub-path (approver review wizard)
437
- const approverReviewPath: PathDefinition<ApproverReviewData> = {
438
- id: "approver-review",
439
- steps: [
440
- { id: "review", title: "Review Document" },
441
- {
442
- id: "decision",
443
- title: "Make Decision",
444
- canMoveNext: ({ data }) =>
445
- data.decision === "approve" || data.decision === "reject",
446
- validationMessages: ({ data }) =>
447
- !data.decision ? ["Please select Approve or Reject"] : []
448
- },
449
- { id: "comments", title: "Add Comments" }
450
- ]
451
- };
452
-
453
- // Define the main path
454
- const approvalWorkflowPath: PathDefinition<ApprovalWorkflowData> = {
455
- id: "approval-workflow",
456
- steps: [
457
- {
458
- id: "setup",
459
- title: "Setup Approval",
460
- canMoveNext: ({ data }) =>
461
- (data.documentTitle ?? "").trim().length > 0 &&
462
- data.approvers.length > 0
463
- },
464
- {
465
- id: "run-approvals",
466
- title: "Collect Approvals",
467
- // Block "Next" until all approvers have completed their reviews
468
- canMoveNext: ({ data }) =>
469
- data.approvals.length === data.approvers.length,
470
- validationMessages: ({ data }) => {
471
- const remaining = data.approvers.length - data.approvals.length;
472
- return remaining > 0
473
- ? [`${remaining} approver(s) pending review`]
474
- : [];
475
- },
476
- // When an approver finishes their sub-path, record the result
477
- onSubPathComplete(subPathId, subPathData, ctx, meta) {
478
- const approverName = meta?.approverName as string;
479
- const result = subPathData as ApproverReviewData;
480
- return {
481
- approvals: [
482
- ...ctx.data.approvals,
483
- {
484
- approver: approverName,
485
- decision: result.decision,
486
- comments: result.comments
487
- }
488
- ]
489
- };
490
- },
491
- // If an approver cancels (presses Back on first step), you can track it
492
- onSubPathCancel(subPathId, subPathData, ctx, meta) {
493
- console.log(`${meta?.approverName} cancelled their review`);
494
- // Optionally return data changes, or just log
495
- }
496
- },
497
- { id: "summary", title: "Summary" }
498
- ]
499
- };
500
-
501
- // Component
502
- function ApprovalWorkflow() {
503
- const { startSubPath } = usePathContext<ApprovalWorkflowData>();
504
-
505
- function launchReviewForApprover(approverName: string, index: number) {
506
- // Pass correlation data via `meta` — it's echoed back to onSubPathComplete
507
- startSubPath(
508
- approverReviewPath,
509
- { decision: "", comments: "" },
510
- { approverName, approverIndex: index }
511
- );
512
- }
513
-
51
+ export function SignupFlow() {
514
52
  return (
515
53
  <PathShell
516
- path={approvalWorkflowPath}
517
- initialData={{ documentTitle: "", approvers: [], approvals: [] }}
54
+ path={signupPath}
55
+ initialData={{ name: "", email: "" }}
56
+ onComplete={(data) => console.log("Done!", data)}
518
57
  steps={{
519
- setup: <SetupStep />,
520
- "run-approvals": <RunApprovalsStep onLaunchReview={launchReviewForApprover} />,
521
- summary: <SummaryStep />,
522
- // Sub-path steps (must be co-located in the same steps map)
523
- review: <ReviewDocumentStep />,
524
- decision: <MakeDecisionStep />,
525
- comments: <AddCommentsStep />
58
+ details: <DetailsStep />,
59
+ review: <ReviewStep />,
526
60
  }}
527
61
  />
528
62
  );
529
63
  }
530
64
  ```
531
65
 
532
- ### Key Notes
533
-
534
- **1. Sub-path steps must be co-located with main path steps**
535
- All step content (main path + sub-path steps) lives in the same `steps` prop. When a sub-path is active, the shell renders the sub-path's step content. This means:
536
- - Parent and sub-path step IDs **must not collide** (e.g., don't use `summary` in both)
537
- - Sub-path step components can access parent data by referencing the parent path definition, but `usePathContext()` returns the **sub-path** snapshot
538
-
539
- **2. The `meta` correlation field**
540
- `startSubPath` accepts an optional third argument (`meta`) that is returned unchanged to `onSubPathComplete` and `onSubPathCancel`. Use it to correlate which collection item triggered the sub-path:
541
-
542
- ```tsx
543
- startSubPath(subPath, initialData, { itemIndex: 3, itemId: "abc" });
544
-
545
- // In the parent step:
546
- onSubPathComplete(subPathId, subPathData, ctx, meta) {
547
- const itemIndex = meta?.itemIndex; // 3
548
- }
549
- ```
550
-
551
- **3. Root progress bar persists during sub-paths**
552
- When `snapshot.nestingLevel > 0`, you're in a sub-path. The shell automatically renders a compact, muted **root progress bar** above the sub-path's own progress bar so users always see their place in the main flow. The `steps` array in the snapshot contains the sub-path's steps. Use `snapshot.rootProgress` (type `RootProgress`) in custom headers to render your own persistent top-level indicator.
553
-
554
- **4. Accessing parent path data from sub-path components**
555
- There is currently no `useParentPathContext()` hook. If a sub-path step needs parent data (e.g., the document title), pass it via `initialData` when calling `startSubPath`:
556
-
557
- ```tsx
558
- startSubPath(approverReviewPath, {
559
- decision: "",
560
- comments: "",
561
- documentTitle: snapshot.data.documentTitle // copy from parent
562
- });
563
- ```
66
+ Step components call `usePathContext()` to access engine state — no prop drilling needed. `<PathShell>` provides the context automatically.
564
67
 
565
68
  ---
566
69
 
567
- ## Guards and Lifecycle Hooks
568
-
569
- ### Defensive Guards (Important!)
570
-
571
- **Guards and `validationMessages` are evaluated *before* `onEnter` runs on first entry.**
572
-
573
- If you access fields in a guard that `onEnter` is supposed to initialize, the guard will throw a `TypeError` on startup. Write guards defensively using nullish coalescing:
70
+ ## usePath
574
71
 
575
- ```ts
576
- // ✗ Unsafe — crashes if data.name is undefined
577
- canMoveNext: ({ data }) => data.name.trim().length > 0
72
+ `usePath<TData, TServices>()` creates an isolated path engine instance scoped to the calling component. Use it when you need manual control over the shell UI.
578
73
 
579
- // Safe handles undefined gracefully
580
- canMoveNext: ({ data }) => (data.name ?? "").trim().length > 0
581
- ```
582
-
583
- Alternatively, pass `initialData` to `start()` / `<PathShell>` so all fields are present from the first snapshot:
584
-
585
- ```tsx
586
- <PathShell path={myPath} initialData={{ name: "", age: 0 }} />
587
- ```
74
+ | Return value | Type | Description |
75
+ |---|---|---|
76
+ | `snapshot` | `PathSnapshot \| null` | Current snapshot. `null` when no path is active. Triggers re-render on change. |
77
+ | `start(definition, data?)` | function | Start or re-start a path. |
78
+ | `next()` | function | Advance one step. Completes the path on the last step. |
79
+ | `previous()` | function | Go back one step. No-op on the first step of a top-level path. |
80
+ | `cancel()` | function | Cancel the active path or sub-path. |
81
+ | `goToStep(stepId)` | function | Jump to a step by ID, bypassing guards and `shouldSkip`. |
82
+ | `goToStepChecked(stepId)` | function | Jump to a step by ID, checking the relevant navigation guard first. |
83
+ | `setData(key, value)` | function | Update a single data field. Type-checked when `TData` is provided. |
84
+ | `resetStep()` | function | Re-run `onEnter` for the current step without changing step index. |
85
+ | `startSubPath(definition, data?, meta?)` | function | Push a sub-path. `meta` is echoed back to `onSubPathComplete` / `onSubPathCancel`. |
86
+ | `suspend()` | function | Suspend an async step while work completes. |
87
+ | `retry()` | function | Retry the current step after a suspension or error. |
88
+ | `restart(definition, data?)` | function | Tear down the active path without firing hooks and start fresh. |
588
89
 
589
- If a guard throws, the engine catches it, logs a warning, and returns `true` (allow navigation) as a safe default.
90
+ All returned callbacks are referentially stable safe to pass as props or include in `useEffect` dependency arrays.
590
91
 
591
- ### Async Guards and Validation Messages
92
+ ---
592
93
 
593
- Guards and `validationMessages` must be **synchronous** for inclusion in snapshots. Async functions are detected and warned about:
594
- - Async `canMoveNext` / `canMovePrevious` default to `true` (optimistic)
595
- - Async `validationMessages` default to `[]`
94
+ ## PathShell props
596
95
 
597
- The async version is still enforced during actual navigation (when you call `next()` / `previous()`), but the snapshot won't reflect the pending state. If you need async validation, perform it in the guard and store the result in `data` so the guard can read it synchronously.
96
+ `<PathShell>` renders a progress indicator, step content, validation messages, and navigation buttons. Step components access engine state via `usePathContext()`.
598
97
 
599
- ### `isFirstEntry` Flag
98
+ | Prop | Type | Default | Description |
99
+ |---|---|---|---|
100
+ | `path` | `PathDefinition` | required | The path to run. |
101
+ | `steps` | `Record<string, ReactNode>` | required | Map of step ID to content. Keys must exactly match step IDs. |
102
+ | `initialData` | `PathData` | `{}` | Initial data passed to `engine.start()`. |
103
+ | `onComplete` | `(data: PathData) => void` | — | Called when the path completes. |
104
+ | `onCancel` | `(data: PathData) => void` | — | Called when the path is cancelled. |
105
+ | `engine` | `PathEngine` | — | An externally-managed engine. When provided, `PathShell` skips its own `start()`. |
106
+ | `validationDisplay` | `"summary" \| "inline" \| "both"` | `"summary"` | Where `fieldErrors` are rendered. Use `"inline"` so step components render their own errors. |
107
+ | `loadingLabel` | `string` | `"Loading…"` | Label shown during async step suspension. |
108
+ | `footerLayout` | `"wizard" \| "form" \| "auto"` | `"auto"` | `"wizard"`: Back on left, Cancel+Submit on right. `"form"`: Cancel on left, Submit on right, no Back. `"auto"` picks `"form"` for single-step paths. |
109
+ | `hideProgress` | `boolean` | `false` | Hide the progress indicator. Also hidden automatically for single-step top-level paths. |
110
+ | `services` | `TServices` | — | Services object injected into step lifecycle hooks via `PathStepContext`. |
600
111
 
601
- The `PathStepContext` passed to all hooks includes an `isFirstEntry: boolean` flag. It's `true` the first time a step is visited, `false` on re-entry (e.g., after navigating back then forward again).
112
+ Step components rendered inside `<PathShell>` call `usePathContext()` to read `snapshot` and invoke actions no prop drilling required.
602
113
 
603
- Use it to distinguish initialization from re-entry:
114
+ ---
604
115
 
605
- ```ts
606
- {
607
- id: "details",
608
- onEnter: ({ isFirstEntry, data }) => {
609
- if (isFirstEntry) {
610
- // Only pre-fill on first visit, not when returning via Back
611
- return { name: "Default Name" };
612
- }
613
- }
614
- }
615
- ```
116
+ ## usePathContext
616
117
 
617
- **Important:** `onEnter` fires every time you enter the step. If you want "initialize once" behavior, either:
618
- 1. Use `isFirstEntry` to conditionally return data
619
- 2. Provide `initialData` to `start()` instead of using `onEnter`
118
+ `usePathContext<TData, TServices>()` reads the engine instance provided by the nearest `<PathShell>` or `<PathProvider>` ancestor. It returns the same shape as `usePath` `snapshot`, `next`, `previous`, `cancel`, `setData`, and the rest of the action callbacks. Pass your data type as `TData` to get typed access to `snapshot.data` and `setData`; pass `TServices` to type the `services` field on `PathStepContext`. Throws if called outside a provider.
620
119
 
621
120
  ---
622
121
 
623
- ## Design notes
122
+ ## Further reading
624
123
 
124
+ - [React getting started guide](../../docs/getting-started/frameworks/react.md)
125
+ - [Navigation guide](../../docs/guides/navigation.md)
126
+ - [Full docs](../../docs/README.md)
625
127
 
626
128
  ---
627
129
 
628
130
  © 2026 Devjoy Ltd. MIT License.
629
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daltonr/pathwrite-react",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React adapter for @daltonr/pathwrite-core — hooks, context provider, and optional <PathShell> default UI.",
@@ -46,7 +46,7 @@
46
46
  "react": ">=18.0.0"
47
47
  },
48
48
  "dependencies": {
49
- "@daltonr/pathwrite-core": "^0.10.0"
49
+ "@daltonr/pathwrite-core": "^0.10.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "react": "^18.3.1",