@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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @daltonr/pathwrite-react
|
|
2
2
|
|
|
3
|
-
React
|
|
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
|
-
|
|
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
|
-
##
|
|
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 {
|
|
18
|
+
import { PathShell, usePathContext } from "@daltonr/pathwrite-react";
|
|
19
|
+
import type { PathDefinition, PathData } from "@daltonr/pathwrite-core";
|
|
76
20
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<StepDisplay />
|
|
81
|
-
<NavButtons />
|
|
82
|
-
</PathProvider>
|
|
83
|
-
);
|
|
21
|
+
interface SignupData extends PathData {
|
|
22
|
+
name: string;
|
|
23
|
+
email: string;
|
|
84
24
|
}
|
|
85
25
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
93
|
-
const { snapshot,
|
|
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
|
-
<
|
|
284
|
-
<
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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={
|
|
517
|
-
initialData={{
|
|
54
|
+
path={signupPath}
|
|
55
|
+
initialData={{ name: "", email: "" }}
|
|
56
|
+
onComplete={(data) => console.log("Done!", data)}
|
|
518
57
|
steps={{
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
90
|
+
All returned callbacks are referentially stable — safe to pass as props or include in `useEffect` dependency arrays.
|
|
590
91
|
|
|
591
|
-
|
|
92
|
+
---
|
|
592
93
|
|
|
593
|
-
|
|
594
|
-
- Async `canMoveNext` / `canMovePrevious` default to `true` (optimistic)
|
|
595
|
-
- Async `validationMessages` default to `[]`
|
|
94
|
+
## PathShell props
|
|
596
95
|
|
|
597
|
-
|
|
96
|
+
`<PathShell>` renders a progress indicator, step content, validation messages, and navigation buttons. Step components access engine state via `usePathContext()`.
|
|
598
97
|
|
|
599
|
-
|
|
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
|
-
|
|
112
|
+
Step components rendered inside `<PathShell>` call `usePathContext()` to read `snapshot` and invoke actions — no prop drilling required.
|
|
602
113
|
|
|
603
|
-
|
|
114
|
+
---
|
|
604
115
|
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|