@daltonr/pathwrite-core 0.6.3 → 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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Richard Dalton
3
+ Copyright (c) 2026 Devjoy Ltd.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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, and ordered list of step definitions. |
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.goToStep(stepId); // jump to step by ID; bypasses guards and shouldSkip
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: [...] });
@@ -449,10 +486,20 @@ function ReviewStep() {
449
486
  ### What the Shell Renders During Sub-Paths
450
487
 
451
488
  When a sub-path is active:
452
- - Progress bar shows sub-path steps (parent steps disappear)
489
+ - The **root progress bar** stays visible (compact, muted) above the sub-path's progress bar so users always see their place in the main flow
490
+ - The main progress bar shows the sub-path's steps
453
491
  - Back button on sub-path's first step cancels the sub-path
454
492
  - Completing the sub-path returns to parent (parent step re-renders)
455
493
 
494
+ The snapshot includes `rootProgress` (type `RootProgress`) when `nestingLevel > 0`:
495
+
496
+ ```typescript
497
+ if (snapshot.rootProgress) {
498
+ // { pathId, stepIndex, stepCount, progress, steps }
499
+ console.log(`Main flow: step ${snapshot.rootProgress.stepIndex + 1} of ${snapshot.rootProgress.stepCount}`);
500
+ }
501
+ ```
502
+
456
503
  ### Nesting Levels
457
504
 
458
505
  Sub-paths can themselves start sub-paths (unlimited nesting). Use `snapshot.nestingLevel` to determine depth:
@@ -519,3 +566,8 @@ Throws if:
519
566
 
520
567
  The restored engine is fully functional — you can continue navigation, modify data, complete or cancel paths normally.
521
568
  ```
569
+
570
+ ---
571
+
572
+ © 2026 Devjoy Ltd. MIT License.
573
+
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 `fieldMessages` hook. Each key is a field ID; the value
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 `fieldMessages` is provided and `canMoveNext` is **not**, the engine
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
- * fieldMessages: ({ data }) => ({
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
- fieldMessages?: (ctx: PathStepContext<TData>) => FieldErrors;
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 {
@@ -110,11 +182,42 @@ export interface StepSummary {
110
182
  meta?: Record<string, unknown>;
111
183
  status: StepStatus;
112
184
  }
185
+ /**
186
+ * Controls how the shell renders progress bars when a sub-path is active.
187
+ *
188
+ * | Value | Behaviour |
189
+ * |----------------|------------------------------------------------------------------------|
190
+ * | `"merged"` | Root and sub-path bars in one card (default) |
191
+ * | `"split"` | Root and sub-path bars as separate cards |
192
+ * | `"rootOnly"` | Only the root bar — sub-path bar hidden |
193
+ * | `"activeOnly"` | Only the active (sub-path) bar — root bar hidden (pre-v0.7 behaviour) |
194
+ */
195
+ export type ProgressLayout = "merged" | "split" | "rootOnly" | "activeOnly";
196
+ /**
197
+ * Summary of the root (top-level) path's progress. Present on `PathSnapshot`
198
+ * only when `nestingLevel > 0` — i.e. a sub-path is active.
199
+ *
200
+ * Shells use this to keep the top-level progress bar visible while navigating
201
+ * a sub-path, so users never lose sight of where they are in the main flow.
202
+ */
203
+ export interface RootProgress {
204
+ pathId: string;
205
+ stepIndex: number;
206
+ stepCount: number;
207
+ progress: number;
208
+ steps: StepSummary[];
209
+ }
113
210
  export interface PathSnapshot<TData extends PathData = PathData> {
114
211
  pathId: string;
115
212
  stepId: string;
116
213
  stepTitle?: string;
117
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;
118
221
  stepIndex: number;
119
222
  stepCount: number;
120
223
  progress: number;
@@ -122,9 +225,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
122
225
  isFirstStep: boolean;
123
226
  isLastStep: boolean;
124
227
  nestingLevel: number;
228
+ /**
229
+ * Progress summary of the root (top-level) path. Only present when
230
+ * `nestingLevel > 0`. Shells use this to render a persistent top-level
231
+ * progress bar above the sub-path's own progress bar.
232
+ */
233
+ rootProgress?: RootProgress;
125
234
  /** True while an async guard or hook is executing. Use to disable navigation controls. */
126
235
  isNavigating: boolean;
127
- /** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldMessages` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
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. */
128
237
  canMoveNext: boolean;
129
238
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
130
239
  canMovePrevious: boolean;
@@ -137,28 +246,64 @@ export interface PathSnapshot<TData extends PathData = PathData> {
137
246
  * render and only appear after the user has attempted to proceed:
138
247
  *
139
248
  * ```svelte
140
- * {#if snapshot.hasAttemptedNext && snapshot.fieldMessages.email}
141
- * <span class="error">{snapshot.fieldMessages.email}</span>
249
+ * {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
250
+ * <span class="error">{snapshot.fieldErrors.email}</span>
142
251
  * {/if}
143
252
  * ```
144
253
  *
145
- * The shell itself uses this flag to gate its own automatic `fieldMessages`
254
+ * The shell itself uses this flag to gate its own automatic `fieldErrors`
146
255
  * summary rendering — errors are never shown before the first Next attempt.
147
256
  */
148
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;
149
287
  /**
150
288
  * Field-keyed validation messages for the current step. Empty object when there are none.
151
- * Use in step templates to render inline per-field errors: `snapshot.fieldMessages['email']`.
289
+ * Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
152
290
  * The shell also renders these automatically in a labeled summary box.
153
291
  * Use `"_"` as a key for form-level (non-field-specific) errors.
154
292
  */
155
- fieldMessages: Record<string, string>;
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>;
156
301
  data: TData;
157
302
  }
158
303
  /**
159
304
  * Identifies the public method that triggered a `stateChanged` event.
160
305
  */
161
- export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "cancel" | "restart";
306
+ export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "resetStep" | "cancel" | "restart";
162
307
  export type PathEvent = {
163
308
  type: "stateChanged";
164
309
  cause: StateChangeCause;
@@ -286,6 +431,13 @@ export declare class PathEngine {
286
431
  * API consistency. */
287
432
  cancel(): Promise<void>;
288
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>;
289
441
  /** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
290
442
  goToStep(stepId: string): Promise<void>;
291
443
  /**
@@ -323,7 +475,20 @@ export declare class PathEngine {
323
475
  private assertPathHasSteps;
324
476
  private emit;
325
477
  private emitStateChanged;
326
- private getCurrentStep;
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;
327
492
  private applyPatch;
328
493
  private skipSteps;
329
494
  private enterCurrentStep;
@@ -348,18 +513,26 @@ export declare class PathEngine {
348
513
  /**
349
514
  * Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
350
515
  * When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
351
- * When absent but `fieldMessages` is defined, auto-derives: `true` iff no messages.
516
+ * When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
352
517
  * When neither is defined, returns `true`.
353
518
  */
354
519
  private evaluateCanMoveNextSync;
355
520
  /**
356
- * Evaluates a fieldMessages function synchronously for inclusion in the snapshot.
521
+ * Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
357
522
  * If the hook is absent, returns `{}`.
358
523
  * If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
359
524
  * `undefined` values are stripped from the result — only fields with a defined message are included.
360
525
  *
361
- * **Note:** Like guards, `fieldMessages` is evaluated before `onEnter` runs on first
526
+ * **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
362
527
  * entry. Write it defensively so it does not throw when fields are absent.
363
528
  */
364
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;
365
538
  }