@daltonr/pathwrite-svelte 0.11.0 → 0.13.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 CHANGED
@@ -70,16 +70,20 @@ Peer dependencies: Svelte 5+.
70
70
 
71
71
  | Return value | Type | Description |
72
72
  |---|---|---|
73
- | `snapshot` | `PathSnapshot \| null` | Reactive getter. `null` when no path is active. |
73
+ | `snapshot` | `PathSnapshot \| null` | Reactive getter. `null` when no path is active or when `completionBehaviour: "dismiss"` is used. With the default `"stayOnFinal"`, a non-null snapshot with `status === "completed"` is returned after the path finishes. |
74
74
  | `start(definition, data?)` | `Promise<void>` | Start or restart a path. |
75
- | `restart(definition, data?)` | `Promise<void>` | Tear down any active path and start fresh. |
75
+ | `restart()` | `Promise<void>` | Tear down any active path (without firing hooks) and restart the root path with the `initialData` from the original `start()`. Takes no arguments; rejects if nothing has been started. |
76
76
  | `next()` | `Promise<void>` | Advance one step. Completes on the last step. |
77
77
  | `previous()` | `Promise<void>` | Go back one step. No-op on the first step of a top-level path. |
78
78
  | `cancel()` | `Promise<void>` | Cancel the active path (or sub-path). |
79
79
  | `goToStep(stepId)` | `Promise<void>` | Jump to a step by ID. Calls `onLeave`/`onEnter`; bypasses guards. |
80
80
  | `goToStepChecked(stepId)` | `Promise<void>` | Jump to a step by ID, checking the current step's guard first. |
81
81
  | `setData(key, value)` | `Promise<void>` | Update a single data field. Type-safe when `TData` is specified. |
82
+ | `resetStep()` | `Promise<void>` | Restore the current step's data to what it was when the step was entered. Emits `stateChanged` with cause `"resetStep"`; no hooks run. |
83
+ | `retry()` | `Promise<void>` | Re-run the operation that set `snapshot.error`. Increments `retryCount` on repeated failure. No-op when there is no pending error. |
84
+ | `suspend()` | `Promise<void>` | Pause the path with intent to return. Emits `suspended`; all state and data are preserved. |
82
85
  | `startSubPath(definition, data?, meta?)` | `Promise<void>` | Push a sub-path. `meta` is returned to `onSubPathComplete`/`onSubPathCancel`. |
86
+ | `validate()` | `void` | Set `snapshot.hasValidated` without navigating. Triggers all inline field errors simultaneously. Used to validate all tabs in a nested shell at once. |
83
87
 
84
88
  **Options:**
85
89
 
@@ -95,19 +99,33 @@ Step content is supplied as Svelte 5 snippets whose names match each step's `id`
95
99
  | Prop | Type | Default | Description |
96
100
  |---|---|---|---|
97
101
  | `path` | `PathDefinition` | — | Path to run. Mutually exclusive with `engine`. |
98
- | `engine` | `PathEngine` | — | Externally-managed engine (e.g. from `restoreOrStart()`). Mutually exclusive with `path`. |
99
- | `initialData` | `PathData` | `{}` | Initial data passed to `engine.start()`. |
102
+ | `engine` | `PathEngine` | — | Externally-managed engine (e.g. from `restoreOrStart()`). Mutually exclusive with `path`. May be provided after mount (e.g. once an async `restoreOrStart()` resolves): the shell adopts it, re-subscribing and re-seeding from the new engine. Set `autoStart` to `false` if the shell should not start its own path in the meantime. |
103
+ | `initialData` | `PathData` | `{}` | Initial data passed to `engine.start()`. Overridden by the stored snapshot when `restoreKey` is set. |
100
104
  | `autoStart` | `boolean` | `true` | Start on mount. Ignored when `engine` is provided. |
101
- | `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. |
105
+ | `layout` | `"wizard" \| "form" \| "auto" \| "tabs"` | `"auto"` | `"wizard"`: Back on left, Cancel+Submit on right. `"form"`: Cancel on left, Submit on right, no Back. `"tabs"`: No progress header or footer — for tabbed interfaces. `"auto"` picks `"form"` for single-step paths. |
106
+ | `validationDisplay` | `"summary" \| "inline" \| "both"` | `"summary"` | Where `fieldErrors` are rendered. Use `"inline"` so step components render their own errors. |
107
+ | `progressLayout` | `"merged" \| "split" \| "rootOnly" \| "activeOnly"` | `"merged"` | How the root and sub-path progress bars are arranged while a sub-path is active. |
102
108
  | `hideProgress` | `boolean` | `false` | Hide the progress indicator. Also hidden automatically for single-step paths. |
109
+ | `hideFooter` | `boolean` | `false` | Hide the footer (navigation buttons). The error panel is still shown on async failure. |
103
110
  | `backLabel` | `string` | `"Previous"` | Previous button label. |
104
111
  | `nextLabel` | `string` | `"Next"` | Next button label. |
105
112
  | `completeLabel` | `string` | `"Complete"` | Complete button label (last step). |
113
+ | `loadingLabel` | `string` | `undefined` | Label for the Next/Complete button while an async operation is in progress. When unset, the button keeps its label and shows a CSS spinner. |
106
114
  | `cancelLabel` | `string` | `"Cancel"` | Cancel button label. |
107
115
  | `hideCancel` | `boolean` | `false` | Hide the Cancel button. |
116
+ | `validateWhen` | `boolean` | `false` | When `true` (including already at mount), calls `validate()` on the engine so all steps show inline errors at once. Bind to the outer snapshot's `hasAttemptedNext` when this shell is nested inside a step of an outer shell. |
117
+ | `restoreKey` | `string` | — | When set, the shell automatically saves its full state (data + active step) into the nearest outer `PathShell`'s data under this key on every change, and restores from it on remount. No-op on a top-level shell. The stored value also carries the inner engine's serialized state, so a remount restores in place: no `onEnter` / `onLeave` re-run, attempted / visited state kept. |
118
+ | `services` | `unknown` | `null` | Arbitrary services object available to step components via `usePathContext<TData, TServices>().services`. |
108
119
  | `oncomplete` | `(data: PathData) => void` | — | Called when the path finishes naturally. |
109
120
  | `oncancel` | `(data: PathData) => void` | — | Called when the path is cancelled. |
110
121
  | `onevent` | `(event: PathEvent) => void` | — | Called for every engine event. |
122
+ | `header` | `Snippet<[PathSnapshot]>` | — | Replaces the default progress header. A custom header is shown even for single-step paths, and hidden under `hideProgress` or `layout="tabs"`. |
123
+ | `footer` | `Snippet<[PathSnapshot, PathShellActions]>` | — | Replaces the default navigation footer. `actions` contains `next`, `previous`, `cancel`, `goToStep`, `goToStepChecked`, `setData`, `restart`, `retry`, `suspend`. |
124
+ | `completion` | `Snippet<[PathSnapshot<any>]>` | — | Custom snippet rendered when `snapshot.status === "completed"` (`completionBehaviour: "stayOnFinal"`). Receives the completed snapshot. If omitted, a default "All done." panel is shown. |
125
+
126
+ The component instance also exposes `restart()` for `bind:this` refs, which restarts the path with its original `initialData` without remounting.
127
+
128
+ > **Note:** Svelte requires event/callback props to be lowercase. Unlike React/Vue/Angular, passing `onComplete`, `onCancel`, or `onEvent` (camelCase) will be silently ignored. PathShell emits a `console.warn` in development if it detects one of these common mistakes.
111
129
 
112
130
  You can also replace the built-in header and footer with custom snippets:
113
131
 
@@ -130,7 +148,7 @@ You can also replace the built-in header and footer with custom snippets:
130
148
 
131
149
  ## usePathContext
132
150
 
133
- `usePathContext<TData>()` is the preferred way for step components rendered inside `<PathShell>` to access the path engine. `<PathShell>` calls `setContext()` internally with a private `Symbol` key; `usePathContext()` calls the matching `getContext()` and returns the same interface as `usePath`. It throws a clear error if called outside a `<PathShell>` — do not use Svelte's raw `getContext()` directly, as the key is a private `Symbol` and will silently return `undefined`.
151
+ `usePathContext<TData>()` is the preferred way for step components rendered inside `<PathShell>` to access the path engine. `<PathShell>` calls `setContext()` internally with a private `Symbol` key; `usePathContext()` calls the matching `getContext()` and returns `snapshot` (typed `PathSnapshot | null` — narrow with `{#if ctx.snapshot}`), the navigation actions (`next`, `previous`, `cancel`, `goToStep`, `goToStepChecked`, `setData`, `resetStep`, `restart`, `retry`, `suspend`) and `services`; it does not expose `start`, `startSubPath` or `validate`. It throws a clear error if called outside a `<PathShell>` — do not use Svelte's raw `getContext()` directly, as the key is a private `Symbol` and will silently return `undefined`. The context is the full `usePath()` return type (`PathContext` extends `UsePathReturn`), so `start`, `startSubPath` and `validate` are available to step components too — for example to launch a sub-path from a button inside a step.
134
152
 
135
153
  ```svelte
136
154
  <script lang="ts">
@@ -147,12 +165,24 @@ You can also replace the built-in header and footer with custom snippets:
147
165
  {/if}
148
166
  ```
149
167
 
168
+ ## Other exports
169
+
170
+ | Export | Description |
171
+ |---|---|
172
+ | `bindData(getSnapshot, setData, key)` | Two-way binding helper for inputs. Returns an object with a reactive `value` getter (reads `getSnapshot()?.data[key]`) and a `set(value)` method that calls `setData(key, value)`. Example: `const name = bindData(() => path.snapshot, path.setData, "name")`, then `<input value={name.value} oninput={(e) => name.set(e.currentTarget.value)} />`. |
173
+ | `stepIdToCamelCase(id)` | Converts a hyphenated step ID to camelCase (`"cover-letter"` → `"coverLetter"`) — the conversion `<PathShell>` uses to resolve snippets for hyphenated step IDs. |
174
+ | `setPathContext(ctx)` | Sets the `PathContext` that `usePathContext()` reads, under the adapter's private `Symbol` key. Used internally by `<PathShell>`; only needed when building your own shell component. |
175
+ | `getPathContextOrNull()` | Reads the nearest ancestor `PathContext`, or `undefined` when there is none. Used internally by `<PathShell>` to reach the outer shell for `restoreKey`; call it before `setPathContext()` so it reads the parent rather than self. |
176
+ | `formatFieldKey`, `errorPhaseMessage` | Re-exported from `@daltonr/pathwrite-core` for building custom summaries and error panels. |
177
+
178
+ `PathEngine` is re-exported as a **type only**. To construct an engine, import the class from `@daltonr/pathwrite-core`.
179
+
150
180
  ## Further reading
151
181
 
152
182
  - [Svelte getting started guide](../../docs/getting-started/frameworks/svelte.md)
153
- - [Navigation & guards](../../docs/guides/navigation.md)
183
+ - [Navigation & guards](../../docs/developer-guide/04-navigation.md)
154
184
  - [Full documentation](../../docs/README.md)
155
185
 
156
186
  ---
157
187
 
158
- MIT — © 2026 Devjoy Ltd.
188
+ © 2026 Devjoy Ltd. MIT License.
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
- import { usePath, setPathContext, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
4
- import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout } from './index.svelte.js';
3
+ import { usePath, setPathContext, getPathContextOrNull, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
4
+ import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout, PathShellActions } from './index.svelte.js';
5
+ import { PathEngine as PathEngineClass } from '@daltonr/pathwrite-core';
6
+ import type { SerializedPathState } from '@daltonr/pathwrite-core';
5
7
  import type { Snippet, Component } from 'svelte';
6
8
 
7
9
 
@@ -9,6 +11,12 @@
9
11
  path?: PathDefinition<any>;
10
12
  engine?: PathEngine;
11
13
  initialData?: PathData;
14
+ /**
15
+ * When set, this shell automatically saves its state into the nearest outer `PathShell`'s
16
+ * data under this key on every change, and restores from that stored state on remount.
17
+ * No-op when used on a top-level shell with no outer `PathShell` ancestor.
18
+ */
19
+ restoreKey?: string;
12
20
  autoStart?: boolean;
13
21
  backLabel?: string;
14
22
  nextLabel?: string;
@@ -22,12 +30,13 @@
22
30
  /** When true, calls `validate()` on the engine so all steps show inline errors simultaneously. Useful when this shell is nested inside a step of an outer shell: bind to the outer snapshot's `hasAttemptedNext`. */
23
31
  validateWhen?: boolean;
24
32
  /**
25
- * Footer layout mode:
33
+ * Shell layout mode:
26
34
  * - "auto" (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
27
- * - "wizard": Back button on left, Cancel and Submit together on right.
28
- * - "form": Cancel on left, Submit alone on right. Back button never shown.
35
+ * - "wizard": Progress header + Back button on left, Cancel and Submit together on right.
36
+ * - "form": Progress header + Cancel on left, Submit alone on right. Back button never shown.
37
+ * - "tabs": No progress header, no footer. Use for tabbed interfaces with a custom tab bar inside the step body.
29
38
  */
30
- footerLayout?: "wizard" | "form" | "auto";
39
+ layout?: "wizard" | "form" | "auto" | "tabs";
31
40
  /**
32
41
  * Controls whether the shell renders its auto-generated field-error summary box.
33
42
  * - `"summary"` (default): Shell renders the labeled error list below the step body.
@@ -54,7 +63,9 @@
54
63
  onevent?: (event: any) => void;
55
64
  // Optional override snippets for header and footer
56
65
  header?: Snippet<[PathSnapshot<any>]>;
57
- footer?: Snippet<[PathSnapshot<any>, object]>;
66
+ footer?: Snippet<[PathSnapshot<any>, PathShellActions]>;
67
+ /** Snippet rendered when `snapshot.status === "completed"`. Defaults to a simple "All done." panel with a restart button. */
68
+ completion?: Snippet<[PathSnapshot<any>]>;
58
69
  // All other props treated as step components keyed by step ID
59
70
  [key: string]: Component<any> | any;
60
71
  }
@@ -63,6 +74,7 @@
63
74
  path,
64
75
  engine: engineProp,
65
76
  initialData = {},
77
+ restoreKey = undefined,
66
78
  autoStart = true,
67
79
  backLabel = 'Previous',
68
80
  nextLabel = 'Next',
@@ -73,7 +85,7 @@
73
85
  hideProgress = false,
74
86
  hideFooter = false,
75
87
  validateWhen = false,
76
- footerLayout = 'auto',
88
+ layout = 'auto',
77
89
  validationDisplay = 'summary',
78
90
  progressLayout = 'merged',
79
91
  services = null,
@@ -82,42 +94,107 @@
82
94
  onevent,
83
95
  header,
84
96
  footer,
97
+ completion,
85
98
  ...stepSnippets
86
99
  }: Props = $props();
87
100
 
101
+ // Read outer PathShell context BEFORE setting our own — gives access to
102
+ // parent shell's snapshot and setData for restoreKey auto-wiring.
103
+ const outerCtx = getPathContextOrNull();
104
+
105
+ // When remounting under restoreKey, rebuild the inner engine from the state the
106
+ // previous instance exported, instead of starting the path and jumping to the
107
+ // step (which re-ran onEnter/onLeave and lost attempted / visited state).
108
+ // svelte-ignore state_referenced_locally — read once at init on purpose: restore happens at mount only
109
+ const restoredEngine: PathEngine | null = (() => {
110
+ if (engineProp || !restoreKey || !outerCtx || !path) return null;
111
+ const stored = outerCtx.snapshot?.data[restoreKey] as { serializedState?: SerializedPathState } | undefined;
112
+ if (!stored || typeof stored !== 'object' || !stored.serializedState) return null;
113
+ try {
114
+ return PathEngineClass.fromState(stored.serializedState, { [path.id]: path });
115
+ } catch {
116
+ return null; // unusable state (e.g. the path definition changed): start fresh below
117
+ }
118
+ })();
119
+ // The shell's own engine is created once; an `engine` prop — present at
120
+ // mount or arriving later — always takes precedence and is adopted by usePath.
121
+ const ownEngine: PathEngine = restoredEngine ?? new PathEngineClass();
122
+ const currentEngine = (): PathEngine => engineProp ?? ownEngine;
123
+
88
124
  // Initialize path engine
89
125
  const pathReturn = usePath({
90
- get engine() { return engineProp; },
126
+ get engine() { return currentEngine(); },
91
127
  onEvent: (event) => {
92
128
  onevent?.(event);
93
129
  if (event.type === 'completed') oncomplete?.(event.data);
94
130
  if (event.type === 'cancelled') oncancel?.(event.data);
131
+ if (restoreKey && outerCtx && event.type === 'stateChanged') {
132
+ (outerCtx.setData as unknown as (key: string, value: unknown) => Promise<void>)(
133
+ restoreKey, { ...event.snapshot, serializedState: currentEngine().exportState() }
134
+ );
135
+ }
95
136
  }
96
137
  });
97
138
 
98
- const { start, next, previous, cancel, goToStep, goToStepChecked, setData, restart: restartFn, retry, suspend } = pathReturn;
139
+ const { start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart: restartFn, retry, suspend, validate } = pathReturn;
99
140
 
100
141
  // Provide context for child step components
101
142
  setPathContext({
102
143
  get snapshot() { return pathReturn.snapshot; },
144
+ start,
145
+ startSubPath,
146
+ validate,
103
147
  next,
104
148
  previous,
105
149
  cancel,
106
150
  goToStep,
107
151
  goToStepChecked,
108
152
  setData,
109
- restart: () => restartFn(path, initialData),
153
+ resetStep,
154
+ restart: () => restartFn(),
110
155
  retry,
111
156
  suspend,
112
157
  get services() { return services; },
113
158
  });
114
159
 
160
+ // Dev-mode warning: camelCase callback props are silently ignored in Svelte.
161
+ // Warn if the user passed onComplete/onCancel/onEvent instead of the correct
162
+ // lowercase forms oncomplete/oncancel/onevent. Runs once, on mount (a closure
163
+ // — reading the props at the top level would only capture their initial value).
164
+ // `import.meta.env` is a bundler convention (Vite); the cast keeps this
165
+ // package free of Vite's ambient types.
166
+ const isDev = (import.meta as { env?: { DEV?: boolean } }).env?.DEV !== false;
167
+ onMount(() => {
168
+ if (!isDev) return;
169
+ const camelCallbacks = ['onComplete', 'onCancel', 'onEvent'] as const;
170
+ for (const name of camelCallbacks) {
171
+ if (name in stepSnippets) {
172
+ console.warn(
173
+ `[PathShell] "${name}" was passed but will be ignored. Svelte uses lowercase callback props — use "${name.toLowerCase()}" instead.`
174
+ );
175
+ }
176
+ }
177
+ });
178
+
115
179
  // Auto-start the path when no external engine is provided
116
180
  let started = false;
117
181
  onMount(() => {
118
- if (autoStart && !started && !engineProp) {
182
+ if (autoStart && !started && !engineProp && !restoredEngine) {
183
+ if (!path) throw new Error('[PathShell] "path" is required when no "engine" is provided');
119
184
  started = true;
120
- start(path, initialData);
185
+ let startData: PathData = initialData ?? {};
186
+ let restoreStepId: string | undefined;
187
+ if (restoreKey && outerCtx) {
188
+ const stored = outerCtx.snapshot?.data[restoreKey] as PathSnapshot<any> | undefined;
189
+ if (stored != null && typeof stored === 'object' && 'stepId' in stored) {
190
+ startData = stored.data as PathData;
191
+ if (stored.stepIndex > 0) restoreStepId = stored.stepId as string;
192
+ }
193
+ }
194
+ const p = start(path, startData);
195
+ if (restoreStepId) {
196
+ p.then(() => goToStep(restoreStepId!));
197
+ }
121
198
  }
122
199
  });
123
200
 
@@ -134,13 +211,20 @@
134
211
  }
135
212
 
136
213
  let snap = $derived(pathReturn.snapshot);
137
- let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restartFn(path, initialData), retry, suspend });
214
+ let actions: PathShellActions = $derived({
215
+ next, previous, cancel, goToStep, goToStepChecked,
216
+ setData: (key, value) => setData(key as never, value as never),
217
+ restart: () => restartFn(), retry, suspend
218
+ });
219
+
220
+ let effectiveHideProgress = $derived(hideProgress || layout === 'tabs');
221
+ let effectiveHideFooter = $derived(hideFooter || layout === 'tabs');
138
222
 
139
223
  // Auto-detect footer layout: single-step top-level paths use "form", everything else uses "wizard"
140
224
  let resolvedFooterLayout = $derived(
141
- footerLayout === 'auto' && snap
225
+ (layout === 'auto' || layout === 'tabs') && snap
142
226
  ? (snap.stepCount === 1 && snap.nestingLevel === 0 ? 'form' : 'wizard')
143
- : (footerLayout === 'auto' ? 'wizard' : footerLayout)
227
+ : (layout === 'auto' || layout === 'tabs' ? 'wizard' : layout)
144
228
  );
145
229
 
146
230
  /**
@@ -153,7 +237,7 @@
153
237
  * ```
154
238
  */
155
239
  export function restart(): Promise<void> {
156
- return pathReturn.restart(path, initialData);
240
+ return pathReturn.restart();
157
241
  }
158
242
  </script>
159
243
 
@@ -162,14 +246,43 @@
162
246
  <div class="pw-shell__empty">
163
247
  <p>No active path.</p>
164
248
  {#if !autoStart}
165
- <button type="button" class="pw-shell__start-btn" onclick={() => start(path, initialData)}>
249
+ <button type="button" class="pw-shell__start-btn" onclick={() => path && start(path, initialData)}>
166
250
  Start
167
251
  </button>
168
252
  {/if}
169
253
  </div>
254
+ {:else if snap.status === 'completed'}
255
+ <!-- Completion panel: shown after stayOnFinal completion -->
256
+ {#if !effectiveHideProgress && snap.stepCount > 1}
257
+ <div class="pw-shell__header">
258
+ <div class="pw-shell__steps">
259
+ {#each snap.steps as step, i}
260
+ <div class="pw-shell__step pw-shell__step--{step.status}">
261
+ <span class="pw-shell__step-dot">✓</span>
262
+ <span class="pw-shell__step-label">{step.title ?? step.id}</span>
263
+ </div>
264
+ {/each}
265
+ </div>
266
+ <div class="pw-shell__track">
267
+ <div class="pw-shell__track-fill" style="width: 100%"></div>
268
+ </div>
269
+ </div>
270
+ {/if}
271
+ <div class="pw-shell__body">
272
+ {#if completion}
273
+ {@render completion(snap)}
274
+ {:else}
275
+ <div class="pw-shell__completion">
276
+ <p class="pw-shell__completion-message">All done.</p>
277
+ <button type="button" class="pw-shell__completion-restart" onclick={() => restartFn()}>
278
+ Start over
279
+ </button>
280
+ </div>
281
+ {/if}
282
+ </div>
170
283
  {:else}
171
284
  <!-- Root progress: persistent top-level bar visible during sub-paths -->
172
- {#if !hideProgress && snap.rootProgress && progressLayout !== 'activeOnly'}
285
+ {#if !effectiveHideProgress && snap.rootProgress && progressLayout !== 'activeOnly'}
173
286
  <div class="pw-shell__root-progress">
174
287
  <div class="pw-shell__steps">
175
288
  {#each snap.rootProgress.steps as step, i}
@@ -188,7 +301,7 @@
188
301
  {/if}
189
302
 
190
303
  <!-- Header: progress indicator (overridable via header snippet) -->
191
- {#if !hideProgress && progressLayout !== 'rootOnly'}
304
+ {#if !effectiveHideProgress && progressLayout !== 'rootOnly'}
192
305
  {#if header}
193
306
  {@render header(snap)}
194
307
  {:else if snap.stepCount > 1 || snap.nestingLevel > 0}
@@ -283,9 +396,9 @@
283
396
  </div>
284
397
  </div>
285
398
  <!-- Footer: navigation buttons (overridable via footer snippet) -->
286
- {:else if !hideFooter && footer}
399
+ {:else if !effectiveHideFooter && footer}
287
400
  {@render footer(snap, actions)}
288
- {:else if !hideFooter}
401
+ {:else if !effectiveHideFooter}
289
402
  <div class="pw-shell__footer">
290
403
  <div class="pw-shell__footer-left">
291
404
  {#if resolvedFooterLayout === 'form' && !hideCancel}
@@ -1,9 +1,15 @@
1
- import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout } from './index.svelte.js';
1
+ import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout, PathShellActions } from './index.svelte.js';
2
2
  import type { Snippet, Component } from 'svelte';
3
3
  interface Props {
4
4
  path?: PathDefinition<any>;
5
5
  engine?: PathEngine;
6
6
  initialData?: PathData;
7
+ /**
8
+ * When set, this shell automatically saves its state into the nearest outer `PathShell`'s
9
+ * data under this key on every change, and restores from that stored state on remount.
10
+ * No-op when used on a top-level shell with no outer `PathShell` ancestor.
11
+ */
12
+ restoreKey?: string;
7
13
  autoStart?: boolean;
8
14
  backLabel?: string;
9
15
  nextLabel?: string;
@@ -17,12 +23,13 @@ interface Props {
17
23
  /** When true, calls `validate()` on the engine so all steps show inline errors simultaneously. Useful when this shell is nested inside a step of an outer shell: bind to the outer snapshot's `hasAttemptedNext`. */
18
24
  validateWhen?: boolean;
19
25
  /**
20
- * Footer layout mode:
26
+ * Shell layout mode:
21
27
  * - "auto" (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
22
- * - "wizard": Back button on left, Cancel and Submit together on right.
23
- * - "form": Cancel on left, Submit alone on right. Back button never shown.
28
+ * - "wizard": Progress header + Back button on left, Cancel and Submit together on right.
29
+ * - "form": Progress header + Cancel on left, Submit alone on right. Back button never shown.
30
+ * - "tabs": No progress header, no footer. Use for tabbed interfaces with a custom tab bar inside the step body.
24
31
  */
25
- footerLayout?: "wizard" | "form" | "auto";
32
+ layout?: "wizard" | "form" | "auto" | "tabs";
26
33
  /**
27
34
  * Controls whether the shell renders its auto-generated field-error summary box.
28
35
  * - `"summary"` (default): Shell renders the labeled error list below the step body.
@@ -47,7 +54,9 @@ interface Props {
47
54
  oncancel?: (data: PathData) => void;
48
55
  onevent?: (event: any) => void;
49
56
  header?: Snippet<[PathSnapshot<any>]>;
50
- footer?: Snippet<[PathSnapshot<any>, object]>;
57
+ footer?: Snippet<[PathSnapshot<any>, PathShellActions]>;
58
+ /** Snippet rendered when `snapshot.status === "completed"`. Defaults to a simple "All done." panel with a restart button. */
59
+ completion?: Snippet<[PathSnapshot<any>]>;
51
60
  [key: string]: Component<any> | any;
52
61
  }
53
62
  declare const PathShell: Component<Props, {
@@ -1 +1 @@
1
- {"version":3,"file":"PathShell.svelte.d.ts","sourceRoot":"","sources":["../src/PathShell.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC5G,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAI/C,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,WAAW,CAAC,EAAE,QAAQ,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,8HAA8H;IAC9H,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,qNAAqN;IACrN,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;IAC1C;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;IAClD;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACpC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAE/B,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACtC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;IAE9C,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;CACrC;AAqQH,QAAA,MAAM,SAAS;mBAhKQ,QAAQ,IAAI,CAAC;MAgKmB,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"PathShell.svelte.d.ts","sourceRoot":"","sources":["../src/PathShell.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAG9H,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAI/C,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,WAAW,CAAC,EAAE,QAAQ,CAAC;IACvB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,8HAA8H;IAC9H,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,qNAAqN;IACrN,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IAC7C;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;IAClD;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACpC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAE/B,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACtC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC;IACxD,6HAA6H;IAC7H,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAE1C,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;CACrC;AA6WH,QAAA,MAAM,SAAS;mBA7LQ,QAAQ,IAAI,CAAC;MA6LmB,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
package/dist/index.css CHANGED
@@ -77,6 +77,31 @@
77
77
  font-size: 14px;
78
78
  }
79
79
 
80
+ /* ------------------------------------------------------------------ */
81
+ /* Completion panel */
82
+ /* ------------------------------------------------------------------ */
83
+ .pw-shell__completion {
84
+ text-align: center;
85
+ padding: 40px 16px;
86
+ }
87
+
88
+ .pw-shell__completion-message {
89
+ font-size: 18px;
90
+ font-weight: 600;
91
+ color: var(--pw-color-text);
92
+ margin: 0 0 20px;
93
+ }
94
+
95
+ .pw-shell__completion-restart {
96
+ border: 1px solid var(--pw-color-btn-border);
97
+ background: var(--pw-color-btn-bg);
98
+ color: var(--pw-color-text);
99
+ padding: var(--pw-btn-padding);
100
+ border-radius: var(--pw-btn-radius);
101
+ cursor: pointer;
102
+ font-size: 14px;
103
+ }
104
+
80
105
  /* ------------------------------------------------------------------ */
81
106
  /* Root progress — persistent top-level bar visible during sub-paths */
82
107
  /* ------------------------------------------------------------------ */
@@ -332,6 +357,23 @@
332
357
  content: ":";
333
358
  }
334
359
 
360
+ /* ------------------------------------------------------------------ */
361
+ /* Inline field-level errors and warnings (<FieldError /> component) */
362
+ /* ------------------------------------------------------------------ */
363
+ .pw-field-error {
364
+ display: block;
365
+ font-size: 12px;
366
+ color: var(--pw-color-error);
367
+ margin-top: 4px;
368
+ }
369
+
370
+ .pw-field-warning {
371
+ display: block;
372
+ font-size: 12px;
373
+ color: var(--pw-color-warning);
374
+ margin-top: 4px;
375
+ }
376
+
335
377
  /* ------------------------------------------------------------------ */
336
378
  /* Footer — navigation buttons */
337
379
  /* ------------------------------------------------------------------ */
@@ -12,6 +12,12 @@ export interface UsePathOptions {
12
12
  * - The engine lifecycle (start / cleanup) is the **caller's responsibility**.
13
13
  * - `PathShell` will skip its own `autoStart` call.
14
14
  */
15
+ /**
16
+ * An externally managed engine. Pass it as a getter (`get engine() { … }`)
17
+ * over a reactive prop to have the hook track it: an engine that arrives
18
+ * later (e.g. from an async `restoreOrStart()`) or is swapped is adopted —
19
+ * the hook re-subscribes and re-seeds its snapshot from the new engine.
20
+ */
15
21
  engine?: PathEngine;
16
22
  /** Called for every engine event (stateChanged, completed, cancelled, resumed). */
17
23
  onEvent?: (event: PathEvent) => void;
@@ -34,17 +40,22 @@ export interface UsePathReturn<TData extends PathData = PathData> {
34
40
  previous: () => Promise<void>;
35
41
  /** Cancel the active path (or sub-path). */
36
42
  cancel: () => Promise<void>;
37
- /** Jump directly to a step by ID. Calls onLeave / onEnter but bypasses guards and shouldSkip. */
38
- goToStep: (stepId: string) => Promise<void>;
43
+ /** Jump directly to a step by ID. Calls onLeave / onEnter but bypasses guards and shouldSkip. Pass `{ validateOnLeave: true }` to mark the departing step as attempted before navigating. */
44
+ goToStep: (stepId: string, options?: {
45
+ validateOnLeave?: boolean;
46
+ }) => Promise<void>;
39
47
  /** Jump directly to a step by ID, checking the current step's canMoveNext (forward) or canMovePrevious (backward) guard first. Navigation is blocked if the guard returns false. */
40
- goToStepChecked: (stepId: string) => Promise<void>;
48
+ goToStepChecked: (stepId: string, options?: {
49
+ validateOnLeave?: boolean;
50
+ }) => Promise<void>;
41
51
  /** Update a single data value; triggers a re-render via stateChanged. When `TData` is specified, `key` and `value` are type-checked against your data shape. */
42
52
  setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
43
53
  /** Reset the current step's data to what it was when the step was entered. Useful for "Clear" or "Reset" buttons. */
44
54
  resetStep: () => Promise<void>;
45
55
  /**
46
- * Tear down any active path (without firing hooks) and immediately start the
47
- * given path fresh. Safe to call whether or not a path is currently active.
56
+ * Tear down any active path (without firing hooks) and immediately restart
57
+ * the root path with the `initialData` from the original `start()` call.
58
+ * Takes no arguments; rejects if the engine has never been started.
48
59
  * Use for "Start over" / retry flows without remounting the component.
49
60
  */
50
61
  restart: () => Promise<void>;
@@ -110,24 +121,35 @@ export interface UsePathReturn<TData extends PathData = PathData> {
110
121
  * ```
111
122
  */
112
123
  export declare function usePath<TData extends PathData = PathData>(options?: UsePathOptions): UsePathReturn<TData>;
113
- export interface PathContext<TData extends PathData = PathData, TServices = unknown> {
114
- readonly snapshot: PathSnapshot<TData>;
124
+ /**
125
+ * Navigation actions handed to a custom `footer` snippet of `<PathShell>`
126
+ * (`{#snippet footer(snap, actions)}`). Same shape as the other adapters'
127
+ * `PathShellActions`.
128
+ */
129
+ export interface PathShellActions {
115
130
  next: () => Promise<void>;
116
131
  previous: () => Promise<void>;
117
132
  cancel: () => Promise<void>;
118
- goToStep: (stepId: string) => Promise<void>;
119
- goToStepChecked: (stepId: string) => Promise<void>;
120
- setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
121
- resetStep: () => Promise<void>;
133
+ goToStep: (stepId: string, options?: {
134
+ validateOnLeave?: boolean;
135
+ }) => Promise<void>;
136
+ goToStepChecked: (stepId: string, options?: {
137
+ validateOnLeave?: boolean;
138
+ }) => Promise<void>;
139
+ setData: (key: string, value: unknown) => Promise<void>;
140
+ /** Restart the shell's current path with its current `initialData`. */
122
141
  restart: () => Promise<void>;
123
142
  /** Re-run the operation that set `snapshot.error`. */
124
143
  retry: () => Promise<void>;
125
144
  /** Pause with intent to return, preserving all state. Emits `suspended`. */
126
145
  suspend: () => Promise<void>;
127
- /**
128
- * Services object passed through context from `PathShell`.
129
- * Typed as `TServices` when `usePathContext<TData, TServices>()` is used.
130
- */
146
+ }
147
+ /**
148
+ * What step components receive from `usePathContext()`: everything `usePath()`
149
+ * returns (derived from `UsePathReturn`, so the two cannot drift apart) plus
150
+ * the `services` object given to `<PathShell>`.
151
+ */
152
+ export interface PathContext<TData extends PathData = PathData, TServices = unknown> extends UsePathReturn<TData> {
131
153
  services: TServices;
132
154
  }
133
155
  /**
@@ -156,6 +178,13 @@ export declare function usePathContext<TData extends PathData = PathData, TServi
156
178
  * Used by PathShell component.
157
179
  */
158
180
  export declare function setPathContext<TData extends PathData = PathData, TServices = unknown>(ctx: PathContext<TData, TServices>): void;
181
+ /**
182
+ * Internal: Get the PathContext from the nearest ancestor PathShell, or
183
+ * `undefined` if no PathShell is present. Used by PathShell itself to access
184
+ * the outer shell's context for `restoreKey` auto-wiring — must be called
185
+ * before `setPathContext()` so it reads the parent rather than self.
186
+ */
187
+ export declare function getPathContextOrNull<TData extends PathData = PathData, TServices = unknown>(): PathContext<TData, TServices> | undefined;
159
188
  /**
160
189
  * Create a two-way binding helper for form inputs.
161
190
  * Returns an object with a reactive `value` property.
@@ -1 +1 @@
1
- {"version":3,"file":"index.svelte.d.ts","sourceRoot":"","sources":["../src/index.svelte.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.svelte.d.ts","sourceRoot":"","sources":["../src/index.svelte.ts"],"names":[],"mappings":"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"}
@@ -60,28 +60,43 @@ export { formatFieldKey, errorPhaseMessage } from "@daltonr/pathwrite-core";
60
60
  * ```
61
61
  */
62
62
  export function usePath(options) {
63
- const engine = options?.engine ?? new PathEngineClass();
64
- // Reactive snapshot via $state rune
63
+ let ownEngine = null;
64
+ const resolveEngine = () => options?.engine ?? (ownEngine ??= new PathEngineClass());
65
+ let engine = resolveEngine();
65
66
  let _snapshot = $state(engine.snapshot());
66
- // Subscribe to engine events
67
- const unsubscribe = engine.subscribe((event) => {
67
+ const onEngineEvent = (event) => {
68
68
  if (event.type === "stateChanged" || event.type === "resumed") {
69
69
  _snapshot = event.snapshot;
70
70
  }
71
71
  else if (event.type === "completed" || event.type === "cancelled") {
72
- _snapshot = null;
72
+ _snapshot = engine.snapshot();
73
73
  }
74
74
  options?.onEvent?.(event);
75
+ };
76
+ let unsubscribe = engine.subscribe(onEngineEvent);
77
+ // Adopt a late or swapped engine (the `engine` option is a getter over a
78
+ // reactive prop): re-subscribe and re-seed the snapshot. Created in its own
79
+ // effect root so usePath() also works outside a component.
80
+ const stopWatching = $effect.root(() => {
81
+ $effect(() => {
82
+ const next = resolveEngine();
83
+ if (next === engine)
84
+ return;
85
+ unsubscribe();
86
+ engine = next;
87
+ _snapshot = engine.snapshot();
88
+ unsubscribe = engine.subscribe(onEngineEvent);
89
+ });
75
90
  });
76
91
  // Auto-cleanup when component is destroyed
77
- onDestroy(unsubscribe);
92
+ onDestroy(() => { unsubscribe(); stopWatching(); });
78
93
  const start = (path, initialData = {}) => engine.start(path, initialData);
79
94
  const startSubPath = (path, initialData = {}, meta) => engine.startSubPath(path, initialData, meta);
80
95
  const next = () => engine.next();
81
96
  const previous = () => engine.previous();
82
97
  const cancel = () => engine.cancel();
83
- const goToStep = (stepId) => engine.goToStep(stepId);
84
- const goToStepChecked = (stepId) => engine.goToStepChecked(stepId);
98
+ const goToStep = (stepId, options) => engine.goToStep(stepId, options);
99
+ const goToStepChecked = (stepId, options) => engine.goToStepChecked(stepId, options);
85
100
  const setData = ((key, value) => engine.setData(key, value));
86
101
  const resetStep = () => engine.resetStep();
87
102
  const restart = () => engine.restart();
@@ -144,6 +159,15 @@ export function usePathContext() {
144
159
  export function setPathContext(ctx) {
145
160
  setContext(PATH_CONTEXT_KEY, ctx);
146
161
  }
162
+ /**
163
+ * Internal: Get the PathContext from the nearest ancestor PathShell, or
164
+ * `undefined` if no PathShell is present. Used by PathShell itself to access
165
+ * the outer shell's context for `restoreKey` auto-wiring — must be called
166
+ * before `setPathContext()` so it reads the parent rather than self.
167
+ */
168
+ export function getPathContextOrNull() {
169
+ return getContext(PATH_CONTEXT_KEY);
170
+ }
147
171
  // ---------------------------------------------------------------------------
148
172
  // Helper for binding form inputs
149
173
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daltonr/pathwrite-svelte",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Svelte 5 adapter for @daltonr/pathwrite-core — runes-based reactive bindings and optional PathShell component.",
@@ -28,6 +28,7 @@
28
28
  "import": "./dist/index.svelte.js"
29
29
  },
30
30
  "./PathShell.svelte": {
31
+ "types": "./dist/PathShell.svelte.d.ts",
31
32
  "svelte": "./dist/PathShell.svelte",
32
33
  "import": "./dist/PathShell.svelte"
33
34
  },
@@ -44,7 +45,8 @@
44
45
  "LICENSE"
45
46
  ],
46
47
  "scripts": {
47
- "build": "svelte-package -i src -o dist && cp ../shell.css dist/index.css",
48
+ "check": "svelte-check --tsconfig ./tsconfig.check.json --fail-on-warnings",
49
+ "build": "npm run check && svelte-package -i src -o dist && cp ../shell.css dist/index.css",
48
50
  "clean": "rm -rf dist tsconfig.tsbuildinfo",
49
51
  "prepublishOnly": "test -d dist && echo 'dist already built, skipping' || (npm run clean && npm run build)"
50
52
  },
@@ -52,12 +54,15 @@
52
54
  "svelte": ">=5.0.0"
53
55
  },
54
56
  "dependencies": {
55
- "@daltonr/pathwrite-core": "^0.11.0"
57
+ "@daltonr/pathwrite-core": "^0.13.0"
56
58
  },
57
59
  "devDependencies": {
58
60
  "@sveltejs/package": "^2.5.7",
59
61
  "@sveltejs/vite-plugin-svelte": "^4.0.0",
60
62
  "@testing-library/svelte": "^5.0.0",
61
63
  "svelte": "^5.0.0"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
62
67
  }
63
68
  }
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
- import { usePath, setPathContext, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
4
- import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout } from './index.svelte.js';
3
+ import { usePath, setPathContext, getPathContextOrNull, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
4
+ import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout, PathShellActions } from './index.svelte.js';
5
+ import { PathEngine as PathEngineClass } from '@daltonr/pathwrite-core';
6
+ import type { SerializedPathState } from '@daltonr/pathwrite-core';
5
7
  import type { Snippet, Component } from 'svelte';
6
8
 
7
9
 
@@ -9,6 +11,12 @@
9
11
  path?: PathDefinition<any>;
10
12
  engine?: PathEngine;
11
13
  initialData?: PathData;
14
+ /**
15
+ * When set, this shell automatically saves its state into the nearest outer `PathShell`'s
16
+ * data under this key on every change, and restores from that stored state on remount.
17
+ * No-op when used on a top-level shell with no outer `PathShell` ancestor.
18
+ */
19
+ restoreKey?: string;
12
20
  autoStart?: boolean;
13
21
  backLabel?: string;
14
22
  nextLabel?: string;
@@ -22,12 +30,13 @@
22
30
  /** When true, calls `validate()` on the engine so all steps show inline errors simultaneously. Useful when this shell is nested inside a step of an outer shell: bind to the outer snapshot's `hasAttemptedNext`. */
23
31
  validateWhen?: boolean;
24
32
  /**
25
- * Footer layout mode:
33
+ * Shell layout mode:
26
34
  * - "auto" (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
27
- * - "wizard": Back button on left, Cancel and Submit together on right.
28
- * - "form": Cancel on left, Submit alone on right. Back button never shown.
35
+ * - "wizard": Progress header + Back button on left, Cancel and Submit together on right.
36
+ * - "form": Progress header + Cancel on left, Submit alone on right. Back button never shown.
37
+ * - "tabs": No progress header, no footer. Use for tabbed interfaces with a custom tab bar inside the step body.
29
38
  */
30
- footerLayout?: "wizard" | "form" | "auto";
39
+ layout?: "wizard" | "form" | "auto" | "tabs";
31
40
  /**
32
41
  * Controls whether the shell renders its auto-generated field-error summary box.
33
42
  * - `"summary"` (default): Shell renders the labeled error list below the step body.
@@ -54,7 +63,9 @@
54
63
  onevent?: (event: any) => void;
55
64
  // Optional override snippets for header and footer
56
65
  header?: Snippet<[PathSnapshot<any>]>;
57
- footer?: Snippet<[PathSnapshot<any>, object]>;
66
+ footer?: Snippet<[PathSnapshot<any>, PathShellActions]>;
67
+ /** Snippet rendered when `snapshot.status === "completed"`. Defaults to a simple "All done." panel with a restart button. */
68
+ completion?: Snippet<[PathSnapshot<any>]>;
58
69
  // All other props treated as step components keyed by step ID
59
70
  [key: string]: Component<any> | any;
60
71
  }
@@ -63,6 +74,7 @@
63
74
  path,
64
75
  engine: engineProp,
65
76
  initialData = {},
77
+ restoreKey = undefined,
66
78
  autoStart = true,
67
79
  backLabel = 'Previous',
68
80
  nextLabel = 'Next',
@@ -73,7 +85,7 @@
73
85
  hideProgress = false,
74
86
  hideFooter = false,
75
87
  validateWhen = false,
76
- footerLayout = 'auto',
88
+ layout = 'auto',
77
89
  validationDisplay = 'summary',
78
90
  progressLayout = 'merged',
79
91
  services = null,
@@ -82,42 +94,107 @@
82
94
  onevent,
83
95
  header,
84
96
  footer,
97
+ completion,
85
98
  ...stepSnippets
86
99
  }: Props = $props();
87
100
 
101
+ // Read outer PathShell context BEFORE setting our own — gives access to
102
+ // parent shell's snapshot and setData for restoreKey auto-wiring.
103
+ const outerCtx = getPathContextOrNull();
104
+
105
+ // When remounting under restoreKey, rebuild the inner engine from the state the
106
+ // previous instance exported, instead of starting the path and jumping to the
107
+ // step (which re-ran onEnter/onLeave and lost attempted / visited state).
108
+ // svelte-ignore state_referenced_locally — read once at init on purpose: restore happens at mount only
109
+ const restoredEngine: PathEngine | null = (() => {
110
+ if (engineProp || !restoreKey || !outerCtx || !path) return null;
111
+ const stored = outerCtx.snapshot?.data[restoreKey] as { serializedState?: SerializedPathState } | undefined;
112
+ if (!stored || typeof stored !== 'object' || !stored.serializedState) return null;
113
+ try {
114
+ return PathEngineClass.fromState(stored.serializedState, { [path.id]: path });
115
+ } catch {
116
+ return null; // unusable state (e.g. the path definition changed): start fresh below
117
+ }
118
+ })();
119
+ // The shell's own engine is created once; an `engine` prop — present at
120
+ // mount or arriving later — always takes precedence and is adopted by usePath.
121
+ const ownEngine: PathEngine = restoredEngine ?? new PathEngineClass();
122
+ const currentEngine = (): PathEngine => engineProp ?? ownEngine;
123
+
88
124
  // Initialize path engine
89
125
  const pathReturn = usePath({
90
- get engine() { return engineProp; },
126
+ get engine() { return currentEngine(); },
91
127
  onEvent: (event) => {
92
128
  onevent?.(event);
93
129
  if (event.type === 'completed') oncomplete?.(event.data);
94
130
  if (event.type === 'cancelled') oncancel?.(event.data);
131
+ if (restoreKey && outerCtx && event.type === 'stateChanged') {
132
+ (outerCtx.setData as unknown as (key: string, value: unknown) => Promise<void>)(
133
+ restoreKey, { ...event.snapshot, serializedState: currentEngine().exportState() }
134
+ );
135
+ }
95
136
  }
96
137
  });
97
138
 
98
- const { start, next, previous, cancel, goToStep, goToStepChecked, setData, restart: restartFn, retry, suspend } = pathReturn;
139
+ const { start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart: restartFn, retry, suspend, validate } = pathReturn;
99
140
 
100
141
  // Provide context for child step components
101
142
  setPathContext({
102
143
  get snapshot() { return pathReturn.snapshot; },
144
+ start,
145
+ startSubPath,
146
+ validate,
103
147
  next,
104
148
  previous,
105
149
  cancel,
106
150
  goToStep,
107
151
  goToStepChecked,
108
152
  setData,
109
- restart: () => restartFn(path, initialData),
153
+ resetStep,
154
+ restart: () => restartFn(),
110
155
  retry,
111
156
  suspend,
112
157
  get services() { return services; },
113
158
  });
114
159
 
160
+ // Dev-mode warning: camelCase callback props are silently ignored in Svelte.
161
+ // Warn if the user passed onComplete/onCancel/onEvent instead of the correct
162
+ // lowercase forms oncomplete/oncancel/onevent. Runs once, on mount (a closure
163
+ // — reading the props at the top level would only capture their initial value).
164
+ // `import.meta.env` is a bundler convention (Vite); the cast keeps this
165
+ // package free of Vite's ambient types.
166
+ const isDev = (import.meta as { env?: { DEV?: boolean } }).env?.DEV !== false;
167
+ onMount(() => {
168
+ if (!isDev) return;
169
+ const camelCallbacks = ['onComplete', 'onCancel', 'onEvent'] as const;
170
+ for (const name of camelCallbacks) {
171
+ if (name in stepSnippets) {
172
+ console.warn(
173
+ `[PathShell] "${name}" was passed but will be ignored. Svelte uses lowercase callback props — use "${name.toLowerCase()}" instead.`
174
+ );
175
+ }
176
+ }
177
+ });
178
+
115
179
  // Auto-start the path when no external engine is provided
116
180
  let started = false;
117
181
  onMount(() => {
118
- if (autoStart && !started && !engineProp) {
182
+ if (autoStart && !started && !engineProp && !restoredEngine) {
183
+ if (!path) throw new Error('[PathShell] "path" is required when no "engine" is provided');
119
184
  started = true;
120
- start(path, initialData);
185
+ let startData: PathData = initialData ?? {};
186
+ let restoreStepId: string | undefined;
187
+ if (restoreKey && outerCtx) {
188
+ const stored = outerCtx.snapshot?.data[restoreKey] as PathSnapshot<any> | undefined;
189
+ if (stored != null && typeof stored === 'object' && 'stepId' in stored) {
190
+ startData = stored.data as PathData;
191
+ if (stored.stepIndex > 0) restoreStepId = stored.stepId as string;
192
+ }
193
+ }
194
+ const p = start(path, startData);
195
+ if (restoreStepId) {
196
+ p.then(() => goToStep(restoreStepId!));
197
+ }
121
198
  }
122
199
  });
123
200
 
@@ -134,13 +211,20 @@
134
211
  }
135
212
 
136
213
  let snap = $derived(pathReturn.snapshot);
137
- let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restartFn(path, initialData), retry, suspend });
214
+ let actions: PathShellActions = $derived({
215
+ next, previous, cancel, goToStep, goToStepChecked,
216
+ setData: (key, value) => setData(key as never, value as never),
217
+ restart: () => restartFn(), retry, suspend
218
+ });
219
+
220
+ let effectiveHideProgress = $derived(hideProgress || layout === 'tabs');
221
+ let effectiveHideFooter = $derived(hideFooter || layout === 'tabs');
138
222
 
139
223
  // Auto-detect footer layout: single-step top-level paths use "form", everything else uses "wizard"
140
224
  let resolvedFooterLayout = $derived(
141
- footerLayout === 'auto' && snap
225
+ (layout === 'auto' || layout === 'tabs') && snap
142
226
  ? (snap.stepCount === 1 && snap.nestingLevel === 0 ? 'form' : 'wizard')
143
- : (footerLayout === 'auto' ? 'wizard' : footerLayout)
227
+ : (layout === 'auto' || layout === 'tabs' ? 'wizard' : layout)
144
228
  );
145
229
 
146
230
  /**
@@ -153,7 +237,7 @@
153
237
  * ```
154
238
  */
155
239
  export function restart(): Promise<void> {
156
- return pathReturn.restart(path, initialData);
240
+ return pathReturn.restart();
157
241
  }
158
242
  </script>
159
243
 
@@ -162,14 +246,43 @@
162
246
  <div class="pw-shell__empty">
163
247
  <p>No active path.</p>
164
248
  {#if !autoStart}
165
- <button type="button" class="pw-shell__start-btn" onclick={() => start(path, initialData)}>
249
+ <button type="button" class="pw-shell__start-btn" onclick={() => path && start(path, initialData)}>
166
250
  Start
167
251
  </button>
168
252
  {/if}
169
253
  </div>
254
+ {:else if snap.status === 'completed'}
255
+ <!-- Completion panel: shown after stayOnFinal completion -->
256
+ {#if !effectiveHideProgress && snap.stepCount > 1}
257
+ <div class="pw-shell__header">
258
+ <div class="pw-shell__steps">
259
+ {#each snap.steps as step, i}
260
+ <div class="pw-shell__step pw-shell__step--{step.status}">
261
+ <span class="pw-shell__step-dot">✓</span>
262
+ <span class="pw-shell__step-label">{step.title ?? step.id}</span>
263
+ </div>
264
+ {/each}
265
+ </div>
266
+ <div class="pw-shell__track">
267
+ <div class="pw-shell__track-fill" style="width: 100%"></div>
268
+ </div>
269
+ </div>
270
+ {/if}
271
+ <div class="pw-shell__body">
272
+ {#if completion}
273
+ {@render completion(snap)}
274
+ {:else}
275
+ <div class="pw-shell__completion">
276
+ <p class="pw-shell__completion-message">All done.</p>
277
+ <button type="button" class="pw-shell__completion-restart" onclick={() => restartFn()}>
278
+ Start over
279
+ </button>
280
+ </div>
281
+ {/if}
282
+ </div>
170
283
  {:else}
171
284
  <!-- Root progress: persistent top-level bar visible during sub-paths -->
172
- {#if !hideProgress && snap.rootProgress && progressLayout !== 'activeOnly'}
285
+ {#if !effectiveHideProgress && snap.rootProgress && progressLayout !== 'activeOnly'}
173
286
  <div class="pw-shell__root-progress">
174
287
  <div class="pw-shell__steps">
175
288
  {#each snap.rootProgress.steps as step, i}
@@ -188,7 +301,7 @@
188
301
  {/if}
189
302
 
190
303
  <!-- Header: progress indicator (overridable via header snippet) -->
191
- {#if !hideProgress && progressLayout !== 'rootOnly'}
304
+ {#if !effectiveHideProgress && progressLayout !== 'rootOnly'}
192
305
  {#if header}
193
306
  {@render header(snap)}
194
307
  {:else if snap.stepCount > 1 || snap.nestingLevel > 0}
@@ -283,9 +396,9 @@
283
396
  </div>
284
397
  </div>
285
398
  <!-- Footer: navigation buttons (overridable via footer snippet) -->
286
- {:else if !hideFooter && footer}
399
+ {:else if !effectiveHideFooter && footer}
287
400
  {@render footer(snap, actions)}
288
- {:else if !hideFooter}
401
+ {:else if !effectiveHideFooter}
289
402
  <div class="pw-shell__footer">
290
403
  <div class="pw-shell__footer-left">
291
404
  {#if resolvedFooterLayout === 'form' && !hideCancel}
@@ -39,6 +39,12 @@ export interface UsePathOptions {
39
39
  * - The engine lifecycle (start / cleanup) is the **caller's responsibility**.
40
40
  * - `PathShell` will skip its own `autoStart` call.
41
41
  */
42
+ /**
43
+ * An externally managed engine. Pass it as a getter (`get engine() { … }`)
44
+ * over a reactive prop to have the hook track it: an engine that arrives
45
+ * later (e.g. from an async `restoreOrStart()`) or is swapped is adopted —
46
+ * the hook re-subscribes and re-seeds its snapshot from the new engine.
47
+ */
42
48
  engine?: PathEngine;
43
49
  /** Called for every engine event (stateChanged, completed, cancelled, resumed). */
44
50
  onEvent?: (event: PathEvent) => void;
@@ -62,17 +68,18 @@ export interface UsePathReturn<TData extends PathData = PathData> {
62
68
  previous: () => Promise<void>;
63
69
  /** Cancel the active path (or sub-path). */
64
70
  cancel: () => Promise<void>;
65
- /** Jump directly to a step by ID. Calls onLeave / onEnter but bypasses guards and shouldSkip. */
66
- goToStep: (stepId: string) => Promise<void>;
71
+ /** Jump directly to a step by ID. Calls onLeave / onEnter but bypasses guards and shouldSkip. Pass `{ validateOnLeave: true }` to mark the departing step as attempted before navigating. */
72
+ goToStep: (stepId: string, options?: { validateOnLeave?: boolean }) => Promise<void>;
67
73
  /** Jump directly to a step by ID, checking the current step's canMoveNext (forward) or canMovePrevious (backward) guard first. Navigation is blocked if the guard returns false. */
68
- goToStepChecked: (stepId: string) => Promise<void>;
74
+ goToStepChecked: (stepId: string, options?: { validateOnLeave?: boolean }) => Promise<void>;
69
75
  /** Update a single data value; triggers a re-render via stateChanged. When `TData` is specified, `key` and `value` are type-checked against your data shape. */
70
76
  setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
71
77
  /** Reset the current step's data to what it was when the step was entered. Useful for "Clear" or "Reset" buttons. */
72
78
  resetStep: () => Promise<void>;
73
79
  /**
74
- * Tear down any active path (without firing hooks) and immediately start the
75
- * given path fresh. Safe to call whether or not a path is currently active.
80
+ * Tear down any active path (without firing hooks) and immediately restart
81
+ * the root path with the `initialData` from the original `start()` call.
82
+ * Takes no arguments; rejects if the engine has never been started.
76
83
  * Use for "Start over" / retry flows without remounting the component.
77
84
  */
78
85
  restart: () => Promise<void>;
@@ -145,25 +152,40 @@ export interface UsePathReturn<TData extends PathData = PathData> {
145
152
  export function usePath<TData extends PathData = PathData>(
146
153
  options?: UsePathOptions
147
154
  ): UsePathReturn<TData> {
148
- const engine = options?.engine ?? new PathEngineClass();
155
+ let ownEngine: PathEngine | null = null;
156
+ const resolveEngine = (): PathEngine => options?.engine ?? (ownEngine ??= new PathEngineClass());
157
+ let engine = resolveEngine();
149
158
 
150
- // Reactive snapshot via $state rune
151
159
  let _snapshot: PathSnapshot<TData> | null = $state(
152
160
  engine.snapshot() as PathSnapshot<TData> | null
153
161
  );
154
162
 
155
- // Subscribe to engine events
156
- const unsubscribe = engine.subscribe((event: PathEvent) => {
163
+ const onEngineEvent = (event: PathEvent): void => {
157
164
  if (event.type === "stateChanged" || event.type === "resumed") {
158
165
  _snapshot = event.snapshot as PathSnapshot<TData>;
159
166
  } else if (event.type === "completed" || event.type === "cancelled") {
160
- _snapshot = null;
167
+ _snapshot = engine.snapshot() as PathSnapshot<TData> | null;
161
168
  }
162
169
  options?.onEvent?.(event);
170
+ };
171
+ let unsubscribe = engine.subscribe(onEngineEvent);
172
+
173
+ // Adopt a late or swapped engine (the `engine` option is a getter over a
174
+ // reactive prop): re-subscribe and re-seed the snapshot. Created in its own
175
+ // effect root so usePath() also works outside a component.
176
+ const stopWatching = $effect.root(() => {
177
+ $effect(() => {
178
+ const next = resolveEngine();
179
+ if (next === engine) return;
180
+ unsubscribe();
181
+ engine = next;
182
+ _snapshot = engine.snapshot() as PathSnapshot<TData> | null;
183
+ unsubscribe = engine.subscribe(onEngineEvent);
184
+ });
163
185
  });
164
186
 
165
187
  // Auto-cleanup when component is destroyed
166
- onDestroy(unsubscribe);
188
+ onDestroy(() => { unsubscribe(); stopWatching(); });
167
189
 
168
190
  const start = (path: PathDefinition<any>, initialData: PathData = {}): Promise<void> =>
169
191
  engine.start(path, initialData);
@@ -178,8 +200,8 @@ export function usePath<TData extends PathData = PathData>(
178
200
  const previous = (): Promise<void> => engine.previous();
179
201
  const cancel = (): Promise<void> => engine.cancel();
180
202
 
181
- const goToStep = (stepId: string): Promise<void> => engine.goToStep(stepId);
182
- const goToStepChecked = (stepId: string): Promise<void> => engine.goToStepChecked(stepId);
203
+ const goToStep = (stepId: string, options?: { validateOnLeave?: boolean }): Promise<void> => engine.goToStep(stepId, options);
204
+ const goToStepChecked = (stepId: string, options?: { validateOnLeave?: boolean }): Promise<void> => engine.goToStepChecked(stepId, options);
183
205
 
184
206
  const setData = (<K extends string & keyof TData>(key: K, value: TData[K]): Promise<void> =>
185
207
  engine.setData(key, value as unknown)) as UsePathReturn<TData>["setData"];
@@ -210,30 +232,38 @@ export function usePath<TData extends PathData = PathData>(
210
232
  };
211
233
  }
212
234
 
213
- // ---------------------------------------------------------------------------
214
- // Context API for PathShell
215
- // ---------------------------------------------------------------------------
216
-
217
- const PATH_CONTEXT_KEY = Symbol("pathwrite-context");
218
-
219
- export interface PathContext<TData extends PathData = PathData, TServices = unknown> {
220
- readonly snapshot: PathSnapshot<TData>;
235
+ /**
236
+ * Navigation actions handed to a custom `footer` snippet of `<PathShell>`
237
+ * (`{#snippet footer(snap, actions)}`). Same shape as the other adapters'
238
+ * `PathShellActions`.
239
+ */
240
+ export interface PathShellActions {
221
241
  next: () => Promise<void>;
222
242
  previous: () => Promise<void>;
223
243
  cancel: () => Promise<void>;
224
- goToStep: (stepId: string) => Promise<void>;
225
- goToStepChecked: (stepId: string) => Promise<void>;
226
- setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
227
- resetStep: () => Promise<void>;
244
+ goToStep: (stepId: string, options?: { validateOnLeave?: boolean }) => Promise<void>;
245
+ goToStepChecked: (stepId: string, options?: { validateOnLeave?: boolean }) => Promise<void>;
246
+ setData: (key: string, value: unknown) => Promise<void>;
247
+ /** Restart the shell's current path with its current `initialData`. */
228
248
  restart: () => Promise<void>;
229
249
  /** Re-run the operation that set `snapshot.error`. */
230
250
  retry: () => Promise<void>;
231
251
  /** Pause with intent to return, preserving all state. Emits `suspended`. */
232
252
  suspend: () => Promise<void>;
233
- /**
234
- * Services object passed through context from `PathShell`.
235
- * Typed as `TServices` when `usePathContext<TData, TServices>()` is used.
236
- */
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Context API for PathShell
257
+ // ---------------------------------------------------------------------------
258
+
259
+ const PATH_CONTEXT_KEY = Symbol("pathwrite-context");
260
+
261
+ /**
262
+ * What step components receive from `usePathContext()`: everything `usePath()`
263
+ * returns (derived from `UsePathReturn`, so the two cannot drift apart) plus
264
+ * the `services` object given to `<PathShell>`.
265
+ */
266
+ export interface PathContext<TData extends PathData = PathData, TServices = unknown> extends UsePathReturn<TData> {
237
267
  services: TServices;
238
268
  }
239
269
 
@@ -276,6 +306,16 @@ export function setPathContext<TData extends PathData = PathData, TServices = un
276
306
  setContext(PATH_CONTEXT_KEY, ctx);
277
307
  }
278
308
 
309
+ /**
310
+ * Internal: Get the PathContext from the nearest ancestor PathShell, or
311
+ * `undefined` if no PathShell is present. Used by PathShell itself to access
312
+ * the outer shell's context for `restoreKey` auto-wiring — must be called
313
+ * before `setPathContext()` so it reads the parent rather than self.
314
+ */
315
+ export function getPathContextOrNull<TData extends PathData = PathData, TServices = unknown>(): PathContext<TData, TServices> | undefined {
316
+ return getContext<PathContext<TData, TServices>>(PATH_CONTEXT_KEY);
317
+ }
318
+
279
319
  // ---------------------------------------------------------------------------
280
320
  // Helper for binding form inputs
281
321
  // ---------------------------------------------------------------------------