@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/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";