@daltonr/pathwrite-solid 0.13.1 → 0.14.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/src/index.tsx CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  // Types
32
32
  // ---------------------------------------------------------------------------
33
33
 
34
- export interface UsePathOptions {
34
+ export interface UsePathOptions<TData extends PathData = PathData> {
35
35
  /**
36
36
  * An externally-managed `PathEngine` to subscribe to — for example, the engine
37
37
  * returned by `restoreOrStart()` from `@daltonr/pathwrite-store`.
@@ -48,9 +48,9 @@ export interface UsePathOptions {
48
48
  * async `restoreOrStart()`) or is swapped is adopted — the hook
49
49
  * re-subscribes and re-seeds its snapshot from the new engine.
50
50
  */
51
- engine?: PathEngine | Accessor<PathEngine | undefined>;
51
+ engine?: PathEngine<TData> | Accessor<PathEngine<TData> | undefined>;
52
52
  /** Called for every engine event (stateChanged, completed, cancelled, resumed). */
53
- onEvent?: (event: PathEvent) => void;
53
+ onEvent?: (event: PathEvent<TData>) => void;
54
54
  }
55
55
 
56
56
  export interface UsePathReturn<TData extends PathData = PathData> {
@@ -60,9 +60,13 @@ export interface UsePathReturn<TData extends PathData = PathData> {
60
60
  */
61
61
  snapshot: Accessor<PathSnapshot<TData> | null>;
62
62
  /** Start (or restart) a path. */
63
- start: (path: PathDefinition<any>, initialData?: PathData) => Promise<void>;
64
- /** 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. */
65
- startSubPath: (path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>) => Promise<void>;
63
+ start: (path: PathDefinition<TData>, initialData?: Partial<TData>) => Promise<void>;
64
+ /** Push a sub-path onto the stack. Requires an active path. A sub-path has its own data, so any definition is accepted. Pass an optional `meta` object for correlation — it is returned unchanged to the parent step's `onSubPathComplete` / `onSubPathCancel` hooks. */
65
+ startSubPath: (
66
+ path: PathDefinition,
67
+ initialData?: PathData,
68
+ meta?: Record<string, unknown>
69
+ ) => Promise<void>;
66
70
  /** Advance one step. Completes the path on the last step. */
67
71
  next: () => Promise<void>;
68
72
  /** 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. */
@@ -96,55 +100,68 @@ export interface UsePathReturn<TData extends PathData = PathData> {
96
100
  // usePath composable
97
101
  // ---------------------------------------------------------------------------
98
102
 
99
- export function usePath<TData extends PathData = PathData>(options?: UsePathOptions): UsePathReturn<TData> {
100
- let ownEngine: PathEngine | null = null;
101
- const resolveEngine = (): PathEngine => {
103
+ export function usePath<TData extends PathData = PathData>(
104
+ options?: UsePathOptions<TData>
105
+ ): UsePathReturn<TData> {
106
+ let ownEngine: PathEngine<TData> | null = null;
107
+ const resolveEngine = (): PathEngine<TData> => {
102
108
  const external = typeof options?.engine === "function" ? options.engine() : options?.engine;
103
- return external ?? (ownEngine ??= new PathEngine());
109
+ return external ?? (ownEngine ??= new PathEngine<TData>());
104
110
  };
105
111
  let engine = resolveEngine();
106
112
 
107
113
  const [snapshot, setSnapshot] = createSignal<PathSnapshot<TData> | null>(
108
- engine.snapshot() as PathSnapshot<TData> | null,
114
+ engine.snapshot(),
109
115
  // always notify — PathEngine produces new snapshot objects on every event
110
116
  { equals: false }
111
117
  );
112
118
 
113
- const onEngineEvent = (event: PathEvent): void => {
119
+ const onEngineEvent = (event: PathEvent<TData>): void => {
114
120
  if (event.type === "stateChanged" || event.type === "resumed") {
115
- setSnapshot(event.snapshot as PathSnapshot<TData>);
121
+ setSnapshot(event.snapshot);
116
122
  } else if (event.type === "completed" || event.type === "cancelled") {
117
- setSnapshot(engine.snapshot() as PathSnapshot<TData> | null);
123
+ setSnapshot(engine.snapshot());
118
124
  }
119
125
  options?.onEvent?.(event);
120
126
  };
121
127
  let unsubscribe = engine.subscribe(onEngineEvent);
122
128
 
123
129
  // Adopt a late or swapped engine: re-subscribe and re-seed the snapshot.
124
- createEffect(on(resolveEngine, (next) => {
125
- if (next === engine) return;
126
- unsubscribe();
127
- engine = next;
128
- setSnapshot(engine.snapshot() as PathSnapshot<TData> | null);
129
- unsubscribe = engine.subscribe(onEngineEvent);
130
- }, { defer: true }));
130
+ createEffect(
131
+ on(
132
+ resolveEngine,
133
+ (next) => {
134
+ if (next === engine) return;
135
+ unsubscribe();
136
+ engine = next;
137
+ setSnapshot(engine.snapshot());
138
+ unsubscribe = engine.subscribe(onEngineEvent);
139
+ },
140
+ { defer: true }
141
+ )
142
+ );
131
143
 
132
144
  onCleanup(() => unsubscribe());
133
145
 
134
- const start = (path: PathDefinition<any>, initialData: PathData = {}): Promise<void> =>
146
+ const start = (path: PathDefinition<TData>, initialData: Partial<TData> = {}): Promise<void> =>
135
147
  engine.start(path, initialData);
136
148
 
137
- const startSubPath = (path: PathDefinition<any>, initialData: PathData = {}, meta?: Record<string, unknown>): Promise<void> =>
138
- engine.startSubPath(path, initialData, meta);
149
+ const startSubPath = (
150
+ path: PathDefinition,
151
+ initialData: PathData = {},
152
+ meta?: Record<string, unknown>
153
+ ): Promise<void> => engine.startSubPath(path, initialData, meta);
139
154
 
140
155
  const next = (): Promise<void> => engine.next();
141
156
  const previous = (): Promise<void> => engine.previous();
142
157
  const cancel = (): Promise<void> => engine.cancel();
143
- const goToStep = (stepId: string, options?: { validateOnLeave?: boolean }): Promise<void> => engine.goToStep(stepId, options);
144
- const goToStepChecked = (stepId: string, options?: { validateOnLeave?: boolean }): Promise<void> => engine.goToStepChecked(stepId, options);
158
+ const goToStep = (stepId: string, options?: { validateOnLeave?: boolean }): Promise<void> =>
159
+ engine.goToStep(stepId, options);
160
+ const goToStepChecked = (stepId: string, options?: { validateOnLeave?: boolean }): Promise<void> =>
161
+ engine.goToStepChecked(stepId, options);
145
162
 
146
- const setData = (<K extends string & keyof TData>(key: K, value: TData[K]): Promise<void> =>
147
- engine.setData(key, value as unknown)) as UsePathReturn<TData>["setData"];
163
+ const setData = <K extends string & keyof TData>(key: K, value: TData[K]): Promise<void> =>
164
+ engine.setData(key, value);
148
165
 
149
166
  const resetStep = (): Promise<void> => engine.resetStep();
150
167
  const restart = (): Promise<void> => engine.restart();
@@ -152,7 +169,22 @@ export function usePath<TData extends PathData = PathData>(options?: UsePathOpti
152
169
  const suspend = (): Promise<void> => engine.suspend();
153
170
  const validate = (): void => engine.validate();
154
171
 
155
- return { snapshot, start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart, retry, suspend, validate };
172
+ return {
173
+ snapshot,
174
+ start,
175
+ startSubPath,
176
+ next,
177
+ previous,
178
+ cancel,
179
+ goToStep,
180
+ goToStepChecked,
181
+ setData,
182
+ resetStep,
183
+ restart,
184
+ retry,
185
+ suspend,
186
+ validate,
187
+ };
156
188
  }
157
189
 
158
190
  // ---------------------------------------------------------------------------
@@ -174,13 +206,18 @@ const PathContext = createContext<PathContextValue | undefined>(undefined);
174
206
  * - `TData` narrows `snapshot().data`
175
207
  * - `TServices` types the `services` value — must match what was passed to `PathShell`
176
208
  */
177
- export function usePathContext<TData extends PathData = PathData, TServices = unknown>(): Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: Accessor<PathSnapshot<TData>>; services: TServices } {
209
+ export function usePathContext<TData extends PathData = PathData, TServices = unknown>(): Omit<
210
+ UsePathReturn<TData>,
211
+ "snapshot"
212
+ > & { snapshot: Accessor<PathSnapshot<TData>>; services: TServices } {
178
213
  const ctx = useContext(PathContext);
179
214
  if (!ctx) {
180
215
  throw new Error("usePathContext must be used within a PathShell component.");
181
216
  }
182
217
  return {
183
- ...(ctx.path as unknown as Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: Accessor<PathSnapshot<TData>> }),
218
+ ...(ctx.path as unknown as Omit<UsePathReturn<TData>, "snapshot"> & {
219
+ snapshot: Accessor<PathSnapshot<TData>>;
220
+ }),
184
221
  services: ctx.services as TServices,
185
222
  };
186
223
  }
@@ -202,7 +239,8 @@ export interface PathShellActions {
202
239
  }
203
240
 
204
241
  export interface PathShellProps {
205
- path: PathDefinition<any>;
242
+ /** The path to run. The shell is not typed over the path's data, so a definition of any data type is accepted. */
243
+ path: PathDefinition;
206
244
  /**
207
245
  * An externally-managed engine — for example, the engine returned by
208
246
  * `restoreOrStart()` from `@daltonr/pathwrite-store`. When supplied, `PathShell` will skip its own
@@ -294,7 +332,8 @@ export const PathShell: Component<PathShellProps> = (props) => {
294
332
  // step (which re-ran onEnter/onLeave and lost attempted / visited state).
295
333
  const restoredEngine: PathEngine | null = (() => {
296
334
  if (props.engine || !props.restoreKey || !outerCtx) return null;
297
- const stored = outerCtx.path.snapshot()?.data[props.restoreKey] as { serializedState?: SerializedPathState } | undefined;
335
+ const stored = outerCtx.path.snapshot()?.data[props.restoreKey] as
336
+ { serializedState?: SerializedPathState } | undefined;
298
337
  if (!stored || typeof stored !== "object" || !stored.serializedState) return null;
299
338
  try {
300
339
  return PathEngine.fromState(stored.serializedState, { [props.path.id]: props.path });
@@ -314,14 +353,28 @@ export const PathShell: Component<PathShellProps> = (props) => {
314
353
  if (event.type === "completed") props.onComplete?.(event.data as PathData);
315
354
  if (event.type === "cancelled") props.onCancel?.(event.data as PathData);
316
355
  if (props.restoreKey && outerCtx && event.type === "stateChanged") {
317
- (outerCtx.path.setData as unknown as (key: string, value: unknown) => void)(
318
- props.restoreKey, { ...event.snapshot, serializedState: currentEngine().exportState() }
319
- );
356
+ void outerCtx.path.setData(props.restoreKey, {
357
+ ...event.snapshot,
358
+ serializedState: currentEngine().exportState(),
359
+ });
320
360
  }
321
361
  },
322
362
  });
323
363
 
324
- const { snapshot, start, next, previous, cancel, goToStep, goToStepChecked, setData, restart, retry, suspend, validate } = pathReturn;
364
+ const {
365
+ snapshot,
366
+ start,
367
+ next,
368
+ previous,
369
+ cancel,
370
+ goToStep,
371
+ goToStepChecked,
372
+ setData,
373
+ restart,
374
+ retry,
375
+ suspend,
376
+ validate,
377
+ } = pathReturn;
325
378
 
326
379
  onMount(() => {
327
380
  if (props.autoStart !== false && !props.engine && !restoredEngine) {
@@ -348,8 +401,12 @@ export const PathShell: Component<PathShellProps> = (props) => {
348
401
  const contextValue: PathContextValue = { path: pathReturn, services: props.services ?? null };
349
402
 
350
403
  const actions: PathShellActions = {
351
- next, previous, cancel, goToStep, goToStepChecked,
352
- setData: (key, value) => setData(key as any, value as any),
404
+ next,
405
+ previous,
406
+ cancel,
407
+ goToStep,
408
+ goToStepChecked,
409
+ setData,
353
410
  restart: () => restart(),
354
411
  retry: () => retry(),
355
412
  suspend: () => suspend(),
@@ -367,13 +424,16 @@ export const PathShell: Component<PathShellProps> = (props) => {
367
424
 
368
425
  const effectiveHideProgress = () => props.hideProgress || props.layout === "tabs";
369
426
  const effectiveHideFooter = () => props.hideFooter || props.layout === "tabs";
370
- const showRoot = () => !effectiveHideProgress() && !!snap().rootProgress && props.progressLayout !== "activeOnly";
427
+ const showRoot = () =>
428
+ !effectiveHideProgress() && !!snap().rootProgress && props.progressLayout !== "activeOnly";
371
429
  // A custom header is the consumer's decision: show it whenever progress is
372
430
  // not hidden, even for a single-step path. Only the *default* header hides
373
431
  // for one step (same rule as the React / Vue shells).
374
- const showActive = () => !effectiveHideProgress() && (props.renderHeader
375
- ? true
376
- : (snap().stepCount > 1 || snap().nestingLevel > 0) && props.progressLayout !== "rootOnly");
432
+ const showActive = () =>
433
+ !effectiveHideProgress() &&
434
+ (props.renderHeader
435
+ ? true
436
+ : (snap().stepCount > 1 || snap().nestingLevel > 0) && props.progressLayout !== "rootOnly");
377
437
 
378
438
  // The step render function must only run when the *step* changes. The
379
439
  // snapshot signal is `{ equals: false }` (a new object on every engine
@@ -401,8 +461,14 @@ export const PathShell: Component<PathShellProps> = (props) => {
401
461
  // current values reactively even though the step itself is created once.
402
462
  const liveSnapshot = new Proxy({} as PathSnapshot, {
403
463
  get: (_target, key) => (snapshot() as unknown as Record<PropertyKey, unknown> | null)?.[key],
404
- has: (_target, key) => { const s = snapshot(); return s ? key in s : false; },
405
- ownKeys: () => { const s = snapshot(); return s ? Reflect.ownKeys(s) : []; },
464
+ has: (_target, key) => {
465
+ const s = snapshot();
466
+ return s ? key in s : false;
467
+ },
468
+ ownKeys: () => {
469
+ const s = snapshot();
470
+ return s ? Reflect.ownKeys(s) : [];
471
+ },
406
472
  getOwnPropertyDescriptor: (_target, key) => {
407
473
  const s = snapshot();
408
474
  const d = s ? Object.getOwnPropertyDescriptor(s, key) : undefined;
@@ -427,8 +493,7 @@ export const PathShell: Component<PathShellProps> = (props) => {
427
493
  Object.keys(snap().fieldErrors).length > 0;
428
494
 
429
495
  const showWarnings = () =>
430
- props.validationDisplay !== "inline" &&
431
- Object.keys(snap().fieldWarnings).length > 0;
496
+ props.validationDisplay !== "inline" && Object.keys(snap().fieldWarnings).length > 0;
432
497
 
433
498
  const showBlockingError = () =>
434
499
  props.validationDisplay !== "inline" &&
@@ -469,75 +534,71 @@ export const PathShell: Component<PathShellProps> = (props) => {
469
534
  </Show>
470
535
  {/* Header — progress (active path) */}
471
536
  <Show when={showActive()}>
472
- {props.renderHeader
473
- ? props.renderHeader(snap())
474
- : <SolidHeader snapshot={snap()} />}
537
+ {props.renderHeader ? props.renderHeader(snap()) : <SolidHeader snapshot={snap()} />}
475
538
  </Show>
476
539
  {/* Completion panel — shown when path finishes with stayOnFinal */}
477
540
  <Show when={snap().status === "completed"}>
478
541
  <div class="pw-shell__body">
479
- {props.completionContent
480
- ? props.completionContent(snap())
481
- : (
482
- <div class="pw-shell__completion">
483
- <p class="pw-shell__completion-message">All done.</p>
484
- <button
485
- type="button"
486
- class="pw-shell__completion-restart"
487
- onClick={() => restart()}
488
- >
489
- Start over
490
- </button>
491
- </div>
492
- )
493
- }
542
+ {props.completionContent ? (
543
+ props.completionContent(snap())
544
+ ) : (
545
+ <div class="pw-shell__completion">
546
+ <p class="pw-shell__completion-message">All done.</p>
547
+ <button type="button" class="pw-shell__completion-restart" onClick={() => restart()}>
548
+ Start over
549
+ </button>
550
+ </div>
551
+ )}
494
552
  </div>
495
553
  </Show>
496
554
  {/* Body — step content (hidden when completed) */}
497
555
  <Show when={snap().status !== "completed"}>
498
- <div class="pw-shell__body"><StepContent /></div>
499
- {/* Validation messages */}
500
- <Show when={showValidation()}>
501
- <ul class="pw-shell__validation">
502
- <For each={Object.entries(snap().fieldErrors)}>
503
- {([key, msg]) => (
504
- <li class="pw-shell__validation-item">
505
- <Show when={key !== "_"}>
506
- <span class="pw-shell__validation-label">{formatFieldKey(key)}</span>
507
- </Show>
508
- {msg}
509
- </li>
510
- )}
511
- </For>
512
- </ul>
513
- </Show>
514
- {/* Warning messages — non-blocking, shown immediately */}
515
- <Show when={showWarnings()}>
516
- <ul class="pw-shell__warnings">
517
- <For each={Object.entries(snap().fieldWarnings)}>
518
- {([key, msg]) => (
519
- <li class="pw-shell__warnings-item">
520
- <Show when={key !== "_"}>
521
- <span class="pw-shell__warnings-label">{formatFieldKey(key)}</span>
522
- </Show>
523
- {msg}
524
- </li>
525
- )}
526
- </For>
527
- </ul>
528
- </Show>
529
- {/* Blocking error */}
530
- <Show when={showBlockingError()}>
531
- <p class="pw-shell__blocking-error">{snap().blockingError}</p>
532
- </Show>
533
- {/* Error panel or footer */}
534
- <Show
535
- when={snap().status === "error" && snap().error}
536
- fallback={
537
- <Show when={!effectiveHideFooter()}>
538
- {props.renderFooter
539
- ? props.renderFooter(snap(), actions)
540
- : <SolidFooter
556
+ <div class="pw-shell__body">
557
+ <StepContent />
558
+ </div>
559
+ {/* Validation messages */}
560
+ <Show when={showValidation()}>
561
+ <ul class="pw-shell__validation">
562
+ <For each={Object.entries(snap().fieldErrors)}>
563
+ {([key, msg]) => (
564
+ <li class="pw-shell__validation-item">
565
+ <Show when={key !== "_"}>
566
+ <span class="pw-shell__validation-label">{formatFieldKey(key)}</span>
567
+ </Show>
568
+ {msg}
569
+ </li>
570
+ )}
571
+ </For>
572
+ </ul>
573
+ </Show>
574
+ {/* Warning messages — non-blocking, shown immediately */}
575
+ <Show when={showWarnings()}>
576
+ <ul class="pw-shell__warnings">
577
+ <For each={Object.entries(snap().fieldWarnings)}>
578
+ {([key, msg]) => (
579
+ <li class="pw-shell__warnings-item">
580
+ <Show when={key !== "_"}>
581
+ <span class="pw-shell__warnings-label">{formatFieldKey(key)}</span>
582
+ </Show>
583
+ {msg}
584
+ </li>
585
+ )}
586
+ </For>
587
+ </ul>
588
+ </Show>
589
+ {/* Blocking error */}
590
+ <Show when={showBlockingError()}>
591
+ <p class="pw-shell__blocking-error">{snap().blockingError}</p>
592
+ </Show>
593
+ {/* Error panel or footer */}
594
+ <Show
595
+ when={snap().status === "error" && snap().error}
596
+ fallback={
597
+ <Show when={!effectiveHideFooter()}>
598
+ {props.renderFooter ? (
599
+ props.renderFooter(snap(), actions)
600
+ ) : (
601
+ <SolidFooter
541
602
  snapshot={snap()}
542
603
  actions={actions}
543
604
  backLabel={props.backLabel ?? "Previous"}
@@ -548,13 +609,14 @@ export const PathShell: Component<PathShellProps> = (props) => {
548
609
  hideCancel={props.hideCancel ?? false}
549
610
  layout={resolvedFooterLayout()}
550
611
  />
551
- }
552
- </Show>
553
- }
554
- >
555
- <SolidErrorPanel snapshot={snap()} actions={actions} />
612
+ )}
613
+ </Show>
614
+ }
615
+ >
616
+ <SolidErrorPanel snapshot={snap()} actions={actions} />
617
+ </Show>
556
618
  </Show>
557
- </Show>{/* end status !== completed */}
619
+ {/* end status !== completed */}
558
620
  </div>
559
621
  </Show>
560
622
  </PathContext.Provider>
@@ -572,9 +634,7 @@ function SolidRootProgress(props: { root: RootProgress }) {
572
634
  <For each={props.root.steps}>
573
635
  {(step, i) => (
574
636
  <div class={`pw-shell__step pw-shell__step--${step.status}`}>
575
- <span class="pw-shell__step-dot">
576
- {step.status === "completed" ? "✓" : String(i() + 1)}
577
- </span>
637
+ <span class="pw-shell__step-dot">{step.status === "completed" ? "✓" : String(i() + 1)}</span>
578
638
  <span class="pw-shell__step-label">{step.title ?? step.id}</span>
579
639
  </div>
580
640
  )}
@@ -598,9 +658,7 @@ function SolidHeader(props: { snapshot: PathSnapshot }) {
598
658
  <For each={props.snapshot.steps}>
599
659
  {(step, i) => (
600
660
  <div class={`pw-shell__step pw-shell__step--${step.status}`}>
601
- <span class="pw-shell__step-dot">
602
- {step.status === "completed" ? "✓" : String(i() + 1)}
603
- </span>
661
+ <span class="pw-shell__step-dot">{step.status === "completed" ? "✓" : String(i() + 1)}</span>
604
662
  <span class="pw-shell__step-label">{step.title ?? step.id}</span>
605
663
  </div>
606
664
  )}
@@ -620,14 +678,15 @@ function SolidHeader(props: { snapshot: PathSnapshot }) {
620
678
  function SolidErrorPanel(props: { snapshot: PathSnapshot; actions: PathShellActions }) {
621
679
  const error = () => props.snapshot.error!;
622
680
  const escalated = () => error().retryCount >= 2;
623
- const title = () => escalated() ? "Still having trouble." : "Something went wrong.";
681
+ const title = () => (escalated() ? "Still having trouble." : "Something went wrong.");
624
682
  const phaseMsg = () => errorPhaseMessage(error().phase);
625
683
 
626
684
  return (
627
685
  <div class="pw-shell__error">
628
686
  <div class="pw-shell__error-title">{title()}</div>
629
687
  <div class="pw-shell__error-message">
630
- {phaseMsg()}{error().message ? ` ${error().message}` : ""}
688
+ {phaseMsg()}
689
+ {error().message ? ` ${error().message}` : ""}
631
690
  </div>
632
691
  <div class="pw-shell__error-actions">
633
692
  <Show when={!escalated()}>
@@ -740,6 +799,7 @@ export type {
740
799
  PathDefinition,
741
800
  PathEvent,
742
801
  PathSnapshot,
802
+ StepStatus,
743
803
  PathStep,
744
804
  PathStepContext,
745
805
  ProgressLayout,