@daltonr/pathwrite-core 0.7.0 → 0.8.0
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 +41 -4
- package/dist/index.d.ts +158 -16
- package/dist/index.js +176 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +325 -66
package/README.md
CHANGED
|
@@ -21,6 +21,30 @@ onEnter: (ctx) => {
|
|
|
21
21
|
}
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
### ✅ Track Unsaved Changes with `isDirty`
|
|
25
|
+
```typescript
|
|
26
|
+
// In your UI component
|
|
27
|
+
const snapshot = engine.snapshot();
|
|
28
|
+
if (snapshot.isDirty) {
|
|
29
|
+
// Show "unsaved changes" warning, disable Save button until changes made, etc.
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Revert changes
|
|
33
|
+
<button onClick={() => engine.resetStep()}>Undo Changes</button>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### ✅ Compute Time on Step with `stepEnteredAt`
|
|
37
|
+
```typescript
|
|
38
|
+
// Analytics or timeout warnings
|
|
39
|
+
const snapshot = engine.snapshot();
|
|
40
|
+
const durationMs = Date.now() - snapshot.stepEnteredAt;
|
|
41
|
+
const durationSec = Math.floor(durationMs / 1000);
|
|
42
|
+
|
|
43
|
+
if (durationSec > 300) { // 5 minutes
|
|
44
|
+
console.warn("User has been on this step for 5+ minutes");
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
24
48
|
### ✅ Correlate Sub-Paths with `meta`
|
|
25
49
|
```typescript
|
|
26
50
|
// Starting sub-path
|
|
@@ -53,18 +77,26 @@ const path: PathDefinition<CourseData> = {
|
|
|
53
77
|
onLeave: (ctx) => ({ courseName: ctx.data.courseName.trim() })
|
|
54
78
|
},
|
|
55
79
|
{ id: "review" }
|
|
56
|
-
]
|
|
80
|
+
],
|
|
81
|
+
onComplete: (data) => {
|
|
82
|
+
// Called when the path completes
|
|
83
|
+
console.log("Course created:", data.courseName);
|
|
84
|
+
},
|
|
85
|
+
onCancel: (data) => {
|
|
86
|
+
// Called when the path is cancelled
|
|
87
|
+
console.log("Course creation cancelled");
|
|
88
|
+
}
|
|
57
89
|
};
|
|
58
90
|
```
|
|
59
91
|
|
|
60
92
|
| Type | Description |
|
|
61
93
|
|------|-------------|
|
|
62
|
-
| `PathDefinition<TData>` | A path's ID, title,
|
|
94
|
+
| `PathDefinition<TData>` | A path's ID, title, ordered list of step definitions, and optional `onComplete` / `onCancel` callbacks. |
|
|
63
95
|
| `PathStep<TData>` | A single step: guards, lifecycle hooks. |
|
|
64
96
|
| `PathStepContext<TData>` | Passed to every hook and guard. `data` is a **readonly snapshot copy** — return a patch to update state. |
|
|
65
97
|
| `PathSnapshot<TData>` | Point-in-time read of the engine: step ID, index, count, flags, and a copy of data. |
|
|
66
98
|
| `PathEvent` | Union of `stateChanged` (includes `cause`), `completed`, `cancelled`, and `resumed`. |
|
|
67
|
-
| `StateChangeCause` | Identifies the method that triggered a `stateChanged` event: `"start"` \| `"next"` \| `"previous"` \| `"goToStep"` \| `"goToStepChecked"` \| `"setData"` \| `"cancel"` \| `"restart"`. |
|
|
99
|
+
| `StateChangeCause` | Identifies the method that triggered a `stateChanged` event: `"start"` \| `"next"` \| `"previous"` \| `"goToStep"` \| `"goToStepChecked"` \| `"setData"` \| `"resetStep"` \| `"cancel"` \| `"restart"`. |
|
|
68
100
|
| `PathObserver` | `(event: PathEvent, engine: PathEngine) => void` — a function registered at construction time that receives every event for the engine's lifetime. |
|
|
69
101
|
| `PathEngineOptions` | `{ observers?: PathObserver[] }` — options accepted by the `PathEngine` constructor and `PathEngine.fromState()`. |
|
|
70
102
|
| `ObserverStrategy` | Union type for the five built-in trigger strategies: `"onEveryChange" \| "onNext" \| "onSubPathComplete" \| "onComplete" \| "manual"`. Import and use in your own observer factories. |
|
|
@@ -83,10 +115,15 @@ engine.next();
|
|
|
83
115
|
engine.previous();
|
|
84
116
|
engine.cancel();
|
|
85
117
|
engine.setData(key, value); // update a single data value; emits stateChanged
|
|
86
|
-
engine.
|
|
118
|
+
engine.resetStep(); // revert current step data to what it was on entry; emits stateChanged
|
|
87
119
|
engine.goToStepChecked(stepId); // jump to step by ID; checks canMoveNext / canMovePrevious first
|
|
88
120
|
engine.snapshot(); // returns PathSnapshot | null
|
|
89
121
|
|
|
122
|
+
// Key PathSnapshot fields
|
|
123
|
+
snapshot.isDirty // true if any data changed since entering this step (resets on navigation or resetStep)
|
|
124
|
+
snapshot.hasAttemptedNext // true after user clicks Next at least once on this step
|
|
125
|
+
snapshot.stepEnteredAt // Date.now() timestamp when step was entered (for analytics, timeout warnings)
|
|
126
|
+
|
|
90
127
|
// Serialization API (for persistence)
|
|
91
128
|
const state = engine.exportState(); // returns SerializedPathState | null
|
|
92
129
|
const restoredEngine = PathEngine.fromState(state, pathDefinitions, { observers: [...] });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type PathData = Record<string, unknown>;
|
|
2
2
|
/**
|
|
3
|
-
* The return type of a `
|
|
3
|
+
* The return type of a `fieldErrors` hook. Each key is a field ID; the value
|
|
4
4
|
* is an error string, or `undefined` / omitted to indicate no error for that field.
|
|
5
5
|
*
|
|
6
6
|
* Use `"_"` as a key for form-level errors that don't belong to a specific field:
|
|
@@ -16,12 +16,16 @@ export interface SerializedPathState {
|
|
|
16
16
|
data: PathData;
|
|
17
17
|
visitedStepIds: string[];
|
|
18
18
|
subPathMeta?: Record<string, unknown>;
|
|
19
|
+
stepEntryData?: PathData;
|
|
20
|
+
stepEnteredAt?: number;
|
|
19
21
|
pathStack: Array<{
|
|
20
22
|
pathId: string;
|
|
21
23
|
currentStepIndex: number;
|
|
22
24
|
data: PathData;
|
|
23
25
|
visitedStepIds: string[];
|
|
24
26
|
subPathMeta?: Record<string, unknown>;
|
|
27
|
+
stepEntryData?: PathData;
|
|
28
|
+
stepEnteredAt?: number;
|
|
25
29
|
}>;
|
|
26
30
|
_isNavigating: boolean;
|
|
27
31
|
}
|
|
@@ -51,6 +55,45 @@ export interface PathStepContext<TData extends PathData = PathData> {
|
|
|
51
55
|
*/
|
|
52
56
|
readonly isFirstEntry: boolean;
|
|
53
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* A conditional step selection placed in a path's `steps` array in place of a
|
|
60
|
+
* single `PathStep`. When the engine reaches a `StepChoice` it calls `select`
|
|
61
|
+
* to decide which of the bundled `steps` to activate. The chosen step is then
|
|
62
|
+
* treated exactly like any other step — its hooks, guards, and validation all
|
|
63
|
+
* apply normally.
|
|
64
|
+
*
|
|
65
|
+
* `StepChoice` has its own `id` (used for progress tracking and `goToStep`)
|
|
66
|
+
* while `formId` on the snapshot exposes which inner step was selected, so the
|
|
67
|
+
* UI can render the right component.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* {
|
|
72
|
+
* id: "contact-details",
|
|
73
|
+
* select: ({ data }) => data.accountType === "company" ? "company" : "individual",
|
|
74
|
+
* steps: [
|
|
75
|
+
* {
|
|
76
|
+
* id: "individual",
|
|
77
|
+
* fieldErrors: ({ data }) => ({ name: !data.name ? "Required." : undefined }),
|
|
78
|
+
* },
|
|
79
|
+
* {
|
|
80
|
+
* id: "company",
|
|
81
|
+
* fieldErrors: ({ data }) => ({ companyName: !data.companyName ? "Required." : undefined }),
|
|
82
|
+
* },
|
|
83
|
+
* ],
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export interface StepChoice<TData extends PathData = PathData> {
|
|
88
|
+
id: string;
|
|
89
|
+
title?: string;
|
|
90
|
+
meta?: Record<string, unknown>;
|
|
91
|
+
/** Called on step entry. Return the `id` of the step to activate. Throws if the returned id is not found in `steps`. */
|
|
92
|
+
select: (ctx: PathStepContext<TData>) => string;
|
|
93
|
+
steps: PathStep<TData>[];
|
|
94
|
+
/** When `true`, the engine skips this choice slot entirely (same semantics as `PathStep.shouldSkip`). */
|
|
95
|
+
shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
|
|
96
|
+
}
|
|
54
97
|
export interface PathStep<TData extends PathData = PathData> {
|
|
55
98
|
id: string;
|
|
56
99
|
title?: string;
|
|
@@ -64,7 +107,7 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
64
107
|
* by field name) so consumers do not need to duplicate validation logic in
|
|
65
108
|
* the template. Return `undefined` for a field to indicate no error.
|
|
66
109
|
*
|
|
67
|
-
* When `
|
|
110
|
+
* When `fieldErrors` is provided and `canMoveNext` is **not**, the engine
|
|
68
111
|
* automatically derives `canMoveNext` as `true` when all values are `undefined`
|
|
69
112
|
* (i.e. no messages), eliminating the need to express the same logic twice.
|
|
70
113
|
*
|
|
@@ -72,13 +115,29 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
72
115
|
*
|
|
73
116
|
* @example
|
|
74
117
|
* ```typescript
|
|
75
|
-
*
|
|
118
|
+
* fieldErrors: ({ data }) => ({
|
|
76
119
|
* name: !data.name?.trim() ? "Required." : undefined,
|
|
77
120
|
* email: !isValidEmail(data.email) ? "Invalid email address." : undefined,
|
|
78
121
|
* })
|
|
79
122
|
* ```
|
|
80
123
|
*/
|
|
81
|
-
|
|
124
|
+
fieldErrors?: (ctx: PathStepContext<TData>) => FieldErrors;
|
|
125
|
+
/**
|
|
126
|
+
* Returns a map of field ID → warning message for non-blocking advisories.
|
|
127
|
+
* Same shape as `fieldErrors`, but warnings never affect `canMoveNext` —
|
|
128
|
+
* they are purely informational. Shells render them in amber/yellow instead
|
|
129
|
+
* of red.
|
|
130
|
+
*
|
|
131
|
+
* Evaluated synchronously on every snapshot; async functions default to `{}`.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* fieldWarnings: ({ data }) => ({
|
|
136
|
+
* email: looksLikeTypo(data.email) ? "Did you mean gmail.com?" : undefined,
|
|
137
|
+
* })
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
fieldWarnings?: (ctx: PathStepContext<TData>) => FieldErrors;
|
|
82
141
|
onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
83
142
|
onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
84
143
|
/**
|
|
@@ -101,7 +160,20 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
101
160
|
export interface PathDefinition<TData extends PathData = PathData> {
|
|
102
161
|
id: string;
|
|
103
162
|
title?: string;
|
|
104
|
-
steps: PathStep<TData>[];
|
|
163
|
+
steps: (PathStep<TData> | StepChoice<TData>)[];
|
|
164
|
+
/**
|
|
165
|
+
* Optional callback invoked when this path completes (i.e. the user
|
|
166
|
+
* reaches the end of the last step). Receives the final path data.
|
|
167
|
+
* Only called for top-level paths — sub-path completion is handled by
|
|
168
|
+
* the parent step's `onSubPathComplete` hook.
|
|
169
|
+
*/
|
|
170
|
+
onComplete?: (data: TData) => void | Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Optional callback invoked when this path is cancelled. Receives the
|
|
173
|
+
* path data at the time of cancellation. Only called for top-level paths —
|
|
174
|
+
* sub-path cancellation is handled by the parent step's `onSubPathCancel` hook.
|
|
175
|
+
*/
|
|
176
|
+
onCancel?: (data: TData) => void | Promise<void>;
|
|
105
177
|
}
|
|
106
178
|
export type StepStatus = "completed" | "current" | "upcoming";
|
|
107
179
|
export interface StepSummary {
|
|
@@ -140,6 +212,12 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
140
212
|
stepId: string;
|
|
141
213
|
stepTitle?: string;
|
|
142
214
|
stepMeta?: Record<string, unknown>;
|
|
215
|
+
/**
|
|
216
|
+
* The `id` of the selected inner `PathStep` when the current position in
|
|
217
|
+
* the path is a `StepChoice`. `undefined` for ordinary steps.
|
|
218
|
+
* Use this to decide which form component to render.
|
|
219
|
+
*/
|
|
220
|
+
formId?: string;
|
|
143
221
|
stepIndex: number;
|
|
144
222
|
stepCount: number;
|
|
145
223
|
progress: number;
|
|
@@ -155,7 +233,7 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
155
233
|
rootProgress?: RootProgress;
|
|
156
234
|
/** True while an async guard or hook is executing. Use to disable navigation controls. */
|
|
157
235
|
isNavigating: boolean;
|
|
158
|
-
/** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `
|
|
236
|
+
/** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldErrors` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
|
|
159
237
|
canMoveNext: boolean;
|
|
160
238
|
/** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
|
|
161
239
|
canMovePrevious: boolean;
|
|
@@ -168,28 +246,64 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
168
246
|
* render and only appear after the user has attempted to proceed:
|
|
169
247
|
*
|
|
170
248
|
* ```svelte
|
|
171
|
-
* {#if snapshot.hasAttemptedNext && snapshot.
|
|
172
|
-
* <span class="error">{snapshot.
|
|
249
|
+
* {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
|
|
250
|
+
* <span class="error">{snapshot.fieldErrors.email}</span>
|
|
173
251
|
* {/if}
|
|
174
252
|
* ```
|
|
175
253
|
*
|
|
176
|
-
* The shell itself uses this flag to gate its own automatic `
|
|
254
|
+
* The shell itself uses this flag to gate its own automatic `fieldErrors`
|
|
177
255
|
* summary rendering — errors are never shown before the first Next attempt.
|
|
178
256
|
*/
|
|
179
257
|
hasAttemptedNext: boolean;
|
|
258
|
+
/**
|
|
259
|
+
* True if any data has changed since entering this step. Automatically computed
|
|
260
|
+
* by comparing the current data to the snapshot taken on step entry. Resets to
|
|
261
|
+
* `false` when navigating to a new step or calling `resetStep()`.
|
|
262
|
+
*
|
|
263
|
+
* Useful for "unsaved changes" warnings, disabling Save buttons until changes
|
|
264
|
+
* are made, or styling forms to indicate modifications.
|
|
265
|
+
*
|
|
266
|
+
* ```typescript
|
|
267
|
+
* {#if snapshot.isDirty}
|
|
268
|
+
* <span class="warning">You have unsaved changes</span>
|
|
269
|
+
* {/if}
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
isDirty: boolean;
|
|
273
|
+
/**
|
|
274
|
+
* Timestamp (from `Date.now()`) captured when the current step was entered.
|
|
275
|
+
* Useful for analytics, timeout warnings, or computing how long a user has
|
|
276
|
+
* been on a step.
|
|
277
|
+
*
|
|
278
|
+
* ```typescript
|
|
279
|
+
* const durationMs = Date.now() - snapshot.stepEnteredAt;
|
|
280
|
+
* const durationSec = Math.floor(durationMs / 1000);
|
|
281
|
+
* ```
|
|
282
|
+
*
|
|
283
|
+
* Resets to a new timestamp each time the step is entered (including when
|
|
284
|
+
* navigating back to a previously visited step).
|
|
285
|
+
*/
|
|
286
|
+
stepEnteredAt: number;
|
|
180
287
|
/**
|
|
181
288
|
* Field-keyed validation messages for the current step. Empty object when there are none.
|
|
182
|
-
* Use in step templates to render inline per-field errors: `snapshot.
|
|
289
|
+
* Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
|
|
183
290
|
* The shell also renders these automatically in a labeled summary box.
|
|
184
291
|
* Use `"_"` as a key for form-level (non-field-specific) errors.
|
|
185
292
|
*/
|
|
186
|
-
|
|
293
|
+
fieldErrors: Record<string, string>;
|
|
294
|
+
/**
|
|
295
|
+
* Field-keyed warning messages for the current step. Empty object when there are none.
|
|
296
|
+
* Same shape as `fieldErrors` but purely informational — warnings never block navigation.
|
|
297
|
+
* Shells render these in amber/yellow instead of red.
|
|
298
|
+
* Use `"_"` as a key for form-level (non-field-specific) warnings.
|
|
299
|
+
*/
|
|
300
|
+
fieldWarnings: Record<string, string>;
|
|
187
301
|
data: TData;
|
|
188
302
|
}
|
|
189
303
|
/**
|
|
190
304
|
* Identifies the public method that triggered a `stateChanged` event.
|
|
191
305
|
*/
|
|
192
|
-
export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "cancel" | "restart";
|
|
306
|
+
export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "resetStep" | "cancel" | "restart";
|
|
193
307
|
export type PathEvent = {
|
|
194
308
|
type: "stateChanged";
|
|
195
309
|
cause: StateChangeCause;
|
|
@@ -317,6 +431,13 @@ export declare class PathEngine {
|
|
|
317
431
|
* API consistency. */
|
|
318
432
|
cancel(): Promise<void>;
|
|
319
433
|
setData(key: string, value: unknown): Promise<void>;
|
|
434
|
+
/**
|
|
435
|
+
* Resets the current step's data to what it was when the step was entered.
|
|
436
|
+
* Useful for "Clear" or "Reset" buttons that undo changes within a step.
|
|
437
|
+
* Emits a `stateChanged` event with cause `"resetStep"`.
|
|
438
|
+
* Throws if no path is active.
|
|
439
|
+
*/
|
|
440
|
+
resetStep(): Promise<void>;
|
|
320
441
|
/** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
|
|
321
442
|
goToStep(stepId: string): Promise<void>;
|
|
322
443
|
/**
|
|
@@ -354,7 +475,20 @@ export declare class PathEngine {
|
|
|
354
475
|
private assertPathHasSteps;
|
|
355
476
|
private emit;
|
|
356
477
|
private emitStateChanged;
|
|
357
|
-
|
|
478
|
+
/** Returns the raw item at the current index — either a PathStep or a StepChoice. */
|
|
479
|
+
private getCurrentItem;
|
|
480
|
+
/**
|
|
481
|
+
* Calls `StepChoice.select` and caches the chosen inner step in
|
|
482
|
+
* `active.resolvedChoiceStep`. Clears the cache when the current item is a
|
|
483
|
+
* plain `PathStep`. Throws if `select` returns an id not present in `steps`.
|
|
484
|
+
*/
|
|
485
|
+
private cacheResolvedChoiceStep;
|
|
486
|
+
/**
|
|
487
|
+
* Returns the effective `PathStep` for the current position. When the
|
|
488
|
+
* current item is a `StepChoice`, returns the cached resolved inner step.
|
|
489
|
+
* When it is a plain `PathStep`, returns it directly.
|
|
490
|
+
*/
|
|
491
|
+
private getEffectiveStep;
|
|
358
492
|
private applyPatch;
|
|
359
493
|
private skipSteps;
|
|
360
494
|
private enterCurrentStep;
|
|
@@ -379,18 +513,26 @@ export declare class PathEngine {
|
|
|
379
513
|
/**
|
|
380
514
|
* Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
|
|
381
515
|
* When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
|
|
382
|
-
* When absent but `
|
|
516
|
+
* When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
|
|
383
517
|
* When neither is defined, returns `true`.
|
|
384
518
|
*/
|
|
385
519
|
private evaluateCanMoveNextSync;
|
|
386
520
|
/**
|
|
387
|
-
* Evaluates a
|
|
521
|
+
* Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
|
|
388
522
|
* If the hook is absent, returns `{}`.
|
|
389
523
|
* If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
|
|
390
524
|
* `undefined` values are stripped from the result — only fields with a defined message are included.
|
|
391
525
|
*
|
|
392
|
-
* **Note:** Like guards, `
|
|
526
|
+
* **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
|
|
393
527
|
* entry. Write it defensively so it does not throw when fields are absent.
|
|
394
528
|
*/
|
|
395
529
|
private evaluateFieldMessagesSync;
|
|
530
|
+
/**
|
|
531
|
+
* Compares the current step data to the snapshot taken when the step was entered.
|
|
532
|
+
* Returns `true` if any data value has changed.
|
|
533
|
+
*
|
|
534
|
+
* Performs a shallow comparison — only top-level keys are checked. Nested objects
|
|
535
|
+
* are compared by reference, not by deep equality.
|
|
536
|
+
*/
|
|
537
|
+
private computeIsDirty;
|
|
396
538
|
}
|