@daltonr/pathwrite-solid 0.11.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 +351 -0
- package/dist/index.css +476 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +157 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/index.tsx +597 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSignal,
|
|
3
|
+
onCleanup,
|
|
4
|
+
onMount,
|
|
5
|
+
createContext,
|
|
6
|
+
useContext,
|
|
7
|
+
createEffect,
|
|
8
|
+
For,
|
|
9
|
+
Show,
|
|
10
|
+
type Component,
|
|
11
|
+
type JSX,
|
|
12
|
+
type Accessor,
|
|
13
|
+
} from "solid-js";
|
|
14
|
+
import {
|
|
15
|
+
PathData,
|
|
16
|
+
PathDefinition,
|
|
17
|
+
PathEngine,
|
|
18
|
+
PathEvent,
|
|
19
|
+
PathSnapshot,
|
|
20
|
+
ProgressLayout,
|
|
21
|
+
RootProgress,
|
|
22
|
+
formatFieldKey,
|
|
23
|
+
errorPhaseMessage,
|
|
24
|
+
} from "@daltonr/pathwrite-core";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface UsePathOptions {
|
|
31
|
+
/**
|
|
32
|
+
* An externally-managed `PathEngine` to subscribe to — for example, the engine
|
|
33
|
+
* returned by `createPersistedEngine()` from `@daltonr/pathwrite-store`.
|
|
34
|
+
*
|
|
35
|
+
* When provided:
|
|
36
|
+
* - `usePath` will **not** create its own engine.
|
|
37
|
+
* - The snapshot is seeded immediately from the engine's current state.
|
|
38
|
+
* - The engine lifecycle (start / cleanup) is the **caller's responsibility**.
|
|
39
|
+
* - `PathShell` will skip its own `autoStart` call.
|
|
40
|
+
*/
|
|
41
|
+
engine?: PathEngine;
|
|
42
|
+
/** Called for every engine event (stateChanged, completed, cancelled, resumed). */
|
|
43
|
+
onEvent?: (event: PathEvent) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UsePathReturn<TData extends PathData = PathData> {
|
|
47
|
+
/**
|
|
48
|
+
* Reactive snapshot accessor. Call `snapshot()` to read the current value.
|
|
49
|
+
* Automatically tracked as a reactive dependency when read inside JSX or effects.
|
|
50
|
+
*/
|
|
51
|
+
snapshot: Accessor<PathSnapshot<TData> | null>;
|
|
52
|
+
/** Start (or restart) a path. */
|
|
53
|
+
start: (path: PathDefinition<any>, initialData?: PathData) => Promise<void>;
|
|
54
|
+
/** 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. */
|
|
55
|
+
startSubPath: (path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>) => Promise<void>;
|
|
56
|
+
/** Advance one step. Completes the path on the last step. */
|
|
57
|
+
next: () => Promise<void>;
|
|
58
|
+
/** 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. */
|
|
59
|
+
previous: () => Promise<void>;
|
|
60
|
+
/** Cancel the active path (or sub-path). */
|
|
61
|
+
cancel: () => Promise<void>;
|
|
62
|
+
/** Jump directly to a step by ID. Calls onLeave / onEnter but bypasses guards and shouldSkip. */
|
|
63
|
+
goToStep: (stepId: string) => Promise<void>;
|
|
64
|
+
/** 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. */
|
|
65
|
+
goToStepChecked: (stepId: string) => Promise<void>;
|
|
66
|
+
/** Update a single data value; triggers re-renders via stateChanged. When `TData` is specified, `key` and `value` are type-checked against your data shape. */
|
|
67
|
+
setData: <K extends string & keyof TData>(key: K, value: TData[K]) => Promise<void>;
|
|
68
|
+
/** Reset the current step's data to what it was when the step was entered. Useful for "Clear" or "Reset" buttons. */
|
|
69
|
+
resetStep: () => Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Tear down any active path (without firing hooks) and immediately start the
|
|
72
|
+
* given path fresh. Safe to call whether or not a path is currently active.
|
|
73
|
+
* Use for "Start over" / retry flows without remounting the component.
|
|
74
|
+
*/
|
|
75
|
+
restart: () => Promise<void>;
|
|
76
|
+
/** Re-runs the operation that set `snapshot().error`. Increments `retryCount` on repeated failure. No-op when there is no pending error. */
|
|
77
|
+
retry: () => Promise<void>;
|
|
78
|
+
/** Pauses the path with intent to return. Emits `suspended`. All state is preserved. */
|
|
79
|
+
suspend: () => Promise<void>;
|
|
80
|
+
/** Trigger inline validation on all steps without navigating. Sets `snapshot().hasValidated`. */
|
|
81
|
+
validate: () => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// usePath composable
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export function usePath<TData extends PathData = PathData>(options?: UsePathOptions): UsePathReturn<TData> {
|
|
89
|
+
const engine = options?.engine ?? new PathEngine();
|
|
90
|
+
|
|
91
|
+
const [snapshot, setSnapshot] = createSignal<PathSnapshot<TData> | null>(
|
|
92
|
+
engine.snapshot() as PathSnapshot<TData> | null,
|
|
93
|
+
// always notify — PathEngine produces new snapshot objects on every event
|
|
94
|
+
{ equals: false }
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const unsubscribe = engine.subscribe((event: PathEvent) => {
|
|
98
|
+
if (event.type === "stateChanged" || event.type === "resumed") {
|
|
99
|
+
setSnapshot(event.snapshot as PathSnapshot<TData>);
|
|
100
|
+
} else if (event.type === "completed" || event.type === "cancelled") {
|
|
101
|
+
setSnapshot(null);
|
|
102
|
+
}
|
|
103
|
+
options?.onEvent?.(event);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
onCleanup(unsubscribe);
|
|
107
|
+
|
|
108
|
+
const start = (path: PathDefinition<any>, initialData: PathData = {}): Promise<void> =>
|
|
109
|
+
engine.start(path, initialData);
|
|
110
|
+
|
|
111
|
+
const startSubPath = (path: PathDefinition<any>, initialData: PathData = {}, meta?: Record<string, unknown>): Promise<void> =>
|
|
112
|
+
engine.startSubPath(path, initialData, meta);
|
|
113
|
+
|
|
114
|
+
const next = (): Promise<void> => engine.next();
|
|
115
|
+
const previous = (): Promise<void> => engine.previous();
|
|
116
|
+
const cancel = (): Promise<void> => engine.cancel();
|
|
117
|
+
const goToStep = (stepId: string): Promise<void> => engine.goToStep(stepId);
|
|
118
|
+
const goToStepChecked = (stepId: string): Promise<void> => engine.goToStepChecked(stepId);
|
|
119
|
+
|
|
120
|
+
const setData = (<K extends string & keyof TData>(key: K, value: TData[K]): Promise<void> =>
|
|
121
|
+
engine.setData(key, value as unknown)) as UsePathReturn<TData>["setData"];
|
|
122
|
+
|
|
123
|
+
const resetStep = (): Promise<void> => engine.resetStep();
|
|
124
|
+
const restart = (): Promise<void> => engine.restart();
|
|
125
|
+
const retry = (): Promise<void> => engine.retry();
|
|
126
|
+
const suspend = (): Promise<void> => engine.suspend();
|
|
127
|
+
const validate = (): void => engine.validate();
|
|
128
|
+
|
|
129
|
+
return { snapshot, start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart, retry, suspend, validate };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Context — provide / useContext
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface PathContextValue {
|
|
137
|
+
path: UsePathReturn;
|
|
138
|
+
services: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const PathContext = createContext<PathContextValue | undefined>(undefined);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Access the nearest `PathShell`'s path instance and optional services object.
|
|
145
|
+
* Throws if used outside of a PathShell component.
|
|
146
|
+
*
|
|
147
|
+
* Both generics are type-level assertions, not runtime guarantees:
|
|
148
|
+
* - `TData` narrows `snapshot().data`
|
|
149
|
+
* - `TServices` types the `services` value — must match what was passed to `PathShell`
|
|
150
|
+
*/
|
|
151
|
+
export function usePathContext<TData extends PathData = PathData, TServices = unknown>(): Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: Accessor<PathSnapshot<TData>>; services: TServices } {
|
|
152
|
+
const ctx = useContext(PathContext);
|
|
153
|
+
if (!ctx) {
|
|
154
|
+
throw new Error("usePathContext must be used within a PathShell component.");
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
...(ctx.path as unknown as Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: Accessor<PathSnapshot<TData>> }),
|
|
158
|
+
services: ctx.services as TServices,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Default UI — PathShell
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export interface PathShellActions {
|
|
167
|
+
next: () => Promise<void>;
|
|
168
|
+
previous: () => Promise<void>;
|
|
169
|
+
cancel: () => Promise<void>;
|
|
170
|
+
goToStep: (stepId: string) => Promise<void>;
|
|
171
|
+
goToStepChecked: (stepId: string) => Promise<void>;
|
|
172
|
+
setData: (key: string, value: unknown) => Promise<void>;
|
|
173
|
+
restart: () => Promise<void>;
|
|
174
|
+
retry: () => Promise<void>;
|
|
175
|
+
suspend: () => Promise<void>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PathShellProps {
|
|
179
|
+
path: PathDefinition<any>;
|
|
180
|
+
/**
|
|
181
|
+
* An externally-managed engine — for example, the engine returned by
|
|
182
|
+
* `createPersistedEngine()`. When supplied, `PathShell` will skip its own
|
|
183
|
+
* `start()` call and drive the UI from the provided engine instead.
|
|
184
|
+
*/
|
|
185
|
+
engine?: PathEngine;
|
|
186
|
+
initialData?: PathData;
|
|
187
|
+
autoStart?: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* Step render functions keyed by step ID (or `formId` for StepChoice steps).
|
|
190
|
+
* ```tsx
|
|
191
|
+
* <PathShell steps={{ details: (snap) => <DetailsStep snapshot={snap} />, review: (snap) => <ReviewStep snapshot={snap} /> }} />
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
steps?: Record<string, (snapshot: PathSnapshot) => JSX.Element>;
|
|
195
|
+
onComplete?: (data: PathData) => void;
|
|
196
|
+
onCancel?: (data: PathData) => void;
|
|
197
|
+
onEvent?: (event: PathEvent) => void;
|
|
198
|
+
renderHeader?: (snapshot: PathSnapshot) => JSX.Element;
|
|
199
|
+
renderFooter?: (snapshot: PathSnapshot, actions: PathShellActions) => JSX.Element;
|
|
200
|
+
backLabel?: string;
|
|
201
|
+
nextLabel?: string;
|
|
202
|
+
completeLabel?: string;
|
|
203
|
+
loadingLabel?: string;
|
|
204
|
+
cancelLabel?: string;
|
|
205
|
+
hideCancel?: boolean;
|
|
206
|
+
hideProgress?: boolean;
|
|
207
|
+
/** If true, hide the footer (navigation buttons). The error panel is still shown on async failure regardless of this prop. */
|
|
208
|
+
hideFooter?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Footer layout mode:
|
|
211
|
+
* - `"auto"` (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
|
|
212
|
+
* - `"wizard"`: Back button on left, Cancel and Submit together on right.
|
|
213
|
+
* - `"form"`: Cancel on left, Submit alone on right. Back button never shown.
|
|
214
|
+
*/
|
|
215
|
+
footerLayout?: "wizard" | "form" | "auto";
|
|
216
|
+
/**
|
|
217
|
+
* Controls whether the shell renders its auto-generated field-error summary box.
|
|
218
|
+
* - `"summary"` (default): Shell renders the labeled error list below the step body.
|
|
219
|
+
* - `"inline"`: Suppress the summary — handle errors inside the step component instead.
|
|
220
|
+
* - `"both"`: Render the shell summary AND whatever the step renders.
|
|
221
|
+
*/
|
|
222
|
+
validationDisplay?: "summary" | "inline" | "both";
|
|
223
|
+
/**
|
|
224
|
+
* Controls how progress bars are arranged when a sub-path is active.
|
|
225
|
+
* - `"merged"` (default): Root and sub-path bars in one card.
|
|
226
|
+
* - `"split"`: Root and sub-path bars as separate cards.
|
|
227
|
+
* - `"rootOnly"`: Only the root bar — sub-path bar hidden.
|
|
228
|
+
* - `"activeOnly"`: Only the active (sub-path) bar — root bar hidden.
|
|
229
|
+
*/
|
|
230
|
+
progressLayout?: ProgressLayout;
|
|
231
|
+
/**
|
|
232
|
+
* Services object passed through context to all step components.
|
|
233
|
+
* Step components access it via `usePathContext<TData, TServices>()`.
|
|
234
|
+
*/
|
|
235
|
+
services?: object | null;
|
|
236
|
+
/** 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`. */
|
|
237
|
+
validateWhen?: boolean;
|
|
238
|
+
class?: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const PathShell: Component<PathShellProps> = (props) => {
|
|
242
|
+
const pathReturn = usePath({
|
|
243
|
+
engine: props.engine,
|
|
244
|
+
onEvent(event) {
|
|
245
|
+
props.onEvent?.(event);
|
|
246
|
+
if (event.type === "completed") props.onComplete?.(event.data as PathData);
|
|
247
|
+
if (event.type === "cancelled") props.onCancel?.(event.data as PathData);
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const { snapshot, start, next, previous, cancel, goToStep, goToStepChecked, setData, restart, retry, suspend, validate } = pathReturn;
|
|
252
|
+
|
|
253
|
+
onMount(() => {
|
|
254
|
+
if (props.autoStart !== false && !props.engine) {
|
|
255
|
+
start(props.path, props.initialData ?? {});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
createEffect(() => {
|
|
260
|
+
if (props.validateWhen) validate();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const contextValue: PathContextValue = { path: pathReturn, services: props.services ?? null };
|
|
264
|
+
|
|
265
|
+
const actions: PathShellActions = {
|
|
266
|
+
next, previous, cancel, goToStep, goToStepChecked,
|
|
267
|
+
setData: (key, value) => setData(key as any, value as any),
|
|
268
|
+
restart: () => restart(),
|
|
269
|
+
retry: () => retry(),
|
|
270
|
+
suspend: () => suspend(),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Convenience — non-null snapshot, only valid inside <Show when={snapshot()}>
|
|
274
|
+
const snap = () => snapshot()!;
|
|
275
|
+
|
|
276
|
+
const shellClass = () => {
|
|
277
|
+
const base = "pw-shell";
|
|
278
|
+
const layout = props.progressLayout;
|
|
279
|
+
const mod = layout && layout !== "merged" ? ` pw-shell--progress-${layout}` : "";
|
|
280
|
+
return props.class ? `${base}${mod} ${props.class}` : `${base}${mod}`;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const showRoot = () => !props.hideProgress && !!snap().rootProgress && props.progressLayout !== "activeOnly";
|
|
284
|
+
const showActive = () => !props.hideProgress && (snap().stepCount > 1 || snap().nestingLevel > 0) && props.progressLayout !== "rootOnly";
|
|
285
|
+
|
|
286
|
+
const stepContent = () => {
|
|
287
|
+
const s = snap();
|
|
288
|
+
const key = s.formId ?? s.stepId;
|
|
289
|
+
const render = props.steps?.[key];
|
|
290
|
+
return render ? render(s) : null;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const showValidation = () =>
|
|
294
|
+
props.validationDisplay !== "inline" &&
|
|
295
|
+
(snap().hasAttemptedNext || snap().hasValidated) &&
|
|
296
|
+
Object.keys(snap().fieldErrors).length > 0;
|
|
297
|
+
|
|
298
|
+
const showWarnings = () =>
|
|
299
|
+
props.validationDisplay !== "inline" &&
|
|
300
|
+
Object.keys(snap().fieldWarnings).length > 0;
|
|
301
|
+
|
|
302
|
+
const showBlockingError = () =>
|
|
303
|
+
props.validationDisplay !== "inline" &&
|
|
304
|
+
(snap().hasAttemptedNext || snap().hasValidated) &&
|
|
305
|
+
!!snap().blockingError;
|
|
306
|
+
|
|
307
|
+
const resolvedFooterLayout = () => {
|
|
308
|
+
const fl = props.footerLayout ?? "auto";
|
|
309
|
+
if (fl !== "auto") return fl;
|
|
310
|
+
return snap().stepCount === 1 && snap().nestingLevel === 0 ? "form" : "wizard";
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<PathContext.Provider value={contextValue}>
|
|
315
|
+
<Show
|
|
316
|
+
when={snapshot()}
|
|
317
|
+
fallback={
|
|
318
|
+
<div class="pw-shell">
|
|
319
|
+
<div class="pw-shell__empty">
|
|
320
|
+
<p>No active path.</p>
|
|
321
|
+
<Show when={props.autoStart === false}>
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
class="pw-shell__start-btn"
|
|
325
|
+
onClick={() => start(props.path, props.initialData ?? {})}
|
|
326
|
+
>
|
|
327
|
+
Start
|
|
328
|
+
</button>
|
|
329
|
+
</Show>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
}
|
|
333
|
+
>
|
|
334
|
+
<div class={shellClass()}>
|
|
335
|
+
{/* Root progress — persistent top-level bar visible during sub-paths */}
|
|
336
|
+
<Show when={showRoot()}>
|
|
337
|
+
<SolidRootProgress root={snap().rootProgress!} />
|
|
338
|
+
</Show>
|
|
339
|
+
{/* Header — progress (active path) */}
|
|
340
|
+
<Show when={showActive()}>
|
|
341
|
+
{props.renderHeader
|
|
342
|
+
? props.renderHeader(snap())
|
|
343
|
+
: <SolidHeader snapshot={snap()} />}
|
|
344
|
+
</Show>
|
|
345
|
+
{/* Body — step content */}
|
|
346
|
+
<div class="pw-shell__body">{stepContent()}</div>
|
|
347
|
+
{/* Validation messages */}
|
|
348
|
+
<Show when={showValidation()}>
|
|
349
|
+
<ul class="pw-shell__validation">
|
|
350
|
+
<For each={Object.entries(snap().fieldErrors)}>
|
|
351
|
+
{([key, msg]) => (
|
|
352
|
+
<li class="pw-shell__validation-item">
|
|
353
|
+
<Show when={key !== "_"}>
|
|
354
|
+
<span class="pw-shell__validation-label">{formatFieldKey(key)}</span>
|
|
355
|
+
</Show>
|
|
356
|
+
{msg}
|
|
357
|
+
</li>
|
|
358
|
+
)}
|
|
359
|
+
</For>
|
|
360
|
+
</ul>
|
|
361
|
+
</Show>
|
|
362
|
+
{/* Warning messages — non-blocking, shown immediately */}
|
|
363
|
+
<Show when={showWarnings()}>
|
|
364
|
+
<ul class="pw-shell__warnings">
|
|
365
|
+
<For each={Object.entries(snap().fieldWarnings)}>
|
|
366
|
+
{([key, msg]) => (
|
|
367
|
+
<li class="pw-shell__warnings-item">
|
|
368
|
+
<Show when={key !== "_"}>
|
|
369
|
+
<span class="pw-shell__warnings-label">{formatFieldKey(key)}</span>
|
|
370
|
+
</Show>
|
|
371
|
+
{msg}
|
|
372
|
+
</li>
|
|
373
|
+
)}
|
|
374
|
+
</For>
|
|
375
|
+
</ul>
|
|
376
|
+
</Show>
|
|
377
|
+
{/* Blocking error */}
|
|
378
|
+
<Show when={showBlockingError()}>
|
|
379
|
+
<p class="pw-shell__blocking-error">{snap().blockingError}</p>
|
|
380
|
+
</Show>
|
|
381
|
+
{/* Error panel or footer */}
|
|
382
|
+
<Show
|
|
383
|
+
when={snap().status === "error" && snap().error}
|
|
384
|
+
fallback={
|
|
385
|
+
<Show when={!props.hideFooter}>
|
|
386
|
+
{props.renderFooter
|
|
387
|
+
? props.renderFooter(snap(), actions)
|
|
388
|
+
: <SolidFooter
|
|
389
|
+
snapshot={snap()}
|
|
390
|
+
actions={actions}
|
|
391
|
+
backLabel={props.backLabel ?? "Previous"}
|
|
392
|
+
nextLabel={props.nextLabel ?? "Next"}
|
|
393
|
+
completeLabel={props.completeLabel ?? "Complete"}
|
|
394
|
+
loadingLabel={props.loadingLabel}
|
|
395
|
+
cancelLabel={props.cancelLabel ?? "Cancel"}
|
|
396
|
+
hideCancel={props.hideCancel ?? false}
|
|
397
|
+
footerLayout={resolvedFooterLayout()}
|
|
398
|
+
/>
|
|
399
|
+
}
|
|
400
|
+
</Show>
|
|
401
|
+
}
|
|
402
|
+
>
|
|
403
|
+
<SolidErrorPanel snapshot={snap()} actions={actions} />
|
|
404
|
+
</Show>
|
|
405
|
+
</div>
|
|
406
|
+
</Show>
|
|
407
|
+
</PathContext.Provider>
|
|
408
|
+
);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Root progress
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
function SolidRootProgress(props: { root: RootProgress }) {
|
|
416
|
+
return (
|
|
417
|
+
<div class="pw-shell__root-progress">
|
|
418
|
+
<div class="pw-shell__steps">
|
|
419
|
+
<For each={props.root.steps}>
|
|
420
|
+
{(step, i) => (
|
|
421
|
+
<div class={`pw-shell__step pw-shell__step--${step.status}`}>
|
|
422
|
+
<span class="pw-shell__step-dot">
|
|
423
|
+
{step.status === "completed" ? "✓" : String(i() + 1)}
|
|
424
|
+
</span>
|
|
425
|
+
<span class="pw-shell__step-label">{step.title ?? step.id}</span>
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
</For>
|
|
429
|
+
</div>
|
|
430
|
+
<div class="pw-shell__track">
|
|
431
|
+
<div class="pw-shell__track-fill" style={{ width: `${props.root.progress * 100}%` }} />
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Default header (progress indicator)
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
function SolidHeader(props: { snapshot: PathSnapshot }) {
|
|
442
|
+
return (
|
|
443
|
+
<div class="pw-shell__header">
|
|
444
|
+
<div class="pw-shell__steps">
|
|
445
|
+
<For each={props.snapshot.steps}>
|
|
446
|
+
{(step, i) => (
|
|
447
|
+
<div class={`pw-shell__step pw-shell__step--${step.status}`}>
|
|
448
|
+
<span class="pw-shell__step-dot">
|
|
449
|
+
{step.status === "completed" ? "✓" : String(i() + 1)}
|
|
450
|
+
</span>
|
|
451
|
+
<span class="pw-shell__step-label">{step.title ?? step.id}</span>
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
</For>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="pw-shell__track">
|
|
457
|
+
<div class="pw-shell__track-fill" style={{ width: `${props.snapshot.progress * 100}%` }} />
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Error panel
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
function SolidErrorPanel(props: { snapshot: PathSnapshot; actions: PathShellActions }) {
|
|
468
|
+
const error = () => props.snapshot.error!;
|
|
469
|
+
const escalated = () => error().retryCount >= 2;
|
|
470
|
+
const title = () => escalated() ? "Still having trouble." : "Something went wrong.";
|
|
471
|
+
const phaseMsg = () => errorPhaseMessage(error().phase);
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<div class="pw-shell__error">
|
|
475
|
+
<div class="pw-shell__error-title">{title()}</div>
|
|
476
|
+
<div class="pw-shell__error-message">
|
|
477
|
+
{phaseMsg()}{error().message ? ` ${error().message}` : ""}
|
|
478
|
+
</div>
|
|
479
|
+
<div class="pw-shell__error-actions">
|
|
480
|
+
<Show when={!escalated()}>
|
|
481
|
+
<button type="button" class="pw-shell__btn pw-shell__btn--retry" onClick={props.actions.retry}>
|
|
482
|
+
Try again
|
|
483
|
+
</button>
|
|
484
|
+
</Show>
|
|
485
|
+
<Show when={props.snapshot.hasPersistence}>
|
|
486
|
+
<button
|
|
487
|
+
type="button"
|
|
488
|
+
class={`pw-shell__btn ${escalated() ? "pw-shell__btn--retry" : "pw-shell__btn--suspend"}`}
|
|
489
|
+
onClick={props.actions.suspend}
|
|
490
|
+
>
|
|
491
|
+
Save and come back later
|
|
492
|
+
</button>
|
|
493
|
+
</Show>
|
|
494
|
+
<Show when={escalated() && !props.snapshot.hasPersistence}>
|
|
495
|
+
<button type="button" class="pw-shell__btn pw-shell__btn--retry" onClick={props.actions.retry}>
|
|
496
|
+
Try again
|
|
497
|
+
</button>
|
|
498
|
+
</Show>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Default footer (navigation buttons)
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
function SolidFooter(props: {
|
|
509
|
+
snapshot: PathSnapshot;
|
|
510
|
+
actions: PathShellActions;
|
|
511
|
+
backLabel: string;
|
|
512
|
+
nextLabel: string;
|
|
513
|
+
completeLabel: string;
|
|
514
|
+
loadingLabel?: string;
|
|
515
|
+
cancelLabel: string;
|
|
516
|
+
hideCancel: boolean;
|
|
517
|
+
footerLayout: "wizard" | "form";
|
|
518
|
+
}) {
|
|
519
|
+
const isFormMode = () => props.footerLayout === "form";
|
|
520
|
+
const isLoading = () => props.snapshot.status !== "idle";
|
|
521
|
+
const submitLabel = () =>
|
|
522
|
+
isLoading() && props.loadingLabel
|
|
523
|
+
? props.loadingLabel
|
|
524
|
+
: props.snapshot.isLastStep
|
|
525
|
+
? props.completeLabel
|
|
526
|
+
: props.nextLabel;
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<div class="pw-shell__footer">
|
|
530
|
+
<div class="pw-shell__footer-left">
|
|
531
|
+
{/* Form mode: Cancel on the left */}
|
|
532
|
+
<Show when={isFormMode() && !props.hideCancel}>
|
|
533
|
+
<button
|
|
534
|
+
type="button"
|
|
535
|
+
class="pw-shell__btn pw-shell__btn--cancel"
|
|
536
|
+
disabled={isLoading()}
|
|
537
|
+
onClick={props.actions.cancel}
|
|
538
|
+
>
|
|
539
|
+
{props.cancelLabel}
|
|
540
|
+
</button>
|
|
541
|
+
</Show>
|
|
542
|
+
{/* Wizard mode: Back on the left */}
|
|
543
|
+
<Show when={!isFormMode() && !props.snapshot.isFirstStep}>
|
|
544
|
+
<button
|
|
545
|
+
type="button"
|
|
546
|
+
class="pw-shell__btn pw-shell__btn--back"
|
|
547
|
+
disabled={isLoading() || !props.snapshot.canMovePrevious}
|
|
548
|
+
onClick={props.actions.previous}
|
|
549
|
+
>
|
|
550
|
+
{props.backLabel}
|
|
551
|
+
</button>
|
|
552
|
+
</Show>
|
|
553
|
+
</div>
|
|
554
|
+
<div class="pw-shell__footer-right">
|
|
555
|
+
{/* Wizard mode: Cancel on the right */}
|
|
556
|
+
<Show when={!isFormMode() && !props.hideCancel}>
|
|
557
|
+
<button
|
|
558
|
+
type="button"
|
|
559
|
+
class="pw-shell__btn pw-shell__btn--cancel"
|
|
560
|
+
disabled={isLoading()}
|
|
561
|
+
onClick={props.actions.cancel}
|
|
562
|
+
>
|
|
563
|
+
{props.cancelLabel}
|
|
564
|
+
</button>
|
|
565
|
+
</Show>
|
|
566
|
+
{/* Both modes: Submit on the right */}
|
|
567
|
+
<button
|
|
568
|
+
type="button"
|
|
569
|
+
class={`pw-shell__btn pw-shell__btn--next${isLoading() ? " pw-shell__btn--loading" : ""}`}
|
|
570
|
+
disabled={isLoading()}
|
|
571
|
+
onClick={props.actions.next}
|
|
572
|
+
>
|
|
573
|
+
{submitLabel()}
|
|
574
|
+
</button>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// Re-export core types for convenience
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
export type {
|
|
585
|
+
PathData,
|
|
586
|
+
FieldErrors,
|
|
587
|
+
PathDefinition,
|
|
588
|
+
PathEvent,
|
|
589
|
+
PathSnapshot,
|
|
590
|
+
PathStep,
|
|
591
|
+
PathStepContext,
|
|
592
|
+
ProgressLayout,
|
|
593
|
+
RootProgress,
|
|
594
|
+
SerializedPathState,
|
|
595
|
+
} from "@daltonr/pathwrite-core";
|
|
596
|
+
|
|
597
|
+
export { PathEngine } from "@daltonr/pathwrite-core";
|