@daltonr/pathwrite-react-native 0.9.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 ADDED
@@ -0,0 +1,302 @@
1
+ # @daltonr/pathwrite-react-native
2
+
3
+ React Native adapter for `@daltonr/pathwrite-core`. Exposes path state as reactive React state via `useSyncExternalStore`, with stable action callbacks, an optional context provider, and an optional `PathShell` default UI built from React Native primitives.
4
+
5
+ Works with Expo (managed and bare workflow) and bare React Native projects. Targets iOS and Android.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @daltonr/pathwrite-core @daltonr/pathwrite-react-native
11
+ ```
12
+
13
+ Peer dependencies: `react >= 18.0.0`, `react-native >= 0.72.0`.
14
+
15
+ ## Exports
16
+
17
+ ```typescript
18
+ import {
19
+ PathShell, // React Native UI shell
20
+ usePath, // Hook — creates a scoped engine
21
+ usePathContext, // Hook — reads the nearest PathProvider
22
+ PathProvider, // Context provider
23
+ // Core types re-exported for convenience:
24
+ PathEngine,
25
+ PathData,
26
+ PathDefinition,
27
+ PathEvent,
28
+ PathSnapshot,
29
+ PathStep,
30
+ PathStepContext,
31
+ StepChoice,
32
+ SerializedPathState,
33
+ } from "@daltonr/pathwrite-react-native";
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick Start — PathShell
39
+
40
+ The fastest way to get started. `PathShell` manages the engine lifecycle, renders the active step, and provides navigation buttons. The default header shows numbered step dots (✓ when completed), the current step title, and a progress bar.
41
+
42
+ ```tsx
43
+ import { PathShell } from "@daltonr/pathwrite-react-native";
44
+ import { myPath, INITIAL_DATA } from "./my-path";
45
+ import { DetailsStep } from "./DetailsStep";
46
+ import { ReviewStep } from "./ReviewStep";
47
+
48
+ export function MyFlow() {
49
+ return (
50
+ <PathShell
51
+ path={myPath}
52
+ initialData={INITIAL_DATA}
53
+ onComplete={(data) => console.log("Done!", data)}
54
+ steps={{
55
+ details: <DetailsStep />,
56
+ review: <ReviewStep />,
57
+ }}
58
+ />
59
+ );
60
+ }
61
+ ```
62
+
63
+ Inside step components, use `usePathContext()` to read state and dispatch actions:
64
+
65
+ ```tsx
66
+ import { usePathContext } from "@daltonr/pathwrite-react-native";
67
+ import type { MyData } from "./my-path";
68
+
69
+ export function DetailsStep() {
70
+ const { snapshot, setData } = usePathContext<MyData>();
71
+ const name = snapshot?.data.name ?? "";
72
+
73
+ return (
74
+ <TextInput
75
+ value={name}
76
+ onChangeText={(text) => setData("name", text)}
77
+ placeholder="Your name"
78
+ />
79
+ );
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Option A — `usePath` hook (component-scoped)
86
+
87
+ Each call creates an isolated engine. Good when a single screen owns the path.
88
+
89
+ ```tsx
90
+ import { usePath } from "@daltonr/pathwrite-react-native";
91
+ import { myPath } from "./my-path";
92
+
93
+ function MyScreen() {
94
+ const { snapshot, start, next, previous, setData } = usePath({
95
+ onEvent(event) {
96
+ if (event.type === "completed") console.log("Done!", event.data);
97
+ }
98
+ });
99
+
100
+ useEffect(() => {
101
+ start(myPath, { name: "" });
102
+ }, []);
103
+
104
+ if (!snapshot) return null;
105
+
106
+ return (
107
+ <View>
108
+ <Text>{snapshot.stepTitle ?? snapshot.stepId}</Text>
109
+ <Text>Step {snapshot.stepIndex + 1} of {snapshot.stepCount}</Text>
110
+ <Pressable onPress={previous} disabled={snapshot.isNavigating}>
111
+ <Text>Back</Text>
112
+ </Pressable>
113
+ <Pressable onPress={next} disabled={snapshot.isNavigating || !snapshot.canMoveNext}>
114
+ <Text>{snapshot.isLastStep ? "Complete" : "Next"}</Text>
115
+ </Pressable>
116
+ </View>
117
+ );
118
+ }
119
+ ```
120
+
121
+ ## Option B — `PathProvider` + `usePathContext` (tree-scoped)
122
+
123
+ Share one engine across a component tree — step components read state without prop-drilling.
124
+
125
+ ```tsx
126
+ import { PathProvider } from "@daltonr/pathwrite-react-native";
127
+
128
+ // Root — start the path once, then render children
129
+ function WizardRoot() {
130
+ const { snapshot, start, next } = usePathContext();
131
+ useEffect(() => { start(myPath, {}); }, []);
132
+ return (
133
+ <View>
134
+ {snapshot && <Text>Step {snapshot.stepIndex + 1}</Text>}
135
+ <Pressable onPress={next}><Text>Next</Text></Pressable>
136
+ </View>
137
+ );
138
+ }
139
+
140
+ // Wrap in the provider at the navigation/screen level
141
+ export function WizardScreen() {
142
+ return (
143
+ <PathProvider>
144
+ <WizardRoot />
145
+ </PathProvider>
146
+ );
147
+ }
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Path definition
153
+
154
+ Path definitions are plain objects — no classes, no decorators, framework-agnostic.
155
+
156
+ ```typescript
157
+ import type { PathDefinition } from "@daltonr/pathwrite-react-native";
158
+
159
+ interface MyData {
160
+ name: string;
161
+ agreed: boolean;
162
+ }
163
+
164
+ export const myPath: PathDefinition<MyData> = {
165
+ id: "onboarding",
166
+ steps: [
167
+ {
168
+ id: "name",
169
+ title: "Your Name",
170
+ canMoveNext: ({ data }) => data.name.trim().length >= 2,
171
+ },
172
+ {
173
+ id: "terms",
174
+ title: "Terms",
175
+ canMoveNext: ({ data }) => data.agreed,
176
+ },
177
+ {
178
+ id: "done",
179
+ title: "All Done",
180
+ },
181
+ ],
182
+ };
183
+ ```
184
+
185
+ ---
186
+
187
+ ## Conditional steps — `shouldSkip`
188
+
189
+ Steps with a `shouldSkip` guard are removed from the flow when the guard returns `true`. The progress indicator updates automatically.
190
+
191
+ ```typescript
192
+ {
193
+ id: "address-details",
194
+ shouldSkip: ({ data }) => data.deliveryMethod !== "postal",
195
+ }
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Conditional forms — `StepChoice`
201
+
202
+ A `StepChoice` step selects one inner step at runtime based on current data. Useful for showing different form variants without branching the path definition.
203
+
204
+ ```typescript
205
+ {
206
+ id: "address",
207
+ select: ({ data }) => data.country === "US" ? "address-us" : "address-ie",
208
+ steps: [
209
+ { id: "address-us", title: "US Address" },
210
+ { id: "address-ie", title: "Irish Address" },
211
+ ],
212
+ }
213
+ ```
214
+
215
+ The snapshot exposes both `stepId` (the outer choice id, `"address"`) and `formId` (the selected inner step, `"address-us"`). When using `PathShell`, pass both inner step IDs as keys in the `steps` map — the shell resolves the correct one automatically:
216
+
217
+ ```tsx
218
+ <PathShell
219
+ path={myPath}
220
+ steps={{
221
+ name: <NameStep />,
222
+ "address-us": <USAddressStep />,
223
+ "address-ie": <IrishAddressStep />,
224
+ done: <DoneStep />,
225
+ }}
226
+ />
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Sub-paths
232
+
233
+ Push a sub-path onto the stack from within a step. The parent path resumes from the same step when the sub-path completes.
234
+
235
+ ```typescript
236
+ const { startSubPath } = usePathContext();
237
+
238
+ function handleStartSubWizard() {
239
+ startSubPath(subWizardPath, {});
240
+ }
241
+ ```
242
+
243
+ ---
244
+
245
+ ## PathShell props
246
+
247
+ | Prop | Type | Default | Description |
248
+ |---|---|---|---|
249
+ | `path` | `PathDefinition` | required | The path to drive. |
250
+ | `steps` | `Record<string, ReactNode>` | required | Map of step ID → content. |
251
+ | `initialData` | `PathData` | `{}` | Initial data passed to `engine.start()`. |
252
+ | `engine` | `PathEngine` | — | Externally managed engine (e.g., from `restoreOrStart()`). |
253
+ | `autoStart` | `boolean` | `true` | Start the path automatically on mount. |
254
+ | `onComplete` | `(data) => void` | — | Called when the path completes. |
255
+ | `onCancel` | `(data) => void` | — | Called when the path is cancelled. |
256
+ | `onEvent` | `(event) => void` | — | Called for every engine event. |
257
+ | `backLabel` | `string` | `"Previous"` | Label for the back button. |
258
+ | `nextLabel` | `string` | `"Next"` | Label for the next button. |
259
+ | `completeLabel` | `string` | `"Complete"` | Label for the next button on the last step. |
260
+ | `cancelLabel` | `string` | `"Cancel"` | Label for the cancel button. |
261
+ | `hideCancel` | `boolean` | `false` | Hide the cancel button. |
262
+ | `hideProgress` | `boolean` | `false` | Hide the progress header (numbered dots, current step title, and progress bar). Also hidden automatically for single-step top-level paths. |
263
+ | `disableBodyScroll` | `boolean` | `false` | Replace the `ScrollView` body wrapper with a plain `View`. Use when the step content contains a `FlatList` or other virtualized list to avoid the "VirtualizedList inside ScrollView" warning. The step is then responsible for its own scroll. |
264
+ | `footerLayout` | `"wizard" \| "form" \| "auto"` | `"auto"` | `"wizard"` puts back on the left; `"form"` puts cancel on the left. |
265
+ | `renderHeader` | `(snapshot) => ReactNode` | — | Replace the default progress header entirely. |
266
+ | `renderFooter` | `(snapshot, actions) => ReactNode` | — | Replace the default nav buttons. |
267
+ | `style` | `StyleProp<ViewStyle>` | — | Override for the root `View`. |
268
+
269
+ ---
270
+
271
+ ## usePath options and return value
272
+
273
+ ```typescript
274
+ usePath(options?: {
275
+ engine?: PathEngine; // Use an externally-managed engine
276
+ onEvent?: (event: PathEvent) => void;
277
+ })
278
+ ```
279
+
280
+ Returns `UsePathReturn<TData>`:
281
+
282
+ | Field | Type | Description |
283
+ |---|---|---|
284
+ | `snapshot` | `PathSnapshot<TData> \| null` | Current state. `null` when no path is active. |
285
+ | `start` | `(path, data?) => void` | Start a path. |
286
+ | `startSubPath` | `(path, data?, meta?) => void` | Push a sub-path. |
287
+ | `next` | `() => void` | Advance. Completes on the last step. |
288
+ | `previous` | `() => void` | Go back. |
289
+ | `cancel` | `() => void` | Cancel the active path. |
290
+ | `goToStep` | `(stepId) => void` | Jump to a step (no guard check). |
291
+ | `goToStepChecked` | `(stepId) => void` | Jump to a step (checks current step guard first). |
292
+ | `setData` | `(key, value) => void` | Update one data field. |
293
+ | `resetStep` | `() => void` | Restore data to step-entry state. |
294
+ | `restart` | `(path, data?) => void` | Tear down and restart fresh. |
295
+
296
+ ---
297
+
298
+ ## Testing
299
+
300
+ Hook tests (`usePath`, `usePathContext`) use `@testing-library/react` in a jsdom environment — no device or simulator required. The hooks are pure React (they use only `useSyncExternalStore`, `useCallback`, `useRef`) and work identically in both RN and web contexts.
301
+
302
+ PathShell component tests require `@testing-library/react-native` and a React Native test environment (e.g., Jest with `react-native` preset or Expo's Jest preset).
@@ -0,0 +1,151 @@
1
+ import type { PropsWithChildren, ReactElement, ReactNode } from "react";
2
+ import type { StyleProp, ViewStyle } from "react-native";
3
+ import { PathData, PathDefinition, PathEngine, PathEvent, PathSnapshot } from "@daltonr/pathwrite-core";
4
+ export interface UsePathOptions {
5
+ /**
6
+ * An externally-managed `PathEngine` to subscribe to — for example, one
7
+ * returned by `restoreOrStart()` from `@daltonr/pathwrite-store`.
8
+ *
9
+ * When provided:
10
+ * - `usePath` will **not** create its own engine.
11
+ * - The snapshot is seeded immediately from the engine's current state.
12
+ * - The engine lifecycle (start / cleanup) is the **caller's responsibility**.
13
+ */
14
+ engine?: PathEngine;
15
+ /** Called for every engine event. The callback ref is kept current — changing it does not re-subscribe. */
16
+ onEvent?: (event: PathEvent) => void;
17
+ }
18
+ export interface UsePathReturn<TData extends PathData = PathData> {
19
+ /** Current path snapshot, or `null` when no path is active. Triggers a re-render on change. */
20
+ snapshot: PathSnapshot<TData> | null;
21
+ /** Start (or restart) a path. */
22
+ start: (path: PathDefinition<any>, initialData?: PathData) => void;
23
+ /** Push a sub-path onto the stack. */
24
+ startSubPath: (path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>) => void;
25
+ /** Advance one step. Completes the path on the last step. */
26
+ next: () => void;
27
+ /** Go back one step. */
28
+ previous: () => void;
29
+ /** Cancel the active path (or sub-path). */
30
+ cancel: () => void;
31
+ /** Jump directly to a step by ID, bypassing guards and shouldSkip. */
32
+ goToStep: (stepId: string) => void;
33
+ /** Jump directly to a step by ID, checking the current step's guard first. */
34
+ goToStepChecked: (stepId: string) => void;
35
+ /** Update a single data value. When `TData` is specified, key and value are type-checked. */
36
+ setData: <K extends string & keyof TData>(key: K, value: TData[K]) => void;
37
+ /** Reset the current step's data to what it was when the step was entered. */
38
+ resetStep: () => void;
39
+ /** Tear down any active path and immediately start the given path fresh. */
40
+ restart: () => void;
41
+ }
42
+ export type PathProviderProps = PropsWithChildren<{
43
+ onEvent?: (event: PathEvent) => void;
44
+ }>;
45
+ export declare function usePath<TData extends PathData = PathData>(options?: UsePathOptions): UsePathReturn<TData>;
46
+ /**
47
+ * Provides a single `usePath` instance to all descendants.
48
+ * Consume with `usePathContext()`.
49
+ */
50
+ export declare function PathProvider({ children, onEvent }: PathProviderProps): ReactElement;
51
+ /**
52
+ * Access the nearest `PathProvider`'s path instance.
53
+ * Throws if used outside of a `<PathProvider>`.
54
+ */
55
+ export declare function usePathContext<TData extends PathData = PathData>(): Omit<UsePathReturn<TData>, "snapshot"> & {
56
+ snapshot: PathSnapshot<TData>;
57
+ };
58
+ export interface PathShellHandle {
59
+ /** Restart the shell's current path with its original `initialData`, without unmounting. */
60
+ restart: () => void;
61
+ }
62
+ export interface PathShellActions {
63
+ next: () => void;
64
+ previous: () => void;
65
+ cancel: () => void;
66
+ goToStep: (stepId: string) => void;
67
+ goToStepChecked: (stepId: string) => void;
68
+ setData: (key: string, value: unknown) => void;
69
+ restart: () => void;
70
+ }
71
+ export interface PathShellProps {
72
+ /** The path definition to drive. */
73
+ path: PathDefinition<any>;
74
+ /** An externally-managed engine. When supplied, PathShell skips its own start() call. */
75
+ engine?: PathEngine;
76
+ /** Map of step ID → React Native content. The shell renders `steps[snapshot.stepId]` for the current step. */
77
+ steps: Record<string, ReactNode>;
78
+ /** Initial data passed to engine.start(). */
79
+ initialData?: PathData;
80
+ /** If true, the path is started automatically on mount. Defaults to true. */
81
+ autoStart?: boolean;
82
+ /** Called when the path completes. */
83
+ onComplete?: (data: PathData) => void;
84
+ /** Called when the path is cancelled. */
85
+ onCancel?: (data: PathData) => void;
86
+ /** Called for every engine event. */
87
+ onEvent?: (event: PathEvent) => void;
88
+ /** Label for the Previous button. Defaults to "Previous". */
89
+ backLabel?: string;
90
+ /** Label for the Next button. Defaults to "Next". */
91
+ nextLabel?: string;
92
+ /** Label for the Complete button (last step). Defaults to "Complete". */
93
+ completeLabel?: string;
94
+ /** Label for the Cancel button. Defaults to "Cancel". */
95
+ cancelLabel?: string;
96
+ /** If true, hide the Cancel button. */
97
+ hideCancel?: boolean;
98
+ /** If true, hide the progress dots. Also hidden automatically when the path has only one step. */
99
+ hideProgress?: boolean;
100
+ /**
101
+ * Footer layout mode:
102
+ * - `"auto"` (default): "form" for single-step top-level paths, "wizard" otherwise.
103
+ * - `"wizard"`: Back on left, Cancel and Next on right.
104
+ * - `"form"`: Cancel on left, Next alone on right.
105
+ */
106
+ footerLayout?: "wizard" | "form" | "auto";
107
+ /**
108
+ * Controls whether the shell renders its auto-generated field-error summary box.
109
+ * - `"summary"` (default): Shell renders the labeled error list below the step body.
110
+ * - `"inline"`: Suppress the summary — handle errors inside the step component instead.
111
+ * - `"both"`: Render the shell summary AND whatever the step component renders.
112
+ */
113
+ validationDisplay?: "summary" | "inline" | "both";
114
+ /** Render prop to replace the header (progress area). */
115
+ renderHeader?: (snapshot: PathSnapshot) => ReactNode;
116
+ /** Render prop to replace the footer (navigation area). */
117
+ renderFooter?: (snapshot: PathSnapshot, actions: PathShellActions) => ReactNode;
118
+ /** Style override for the root container. */
119
+ style?: StyleProp<ViewStyle>;
120
+ /**
121
+ * Passed to the internal `KeyboardAvoidingView`. Use this to account for
122
+ * any header or navigation bar above the shell (e.g. a React Navigation header).
123
+ * Defaults to `0`.
124
+ */
125
+ keyboardVerticalOffset?: number;
126
+ /**
127
+ * When `true`, replaces the `ScrollView` body wrapper with a plain `View`.
128
+ * Use this when the step content contains a `FlatList`, `SectionList`, or
129
+ * other virtualized list to avoid the "VirtualizedList inside ScrollView"
130
+ * warning. The step is then responsible for managing its own scroll.
131
+ */
132
+ disableBodyScroll?: boolean;
133
+ }
134
+ /**
135
+ * Default UI shell for React Native. Renders a progress dot indicator,
136
+ * step content, and navigation buttons.
137
+ *
138
+ * ```tsx
139
+ * <PathShell
140
+ * path={myPath}
141
+ * initialData={{ name: "" }}
142
+ * onComplete={handleDone}
143
+ * steps={{
144
+ * details: <DetailsStep />,
145
+ * review: <ReviewStep />,
146
+ * }}
147
+ * />
148
+ * ```
149
+ */
150
+ export declare const PathShell: import("react").ForwardRefExoticComponent<PathShellProps & import("react").RefAttributes<PathShellHandle>>;
151
+ export type { PathData, FieldErrors, PathDefinition, PathEngine, PathEvent, PathSnapshot, PathStep, PathStepContext, ProgressLayout, RootProgress, SerializedPathState, StepChoice, } from "@daltonr/pathwrite-core";