@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 +302 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +321 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/index.tsx +676 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
useRef,
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
} from "react";
|
|
12
|
+
import type { PropsWithChildren, ReactElement, ReactNode } from "react";
|
|
13
|
+
import {
|
|
14
|
+
View,
|
|
15
|
+
Text,
|
|
16
|
+
Pressable,
|
|
17
|
+
ScrollView,
|
|
18
|
+
StyleSheet,
|
|
19
|
+
ActivityIndicator,
|
|
20
|
+
KeyboardAvoidingView,
|
|
21
|
+
Platform,
|
|
22
|
+
} from "react-native";
|
|
23
|
+
import type { StyleProp, ViewStyle, TextStyle } from "react-native";
|
|
24
|
+
import {
|
|
25
|
+
PathData,
|
|
26
|
+
PathDefinition,
|
|
27
|
+
PathEngine,
|
|
28
|
+
PathEvent,
|
|
29
|
+
PathSnapshot,
|
|
30
|
+
ProgressLayout,
|
|
31
|
+
} from "@daltonr/pathwrite-core";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Types
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface UsePathOptions {
|
|
38
|
+
/**
|
|
39
|
+
* An externally-managed `PathEngine` to subscribe to — for example, one
|
|
40
|
+
* returned by `restoreOrStart()` from `@daltonr/pathwrite-store`.
|
|
41
|
+
*
|
|
42
|
+
* When provided:
|
|
43
|
+
* - `usePath` will **not** create its own engine.
|
|
44
|
+
* - The snapshot is seeded immediately from the engine's current state.
|
|
45
|
+
* - The engine lifecycle (start / cleanup) is the **caller's responsibility**.
|
|
46
|
+
*/
|
|
47
|
+
engine?: PathEngine;
|
|
48
|
+
/** Called for every engine event. The callback ref is kept current — changing it does not re-subscribe. */
|
|
49
|
+
onEvent?: (event: PathEvent) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UsePathReturn<TData extends PathData = PathData> {
|
|
53
|
+
/** Current path snapshot, or `null` when no path is active. Triggers a re-render on change. */
|
|
54
|
+
snapshot: PathSnapshot<TData> | null;
|
|
55
|
+
/** Start (or restart) a path. */
|
|
56
|
+
start: (path: PathDefinition<any>, initialData?: PathData) => void;
|
|
57
|
+
/** Push a sub-path onto the stack. */
|
|
58
|
+
startSubPath: (path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>) => void;
|
|
59
|
+
/** Advance one step. Completes the path on the last step. */
|
|
60
|
+
next: () => void;
|
|
61
|
+
/** Go back one step. */
|
|
62
|
+
previous: () => void;
|
|
63
|
+
/** Cancel the active path (or sub-path). */
|
|
64
|
+
cancel: () => void;
|
|
65
|
+
/** Jump directly to a step by ID, bypassing guards and shouldSkip. */
|
|
66
|
+
goToStep: (stepId: string) => void;
|
|
67
|
+
/** Jump directly to a step by ID, checking the current step's guard first. */
|
|
68
|
+
goToStepChecked: (stepId: string) => void;
|
|
69
|
+
/** Update a single data value. When `TData` is specified, key and value are type-checked. */
|
|
70
|
+
setData: <K extends string & keyof TData>(key: K, value: TData[K]) => void;
|
|
71
|
+
/** Reset the current step's data to what it was when the step was entered. */
|
|
72
|
+
resetStep: () => void;
|
|
73
|
+
/** Tear down any active path and immediately start the given path fresh. */
|
|
74
|
+
restart: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type PathProviderProps = PropsWithChildren<{
|
|
78
|
+
onEvent?: (event: PathEvent) => void;
|
|
79
|
+
}>;
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// usePath hook
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export function usePath<TData extends PathData = PathData>(options?: UsePathOptions): UsePathReturn<TData> {
|
|
86
|
+
const engineRef = useRef<PathEngine | null>(null);
|
|
87
|
+
if (engineRef.current === null) {
|
|
88
|
+
engineRef.current = options?.engine ?? new PathEngine();
|
|
89
|
+
}
|
|
90
|
+
const engine = engineRef.current;
|
|
91
|
+
|
|
92
|
+
const onEventRef = useRef(options?.onEvent);
|
|
93
|
+
onEventRef.current = options?.onEvent;
|
|
94
|
+
|
|
95
|
+
const seededRef = useRef(false);
|
|
96
|
+
const snapshotRef = useRef<PathSnapshot<TData> | null>(null);
|
|
97
|
+
if (!seededRef.current) {
|
|
98
|
+
seededRef.current = true;
|
|
99
|
+
try {
|
|
100
|
+
snapshotRef.current = engine.snapshot() as PathSnapshot<TData> | null;
|
|
101
|
+
} catch {
|
|
102
|
+
snapshotRef.current = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const subscribe = useCallback(
|
|
107
|
+
(callback: () => void) =>
|
|
108
|
+
engine.subscribe((event: PathEvent) => {
|
|
109
|
+
if (event.type === "stateChanged" || event.type === "resumed") {
|
|
110
|
+
snapshotRef.current = event.snapshot as PathSnapshot<TData>;
|
|
111
|
+
} else if (event.type === "completed" || event.type === "cancelled") {
|
|
112
|
+
snapshotRef.current = null;
|
|
113
|
+
}
|
|
114
|
+
onEventRef.current?.(event);
|
|
115
|
+
callback();
|
|
116
|
+
}),
|
|
117
|
+
[engine]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const getSnapshot = useCallback(() => snapshotRef.current, []);
|
|
121
|
+
|
|
122
|
+
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
|
123
|
+
|
|
124
|
+
const start = useCallback(
|
|
125
|
+
(path: PathDefinition<any>, initialData: PathData = {}) => engine.start(path, initialData),
|
|
126
|
+
[engine]
|
|
127
|
+
);
|
|
128
|
+
const startSubPath = useCallback(
|
|
129
|
+
(path: PathDefinition<any>, initialData: PathData = {}, meta?: Record<string, unknown>) =>
|
|
130
|
+
engine.startSubPath(path, initialData, meta),
|
|
131
|
+
[engine]
|
|
132
|
+
);
|
|
133
|
+
const next = useCallback(() => engine.next(), [engine]);
|
|
134
|
+
const previous = useCallback(() => engine.previous(), [engine]);
|
|
135
|
+
const cancel = useCallback(() => engine.cancel(), [engine]);
|
|
136
|
+
const goToStep = useCallback((stepId: string) => engine.goToStep(stepId), [engine]);
|
|
137
|
+
const goToStepChecked = useCallback((stepId: string) => engine.goToStepChecked(stepId), [engine]);
|
|
138
|
+
const setData = useCallback(
|
|
139
|
+
<K extends string & keyof TData>(key: K, value: TData[K]) => engine.setData(key, value as unknown),
|
|
140
|
+
[engine]
|
|
141
|
+
) as UsePathReturn<TData>["setData"];
|
|
142
|
+
const resetStep = useCallback(() => engine.resetStep(), [engine]);
|
|
143
|
+
const restart = useCallback(() => engine.restart(), [engine]);
|
|
144
|
+
|
|
145
|
+
return { snapshot, start, startSubPath, next, previous, cancel, goToStep, goToStepChecked, setData, resetStep, restart };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Context + Provider
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const PathContext = createContext<UsePathReturn | null>(null);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Provides a single `usePath` instance to all descendants.
|
|
156
|
+
* Consume with `usePathContext()`.
|
|
157
|
+
*/
|
|
158
|
+
export function PathProvider({ children, onEvent }: PathProviderProps): ReactElement {
|
|
159
|
+
const path = usePath({ onEvent });
|
|
160
|
+
return createElement(PathContext.Provider, { value: path }, children);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Access the nearest `PathProvider`'s path instance.
|
|
165
|
+
* Throws if used outside of a `<PathProvider>`.
|
|
166
|
+
*/
|
|
167
|
+
export function usePathContext<TData extends PathData = PathData>(): Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: PathSnapshot<TData> } {
|
|
168
|
+
const ctx = useContext(PathContext);
|
|
169
|
+
if (ctx === null) {
|
|
170
|
+
throw new Error("usePathContext must be used within a <PathProvider>.");
|
|
171
|
+
}
|
|
172
|
+
return ctx as Omit<UsePathReturn<TData>, "snapshot"> & { snapshot: PathSnapshot<TData> };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// PathShell — React Native UI
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export interface PathShellHandle {
|
|
180
|
+
/** Restart the shell's current path with its original `initialData`, without unmounting. */
|
|
181
|
+
restart: () => void;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface PathShellActions {
|
|
185
|
+
next: () => void;
|
|
186
|
+
previous: () => void;
|
|
187
|
+
cancel: () => void;
|
|
188
|
+
goToStep: (stepId: string) => void;
|
|
189
|
+
goToStepChecked: (stepId: string) => void;
|
|
190
|
+
setData: (key: string, value: unknown) => void;
|
|
191
|
+
restart: () => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface PathShellProps {
|
|
195
|
+
/** The path definition to drive. */
|
|
196
|
+
path: PathDefinition<any>;
|
|
197
|
+
/** An externally-managed engine. When supplied, PathShell skips its own start() call. */
|
|
198
|
+
engine?: PathEngine;
|
|
199
|
+
/** Map of step ID → React Native content. The shell renders `steps[snapshot.stepId]` for the current step. */
|
|
200
|
+
steps: Record<string, ReactNode>;
|
|
201
|
+
/** Initial data passed to engine.start(). */
|
|
202
|
+
initialData?: PathData;
|
|
203
|
+
/** If true, the path is started automatically on mount. Defaults to true. */
|
|
204
|
+
autoStart?: boolean;
|
|
205
|
+
/** Called when the path completes. */
|
|
206
|
+
onComplete?: (data: PathData) => void;
|
|
207
|
+
/** Called when the path is cancelled. */
|
|
208
|
+
onCancel?: (data: PathData) => void;
|
|
209
|
+
/** Called for every engine event. */
|
|
210
|
+
onEvent?: (event: PathEvent) => void;
|
|
211
|
+
/** Label for the Previous button. Defaults to "Previous". */
|
|
212
|
+
backLabel?: string;
|
|
213
|
+
/** Label for the Next button. Defaults to "Next". */
|
|
214
|
+
nextLabel?: string;
|
|
215
|
+
/** Label for the Complete button (last step). Defaults to "Complete". */
|
|
216
|
+
completeLabel?: string;
|
|
217
|
+
/** Label for the Cancel button. Defaults to "Cancel". */
|
|
218
|
+
cancelLabel?: string;
|
|
219
|
+
/** If true, hide the Cancel button. */
|
|
220
|
+
hideCancel?: boolean;
|
|
221
|
+
/** If true, hide the progress dots. Also hidden automatically when the path has only one step. */
|
|
222
|
+
hideProgress?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* Footer layout mode:
|
|
225
|
+
* - `"auto"` (default): "form" for single-step top-level paths, "wizard" otherwise.
|
|
226
|
+
* - `"wizard"`: Back on left, Cancel and Next on right.
|
|
227
|
+
* - `"form"`: Cancel on left, Next alone on right.
|
|
228
|
+
*/
|
|
229
|
+
footerLayout?: "wizard" | "form" | "auto";
|
|
230
|
+
/**
|
|
231
|
+
* Controls whether the shell renders its auto-generated field-error summary box.
|
|
232
|
+
* - `"summary"` (default): Shell renders the labeled error list below the step body.
|
|
233
|
+
* - `"inline"`: Suppress the summary — handle errors inside the step component instead.
|
|
234
|
+
* - `"both"`: Render the shell summary AND whatever the step component renders.
|
|
235
|
+
*/
|
|
236
|
+
validationDisplay?: "summary" | "inline" | "both";
|
|
237
|
+
/** Render prop to replace the header (progress area). */
|
|
238
|
+
renderHeader?: (snapshot: PathSnapshot) => ReactNode;
|
|
239
|
+
/** Render prop to replace the footer (navigation area). */
|
|
240
|
+
renderFooter?: (snapshot: PathSnapshot, actions: PathShellActions) => ReactNode;
|
|
241
|
+
/** Style override for the root container. */
|
|
242
|
+
style?: StyleProp<ViewStyle>;
|
|
243
|
+
/**
|
|
244
|
+
* Passed to the internal `KeyboardAvoidingView`. Use this to account for
|
|
245
|
+
* any header or navigation bar above the shell (e.g. a React Navigation header).
|
|
246
|
+
* Defaults to `0`.
|
|
247
|
+
*/
|
|
248
|
+
keyboardVerticalOffset?: number;
|
|
249
|
+
/**
|
|
250
|
+
* When `true`, replaces the `ScrollView` body wrapper with a plain `View`.
|
|
251
|
+
* Use this when the step content contains a `FlatList`, `SectionList`, or
|
|
252
|
+
* other virtualized list to avoid the "VirtualizedList inside ScrollView"
|
|
253
|
+
* warning. The step is then responsible for managing its own scroll.
|
|
254
|
+
*/
|
|
255
|
+
disableBodyScroll?: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Default UI shell for React Native. Renders a progress dot indicator,
|
|
260
|
+
* step content, and navigation buttons.
|
|
261
|
+
*
|
|
262
|
+
* ```tsx
|
|
263
|
+
* <PathShell
|
|
264
|
+
* path={myPath}
|
|
265
|
+
* initialData={{ name: "" }}
|
|
266
|
+
* onComplete={handleDone}
|
|
267
|
+
* steps={{
|
|
268
|
+
* details: <DetailsStep />,
|
|
269
|
+
* review: <ReviewStep />,
|
|
270
|
+
* }}
|
|
271
|
+
* />
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
export const PathShell = forwardRef<PathShellHandle, PathShellProps>(function PathShell({
|
|
275
|
+
path: pathDef,
|
|
276
|
+
engine: externalEngine,
|
|
277
|
+
steps,
|
|
278
|
+
initialData = {},
|
|
279
|
+
autoStart = true,
|
|
280
|
+
onComplete,
|
|
281
|
+
onCancel,
|
|
282
|
+
onEvent,
|
|
283
|
+
backLabel = "Previous",
|
|
284
|
+
nextLabel = "Next",
|
|
285
|
+
completeLabel = "Complete",
|
|
286
|
+
cancelLabel = "Cancel",
|
|
287
|
+
hideCancel = false,
|
|
288
|
+
hideProgress = false,
|
|
289
|
+
footerLayout = "auto",
|
|
290
|
+
validationDisplay = "summary",
|
|
291
|
+
renderHeader,
|
|
292
|
+
renderFooter,
|
|
293
|
+
style,
|
|
294
|
+
keyboardVerticalOffset = 0,
|
|
295
|
+
disableBodyScroll = false,
|
|
296
|
+
}: PathShellProps, ref): ReactElement {
|
|
297
|
+
const pathReturn = usePath({
|
|
298
|
+
engine: externalEngine,
|
|
299
|
+
onEvent(event) {
|
|
300
|
+
onEvent?.(event);
|
|
301
|
+
if (event.type === "completed") onComplete?.(event.data);
|
|
302
|
+
if (event.type === "cancelled") onCancel?.(event.data);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const { snapshot, start, next, previous, cancel, goToStep, goToStepChecked, setData, restart } = pathReturn;
|
|
307
|
+
|
|
308
|
+
useImperativeHandle(ref, () => ({
|
|
309
|
+
restart: () => restart(),
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
const startedRef = useRef(false);
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (autoStart && !startedRef.current && !externalEngine) {
|
|
315
|
+
startedRef.current = true;
|
|
316
|
+
start(pathDef, initialData);
|
|
317
|
+
}
|
|
318
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
// Look up step content — prefer formId (StepChoice inner step) over stepId
|
|
322
|
+
const stepContent = snapshot
|
|
323
|
+
? ((snapshot.formId ? steps[snapshot.formId] : undefined) ?? steps[snapshot.stepId] ?? null)
|
|
324
|
+
: null;
|
|
325
|
+
|
|
326
|
+
const actions: PathShellActions = {
|
|
327
|
+
next, previous, cancel, goToStep, goToStepChecked, setData,
|
|
328
|
+
restart: () => restart(),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (!snapshot) {
|
|
332
|
+
return (
|
|
333
|
+
<PathContext.Provider value={pathReturn}>
|
|
334
|
+
<View style={[styles.shell, style]}>
|
|
335
|
+
<View style={styles.emptyState}>
|
|
336
|
+
<Text style={styles.emptyText}>No active path.</Text>
|
|
337
|
+
{!autoStart && (
|
|
338
|
+
<Pressable style={styles.btnPrimary} onPress={() => start(pathDef, initialData)}>
|
|
339
|
+
<Text style={styles.btnPrimaryText}>Start</Text>
|
|
340
|
+
</Pressable>
|
|
341
|
+
)}
|
|
342
|
+
</View>
|
|
343
|
+
</View>
|
|
344
|
+
</PathContext.Provider>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const resolvedLayout =
|
|
349
|
+
footerLayout === "auto"
|
|
350
|
+
? snapshot.stepCount === 1 && snapshot.nestingLevel === 0
|
|
351
|
+
? "form"
|
|
352
|
+
: "wizard"
|
|
353
|
+
: footerLayout;
|
|
354
|
+
const isFormMode = resolvedLayout === "form";
|
|
355
|
+
const showProgress =
|
|
356
|
+
!hideProgress && (snapshot.stepCount > 1 || snapshot.nestingLevel > 0);
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<PathContext.Provider value={pathReturn}>
|
|
360
|
+
<KeyboardAvoidingView
|
|
361
|
+
style={[styles.shell, style]}
|
|
362
|
+
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
363
|
+
keyboardVerticalOffset={keyboardVerticalOffset}
|
|
364
|
+
>
|
|
365
|
+
{/* Header — progress dots or custom */}
|
|
366
|
+
{showProgress && (
|
|
367
|
+
renderHeader
|
|
368
|
+
? renderHeader(snapshot)
|
|
369
|
+
: (
|
|
370
|
+
<View style={styles.header}>
|
|
371
|
+
<View style={styles.stepper}>
|
|
372
|
+
{snapshot.steps.map((step, i) => (
|
|
373
|
+
<View
|
|
374
|
+
key={step.id}
|
|
375
|
+
style={[
|
|
376
|
+
styles.dot,
|
|
377
|
+
step.status === "completed" && styles.dotCompleted,
|
|
378
|
+
step.status === "current" && styles.dotCurrent,
|
|
379
|
+
]}
|
|
380
|
+
>
|
|
381
|
+
<Text style={[styles.dotLabel, step.status === "upcoming" && styles.dotLabelUpcoming]}>
|
|
382
|
+
{step.status === "completed" ? "✓" : String(i + 1)}
|
|
383
|
+
</Text>
|
|
384
|
+
</View>
|
|
385
|
+
))}
|
|
386
|
+
</View>
|
|
387
|
+
{(() => {
|
|
388
|
+
const cur = snapshot.steps.find(s => s.status === "current");
|
|
389
|
+
const title = cur?.title ?? cur?.id;
|
|
390
|
+
return title ? <Text style={styles.stepTitle}>{title}</Text> : null;
|
|
391
|
+
})()}
|
|
392
|
+
<View style={styles.track}>
|
|
393
|
+
<View style={[styles.trackFill, { width: `${snapshot.progress * 100}%` as any }]} />
|
|
394
|
+
</View>
|
|
395
|
+
</View>
|
|
396
|
+
)
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{/* Body — step content */}
|
|
400
|
+
{disableBodyScroll
|
|
401
|
+
? <View style={[styles.body, styles.bodyContent]}>{stepContent}</View>
|
|
402
|
+
: <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>{stepContent}</ScrollView>
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
{/* Validation messages */}
|
|
406
|
+
{validationDisplay !== "inline" && snapshot.hasAttemptedNext && Object.keys(snapshot.fieldErrors).length > 0 && (
|
|
407
|
+
<View style={styles.validation}>
|
|
408
|
+
{Object.entries(snapshot.fieldErrors).map(([key, msg]) => (
|
|
409
|
+
<Text key={key} style={styles.validationItem}>
|
|
410
|
+
{key !== "_" && <Text style={styles.validationLabel}>{formatFieldKey(key)}: </Text>}
|
|
411
|
+
{msg}
|
|
412
|
+
</Text>
|
|
413
|
+
))}
|
|
414
|
+
</View>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
{/* Warning messages */}
|
|
418
|
+
{Object.keys(snapshot.fieldWarnings).length > 0 && (
|
|
419
|
+
<View style={styles.warnings}>
|
|
420
|
+
{Object.entries(snapshot.fieldWarnings).map(([key, msg]) => (
|
|
421
|
+
<Text key={key} style={styles.warningItem}>
|
|
422
|
+
{key !== "_" && <Text style={styles.warningLabel}>{formatFieldKey(key)}: </Text>}
|
|
423
|
+
{msg}
|
|
424
|
+
</Text>
|
|
425
|
+
))}
|
|
426
|
+
</View>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{/* Footer — navigation or custom */}
|
|
430
|
+
{renderFooter
|
|
431
|
+
? renderFooter(snapshot, actions)
|
|
432
|
+
: (
|
|
433
|
+
<View style={styles.footer}>
|
|
434
|
+
<View style={styles.footerLeft}>
|
|
435
|
+
{isFormMode && !hideCancel && (
|
|
436
|
+
<Pressable
|
|
437
|
+
style={[styles.btn, styles.btnCancel, snapshot.isNavigating && styles.btnDisabled]}
|
|
438
|
+
onPress={cancel}
|
|
439
|
+
disabled={snapshot.isNavigating}
|
|
440
|
+
>
|
|
441
|
+
<Text style={styles.btnCancelText}>{cancelLabel}</Text>
|
|
442
|
+
</Pressable>
|
|
443
|
+
)}
|
|
444
|
+
{!isFormMode && !snapshot.isFirstStep && (
|
|
445
|
+
<Pressable
|
|
446
|
+
style={[styles.btn, styles.btnBack, (snapshot.isNavigating || !snapshot.canMovePrevious) && styles.btnDisabled]}
|
|
447
|
+
onPress={previous}
|
|
448
|
+
disabled={snapshot.isNavigating || !snapshot.canMovePrevious}
|
|
449
|
+
>
|
|
450
|
+
<Text style={styles.btnBackText}>← {backLabel}</Text>
|
|
451
|
+
</Pressable>
|
|
452
|
+
)}
|
|
453
|
+
</View>
|
|
454
|
+
<View style={styles.footerRight}>
|
|
455
|
+
{!isFormMode && !hideCancel && (
|
|
456
|
+
<Pressable
|
|
457
|
+
style={[styles.btn, styles.btnCancel, snapshot.isNavigating && styles.btnDisabled]}
|
|
458
|
+
onPress={cancel}
|
|
459
|
+
disabled={snapshot.isNavigating}
|
|
460
|
+
>
|
|
461
|
+
<Text style={styles.btnCancelText}>{cancelLabel}</Text>
|
|
462
|
+
</Pressable>
|
|
463
|
+
)}
|
|
464
|
+
<Pressable
|
|
465
|
+
style={[styles.btn, styles.btnPrimary, snapshot.isNavigating && styles.btnDisabled]}
|
|
466
|
+
onPress={next}
|
|
467
|
+
disabled={snapshot.isNavigating || !snapshot.canMoveNext}
|
|
468
|
+
>
|
|
469
|
+
{snapshot.isNavigating
|
|
470
|
+
? <ActivityIndicator size="small" color="#ffffff" />
|
|
471
|
+
: <Text style={styles.btnPrimaryText}>{snapshot.isLastStep ? completeLabel : `${nextLabel} →`}</Text>
|
|
472
|
+
}
|
|
473
|
+
</Pressable>
|
|
474
|
+
</View>
|
|
475
|
+
</View>
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
</KeyboardAvoidingView>
|
|
479
|
+
</PathContext.Provider>
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Helpers
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
function formatFieldKey(key: string): string {
|
|
488
|
+
return key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Styles
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
const styles = StyleSheet.create({
|
|
496
|
+
shell: {
|
|
497
|
+
flex: 1,
|
|
498
|
+
backgroundColor: "#ffffff",
|
|
499
|
+
borderRadius: 12,
|
|
500
|
+
overflow: "hidden",
|
|
501
|
+
},
|
|
502
|
+
emptyState: {
|
|
503
|
+
flex: 1,
|
|
504
|
+
alignItems: "center",
|
|
505
|
+
justifyContent: "center",
|
|
506
|
+
padding: 24,
|
|
507
|
+
},
|
|
508
|
+
emptyText: {
|
|
509
|
+
fontSize: 16,
|
|
510
|
+
color: "#6b7280",
|
|
511
|
+
marginBottom: 16,
|
|
512
|
+
},
|
|
513
|
+
header: {
|
|
514
|
+
paddingHorizontal: 20,
|
|
515
|
+
paddingTop: 16,
|
|
516
|
+
paddingBottom: 8,
|
|
517
|
+
borderBottomWidth: 1,
|
|
518
|
+
borderBottomColor: "#f3f4f6",
|
|
519
|
+
},
|
|
520
|
+
stepper: {
|
|
521
|
+
flexDirection: "row",
|
|
522
|
+
gap: 8,
|
|
523
|
+
marginBottom: 6,
|
|
524
|
+
},
|
|
525
|
+
dot: {
|
|
526
|
+
width: 24,
|
|
527
|
+
height: 24,
|
|
528
|
+
borderRadius: 12,
|
|
529
|
+
backgroundColor: "#e5e7eb",
|
|
530
|
+
alignItems: "center",
|
|
531
|
+
justifyContent: "center",
|
|
532
|
+
},
|
|
533
|
+
dotCompleted: {
|
|
534
|
+
backgroundColor: "#10b981",
|
|
535
|
+
},
|
|
536
|
+
dotCurrent: {
|
|
537
|
+
backgroundColor: "#6366f1",
|
|
538
|
+
},
|
|
539
|
+
dotLabel: {
|
|
540
|
+
fontSize: 11,
|
|
541
|
+
fontWeight: "600",
|
|
542
|
+
color: "#ffffff",
|
|
543
|
+
},
|
|
544
|
+
dotLabelUpcoming: {
|
|
545
|
+
color: "#9ca3af",
|
|
546
|
+
},
|
|
547
|
+
stepTitle: {
|
|
548
|
+
fontSize: 13,
|
|
549
|
+
fontWeight: "500",
|
|
550
|
+
color: "#374151",
|
|
551
|
+
marginBottom: 8,
|
|
552
|
+
},
|
|
553
|
+
track: {
|
|
554
|
+
height: 4,
|
|
555
|
+
backgroundColor: "#e5e7eb",
|
|
556
|
+
borderRadius: 2,
|
|
557
|
+
overflow: "hidden",
|
|
558
|
+
},
|
|
559
|
+
trackFill: {
|
|
560
|
+
height: 4,
|
|
561
|
+
backgroundColor: "#6366f1",
|
|
562
|
+
borderRadius: 2,
|
|
563
|
+
},
|
|
564
|
+
body: {
|
|
565
|
+
flex: 1,
|
|
566
|
+
},
|
|
567
|
+
bodyContent: {
|
|
568
|
+
padding: 20,
|
|
569
|
+
},
|
|
570
|
+
validation: {
|
|
571
|
+
marginHorizontal: 20,
|
|
572
|
+
marginBottom: 8,
|
|
573
|
+
padding: 12,
|
|
574
|
+
backgroundColor: "#fef2f2",
|
|
575
|
+
borderRadius: 8,
|
|
576
|
+
borderLeftWidth: 3,
|
|
577
|
+
borderLeftColor: "#ef4444",
|
|
578
|
+
},
|
|
579
|
+
validationItem: {
|
|
580
|
+
fontSize: 13,
|
|
581
|
+
color: "#991b1b",
|
|
582
|
+
marginBottom: 2,
|
|
583
|
+
},
|
|
584
|
+
validationLabel: {
|
|
585
|
+
fontWeight: "600",
|
|
586
|
+
},
|
|
587
|
+
warnings: {
|
|
588
|
+
marginHorizontal: 20,
|
|
589
|
+
marginBottom: 8,
|
|
590
|
+
padding: 12,
|
|
591
|
+
backgroundColor: "#fffbeb",
|
|
592
|
+
borderRadius: 8,
|
|
593
|
+
borderLeftWidth: 3,
|
|
594
|
+
borderLeftColor: "#f59e0b",
|
|
595
|
+
},
|
|
596
|
+
warningItem: {
|
|
597
|
+
fontSize: 13,
|
|
598
|
+
color: "#92400e",
|
|
599
|
+
marginBottom: 2,
|
|
600
|
+
},
|
|
601
|
+
warningLabel: {
|
|
602
|
+
fontWeight: "600",
|
|
603
|
+
},
|
|
604
|
+
footer: {
|
|
605
|
+
flexDirection: "row",
|
|
606
|
+
justifyContent: "space-between",
|
|
607
|
+
alignItems: "center",
|
|
608
|
+
padding: 16,
|
|
609
|
+
borderTopWidth: 1,
|
|
610
|
+
borderTopColor: "#f3f4f6",
|
|
611
|
+
gap: 8,
|
|
612
|
+
},
|
|
613
|
+
footerLeft: {
|
|
614
|
+
flexDirection: "row",
|
|
615
|
+
gap: 8,
|
|
616
|
+
},
|
|
617
|
+
footerRight: {
|
|
618
|
+
flexDirection: "row",
|
|
619
|
+
gap: 8,
|
|
620
|
+
marginLeft: "auto",
|
|
621
|
+
},
|
|
622
|
+
btn: {
|
|
623
|
+
paddingHorizontal: 16,
|
|
624
|
+
paddingVertical: 10,
|
|
625
|
+
borderRadius: 8,
|
|
626
|
+
alignItems: "center",
|
|
627
|
+
justifyContent: "center",
|
|
628
|
+
minWidth: 80,
|
|
629
|
+
},
|
|
630
|
+
btnDisabled: {
|
|
631
|
+
opacity: 0.5,
|
|
632
|
+
},
|
|
633
|
+
btnPrimary: {
|
|
634
|
+
backgroundColor: "#6366f1",
|
|
635
|
+
},
|
|
636
|
+
btnPrimaryText: {
|
|
637
|
+
color: "#ffffff",
|
|
638
|
+
fontWeight: "600",
|
|
639
|
+
fontSize: 15,
|
|
640
|
+
},
|
|
641
|
+
btnBack: {
|
|
642
|
+
backgroundColor: "#f3f4f6",
|
|
643
|
+
},
|
|
644
|
+
btnBackText: {
|
|
645
|
+
color: "#374151",
|
|
646
|
+
fontWeight: "500",
|
|
647
|
+
fontSize: 15,
|
|
648
|
+
},
|
|
649
|
+
btnCancel: {
|
|
650
|
+
backgroundColor: "transparent",
|
|
651
|
+
},
|
|
652
|
+
btnCancelText: {
|
|
653
|
+
color: "#6b7280",
|
|
654
|
+
fontWeight: "500",
|
|
655
|
+
fontSize: 15,
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// Re-export core types
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
export type {
|
|
664
|
+
PathData,
|
|
665
|
+
FieldErrors,
|
|
666
|
+
PathDefinition,
|
|
667
|
+
PathEngine,
|
|
668
|
+
PathEvent,
|
|
669
|
+
PathSnapshot,
|
|
670
|
+
PathStep,
|
|
671
|
+
PathStepContext,
|
|
672
|
+
ProgressLayout,
|
|
673
|
+
RootProgress,
|
|
674
|
+
SerializedPathState,
|
|
675
|
+
StepChoice,
|
|
676
|
+
} from "@daltonr/pathwrite-core";
|