@daltonr/pathwrite-svelte 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -470
- package/dist/PathShell.svelte +71 -18
- package/dist/PathShell.svelte.d.ts +6 -0
- package/dist/PathShell.svelte.d.ts.map +1 -1
- package/dist/index.css +86 -0
- package/dist/index.svelte.d.ts +30 -6
- package/dist/index.svelte.d.ts.map +1 -1
- package/dist/index.svelte.js +24 -6
- package/package.json +2 -2
- package/src/PathShell.svelte +71 -18
- package/src/index.svelte.ts +41 -10
package/src/PathShell.svelte
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
|
-
import { usePath, setPathContext } from './index.svelte.js';
|
|
3
|
+
import { usePath, setPathContext, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
|
|
4
4
|
import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout } from './index.svelte.js';
|
|
5
5
|
import type { Snippet, Component } from 'svelte';
|
|
6
6
|
|
|
7
|
-
/** Converts a camelCase or lowercase field key to a display label. */
|
|
8
|
-
function formatFieldKey(key: string): string {
|
|
9
|
-
return key.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()).trim();
|
|
10
|
-
}
|
|
11
7
|
|
|
12
8
|
interface Props {
|
|
13
9
|
path?: PathDefinition<any>;
|
|
@@ -17,6 +13,7 @@
|
|
|
17
13
|
backLabel?: string;
|
|
18
14
|
nextLabel?: string;
|
|
19
15
|
completeLabel?: string;
|
|
16
|
+
loadingLabel?: string;
|
|
20
17
|
cancelLabel?: string;
|
|
21
18
|
hideCancel?: boolean;
|
|
22
19
|
hideProgress?: boolean;
|
|
@@ -42,6 +39,11 @@
|
|
|
42
39
|
* - "activeOnly": Only the active (sub-path) bar — root bar hidden.
|
|
43
40
|
*/
|
|
44
41
|
progressLayout?: ProgressLayout;
|
|
42
|
+
/**
|
|
43
|
+
* Services object passed through context to all step components.
|
|
44
|
+
* Step components access it via `usePathContext<TData, TServices>()`.
|
|
45
|
+
*/
|
|
46
|
+
services?: unknown;
|
|
45
47
|
// Callback props replace event dispatching in Svelte 5
|
|
46
48
|
oncomplete?: (data: PathData) => void;
|
|
47
49
|
oncancel?: (data: PathData) => void;
|
|
@@ -61,12 +63,14 @@
|
|
|
61
63
|
backLabel = 'Previous',
|
|
62
64
|
nextLabel = 'Next',
|
|
63
65
|
completeLabel = 'Complete',
|
|
66
|
+
loadingLabel = undefined,
|
|
64
67
|
cancelLabel = 'Cancel',
|
|
65
68
|
hideCancel = false,
|
|
66
69
|
hideProgress = false,
|
|
67
70
|
footerLayout = 'auto',
|
|
68
71
|
validationDisplay = 'summary',
|
|
69
72
|
progressLayout = 'merged',
|
|
73
|
+
services = null,
|
|
70
74
|
oncomplete,
|
|
71
75
|
oncancel,
|
|
72
76
|
onevent,
|
|
@@ -77,7 +81,7 @@
|
|
|
77
81
|
|
|
78
82
|
// Initialize path engine
|
|
79
83
|
const pathReturn = usePath({
|
|
80
|
-
engine
|
|
84
|
+
get engine() { return engineProp; },
|
|
81
85
|
onEvent: (event) => {
|
|
82
86
|
onevent?.(event);
|
|
83
87
|
if (event.type === 'completed') oncomplete?.(event.data);
|
|
@@ -85,7 +89,7 @@
|
|
|
85
89
|
}
|
|
86
90
|
});
|
|
87
91
|
|
|
88
|
-
const { start, next, previous, cancel, goToStep, goToStepChecked, setData, restart: restartFn } = pathReturn;
|
|
92
|
+
const { start, next, previous, cancel, goToStep, goToStepChecked, setData, restart: restartFn, retry, suspend } = pathReturn;
|
|
89
93
|
|
|
90
94
|
// Provide context for child step components
|
|
91
95
|
setPathContext({
|
|
@@ -96,7 +100,10 @@
|
|
|
96
100
|
goToStep,
|
|
97
101
|
goToStepChecked,
|
|
98
102
|
setData,
|
|
99
|
-
restart: () => restartFn(path, initialData)
|
|
103
|
+
restart: () => restartFn(path, initialData),
|
|
104
|
+
retry,
|
|
105
|
+
suspend,
|
|
106
|
+
get services() { return services; },
|
|
100
107
|
});
|
|
101
108
|
|
|
102
109
|
// Auto-start the path when no external engine is provided
|
|
@@ -108,8 +115,16 @@
|
|
|
108
115
|
}
|
|
109
116
|
});
|
|
110
117
|
|
|
118
|
+
function warnMissingStep(stepId: string): void {
|
|
119
|
+
const camel = stepIdToCamelCase(stepId);
|
|
120
|
+
const hint = camel !== stepId
|
|
121
|
+
? ` No snippet found for "${stepId}" or its camelCase form "${camel}". If your step ID contains hyphens, pass the snippet as a camelCase prop: ${camel}={YourComponent}.`
|
|
122
|
+
: ` No snippet found for "${stepId}".`;
|
|
123
|
+
console.warn(`[PathShell]${hint}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
let snap = $derived(pathReturn.snapshot);
|
|
112
|
-
let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restartFn(path, initialData) });
|
|
127
|
+
let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restartFn(path, initialData), retry, suspend });
|
|
113
128
|
|
|
114
129
|
// Auto-detect footer layout: single-step top-level paths use "form", everything else uses "wizard"
|
|
115
130
|
let resolvedFooterLayout = $derived(
|
|
@@ -187,13 +202,22 @@
|
|
|
187
202
|
|
|
188
203
|
<!-- Body: current step rendered via named snippet.
|
|
189
204
|
Prefer formId (inner step id of a StepChoice) so consumers can
|
|
190
|
-
register snippets by inner step ids directly.
|
|
205
|
+
register snippets by inner step ids directly.
|
|
206
|
+
Hyphenated step IDs (e.g. "cover-letter") are normalised to camelCase
|
|
207
|
+
("coverLetter") as a fallback, since Svelte props must be valid JS
|
|
208
|
+
identifiers. -->
|
|
191
209
|
<div class="pw-shell__body">
|
|
192
210
|
{#if snap.formId && stepSnippets[snap.formId]}
|
|
193
|
-
|
|
211
|
+
{@const StepComponent = stepSnippets[snap.formId]}
|
|
212
|
+
<StepComponent />
|
|
194
213
|
{:else if stepSnippets[snap.stepId]}
|
|
195
|
-
|
|
214
|
+
{@const StepComponent = stepSnippets[snap.stepId]}
|
|
215
|
+
<StepComponent />
|
|
216
|
+
{:else if stepSnippets[stepIdToCamelCase(snap.formId ?? snap.stepId)]}
|
|
217
|
+
{@const StepComponent = stepSnippets[stepIdToCamelCase(snap.formId ?? snap.stepId)]}
|
|
218
|
+
<StepComponent />
|
|
196
219
|
{:else}
|
|
220
|
+
{warnMissingStep(snap.stepId)}
|
|
197
221
|
<p>No content for step "{snap.stepId}"</p>
|
|
198
222
|
{/if}
|
|
199
223
|
</div>
|
|
@@ -220,8 +244,36 @@
|
|
|
220
244
|
</ul>
|
|
221
245
|
{/if}
|
|
222
246
|
|
|
247
|
+
<!-- Blocking error — guard returned { allowed: false, reason } -->
|
|
248
|
+
{#if validationDisplay !== 'inline' && snap.hasAttemptedNext && snap.blockingError}
|
|
249
|
+
<p class="pw-shell__blocking-error">{snap.blockingError}</p>
|
|
250
|
+
{/if}
|
|
251
|
+
|
|
252
|
+
<!-- Error panel: replaces footer when an async operation has failed -->
|
|
253
|
+
{#if snap.status === "error" && snap.error}
|
|
254
|
+
{@const err = snap.error}
|
|
255
|
+
{@const escalated = err.retryCount >= 2}
|
|
256
|
+
<div class="pw-shell__error">
|
|
257
|
+
<div class="pw-shell__error-title">{escalated ? "Still having trouble." : "Something went wrong."}</div>
|
|
258
|
+
<div class="pw-shell__error-message">{errorPhaseMessage(err.phase)}{err.message ? ` ${err.message}` : ""}</div>
|
|
259
|
+
<div class="pw-shell__error-actions">
|
|
260
|
+
{#if !escalated}
|
|
261
|
+
<button type="button" class="pw-shell__btn pw-shell__btn--retry" onclick={retry}>Try again</button>
|
|
262
|
+
{/if}
|
|
263
|
+
{#if snap.hasPersistence}
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
class="pw-shell__btn {escalated ? 'pw-shell__btn--retry' : 'pw-shell__btn--suspend'}"
|
|
267
|
+
onclick={suspend}
|
|
268
|
+
>Save and come back later</button>
|
|
269
|
+
{/if}
|
|
270
|
+
{#if escalated && !snap.hasPersistence}
|
|
271
|
+
<button type="button" class="pw-shell__btn pw-shell__btn--retry" onclick={retry}>Try again</button>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
223
275
|
<!-- Footer: navigation buttons (overridable via footer snippet) -->
|
|
224
|
-
{
|
|
276
|
+
{:else if footer}
|
|
225
277
|
{@render footer(snap, actions)}
|
|
226
278
|
{:else}
|
|
227
279
|
<div class="pw-shell__footer">
|
|
@@ -231,7 +283,7 @@
|
|
|
231
283
|
<button
|
|
232
284
|
type="button"
|
|
233
285
|
class="pw-shell__btn pw-shell__btn--cancel"
|
|
234
|
-
disabled={snap.
|
|
286
|
+
disabled={snap.status !== "idle"}
|
|
235
287
|
onclick={cancel}
|
|
236
288
|
>
|
|
237
289
|
{cancelLabel}
|
|
@@ -241,7 +293,7 @@
|
|
|
241
293
|
<button
|
|
242
294
|
type="button"
|
|
243
295
|
class="pw-shell__btn pw-shell__btn--back"
|
|
244
|
-
disabled={snap.
|
|
296
|
+
disabled={snap.status !== "idle" || !snap.canMovePrevious}
|
|
245
297
|
onclick={previous}
|
|
246
298
|
>
|
|
247
299
|
{backLabel}
|
|
@@ -254,7 +306,7 @@
|
|
|
254
306
|
<button
|
|
255
307
|
type="button"
|
|
256
308
|
class="pw-shell__btn pw-shell__btn--cancel"
|
|
257
|
-
disabled={snap.
|
|
309
|
+
disabled={snap.status !== "idle"}
|
|
258
310
|
onclick={cancel}
|
|
259
311
|
>
|
|
260
312
|
{cancelLabel}
|
|
@@ -264,10 +316,11 @@
|
|
|
264
316
|
<button
|
|
265
317
|
type="button"
|
|
266
318
|
class="pw-shell__btn pw-shell__btn--next"
|
|
267
|
-
|
|
319
|
+
class:pw-shell__btn--loading={snap.status !== "idle"}
|
|
320
|
+
disabled={snap.status !== "idle"}
|
|
268
321
|
onclick={next}
|
|
269
322
|
>
|
|
270
|
-
{snap.isLastStep ? completeLabel : nextLabel}
|
|
323
|
+
{snap.status !== 'idle' && loadingLabel ? loadingLabel : snap.isLastStep ? completeLabel : nextLabel}
|
|
271
324
|
</button>
|
|
272
325
|
</div>
|
|
273
326
|
</div>
|
package/src/index.svelte.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type {
|
|
|
8
8
|
} from "@daltonr/pathwrite-core";
|
|
9
9
|
import { PathEngine as PathEngineClass } from "@daltonr/pathwrite-core";
|
|
10
10
|
|
|
11
|
-
// Re-export core types for convenience
|
|
11
|
+
// Re-export core utilities and types for convenience
|
|
12
|
+
export { formatFieldKey, errorPhaseMessage } from "@daltonr/pathwrite-core";
|
|
12
13
|
export type {
|
|
13
14
|
PathData,
|
|
14
15
|
FieldErrors,
|
|
@@ -75,6 +76,10 @@ export interface UsePathReturn<TData extends PathData = PathData> {
|
|
|
75
76
|
* Use for "Start over" / retry flows without remounting the component.
|
|
76
77
|
*/
|
|
77
78
|
restart: () => Promise<void>;
|
|
79
|
+
/** Re-runs the operation that set `snapshot.error`. Increments `retryCount` on repeated failure. No-op when there is no pending error. */
|
|
80
|
+
retry: () => Promise<void>;
|
|
81
|
+
/** Pauses the path with intent to return. Emits `suspended`. All state is preserved. */
|
|
82
|
+
suspend: () => Promise<void>;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
// ---------------------------------------------------------------------------
|
|
@@ -180,6 +185,8 @@ export function usePath<TData extends PathData = PathData>(
|
|
|
180
185
|
const resetStep = (): Promise<void> => engine.resetStep();
|
|
181
186
|
|
|
182
187
|
const restart = (): Promise<void> => engine.restart();
|
|
188
|
+
const retry = (): Promise<void> => engine.retry();
|
|
189
|
+
const suspend = (): Promise<void> => engine.suspend();
|
|
183
190
|
|
|
184
191
|
return {
|
|
185
192
|
get snapshot() { return _snapshot; },
|
|
@@ -192,7 +199,9 @@ export function usePath<TData extends PathData = PathData>(
|
|
|
192
199
|
goToStepChecked,
|
|
193
200
|
setData,
|
|
194
201
|
resetStep,
|
|
195
|
-
restart
|
|
202
|
+
restart,
|
|
203
|
+
retry,
|
|
204
|
+
suspend
|
|
196
205
|
};
|
|
197
206
|
}
|
|
198
207
|
|
|
@@ -202,7 +211,7 @@ export function usePath<TData extends PathData = PathData>(
|
|
|
202
211
|
|
|
203
212
|
const PATH_CONTEXT_KEY = Symbol("pathwrite-context");
|
|
204
213
|
|
|
205
|
-
export interface PathContext<TData extends PathData = PathData> {
|
|
214
|
+
export interface PathContext<TData extends PathData = PathData, TServices = unknown> {
|
|
206
215
|
readonly snapshot: PathSnapshot<TData>;
|
|
207
216
|
next: () => Promise<void>;
|
|
208
217
|
previous: () => Promise<void>;
|
|
@@ -212,18 +221,30 @@ export interface PathContext<TData extends PathData = PathData> {
|
|
|
212
221
|
setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
|
|
213
222
|
resetStep: () => Promise<void>;
|
|
214
223
|
restart: () => Promise<void>;
|
|
224
|
+
/** Re-run the operation that set `snapshot.error`. */
|
|
225
|
+
retry: () => Promise<void>;
|
|
226
|
+
/** Pause with intent to return, preserving all state. Emits `suspended`. */
|
|
227
|
+
suspend: () => Promise<void>;
|
|
228
|
+
/**
|
|
229
|
+
* Services object passed through context from `PathShell`.
|
|
230
|
+
* Typed as `TServices` when `usePathContext<TData, TServices>()` is used.
|
|
231
|
+
*/
|
|
232
|
+
services: TServices;
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
/**
|
|
218
|
-
*
|
|
236
|
+
* Access the nearest `PathShell`'s path instance and optional services object.
|
|
219
237
|
* Use this inside step components to access the path engine.
|
|
220
238
|
*
|
|
239
|
+
* - `TData` narrows `ctx.snapshot?.data`
|
|
240
|
+
* - `TServices` types the `services` value — must match what was passed to `PathShell`
|
|
241
|
+
*
|
|
221
242
|
* @example
|
|
222
243
|
* ```svelte
|
|
223
244
|
* <script lang="ts">
|
|
224
|
-
* import {
|
|
245
|
+
* import { usePathContext } from '@daltonr/pathwrite-svelte';
|
|
225
246
|
*
|
|
226
|
-
* const ctx =
|
|
247
|
+
* const ctx = usePathContext();
|
|
227
248
|
* </script>
|
|
228
249
|
*
|
|
229
250
|
* <input value={ctx.snapshot?.data.name}
|
|
@@ -231,11 +252,11 @@ export interface PathContext<TData extends PathData = PathData> {
|
|
|
231
252
|
* <button onclick={ctx.next}>Next</button>
|
|
232
253
|
* ```
|
|
233
254
|
*/
|
|
234
|
-
export function
|
|
235
|
-
const ctx = getContext<PathContext<TData>>(PATH_CONTEXT_KEY);
|
|
255
|
+
export function usePathContext<TData extends PathData = PathData, TServices = unknown>(): PathContext<TData, TServices> {
|
|
256
|
+
const ctx = getContext<PathContext<TData, TServices>>(PATH_CONTEXT_KEY);
|
|
236
257
|
if (!ctx) {
|
|
237
258
|
throw new Error(
|
|
238
|
-
"
|
|
259
|
+
"usePathContext() must be called from a component inside a <PathShell>. " +
|
|
239
260
|
"Ensure the PathShell component is a parent in the component tree."
|
|
240
261
|
);
|
|
241
262
|
}
|
|
@@ -246,7 +267,7 @@ export function getPathContext<TData extends PathData = PathData>(): PathContext
|
|
|
246
267
|
* Internal: Set the PathContext for child components.
|
|
247
268
|
* Used by PathShell component.
|
|
248
269
|
*/
|
|
249
|
-
export function setPathContext<TData extends PathData = PathData>(ctx: PathContext<TData>): void {
|
|
270
|
+
export function setPathContext<TData extends PathData = PathData, TServices = unknown>(ctx: PathContext<TData, TServices>): void {
|
|
250
271
|
setContext(PATH_CONTEXT_KEY, ctx);
|
|
251
272
|
}
|
|
252
273
|
|
|
@@ -289,6 +310,16 @@ export function bindData<TData extends PathData, K extends string & keyof TData>
|
|
|
289
310
|
};
|
|
290
311
|
}
|
|
291
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Converts a hyphenated step ID to camelCase.
|
|
315
|
+
* Used internally by PathShell to resolve step snippets when a step ID contains
|
|
316
|
+
* hyphens (e.g. "cover-letter" → "coverLetter"), since Svelte prop names must
|
|
317
|
+
* be valid JavaScript identifiers.
|
|
318
|
+
*/
|
|
319
|
+
export function stepIdToCamelCase(id: string): string {
|
|
320
|
+
return id.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
321
|
+
}
|
|
322
|
+
|
|
292
323
|
// Export PathShell component
|
|
293
324
|
export { default as PathShell } from "./PathShell.svelte";
|
|
294
325
|
|