@daltonr/pathwrite-svelte 0.5.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.
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { usePath, setPathContext } from './index.svelte.js';
4
+ import type { PathDefinition, PathData, PathEngine, PathSnapshot } from './index.svelte.js';
5
+ import type { Snippet } from 'svelte';
6
+
7
+ interface Props {
8
+ path?: PathDefinition<any>;
9
+ engine?: PathEngine;
10
+ initialData?: PathData;
11
+ autoStart?: boolean;
12
+ backLabel?: string;
13
+ nextLabel?: string;
14
+ completeLabel?: string;
15
+ cancelLabel?: string;
16
+ hideCancel?: boolean;
17
+ hideProgress?: boolean;
18
+ // Callback props replace event dispatching in Svelte 5
19
+ oncomplete?: (data: PathData) => void;
20
+ oncancel?: (data: PathData) => void;
21
+ onevent?: (event: any) => void;
22
+ // Optional override snippets for header and footer
23
+ header?: Snippet<[PathSnapshot<any>]>;
24
+ footer?: Snippet<[PathSnapshot<any>, object]>;
25
+ // All other props treated as step snippets keyed by step ID
26
+ [key: string]: Snippet | any;
27
+ }
28
+
29
+ let {
30
+ path,
31
+ engine: engineProp,
32
+ initialData = {},
33
+ autoStart = true,
34
+ backLabel = 'Previous',
35
+ nextLabel = 'Next',
36
+ completeLabel = 'Complete',
37
+ cancelLabel = 'Cancel',
38
+ hideCancel = false,
39
+ hideProgress = false,
40
+ oncomplete,
41
+ oncancel,
42
+ onevent,
43
+ header,
44
+ footer,
45
+ ...stepSnippets
46
+ }: Props = $props();
47
+
48
+ // Initialize path engine
49
+ const pathReturn = usePath({
50
+ engine: engineProp,
51
+ onEvent: (event) => {
52
+ onevent?.(event);
53
+ if (event.type === 'completed') oncomplete?.(event.data);
54
+ if (event.type === 'cancelled') oncancel?.(event.data);
55
+ }
56
+ });
57
+
58
+ const { start, next, previous, cancel, goToStep, goToStepChecked, setData, restart } = pathReturn;
59
+
60
+ // Provide context for child step components
61
+ setPathContext({
62
+ get snapshot() { return pathReturn.snapshot; },
63
+ next,
64
+ previous,
65
+ cancel,
66
+ goToStep,
67
+ goToStepChecked,
68
+ setData,
69
+ restart: () => restart(path, initialData)
70
+ });
71
+
72
+ // Auto-start the path when no external engine is provided
73
+ let started = false;
74
+ onMount(() => {
75
+ if (autoStart && !started && !engineProp) {
76
+ started = true;
77
+ start(path, initialData);
78
+ }
79
+ });
80
+
81
+ let snap = $derived(pathReturn.snapshot);
82
+ let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restart(path, initialData) });
83
+ </script>
84
+
85
+ <div class="pw-shell">
86
+ {#if !snap}
87
+ <div class="pw-shell__empty">
88
+ <p>No active path.</p>
89
+ {#if !autoStart}
90
+ <button type="button" class="pw-shell__start-btn" onclick={() => start(path, initialData)}>
91
+ Start
92
+ </button>
93
+ {/if}
94
+ </div>
95
+ {:else}
96
+ <!-- Header: progress indicator (overridable via header snippet) -->
97
+ {#if !hideProgress}
98
+ {#if header}
99
+ {@render header(snap)}
100
+ {:else}
101
+ <div class="pw-shell__header">
102
+ <div class="pw-shell__steps">
103
+ {#each snap.steps as step, i}
104
+ <div class="pw-shell__step pw-shell__step--{step.status}">
105
+ <span class="pw-shell__step-dot">
106
+ {step.status === 'completed' ? '✓' : i + 1}
107
+ </span>
108
+ <span class="pw-shell__step-label">{step.title ?? step.id}</span>
109
+ </div>
110
+ {/each}
111
+ </div>
112
+ <div class="pw-shell__track">
113
+ <div class="pw-shell__track-fill" style="width: {snap.progress * 100}%"></div>
114
+ </div>
115
+ </div>
116
+ {/if}
117
+ {/if}
118
+
119
+ <!-- Body: current step rendered via named snippet -->
120
+ <div class="pw-shell__body">
121
+ {#if stepSnippets[snap.stepId]}
122
+ {@render stepSnippets[snap.stepId]()}
123
+ {:else}
124
+ <p>No content for step "{snap.stepId}"</p>
125
+ {/if}
126
+ </div>
127
+
128
+ <!-- Validation messages -->
129
+ {#if snap.validationMessages.length > 0}
130
+ <ul class="pw-shell__validation">
131
+ {#each snap.validationMessages as msg}
132
+ <li class="pw-shell__validation-item">{msg}</li>
133
+ {/each}
134
+ </ul>
135
+ {/if}
136
+
137
+ <!-- Footer: navigation buttons (overridable via footer snippet) -->
138
+ {#if footer}
139
+ {@render footer(snap, actions)}
140
+ {:else}
141
+ <div class="pw-shell__footer">
142
+ <div class="pw-shell__footer-left">
143
+ {#if !snap.isFirstStep}
144
+ <button
145
+ type="button"
146
+ class="pw-shell__btn pw-shell__btn--back"
147
+ disabled={snap.isNavigating || !snap.canMovePrevious}
148
+ onclick={previous}
149
+ >
150
+ {backLabel}
151
+ </button>
152
+ {/if}
153
+ </div>
154
+ <div class="pw-shell__footer-right">
155
+ {#if !hideCancel}
156
+ <button
157
+ type="button"
158
+ class="pw-shell__btn pw-shell__btn--cancel"
159
+ disabled={snap.isNavigating}
160
+ onclick={cancel}
161
+ >
162
+ {cancelLabel}
163
+ </button>
164
+ {/if}
165
+ <button
166
+ type="button"
167
+ class="pw-shell__btn pw-shell__btn--next"
168
+ disabled={snap.isNavigating || !snap.canMoveNext}
169
+ onclick={next}
170
+ >
171
+ {snap.isLastStep ? completeLabel : nextLabel}
172
+ </button>
173
+ </div>
174
+ </div>
175
+ {/if}
176
+ {/if}
177
+ </div>
178
+
179
+ <style>
180
+ /* Component-level styles inherited from shell.css */
181
+ </style>
@@ -0,0 +1,254 @@
1
+ import { onDestroy, getContext, setContext } from "svelte";
2
+ import type {
3
+ PathData,
4
+ PathDefinition,
5
+ PathEngine,
6
+ PathEvent,
7
+ PathSnapshot
8
+ } from "@daltonr/pathwrite-core";
9
+ import { PathEngine as PathEngineClass } from "@daltonr/pathwrite-core";
10
+
11
+ // Re-export core types for convenience
12
+ export type {
13
+ PathData,
14
+ PathDefinition,
15
+ PathEngine,
16
+ PathEvent,
17
+ PathSnapshot,
18
+ PathStep,
19
+ PathStepContext,
20
+ SerializedPathState
21
+ } from "@daltonr/pathwrite-core";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface UsePathOptions {
28
+ /**
29
+ * An externally-managed `PathEngine` to subscribe to — for example, the engine
30
+ * returned by `restoreOrStart()` from `@daltonr/pathwrite-store-http`.
31
+ *
32
+ * When provided:
33
+ * - `usePath` will **not** create its own engine.
34
+ * - The snapshot is seeded immediately from the engine's current state.
35
+ * - The engine lifecycle (start / cleanup) is the **caller's responsibility**.
36
+ * - `PathShell` will skip its own `autoStart` call.
37
+ */
38
+ engine?: PathEngine;
39
+ /** Called for every engine event (stateChanged, completed, cancelled, resumed). */
40
+ onEvent?: (event: PathEvent) => void;
41
+ }
42
+
43
+ export interface UsePathReturn<TData extends PathData = PathData> {
44
+ /** Current path snapshot, or `null` when no path is active. Reactive via `$state`. */
45
+ readonly snapshot: PathSnapshot<TData> | null;
46
+ /** Start (or restart) a path. */
47
+ start: (path: PathDefinition<any>, initialData?: PathData) => Promise<void>;
48
+ /** Push a sub-path onto the stack. Requires an active path. Pass an optional `meta` object for correlation — it is returned unchanged to the parent step's `onSubPathComplete` / `onSubPathCancel` hooks. */
49
+ startSubPath: (path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>) => Promise<void>;
50
+ /** Advance one step. Completes the path on the last step. */
51
+ next: () => Promise<void>;
52
+ /** Go back one step. No-op when already on the first step of a top-level path. Pops back to the parent path when on the first step of a sub-path. */
53
+ previous: () => Promise<void>;
54
+ /** Cancel the active path (or sub-path). */
55
+ cancel: () => Promise<void>;
56
+ /** Jump directly to a step by ID. Calls onLeave / onEnter but bypasses guards and shouldSkip. */
57
+ goToStep: (stepId: string) => Promise<void>;
58
+ /** 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. */
59
+ goToStepChecked: (stepId: string) => Promise<void>;
60
+ /** 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. */
61
+ setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
62
+ /**
63
+ * Tear down any active path (without firing hooks) and immediately start the
64
+ * given path fresh. Safe to call whether or not a path is currently active.
65
+ * Use for "Start over" / retry flows without remounting the component.
66
+ */
67
+ restart: (path: PathDefinition<any>, initialData?: PathData) => Promise<void>;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // usePath - Runes-based API for Svelte 5
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Create a Pathwrite engine with Svelte 5 runes-based reactivity.
76
+ * Call this from inside a Svelte component to get a reactive snapshot.
77
+ * Cleanup is automatic via onDestroy.
78
+ *
79
+ * **Note:** `snapshot` is a reactive getter — access it via the returned
80
+ * object (e.g. `path.snapshot`). Destructuring `snapshot` will lose reactivity.
81
+ *
82
+ * @example
83
+ * ```svelte
84
+ * <script lang="ts">
85
+ * import { usePath } from '@daltonr/pathwrite-svelte';
86
+ *
87
+ * const path = usePath();
88
+ *
89
+ * onMount(() => {
90
+ * path.start(myPath, { name: '' });
91
+ * });
92
+ * </script>
93
+ *
94
+ * {#if path.snapshot}
95
+ * <h2>{path.snapshot.stepId}</h2>
96
+ * <button onclick={path.previous} disabled={path.snapshot.isFirstStep}>Previous</button>
97
+ * <button onclick={path.next} disabled={!path.snapshot.canMoveNext}>Next</button>
98
+ * {/if}
99
+ * ```
100
+ */
101
+ export function usePath<TData extends PathData = PathData>(
102
+ options?: UsePathOptions
103
+ ): UsePathReturn<TData> {
104
+ const engine = options?.engine ?? new PathEngineClass();
105
+
106
+ // Reactive snapshot via $state rune
107
+ let _snapshot: PathSnapshot<TData> | null = $state(
108
+ engine.snapshot() as PathSnapshot<TData> | null
109
+ );
110
+
111
+ // Subscribe to engine events
112
+ const unsubscribe = engine.subscribe((event: PathEvent) => {
113
+ if (event.type === "stateChanged" || event.type === "resumed") {
114
+ _snapshot = event.snapshot as PathSnapshot<TData>;
115
+ } else if (event.type === "completed" || event.type === "cancelled") {
116
+ _snapshot = null;
117
+ }
118
+ options?.onEvent?.(event);
119
+ });
120
+
121
+ // Auto-cleanup when component is destroyed
122
+ onDestroy(unsubscribe);
123
+
124
+ const start = (path: PathDefinition<any>, initialData: PathData = {}): Promise<void> =>
125
+ engine.start(path, initialData);
126
+
127
+ const startSubPath = (
128
+ path: PathDefinition<any>,
129
+ initialData: PathData = {},
130
+ meta?: Record<string, unknown>
131
+ ): Promise<void> => engine.startSubPath(path, initialData, meta);
132
+
133
+ const next = (): Promise<void> => engine.next();
134
+ const previous = (): Promise<void> => engine.previous();
135
+ const cancel = (): Promise<void> => engine.cancel();
136
+
137
+ const goToStep = (stepId: string): Promise<void> => engine.goToStep(stepId);
138
+ const goToStepChecked = (stepId: string): Promise<void> => engine.goToStepChecked(stepId);
139
+
140
+ const setData = (<K extends string & keyof TData>(key: K, value: TData[K]): Promise<void> =>
141
+ engine.setData(key, value as unknown)) as UsePathReturn<TData>["setData"];
142
+
143
+ const restart = (path: PathDefinition<any>, initialData: PathData = {}): Promise<void> =>
144
+ engine.restart(path, initialData);
145
+
146
+ return {
147
+ get snapshot() { return _snapshot; },
148
+ start,
149
+ startSubPath,
150
+ next,
151
+ previous,
152
+ cancel,
153
+ goToStep,
154
+ goToStepChecked,
155
+ setData,
156
+ restart
157
+ };
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Context API for PathShell
162
+ // ---------------------------------------------------------------------------
163
+
164
+ const PATH_CONTEXT_KEY = Symbol("pathwrite-context");
165
+
166
+ export interface PathContext<TData extends PathData = PathData> {
167
+ readonly snapshot: PathSnapshot<TData> | null;
168
+ next: () => Promise<void>;
169
+ previous: () => Promise<void>;
170
+ cancel: () => Promise<void>;
171
+ goToStep: (stepId: string) => Promise<void>;
172
+ goToStepChecked: (stepId: string) => Promise<void>;
173
+ setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
174
+ restart: () => Promise<void>;
175
+ }
176
+
177
+ /**
178
+ * Get the PathContext from a parent PathShell component.
179
+ * Use this inside step components to access the path engine.
180
+ *
181
+ * @example
182
+ * ```svelte
183
+ * <script lang="ts">
184
+ * import { getPathContext } from '@daltonr/pathwrite-svelte';
185
+ *
186
+ * const ctx = getPathContext();
187
+ * </script>
188
+ *
189
+ * <input value={ctx.snapshot?.data.name}
190
+ * oninput={(e) => ctx.setData('name', e.target.value)} />
191
+ * <button onclick={ctx.next}>Next</button>
192
+ * ```
193
+ */
194
+ export function getPathContext<TData extends PathData = PathData>(): PathContext<TData> {
195
+ const ctx = getContext<PathContext<TData>>(PATH_CONTEXT_KEY);
196
+ if (!ctx) {
197
+ throw new Error(
198
+ "getPathContext() must be called from a component inside a <PathShell>. " +
199
+ "Ensure the PathShell component is a parent in the component tree."
200
+ );
201
+ }
202
+ return ctx;
203
+ }
204
+
205
+ /**
206
+ * Internal: Set the PathContext for child components.
207
+ * Used by PathShell component.
208
+ */
209
+ export function setPathContext<TData extends PathData = PathData>(ctx: PathContext<TData>): void {
210
+ setContext(PATH_CONTEXT_KEY, ctx);
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Helper for binding form inputs
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Create a two-way binding helper for form inputs.
219
+ * Returns an object with a reactive `value` property.
220
+ *
221
+ * @param getSnapshot - A getter function returning the current snapshot (e.g. `() => path.snapshot`)
222
+ * @param setData - The `setData` function from `usePath()`
223
+ * @param key - The data key to bind
224
+ *
225
+ * @example
226
+ * ```svelte
227
+ * <script lang="ts">
228
+ * import { usePath, bindData } from '@daltonr/pathwrite-svelte';
229
+ *
230
+ * const path = usePath();
231
+ * const name = bindData(() => path.snapshot, path.setData, 'name');
232
+ * </script>
233
+ *
234
+ * <input value={name.value} oninput={(e) => name.value = e.target.value} />
235
+ * ```
236
+ */
237
+ export function bindData<TData extends PathData, K extends string & keyof TData>(
238
+ getSnapshot: () => PathSnapshot<TData> | null,
239
+ setData: <Key extends string & keyof TData>(key: Key, value: TData[Key]) => Promise<void>,
240
+ key: K
241
+ ): { readonly value: TData[K]; set: (value: TData[K]) => void } {
242
+ return {
243
+ get value(): TData[K] {
244
+ return (getSnapshot()?.data[key] ?? undefined) as TData[K];
245
+ },
246
+ set(value: TData[K]) {
247
+ setData(key, value);
248
+ }
249
+ };
250
+ }
251
+
252
+ // Export PathShell component
253
+ export { default as PathShell } from "./PathShell.svelte";
254
+