vanilla-jui 1.3.11 → 1.4.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/dist/index.d.ts +2927 -0
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +4 -3
- package/src/components/accordion.ts +522 -0
- package/src/components/drop.ts +497 -0
- package/src/components/flow.ts +1579 -0
- package/src/components/form.ts +953 -0
- package/src/components/{icons.js → icons.ts} +24 -11
- package/src/components/menu.ts +615 -0
- package/src/components/{modal.js → modal.ts} +560 -237
- package/src/components/offcanvas.ts +496 -0
- package/src/components/{pagination.js → pagination.ts} +251 -76
- package/src/components/parabola.ts +335 -0
- package/src/components/{sticky.js → sticky.ts} +123 -98
- package/src/components/{swiper.js → swiper.ts} +510 -232
- package/src/components/tabs.ts +984 -0
- package/src/components/theme.ts +437 -0
- package/src/components/toast.ts +371 -0
- package/src/components/toc.ts +385 -0
- package/src/components/tooltip.ts +179 -0
- package/src/components/validator.ts +596 -0
- package/src/core/{Component.js → Component.ts} +134 -27
- package/src/css/animation.css +31 -1
- package/src/css/card.css +1 -1
- package/src/css/color.css +863 -1013
- package/src/css/color_p3.css +822 -975
- package/src/css/components.css +1 -0
- package/src/css/form.css +1 -1
- package/src/css/icon.css +17 -1
- package/src/css/index.css +2 -2
- package/src/css/menu.css +1 -1
- package/src/css/tabs.css +1 -0
- package/src/css/tokens.css +1 -1
- package/src/index.ts +37 -0
- package/src/locales/{index.js → index.ts} +1 -2
- package/src/types.d.ts +1 -0
- package/src/utilities/browser.ts +131 -0
- package/src/utilities/cache.ts +57 -0
- package/src/utilities/core.ts +614 -0
- package/src/utilities/dom.ts +468 -0
- package/src/utilities/events.ts +106 -0
- package/src/utilities/http.ts +35 -0
- package/src/utilities/storage.ts +46 -0
- package/src/components/accordion.js +0 -373
- package/src/components/drop.js +0 -437
- package/src/components/flow.js +0 -1133
- package/src/components/form.js +0 -585
- package/src/components/menu.js +0 -422
- package/src/components/offcanvas.js +0 -332
- package/src/components/parabola.js +0 -295
- package/src/components/tabs.js +0 -746
- package/src/components/theme.js +0 -353
- package/src/components/toast.js +0 -329
- package/src/components/toc.js +0 -226
- package/src/components/tooltip.js +0 -86
- package/src/components/validator.js +0 -586
- package/src/index.js +0 -45
- package/src/locales/index.min.js +0 -1
- package/src/utilities/browser.js +0 -111
- package/src/utilities/cache.js +0 -159
- package/src/utilities/core.js +0 -502
- package/src/utilities/debug.js +0 -103
- package/src/utilities/dom.js +0 -313
- package/src/utilities/events.js +0 -69
- package/src/utilities/http.js +0 -30
- package/src/utilities/legacy-dom.js +0 -179
- package/src/utilities/storage.js +0 -46
- /package/src/{components/components.js → css/themes/index.css} +0 -0
- /package/src/icons/{index.js → index.ts} +0 -0
|
@@ -0,0 +1,1579 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDeepStore,
|
|
3
|
+
createRoot,
|
|
4
|
+
flushSync,
|
|
5
|
+
jsx,
|
|
6
|
+
onCleanup,
|
|
7
|
+
render,
|
|
8
|
+
} from 'vanilla-signal';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type ResolveSchema,
|
|
12
|
+
isPlainObject,
|
|
13
|
+
randomId,
|
|
14
|
+
resolveProps,
|
|
15
|
+
validateParam,
|
|
16
|
+
} from '../utilities/core.ts';
|
|
17
|
+
import {
|
|
18
|
+
type DOMReference,
|
|
19
|
+
type RenderableContent,
|
|
20
|
+
isRenderableContent,
|
|
21
|
+
requireContainer,
|
|
22
|
+
} from '../utilities/dom.ts';
|
|
23
|
+
|
|
24
|
+
export type FlowData = Record<string, unknown>;
|
|
25
|
+
export type FlowPayload = FlowData | null;
|
|
26
|
+
export type FlowAction = 'next' | 'back' | 'goTo' | 'finish';
|
|
27
|
+
export type FlowBusyStrategy = 'ignore' | 'throw';
|
|
28
|
+
export type FlowDirection = string;
|
|
29
|
+
export type FlowSlotName =
|
|
30
|
+
| 'renderHeader'
|
|
31
|
+
| 'renderSteps'
|
|
32
|
+
| 'renderBody'
|
|
33
|
+
| 'renderFooter';
|
|
34
|
+
export type FlowCleanup = () => void;
|
|
35
|
+
export type FlowStepResult =
|
|
36
|
+
| string
|
|
37
|
+
| {
|
|
38
|
+
id: string;
|
|
39
|
+
data?: FlowPayload;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface FlowClassNames {
|
|
43
|
+
root: string;
|
|
44
|
+
header: string;
|
|
45
|
+
title: string;
|
|
46
|
+
description: string;
|
|
47
|
+
steps: string;
|
|
48
|
+
step: string;
|
|
49
|
+
active: string;
|
|
50
|
+
complete: string;
|
|
51
|
+
stepButton: string;
|
|
52
|
+
stepIndex: string;
|
|
53
|
+
stepTitle: string;
|
|
54
|
+
body: string;
|
|
55
|
+
footer: string;
|
|
56
|
+
button: string;
|
|
57
|
+
buttonGhost: string;
|
|
58
|
+
buttonPrimary: string;
|
|
59
|
+
reset: string;
|
|
60
|
+
back: string;
|
|
61
|
+
next: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type FlowClassNameConfig = Partial<FlowClassNames>;
|
|
65
|
+
|
|
66
|
+
export interface FlowStep {
|
|
67
|
+
id: string;
|
|
68
|
+
title?: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
content?: RenderableContent<FlowContext>;
|
|
71
|
+
data?: FlowData;
|
|
72
|
+
modal?: FlowData | ((context: FlowContext) => FlowData | null) | null;
|
|
73
|
+
view?: FlowData;
|
|
74
|
+
onEnter?: FlowLifecycleHook;
|
|
75
|
+
onLeave?: FlowLifecycleHook;
|
|
76
|
+
onNext?: FlowMoveHook;
|
|
77
|
+
onBack?: FlowMoveHook;
|
|
78
|
+
canEnter?: FlowGuardHook;
|
|
79
|
+
canLeave?: FlowGuardHook;
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type PublicFlowStep = Omit<
|
|
84
|
+
FlowStep,
|
|
85
|
+
'onEnter' | 'onLeave' | 'onNext' | 'onBack' | 'canEnter' | 'canLeave'
|
|
86
|
+
>;
|
|
87
|
+
|
|
88
|
+
export interface FlowSnapshot {
|
|
89
|
+
id: string;
|
|
90
|
+
currentId: string;
|
|
91
|
+
currentIndex: number;
|
|
92
|
+
previousId: string | null;
|
|
93
|
+
previousIndex: number | null;
|
|
94
|
+
direction: FlowDirection | null;
|
|
95
|
+
history: string[];
|
|
96
|
+
data: FlowData;
|
|
97
|
+
stepData: Record<string, FlowData>;
|
|
98
|
+
currentData: FlowData;
|
|
99
|
+
currentStep: PublicFlowStep | null;
|
|
100
|
+
canBack: boolean;
|
|
101
|
+
canNext: boolean;
|
|
102
|
+
isLast: boolean;
|
|
103
|
+
loading: boolean;
|
|
104
|
+
busyAction: FlowAction | null;
|
|
105
|
+
error: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FlowContext {
|
|
109
|
+
flow: Flow;
|
|
110
|
+
step: FlowStep;
|
|
111
|
+
state: FlowState;
|
|
112
|
+
signal: AbortSignal | null;
|
|
113
|
+
snapshot: FlowSnapshot;
|
|
114
|
+
data: FlowData;
|
|
115
|
+
currentData: FlowData;
|
|
116
|
+
payload?: FlowPayload;
|
|
117
|
+
direction?: FlowDirection;
|
|
118
|
+
fromId?: string | null;
|
|
119
|
+
targetId?: string | null;
|
|
120
|
+
setData: (data: FlowPayload) => Flow;
|
|
121
|
+
setStepData: (stepId: string, data: FlowPayload) => Flow;
|
|
122
|
+
getStepData: (stepId: string) => FlowData;
|
|
123
|
+
next: (payload?: FlowPayload) => Promise<FlowSnapshot | null>;
|
|
124
|
+
back: (payload?: FlowPayload) => Promise<FlowSnapshot | null>;
|
|
125
|
+
goTo: (
|
|
126
|
+
target: FlowTarget,
|
|
127
|
+
payload?: FlowPayload,
|
|
128
|
+
options?: FlowGoToOptions
|
|
129
|
+
) => Promise<FlowSnapshot | null>;
|
|
130
|
+
addCleanup: (cleanup: FlowCleanup) => FlowCleanup;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface FlowRenderContext {
|
|
134
|
+
flow: Flow;
|
|
135
|
+
snapshot: FlowSnapshot;
|
|
136
|
+
state: FlowState;
|
|
137
|
+
steps: (PublicFlowStep | null)[];
|
|
138
|
+
currentStep: PublicFlowStep | null;
|
|
139
|
+
currentData: FlowData;
|
|
140
|
+
data: FlowData;
|
|
141
|
+
fallback: () => RenderableContent<FlowRenderContext>;
|
|
142
|
+
next: (payload?: FlowPayload) => Promise<FlowSnapshot | null>;
|
|
143
|
+
back: (payload?: FlowPayload) => Promise<FlowSnapshot | null>;
|
|
144
|
+
goTo: (
|
|
145
|
+
target: FlowTarget,
|
|
146
|
+
payload?: FlowPayload,
|
|
147
|
+
options?: FlowGoToOptions
|
|
148
|
+
) => Promise<FlowSnapshot | null>;
|
|
149
|
+
reset: () => Flow;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type FlowLifecycleHook = (context: FlowContext) => void | Promise<void>;
|
|
153
|
+
export type FlowGuardHook = (
|
|
154
|
+
context: FlowContext
|
|
155
|
+
) => boolean | void | Promise<boolean | void>;
|
|
156
|
+
export type FlowMoveHook = (
|
|
157
|
+
context: FlowContext
|
|
158
|
+
) =>
|
|
159
|
+
| FlowStepResult
|
|
160
|
+
| FlowPayload
|
|
161
|
+
| void
|
|
162
|
+
| Promise<FlowStepResult | FlowPayload | void>;
|
|
163
|
+
export type FlowFinishHook = (
|
|
164
|
+
snapshot: FlowSnapshot,
|
|
165
|
+
flow: Flow
|
|
166
|
+
) => void | Promise<void>;
|
|
167
|
+
export type FlowChangeHook = (
|
|
168
|
+
snapshot: FlowSnapshot,
|
|
169
|
+
flow: Flow,
|
|
170
|
+
previous: FlowSnapshot | null
|
|
171
|
+
) => void;
|
|
172
|
+
export type FlowErrorHook = (
|
|
173
|
+
error: unknown,
|
|
174
|
+
snapshot: FlowSnapshot,
|
|
175
|
+
flow: Flow,
|
|
176
|
+
previous: FlowSnapshot | null
|
|
177
|
+
) => void;
|
|
178
|
+
export type FlowBusyHook = (
|
|
179
|
+
action: FlowAction,
|
|
180
|
+
snapshot: FlowSnapshot,
|
|
181
|
+
flow: Flow
|
|
182
|
+
) => void;
|
|
183
|
+
export type FlowSlot =
|
|
184
|
+
| false
|
|
185
|
+
| null
|
|
186
|
+
| ((context: FlowRenderContext) => RenderableContent<FlowRenderContext>);
|
|
187
|
+
export type FlowSubscriber = (
|
|
188
|
+
snapshot: FlowSnapshot,
|
|
189
|
+
flow: Flow,
|
|
190
|
+
previous: FlowSnapshot | null
|
|
191
|
+
) => void;
|
|
192
|
+
export type FlowTarget = string | number;
|
|
193
|
+
|
|
194
|
+
export interface FlowText {
|
|
195
|
+
back: string;
|
|
196
|
+
next: string;
|
|
197
|
+
finish: string;
|
|
198
|
+
reset: string;
|
|
199
|
+
[key: string]: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface FlowOptions extends Record<string, unknown> {
|
|
203
|
+
id?: string | null;
|
|
204
|
+
steps?: FlowStep[];
|
|
205
|
+
initial?: string | number | null;
|
|
206
|
+
cache?: boolean;
|
|
207
|
+
linear?: boolean;
|
|
208
|
+
render?: boolean;
|
|
209
|
+
rollbackOnError?: boolean;
|
|
210
|
+
busyStrategy?: FlowBusyStrategy;
|
|
211
|
+
showHeader?: boolean;
|
|
212
|
+
showFooter?: boolean;
|
|
213
|
+
showSteps?: boolean;
|
|
214
|
+
showBack?: boolean;
|
|
215
|
+
showNext?: boolean;
|
|
216
|
+
showReset?: boolean;
|
|
217
|
+
text?: Partial<FlowText>;
|
|
218
|
+
className?: FlowClassNameConfig | string;
|
|
219
|
+
renderHeader?: FlowSlot;
|
|
220
|
+
renderSteps?: FlowSlot;
|
|
221
|
+
renderBody?: FlowSlot;
|
|
222
|
+
renderFooter?: FlowSlot;
|
|
223
|
+
onChange?: FlowChangeHook | null;
|
|
224
|
+
onNext?: FlowMoveHook | null;
|
|
225
|
+
onBack?: FlowMoveHook | null;
|
|
226
|
+
onFinish?: FlowFinishHook | null;
|
|
227
|
+
onError?: FlowErrorHook | null;
|
|
228
|
+
onBusy?: FlowBusyHook | null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface ResolvedFlowOptions extends Record<string, unknown> {
|
|
232
|
+
id: string;
|
|
233
|
+
steps: FlowStep[];
|
|
234
|
+
initial: string | number | null;
|
|
235
|
+
cache: boolean;
|
|
236
|
+
linear: boolean;
|
|
237
|
+
render: boolean;
|
|
238
|
+
rollbackOnError: boolean;
|
|
239
|
+
busyStrategy: FlowBusyStrategy;
|
|
240
|
+
showHeader: boolean;
|
|
241
|
+
showFooter: boolean;
|
|
242
|
+
showSteps: boolean;
|
|
243
|
+
showBack: boolean;
|
|
244
|
+
showNext: boolean;
|
|
245
|
+
showReset: boolean;
|
|
246
|
+
text: FlowText;
|
|
247
|
+
className: FlowClassNames;
|
|
248
|
+
renderHeader: FlowSlot;
|
|
249
|
+
renderSteps: FlowSlot;
|
|
250
|
+
renderBody: FlowSlot;
|
|
251
|
+
renderFooter: FlowSlot;
|
|
252
|
+
onChange: FlowChangeHook | null;
|
|
253
|
+
onNext: FlowMoveHook | null;
|
|
254
|
+
onBack: FlowMoveHook | null;
|
|
255
|
+
onFinish: FlowFinishHook | null;
|
|
256
|
+
onError: FlowErrorHook | null;
|
|
257
|
+
onBusy: FlowBusyHook | null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface FlowState extends Record<string, unknown> {
|
|
261
|
+
id: string;
|
|
262
|
+
currentId: string;
|
|
263
|
+
currentIndex: number;
|
|
264
|
+
previousId: string | null;
|
|
265
|
+
previousIndex: number | null;
|
|
266
|
+
direction: FlowDirection | null;
|
|
267
|
+
history: string[];
|
|
268
|
+
data: FlowData;
|
|
269
|
+
stepData: Record<string, FlowData>;
|
|
270
|
+
loading: boolean;
|
|
271
|
+
error: unknown;
|
|
272
|
+
busyAction: FlowAction | null;
|
|
273
|
+
version: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface InitialFlowData {
|
|
277
|
+
stepData: Record<string, FlowData>;
|
|
278
|
+
global: FlowData;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface FlowNodes {
|
|
282
|
+
root?: HTMLElement;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface FlowGoToOptions {
|
|
286
|
+
direction?: FlowDirection;
|
|
287
|
+
internal?: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface FlowRunActionOptions {
|
|
291
|
+
internal?: boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface FlowTransitionOptions {
|
|
295
|
+
direction: FlowDirection;
|
|
296
|
+
payload: FlowPayload;
|
|
297
|
+
fromStep: FlowStep;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface FlowContextExtra {
|
|
301
|
+
step?: FlowStep;
|
|
302
|
+
payload?: FlowPayload;
|
|
303
|
+
direction?: FlowDirection;
|
|
304
|
+
fromId?: string | null;
|
|
305
|
+
targetId?: string | null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface CapturedFlowState {
|
|
309
|
+
currentId: string;
|
|
310
|
+
currentIndex: number;
|
|
311
|
+
previousId: string | null;
|
|
312
|
+
previousIndex: number | null;
|
|
313
|
+
direction: FlowDirection | null;
|
|
314
|
+
history: string[];
|
|
315
|
+
data: FlowData;
|
|
316
|
+
stepData: Record<string, FlowData>;
|
|
317
|
+
loading: boolean;
|
|
318
|
+
busyAction: FlowAction | null;
|
|
319
|
+
error: unknown;
|
|
320
|
+
version: number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface FlowError extends Error {
|
|
324
|
+
code?: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const DEFAULT_CLASS_NAMES: FlowClassNames = {
|
|
328
|
+
root: 'j-flow',
|
|
329
|
+
header: 'flow-header',
|
|
330
|
+
title: 'flow-title',
|
|
331
|
+
description: 'flow-description',
|
|
332
|
+
steps: 'flow-steps',
|
|
333
|
+
step: 'flow-step',
|
|
334
|
+
active: 'is-active',
|
|
335
|
+
complete: 'is-complete',
|
|
336
|
+
stepButton: 'flow-step-button',
|
|
337
|
+
stepIndex: 'flow-step-index',
|
|
338
|
+
stepTitle: 'flow-step-title',
|
|
339
|
+
body: 'flow-body',
|
|
340
|
+
footer: 'flow-footer',
|
|
341
|
+
button: 'j-button',
|
|
342
|
+
buttonGhost: 'is-ghost',
|
|
343
|
+
buttonPrimary: 'is-primary',
|
|
344
|
+
reset: 'flow-reset',
|
|
345
|
+
back: 'flow-back',
|
|
346
|
+
next: 'flow-next',
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
function isFlowStep(step: unknown): step is FlowStep {
|
|
350
|
+
return (
|
|
351
|
+
!!step &&
|
|
352
|
+
typeof step === 'object' &&
|
|
353
|
+
typeof (step as FlowStep).id === 'string'
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isFlowRenderSlot(slot: unknown): slot is FlowSlot {
|
|
358
|
+
return slot == null || slot === false || typeof slot === 'function';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function clonePlainObject(value: unknown): FlowData {
|
|
362
|
+
return isPlainObject(value) ? { ...value } : {};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function cloneArray<T>(value: unknown): T[] {
|
|
366
|
+
return Array.isArray(value) ? [...value] : [];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function cloneSteps(steps: unknown): FlowStep[] {
|
|
370
|
+
if (!Array.isArray(steps)) return [];
|
|
371
|
+
return steps.map((step) => {
|
|
372
|
+
const source = step as FlowStep;
|
|
373
|
+
return {
|
|
374
|
+
...source,
|
|
375
|
+
data: clonePlainObject(source.data),
|
|
376
|
+
modal:
|
|
377
|
+
typeof source.modal === 'function' || source.modal == null
|
|
378
|
+
? source.modal
|
|
379
|
+
: clonePlainObject(source.modal),
|
|
380
|
+
view: clonePlainObject(source.view),
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function normalizeStepResult(
|
|
386
|
+
result: FlowStepResult | FlowPayload | void,
|
|
387
|
+
fallbackId: string
|
|
388
|
+
): { id: string; data?: FlowPayload } {
|
|
389
|
+
if (typeof result === 'string') return { id: result };
|
|
390
|
+
if (isPlainObject(result) && typeof result.id === 'string') {
|
|
391
|
+
return {
|
|
392
|
+
id: result.id,
|
|
393
|
+
data:
|
|
394
|
+
isPlainObject(result.data) || result.data == null
|
|
395
|
+
? result.data
|
|
396
|
+
: undefined,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return { id: fallbackId };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function normalizeClassNames(value: unknown): FlowClassNames {
|
|
403
|
+
if (typeof value === 'string') {
|
|
404
|
+
const extra = value.trim();
|
|
405
|
+
return {
|
|
406
|
+
...DEFAULT_CLASS_NAMES,
|
|
407
|
+
root: extra
|
|
408
|
+
? `${DEFAULT_CLASS_NAMES.root} ${extra}`
|
|
409
|
+
: DEFAULT_CLASS_NAMES.root,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
...DEFAULT_CLASS_NAMES,
|
|
415
|
+
...(isPlainObject(value) ? value : {}),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function joinClasses(
|
|
420
|
+
...classes: Array<string | false | null | undefined>
|
|
421
|
+
): string {
|
|
422
|
+
return classes.filter(Boolean).join(' ');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const FLOW_STEP_RULE = {
|
|
426
|
+
validate: isFlowStep,
|
|
427
|
+
message: 'expects a step object with a string id.',
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const FLOW_CONTENT_RULE = {
|
|
431
|
+
validate: isRenderableContent,
|
|
432
|
+
message: 'expects string, Node, array, function or null.',
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const FLOW_STEPS_RULE = {
|
|
436
|
+
validate: (value: unknown) => Array.isArray(value) && value.length > 0,
|
|
437
|
+
message: 'expects a non-empty steps array.',
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const FLOW_PAYLOAD_RULE = {
|
|
441
|
+
validate: (value: unknown) => value == null || isPlainObject(value),
|
|
442
|
+
message: 'expects an object or null.',
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const FLOW_RENDER_SLOT_RULE = {
|
|
446
|
+
validate: isFlowRenderSlot,
|
|
447
|
+
message: 'expects function, false or null.',
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const FLOW_TEXT_RULE = {
|
|
451
|
+
default: {},
|
|
452
|
+
validate: isPlainObject,
|
|
453
|
+
message: 'expects an object with text fields.',
|
|
454
|
+
normalize: (value: unknown): FlowText => {
|
|
455
|
+
const text = isPlainObject(value) ? value : {};
|
|
456
|
+
return {
|
|
457
|
+
...Object.fromEntries(
|
|
458
|
+
Object.entries(text).filter(([, item]) => typeof item === 'string')
|
|
459
|
+
),
|
|
460
|
+
back: typeof text.back === 'string' ? text.back : 'Back',
|
|
461
|
+
next: typeof text.next === 'string' ? text.next : 'Next',
|
|
462
|
+
finish: typeof text.finish === 'string' ? text.finish : 'Finish',
|
|
463
|
+
reset: typeof text.reset === 'string' ? text.reset : 'Reset',
|
|
464
|
+
};
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const FLOW_OPTIONS_SCHEMA = {
|
|
469
|
+
id: {
|
|
470
|
+
default: null,
|
|
471
|
+
types: ['string', 'null'],
|
|
472
|
+
normalize: (value: unknown) => {
|
|
473
|
+
if (typeof value === 'string') {
|
|
474
|
+
const id = value.trim();
|
|
475
|
+
return id || randomId();
|
|
476
|
+
}
|
|
477
|
+
if (value == null) return randomId();
|
|
478
|
+
return value;
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
steps: { default: [], ...FLOW_STEPS_RULE },
|
|
482
|
+
initial: { default: null, types: ['string', 'number', 'null'] },
|
|
483
|
+
cache: { default: true, type: 'boolean' },
|
|
484
|
+
linear: { default: true, type: 'boolean' },
|
|
485
|
+
render: { default: true, type: 'boolean' },
|
|
486
|
+
rollbackOnError: { default: true, type: 'boolean' },
|
|
487
|
+
busyStrategy: {
|
|
488
|
+
default: 'ignore',
|
|
489
|
+
type: 'string',
|
|
490
|
+
enum: ['ignore', 'throw'],
|
|
491
|
+
},
|
|
492
|
+
showHeader: { default: true, type: 'boolean' },
|
|
493
|
+
showFooter: { default: true, type: 'boolean' },
|
|
494
|
+
showSteps: { default: true, type: 'boolean' },
|
|
495
|
+
showBack: { default: true, type: 'boolean' },
|
|
496
|
+
showNext: { default: true, type: 'boolean' },
|
|
497
|
+
showReset: { default: false, type: 'boolean' },
|
|
498
|
+
text: FLOW_TEXT_RULE,
|
|
499
|
+
className: {
|
|
500
|
+
default: DEFAULT_CLASS_NAMES,
|
|
501
|
+
types: ['object', 'string'],
|
|
502
|
+
normalize: normalizeClassNames,
|
|
503
|
+
},
|
|
504
|
+
renderHeader: { default: null, ...FLOW_RENDER_SLOT_RULE },
|
|
505
|
+
renderSteps: { default: null, ...FLOW_RENDER_SLOT_RULE },
|
|
506
|
+
renderBody: { default: null, ...FLOW_RENDER_SLOT_RULE },
|
|
507
|
+
renderFooter: { default: null, ...FLOW_RENDER_SLOT_RULE },
|
|
508
|
+
onChange: { default: null, types: ['function', 'null'] },
|
|
509
|
+
onNext: { default: null, types: ['function', 'null'] },
|
|
510
|
+
onBack: { default: null, types: ['function', 'null'] },
|
|
511
|
+
onFinish: { default: null, types: ['function', 'null'] },
|
|
512
|
+
onError: { default: null, types: ['function', 'null'] },
|
|
513
|
+
onBusy: { default: null, types: ['function', 'null'] },
|
|
514
|
+
} satisfies ResolveSchema<FlowOptions>;
|
|
515
|
+
|
|
516
|
+
function normalizeOptions(input: FlowOptions): ResolvedFlowOptions {
|
|
517
|
+
const options = resolveProps(input, FLOW_OPTIONS_SCHEMA, 'Flow.options');
|
|
518
|
+
return {
|
|
519
|
+
id: options.id as string,
|
|
520
|
+
steps: cloneSteps(options.steps),
|
|
521
|
+
initial: options.initial as ResolvedFlowOptions['initial'],
|
|
522
|
+
cache: options.cache as boolean,
|
|
523
|
+
linear: options.linear as boolean,
|
|
524
|
+
render: options.render as boolean,
|
|
525
|
+
rollbackOnError: options.rollbackOnError as boolean,
|
|
526
|
+
busyStrategy: options.busyStrategy as FlowBusyStrategy,
|
|
527
|
+
showHeader: options.showHeader as boolean,
|
|
528
|
+
showFooter: options.showFooter as boolean,
|
|
529
|
+
showSteps: options.showSteps as boolean,
|
|
530
|
+
showBack: options.showBack as boolean,
|
|
531
|
+
showNext: options.showNext as boolean,
|
|
532
|
+
showReset: options.showReset as boolean,
|
|
533
|
+
text: options.text as FlowText,
|
|
534
|
+
className: options.className as FlowClassNames,
|
|
535
|
+
renderHeader: options.renderHeader as FlowSlot,
|
|
536
|
+
renderSteps: options.renderSteps as FlowSlot,
|
|
537
|
+
renderBody: options.renderBody as FlowSlot,
|
|
538
|
+
renderFooter: options.renderFooter as FlowSlot,
|
|
539
|
+
onChange: options.onChange as FlowChangeHook | null,
|
|
540
|
+
onNext: options.onNext as FlowMoveHook | null,
|
|
541
|
+
onBack: options.onBack as FlowMoveHook | null,
|
|
542
|
+
onFinish: options.onFinish as FlowFinishHook | null,
|
|
543
|
+
onError: options.onError as FlowErrorHook | null,
|
|
544
|
+
onBusy: options.onBusy as FlowBusyHook | null,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Headless 流程控制器,带可选默认 UI。
|
|
550
|
+
*
|
|
551
|
+
* 适合在 Modal、Offcanvas、页面表单或任意业务组件中复用 next/back/goTo、步骤缓存和生命周期。
|
|
552
|
+
*/
|
|
553
|
+
export class Flow {
|
|
554
|
+
options: ResolvedFlowOptions;
|
|
555
|
+
steps: FlowStep[];
|
|
556
|
+
state: FlowState;
|
|
557
|
+
root: HTMLElement | null;
|
|
558
|
+
private stepMap: Map<string, number>;
|
|
559
|
+
private initialStepId: string;
|
|
560
|
+
private initialData: InitialFlowData;
|
|
561
|
+
private subscribers: Set<FlowSubscriber>;
|
|
562
|
+
private renderDispose: FlowCleanup | null;
|
|
563
|
+
private cleanupTasks: Set<FlowCleanup>;
|
|
564
|
+
private nodes: FlowNodes;
|
|
565
|
+
private destroyed: boolean;
|
|
566
|
+
private activeAction: FlowAction | null;
|
|
567
|
+
private actionController: AbortController | null;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* 创建 Flow 实例。
|
|
571
|
+
* @param {FlowOptions} [options={}] Flow 配置。
|
|
572
|
+
*/
|
|
573
|
+
constructor(options: FlowOptions = {}) {
|
|
574
|
+
this.options = normalizeOptions(options);
|
|
575
|
+
this.steps = this.options.steps;
|
|
576
|
+
this.validateSteps(this.steps);
|
|
577
|
+
this.stepMap = new Map(this.steps.map((step, index) => [step.id, index]));
|
|
578
|
+
this.initialStepId = this.resolveInitialStepId(this.options.initial);
|
|
579
|
+
this.initialData = this.createInitialStepData();
|
|
580
|
+
this.subscribers = new Set();
|
|
581
|
+
this.renderDispose = null;
|
|
582
|
+
this.cleanupTasks = new Set();
|
|
583
|
+
this.nodes = {};
|
|
584
|
+
this.destroyed = false;
|
|
585
|
+
this.activeAction = null;
|
|
586
|
+
this.actionController = null;
|
|
587
|
+
this.root = null;
|
|
588
|
+
|
|
589
|
+
this.state = createDeepStore({
|
|
590
|
+
id: this.options.id,
|
|
591
|
+
currentId: this.initialStepId,
|
|
592
|
+
currentIndex: this.stepMap.get(this.initialStepId) ?? 0,
|
|
593
|
+
previousId: null,
|
|
594
|
+
previousIndex: null,
|
|
595
|
+
direction: null,
|
|
596
|
+
history: [this.initialStepId],
|
|
597
|
+
data: clonePlainObject(this.initialData.global),
|
|
598
|
+
stepData: clonePlainObject(this.initialData.stepData) as Record<
|
|
599
|
+
string,
|
|
600
|
+
FlowData
|
|
601
|
+
>,
|
|
602
|
+
loading: false,
|
|
603
|
+
error: null,
|
|
604
|
+
busyAction: null,
|
|
605
|
+
version: 0,
|
|
606
|
+
}) as FlowState;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* 当前步骤。
|
|
611
|
+
* @returns {FlowStep}
|
|
612
|
+
*/
|
|
613
|
+
get currentStep(): FlowStep {
|
|
614
|
+
return this.steps[this.state.currentIndex];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* 当前步骤数据。
|
|
619
|
+
* @returns {object}
|
|
620
|
+
*/
|
|
621
|
+
get currentData(): FlowData {
|
|
622
|
+
return this.getStepData(this.state.currentId);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 是否可以返回上一步。
|
|
627
|
+
* @returns {boolean}
|
|
628
|
+
*/
|
|
629
|
+
get canBack(): boolean {
|
|
630
|
+
return this.state.currentIndex > 0 && !this.state.loading;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* 是否可以前进。
|
|
635
|
+
* @returns {boolean}
|
|
636
|
+
*/
|
|
637
|
+
get canNext(): boolean {
|
|
638
|
+
return (
|
|
639
|
+
this.state.currentIndex < this.steps.length - 1 && !this.state.loading
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* 是否处于最后一步。
|
|
645
|
+
* @returns {boolean}
|
|
646
|
+
*/
|
|
647
|
+
get isLast(): boolean {
|
|
648
|
+
return this.state.currentIndex === this.steps.length - 1;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 订阅状态变化。
|
|
653
|
+
* @param {Function} handler 订阅函数。
|
|
654
|
+
* @returns {Function} 取消订阅函数。
|
|
655
|
+
*/
|
|
656
|
+
subscribe(handler: FlowSubscriber): FlowCleanup {
|
|
657
|
+
this.assertActive('subscribe');
|
|
658
|
+
if (typeof handler !== 'function') {
|
|
659
|
+
throw new Error('Flow.subscribe: handler expects a function.');
|
|
660
|
+
}
|
|
661
|
+
this.subscribers.add(handler);
|
|
662
|
+
handler(this.snapshot(), this, null);
|
|
663
|
+
return () => this.subscribers.delete(handler);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* 获取不可变快照。
|
|
668
|
+
* @returns {object}
|
|
669
|
+
*/
|
|
670
|
+
snapshot(): FlowSnapshot {
|
|
671
|
+
const currentStep = this.currentStep;
|
|
672
|
+
return {
|
|
673
|
+
id: this.state.id,
|
|
674
|
+
currentId: this.state.currentId,
|
|
675
|
+
currentIndex: this.state.currentIndex,
|
|
676
|
+
previousId: this.state.previousId,
|
|
677
|
+
previousIndex: this.state.previousIndex,
|
|
678
|
+
direction: this.state.direction,
|
|
679
|
+
history: [...this.state.history],
|
|
680
|
+
data: clonePlainObject(this.state.data),
|
|
681
|
+
stepData: clonePlainObject(this.state.stepData) as Record<
|
|
682
|
+
string,
|
|
683
|
+
FlowData
|
|
684
|
+
>,
|
|
685
|
+
currentData: this.currentData,
|
|
686
|
+
currentStep: this.publicStep(currentStep),
|
|
687
|
+
canBack: this.canBack,
|
|
688
|
+
canNext: this.canNext,
|
|
689
|
+
isLast: this.isLast,
|
|
690
|
+
loading: this.state.loading,
|
|
691
|
+
busyAction: this.state.busyAction,
|
|
692
|
+
error: this.state.error,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* 挂载默认 Flow UI。
|
|
698
|
+
* @param {Element|Node|string|Array} container DOM 容器、选择器或 JSX/h 返回节点。
|
|
699
|
+
* @returns {Flow}
|
|
700
|
+
*/
|
|
701
|
+
mount(container: DOMReference): this {
|
|
702
|
+
this.assertActive('mount');
|
|
703
|
+
if (!this.options.render) return this;
|
|
704
|
+
|
|
705
|
+
const target = requireContainer(
|
|
706
|
+
container,
|
|
707
|
+
'Flow.mount.container',
|
|
708
|
+
'element'
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
this.unmount();
|
|
712
|
+
this.root = this.buildRoot();
|
|
713
|
+
target.appendChild(this.root);
|
|
714
|
+
this.mountView();
|
|
715
|
+
return this;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 卸载默认 UI。
|
|
720
|
+
* @returns {Flow}
|
|
721
|
+
*/
|
|
722
|
+
unmount(): this {
|
|
723
|
+
this.renderDispose?.();
|
|
724
|
+
this.renderDispose = null;
|
|
725
|
+
if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
|
|
726
|
+
this.root = null;
|
|
727
|
+
this.nodes = {};
|
|
728
|
+
return this;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 前进一步。
|
|
733
|
+
* @param {object|null} [payload=null] 当前步骤需要缓存的数据。
|
|
734
|
+
* @returns {Promise<object>} 切换后的快照。
|
|
735
|
+
*/
|
|
736
|
+
async next(payload: FlowPayload = null): Promise<FlowSnapshot | null> {
|
|
737
|
+
this.assertActive('next');
|
|
738
|
+
validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.next');
|
|
739
|
+
const busySnapshot = this.handleBusy('next');
|
|
740
|
+
if (busySnapshot) return busySnapshot;
|
|
741
|
+
|
|
742
|
+
return this.runAction('next', async () => {
|
|
743
|
+
if (this.isLast) return this.finish(payload, { internal: true });
|
|
744
|
+
|
|
745
|
+
const fromStep = this.currentStep;
|
|
746
|
+
const fallbackId = this.steps[this.state.currentIndex + 1]?.id;
|
|
747
|
+
const result = await this.runMoveHook(
|
|
748
|
+
'next',
|
|
749
|
+
fromStep,
|
|
750
|
+
payload,
|
|
751
|
+
fallbackId
|
|
752
|
+
);
|
|
753
|
+
const { id, data } = normalizeStepResult(result, fallbackId);
|
|
754
|
+
return this.goTo(id, data ?? payload, {
|
|
755
|
+
direction: 'next',
|
|
756
|
+
internal: true,
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 返回上一步。
|
|
763
|
+
* @param {object|null} [payload=null] 当前步骤需要缓存的数据。
|
|
764
|
+
* @returns {Promise<object>} 切换后的快照。
|
|
765
|
+
*/
|
|
766
|
+
async back(payload: FlowPayload = null): Promise<FlowSnapshot | null> {
|
|
767
|
+
this.assertActive('back');
|
|
768
|
+
validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.back');
|
|
769
|
+
const busySnapshot = this.handleBusy('back');
|
|
770
|
+
if (busySnapshot) return busySnapshot;
|
|
771
|
+
|
|
772
|
+
if (!this.canBack) return this.snapshot();
|
|
773
|
+
|
|
774
|
+
return this.runAction('back', async () => {
|
|
775
|
+
const fromStep = this.currentStep;
|
|
776
|
+
const fallbackId = this.steps[this.state.currentIndex - 1]?.id;
|
|
777
|
+
const result = await this.runMoveHook(
|
|
778
|
+
'back',
|
|
779
|
+
fromStep,
|
|
780
|
+
payload,
|
|
781
|
+
fallbackId
|
|
782
|
+
);
|
|
783
|
+
const { id, data } = normalizeStepResult(result, fallbackId);
|
|
784
|
+
return this.goTo(id, data ?? payload, {
|
|
785
|
+
direction: 'back',
|
|
786
|
+
internal: true,
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* 跳转到指定步骤。
|
|
793
|
+
* @param {string|number} target 目标步骤 id 或索引。
|
|
794
|
+
* @param {object|null} [payload=null] 当前步骤需要缓存的数据。
|
|
795
|
+
* @param {{direction?:string, internal?:boolean}} [options={}] 跳转选项。
|
|
796
|
+
* @returns {Promise<object>} 切换后的快照。
|
|
797
|
+
*/
|
|
798
|
+
async goTo(
|
|
799
|
+
target: FlowTarget,
|
|
800
|
+
payload: FlowPayload = null,
|
|
801
|
+
options: FlowGoToOptions = {}
|
|
802
|
+
): Promise<FlowSnapshot | null> {
|
|
803
|
+
this.assertActive('goTo');
|
|
804
|
+
validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.goTo');
|
|
805
|
+
const busySnapshot = options.internal ? null : this.handleBusy('goTo');
|
|
806
|
+
if (busySnapshot) return busySnapshot;
|
|
807
|
+
|
|
808
|
+
return this.runAction(
|
|
809
|
+
'goTo',
|
|
810
|
+
async () => {
|
|
811
|
+
const toIndex = this.resolveStepIndex(target);
|
|
812
|
+
const toStep = this.steps[toIndex];
|
|
813
|
+
const fromStep = this.currentStep;
|
|
814
|
+
|
|
815
|
+
if (!toStep || toStep.id === this.state.currentId) {
|
|
816
|
+
if (payload) this.setStepData(this.state.currentId, payload);
|
|
817
|
+
return this.snapshot();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
await this.transitionTo(toStep, {
|
|
821
|
+
direction: options.direction || 'go',
|
|
822
|
+
payload,
|
|
823
|
+
fromStep,
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
return this.snapshot();
|
|
827
|
+
},
|
|
828
|
+
{ internal: options.internal }
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 合并全局数据。
|
|
834
|
+
* @param {object} data 数据补丁。
|
|
835
|
+
* @returns {Flow}
|
|
836
|
+
*/
|
|
837
|
+
setData(data: FlowPayload): this {
|
|
838
|
+
this.assertActive('setData');
|
|
839
|
+
validateParam('data', data, FLOW_PAYLOAD_RULE, 'Flow.setData');
|
|
840
|
+
if (!data) return this;
|
|
841
|
+
|
|
842
|
+
flushSync(() => {
|
|
843
|
+
Object.assign(this.state.data, data);
|
|
844
|
+
this.state.version += 1;
|
|
845
|
+
});
|
|
846
|
+
this.emitChange();
|
|
847
|
+
return this;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 合并指定步骤缓存数据。
|
|
852
|
+
* @param {string} stepId 步骤 id。
|
|
853
|
+
* @param {object|null} data 数据补丁。
|
|
854
|
+
* @returns {Flow}
|
|
855
|
+
*/
|
|
856
|
+
setStepData(
|
|
857
|
+
stepId: string,
|
|
858
|
+
data: FlowPayload,
|
|
859
|
+
options: { silent?: boolean } = {}
|
|
860
|
+
): this {
|
|
861
|
+
this.assertActive('setStepData');
|
|
862
|
+
validateParam('stepId', stepId, { type: 'string' }, 'Flow.setStepData');
|
|
863
|
+
validateParam('data', data, FLOW_PAYLOAD_RULE, 'Flow.setStepData');
|
|
864
|
+
if (!data || !this.stepMap.has(stepId)) return this;
|
|
865
|
+
|
|
866
|
+
flushSync(() => {
|
|
867
|
+
this.state.stepData[stepId] = {
|
|
868
|
+
...clonePlainObject(this.state.stepData[stepId]),
|
|
869
|
+
...data,
|
|
870
|
+
};
|
|
871
|
+
if (this.options.cache) Object.assign(this.state.data, data);
|
|
872
|
+
this.state.version += 1;
|
|
873
|
+
});
|
|
874
|
+
if (!options.silent) this.emitChange();
|
|
875
|
+
return this;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* 获取指定步骤缓存数据。
|
|
880
|
+
* @param {string} stepId 步骤 id。
|
|
881
|
+
* @returns {object}
|
|
882
|
+
*/
|
|
883
|
+
getStepData(stepId: string): FlowData {
|
|
884
|
+
return clonePlainObject(this.state.stepData[stepId]);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* 重置流程。
|
|
889
|
+
* @returns {Flow}
|
|
890
|
+
*/
|
|
891
|
+
reset(): this {
|
|
892
|
+
this.assertActive('reset');
|
|
893
|
+
flushSync(() => {
|
|
894
|
+
this.state.currentId = this.initialStepId;
|
|
895
|
+
this.state.currentIndex = this.stepMap.get(this.initialStepId) ?? 0;
|
|
896
|
+
this.state.previousId = null;
|
|
897
|
+
this.state.previousIndex = null;
|
|
898
|
+
this.state.direction = null;
|
|
899
|
+
this.state.history.splice(
|
|
900
|
+
0,
|
|
901
|
+
this.state.history.length,
|
|
902
|
+
this.initialStepId
|
|
903
|
+
);
|
|
904
|
+
this.replaceObject(
|
|
905
|
+
this.state.data,
|
|
906
|
+
clonePlainObject(this.initialData.global)
|
|
907
|
+
);
|
|
908
|
+
this.replaceObject(
|
|
909
|
+
this.state.stepData,
|
|
910
|
+
clonePlainObject(this.initialData.stepData)
|
|
911
|
+
);
|
|
912
|
+
this.state.loading = false;
|
|
913
|
+
this.state.busyAction = null;
|
|
914
|
+
this.state.error = null;
|
|
915
|
+
this.state.version += 1;
|
|
916
|
+
});
|
|
917
|
+
this.emitChange();
|
|
918
|
+
return this;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* 完成流程。
|
|
923
|
+
* @param {object|null} [payload=null] 最后一步需要缓存的数据。
|
|
924
|
+
* @returns {Promise<object>} 当前快照。
|
|
925
|
+
*/
|
|
926
|
+
async finish(
|
|
927
|
+
payload: FlowPayload = null,
|
|
928
|
+
options: FlowRunActionOptions = {}
|
|
929
|
+
): Promise<FlowSnapshot | null> {
|
|
930
|
+
this.assertActive('finish');
|
|
931
|
+
validateParam('payload', payload, FLOW_PAYLOAD_RULE, 'Flow.finish');
|
|
932
|
+
const busySnapshot = options.internal ? null : this.handleBusy('finish');
|
|
933
|
+
if (busySnapshot) return busySnapshot;
|
|
934
|
+
|
|
935
|
+
return this.runAction(
|
|
936
|
+
'finish',
|
|
937
|
+
async () => {
|
|
938
|
+
if (payload) this.setStepData(this.state.currentId, payload);
|
|
939
|
+
await this.callHook(this.options.onFinish, [this.snapshot(), this]);
|
|
940
|
+
return this.snapshot();
|
|
941
|
+
},
|
|
942
|
+
{ internal: options.internal }
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 销毁 Flow 实例。
|
|
948
|
+
* @returns {void}
|
|
949
|
+
*/
|
|
950
|
+
destroy(): void {
|
|
951
|
+
if (this.destroyed) return;
|
|
952
|
+
this.destroyed = true;
|
|
953
|
+
this.abortActiveAction();
|
|
954
|
+
this.unmount();
|
|
955
|
+
for (const cleanup of Array.from(this.cleanupTasks)) {
|
|
956
|
+
cleanup();
|
|
957
|
+
}
|
|
958
|
+
this.cleanupTasks.clear();
|
|
959
|
+
this.subscribers.clear();
|
|
960
|
+
this.steps = [];
|
|
961
|
+
this.stepMap.clear();
|
|
962
|
+
this.activeAction = null;
|
|
963
|
+
this.actionController = null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private resolveInitialStepId(initial: string | number | null): string {
|
|
967
|
+
if (typeof initial === 'number') {
|
|
968
|
+
const step = this.steps[initial];
|
|
969
|
+
if (!step) throw new Error('Flow.options.initial index is out of range.');
|
|
970
|
+
return step.id;
|
|
971
|
+
}
|
|
972
|
+
if (typeof initial === 'string') {
|
|
973
|
+
if (!this.stepMap.has(initial)) {
|
|
974
|
+
throw new Error(
|
|
975
|
+
`Flow.options.initial step "${initial}" does not exist.`
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
return initial;
|
|
979
|
+
}
|
|
980
|
+
return this.steps[0].id;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private validateSteps(steps: FlowStep[]): void {
|
|
984
|
+
const ids = new Set<string>();
|
|
985
|
+
for (const [index, step] of steps.entries()) {
|
|
986
|
+
validateParam(String(index), step, FLOW_STEP_RULE, 'Flow.options.steps');
|
|
987
|
+
validateParam(
|
|
988
|
+
'content',
|
|
989
|
+
step.content ?? null,
|
|
990
|
+
FLOW_CONTENT_RULE,
|
|
991
|
+
`Flow.options.steps.${index}`
|
|
992
|
+
);
|
|
993
|
+
if (ids.has(step.id)) {
|
|
994
|
+
throw new Error(`Flow.options.steps: duplicated step id "${step.id}".`);
|
|
995
|
+
}
|
|
996
|
+
ids.add(step.id);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
private resolveStepIndex(target: FlowTarget): number {
|
|
1001
|
+
if (typeof target === 'number') {
|
|
1002
|
+
if (!this.steps[target])
|
|
1003
|
+
throw new Error('Flow.goTo target is out of range.');
|
|
1004
|
+
return target;
|
|
1005
|
+
}
|
|
1006
|
+
if (typeof target === 'string') {
|
|
1007
|
+
const index = this.stepMap.get(target);
|
|
1008
|
+
if (index == null) {
|
|
1009
|
+
throw new Error(`Flow.goTo target "${target}" does not exist.`);
|
|
1010
|
+
}
|
|
1011
|
+
return index;
|
|
1012
|
+
}
|
|
1013
|
+
throw new Error('Flow.goTo target expects string or number.');
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private createInitialStepData(): InitialFlowData {
|
|
1017
|
+
const stepData: Record<string, FlowData> = {};
|
|
1018
|
+
const global: FlowData = {};
|
|
1019
|
+
for (const step of this.steps) {
|
|
1020
|
+
stepData[step.id] = clonePlainObject(step.data);
|
|
1021
|
+
Object.assign(global, stepData[step.id]);
|
|
1022
|
+
}
|
|
1023
|
+
return { stepData, global };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private async runMoveHook(
|
|
1027
|
+
type: 'next' | 'back',
|
|
1028
|
+
step: FlowStep,
|
|
1029
|
+
payload: FlowPayload,
|
|
1030
|
+
fallbackId: string
|
|
1031
|
+
): Promise<FlowStepResult | FlowPayload | void> {
|
|
1032
|
+
const globalHook =
|
|
1033
|
+
type === 'back' ? this.options.onBack : this.options.onNext;
|
|
1034
|
+
const stepHook = type === 'back' ? step.onBack : step.onNext;
|
|
1035
|
+
const context = this.createContext({ payload, targetId: fallbackId });
|
|
1036
|
+
|
|
1037
|
+
if (typeof stepHook === 'function') {
|
|
1038
|
+
const result = await this.callHook(stepHook, [context]);
|
|
1039
|
+
if (result != null) return result;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (typeof globalHook === 'function') {
|
|
1043
|
+
const result = await this.callHook(globalHook, [context]);
|
|
1044
|
+
if (result != null) return result;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return fallbackId;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private async transitionTo(
|
|
1051
|
+
toStep: FlowStep,
|
|
1052
|
+
{ direction, payload, fromStep }: FlowTransitionOptions
|
|
1053
|
+
): Promise<void> {
|
|
1054
|
+
const fromSnapshot = this.snapshot();
|
|
1055
|
+
const rollbackState = this.captureState();
|
|
1056
|
+
|
|
1057
|
+
try {
|
|
1058
|
+
if (payload) this.setStepData(fromStep.id, payload, { silent: true });
|
|
1059
|
+
|
|
1060
|
+
await this.assertCanLeave(fromStep, toStep, direction);
|
|
1061
|
+
await this.assertCanEnter(toStep, fromStep, direction);
|
|
1062
|
+
|
|
1063
|
+
if (typeof fromStep.onLeave === 'function') {
|
|
1064
|
+
await this.callHook(fromStep.onLeave, [
|
|
1065
|
+
this.createContext({
|
|
1066
|
+
direction,
|
|
1067
|
+
step: fromStep,
|
|
1068
|
+
targetId: toStep.id,
|
|
1069
|
+
}),
|
|
1070
|
+
]);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const previousId = this.state.currentId;
|
|
1074
|
+
const previousIndex = this.state.currentIndex;
|
|
1075
|
+
const currentIndex = this.stepMap.get(toStep.id) ?? 0;
|
|
1076
|
+
|
|
1077
|
+
flushSync(() => {
|
|
1078
|
+
this.state.previousId = previousId;
|
|
1079
|
+
this.state.previousIndex = previousIndex;
|
|
1080
|
+
this.state.currentId = toStep.id;
|
|
1081
|
+
this.state.currentIndex = currentIndex;
|
|
1082
|
+
this.state.direction = direction;
|
|
1083
|
+
if (direction === 'back') this.state.history.pop();
|
|
1084
|
+
else this.state.history.push(toStep.id);
|
|
1085
|
+
this.state.error = null;
|
|
1086
|
+
this.state.version += 1;
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
if (typeof toStep.onEnter === 'function') {
|
|
1090
|
+
await this.callHook(toStep.onEnter, [
|
|
1091
|
+
this.createContext({
|
|
1092
|
+
direction,
|
|
1093
|
+
fromId: previousId,
|
|
1094
|
+
step: toStep,
|
|
1095
|
+
}),
|
|
1096
|
+
]);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
this.emitChange(fromSnapshot);
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (this.destroyed) throw error;
|
|
1102
|
+
if (this.options.rollbackOnError) {
|
|
1103
|
+
this.restoreState(rollbackState, { keepLoading: true });
|
|
1104
|
+
}
|
|
1105
|
+
this.handleError(error, fromSnapshot);
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private async assertCanLeave(
|
|
1111
|
+
fromStep: FlowStep,
|
|
1112
|
+
toStep: FlowStep,
|
|
1113
|
+
direction: FlowDirection
|
|
1114
|
+
): Promise<void> {
|
|
1115
|
+
if (typeof fromStep.canLeave !== 'function') return;
|
|
1116
|
+
const result = await this.callHook(fromStep.canLeave, [
|
|
1117
|
+
this.createContext({ direction, targetId: toStep.id }),
|
|
1118
|
+
]);
|
|
1119
|
+
if (result === false) {
|
|
1120
|
+
throw new Error(`Flow: step "${fromStep.id}" blocked leaving.`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private async assertCanEnter(
|
|
1125
|
+
toStep: FlowStep,
|
|
1126
|
+
fromStep: FlowStep,
|
|
1127
|
+
direction: FlowDirection
|
|
1128
|
+
): Promise<void> {
|
|
1129
|
+
if (typeof toStep.canEnter !== 'function') return;
|
|
1130
|
+
const result = await this.callHook(toStep.canEnter, [
|
|
1131
|
+
this.createContext({ direction, fromId: fromStep.id }),
|
|
1132
|
+
]);
|
|
1133
|
+
if (result === false) {
|
|
1134
|
+
throw new Error(`Flow: step "${toStep.id}" blocked entering.`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private createContext(extra: FlowContextExtra = {}): FlowContext {
|
|
1139
|
+
const step = extra.step || this.currentStep;
|
|
1140
|
+
const snapshot = this.snapshot();
|
|
1141
|
+
return {
|
|
1142
|
+
...extra,
|
|
1143
|
+
flow: this,
|
|
1144
|
+
step,
|
|
1145
|
+
state: this.state,
|
|
1146
|
+
signal: this.actionController?.signal || null,
|
|
1147
|
+
snapshot,
|
|
1148
|
+
data: clonePlainObject(this.state.data),
|
|
1149
|
+
currentData: this.getStepData(step.id),
|
|
1150
|
+
setData: (data) => this.setData(data),
|
|
1151
|
+
setStepData: (stepId, data) => this.setStepData(stepId, data),
|
|
1152
|
+
getStepData: (stepId) => this.getStepData(stepId),
|
|
1153
|
+
next: (payload) => this.next(payload),
|
|
1154
|
+
back: (payload) => this.back(payload),
|
|
1155
|
+
goTo: (target, payload, options) => this.goTo(target, payload, options),
|
|
1156
|
+
addCleanup: (cleanup) => this.addCleanup(cleanup),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
private async runAction(
|
|
1161
|
+
action: FlowAction,
|
|
1162
|
+
task: () => Promise<FlowSnapshot | null>,
|
|
1163
|
+
options: FlowRunActionOptions = {}
|
|
1164
|
+
): Promise<FlowSnapshot | null> {
|
|
1165
|
+
const isOuterAction = !options.internal && !this.state.loading;
|
|
1166
|
+
if (isOuterAction) {
|
|
1167
|
+
this.actionController =
|
|
1168
|
+
typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1169
|
+
this.activeAction = action;
|
|
1170
|
+
this.setLoading(true, action);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
return await task();
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (this.destroyed) return null;
|
|
1177
|
+
if (this.state.error !== error) this.handleError(error);
|
|
1178
|
+
throw error;
|
|
1179
|
+
} finally {
|
|
1180
|
+
if (isOuterAction && !this.destroyed) {
|
|
1181
|
+
this.setLoading(false, null);
|
|
1182
|
+
this.activeAction = null;
|
|
1183
|
+
this.actionController = null;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private handleBusy(action: FlowAction): FlowSnapshot | null {
|
|
1189
|
+
if (!this.state.loading) return null;
|
|
1190
|
+
|
|
1191
|
+
const error = new Error(
|
|
1192
|
+
`Flow: action "${action}" ignored while loading.`
|
|
1193
|
+
) as FlowError;
|
|
1194
|
+
error.code = 'FLOW_BUSY';
|
|
1195
|
+
|
|
1196
|
+
if (typeof this.options.onBusy === 'function') {
|
|
1197
|
+
this.options.onBusy(action, this.snapshot(), this);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (this.options.busyStrategy === 'throw') throw error;
|
|
1201
|
+
|
|
1202
|
+
return this.snapshot();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
private abortActiveAction(): void {
|
|
1206
|
+
this.actionController?.abort();
|
|
1207
|
+
this.actionController = null;
|
|
1208
|
+
this.activeAction = null;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
private async callHook<TResult>(
|
|
1212
|
+
hook: ((...args: never[]) => TResult | Promise<TResult>) | null | undefined,
|
|
1213
|
+
args: unknown[]
|
|
1214
|
+
): Promise<TResult | undefined> {
|
|
1215
|
+
if (typeof hook !== 'function') return undefined;
|
|
1216
|
+
const result = await (
|
|
1217
|
+
hook as (...args: unknown[]) => TResult | Promise<TResult>
|
|
1218
|
+
)(...args);
|
|
1219
|
+
if (this.destroyed) throw this.createAbortError();
|
|
1220
|
+
return result;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
private createAbortError(): FlowError {
|
|
1224
|
+
const error = new Error('Flow: active action was aborted.') as FlowError;
|
|
1225
|
+
error.code = 'FLOW_ABORTED';
|
|
1226
|
+
return error;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private setLoading(
|
|
1230
|
+
value: boolean,
|
|
1231
|
+
action: FlowAction | null = this.state.busyAction
|
|
1232
|
+
): void {
|
|
1233
|
+
flushSync(() => {
|
|
1234
|
+
this.state.loading = value;
|
|
1235
|
+
this.state.busyAction = value ? action : null;
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
private handleError(
|
|
1240
|
+
error: unknown,
|
|
1241
|
+
previous: FlowSnapshot | null = null
|
|
1242
|
+
): void {
|
|
1243
|
+
flushSync(() => {
|
|
1244
|
+
this.state.error = error;
|
|
1245
|
+
this.state.version += 1;
|
|
1246
|
+
});
|
|
1247
|
+
if (typeof this.options.onError === 'function') {
|
|
1248
|
+
this.options.onError(error, this.snapshot(), this, previous);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
private emitChange(previous: FlowSnapshot | null = null): void {
|
|
1253
|
+
const next = this.snapshot();
|
|
1254
|
+
for (const handler of Array.from(this.subscribers)) {
|
|
1255
|
+
handler(next, this, previous);
|
|
1256
|
+
}
|
|
1257
|
+
if (typeof this.options.onChange === 'function') {
|
|
1258
|
+
this.options.onChange(next, this, previous);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private replaceObject(target: FlowData, source: FlowData): void {
|
|
1263
|
+
for (const key of Object.keys(target)) delete target[key];
|
|
1264
|
+
Object.assign(target, source);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
private captureState(): CapturedFlowState {
|
|
1268
|
+
return {
|
|
1269
|
+
currentId: this.state.currentId,
|
|
1270
|
+
currentIndex: this.state.currentIndex,
|
|
1271
|
+
previousId: this.state.previousId,
|
|
1272
|
+
previousIndex: this.state.previousIndex,
|
|
1273
|
+
direction: this.state.direction,
|
|
1274
|
+
history: cloneArray<string>(this.state.history),
|
|
1275
|
+
data: clonePlainObject(this.state.data),
|
|
1276
|
+
stepData: clonePlainObject(this.state.stepData) as Record<
|
|
1277
|
+
string,
|
|
1278
|
+
FlowData
|
|
1279
|
+
>,
|
|
1280
|
+
loading: this.state.loading,
|
|
1281
|
+
busyAction: this.state.busyAction,
|
|
1282
|
+
error: this.state.error,
|
|
1283
|
+
version: this.state.version,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private restoreState(
|
|
1288
|
+
snapshot: CapturedFlowState,
|
|
1289
|
+
options: { keepLoading?: boolean } = {}
|
|
1290
|
+
): void {
|
|
1291
|
+
flushSync(() => {
|
|
1292
|
+
this.state.currentId = snapshot.currentId;
|
|
1293
|
+
this.state.currentIndex = snapshot.currentIndex;
|
|
1294
|
+
this.state.previousId = snapshot.previousId;
|
|
1295
|
+
this.state.previousIndex = snapshot.previousIndex;
|
|
1296
|
+
this.state.direction = snapshot.direction;
|
|
1297
|
+
this.state.history.splice(
|
|
1298
|
+
0,
|
|
1299
|
+
this.state.history.length,
|
|
1300
|
+
...snapshot.history
|
|
1301
|
+
);
|
|
1302
|
+
this.replaceObject(this.state.data, snapshot.data);
|
|
1303
|
+
this.replaceObject(this.state.stepData, snapshot.stepData);
|
|
1304
|
+
this.state.loading = options.keepLoading ? true : snapshot.loading;
|
|
1305
|
+
this.state.busyAction = snapshot.busyAction;
|
|
1306
|
+
this.state.error = snapshot.error;
|
|
1307
|
+
this.state.version = snapshot.version + 1;
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private addCleanup(cleanup: FlowCleanup): FlowCleanup {
|
|
1312
|
+
if (typeof cleanup !== 'function') {
|
|
1313
|
+
throw new Error('Flow.addCleanup: cleanup expects a function.');
|
|
1314
|
+
}
|
|
1315
|
+
if (this.destroyed) {
|
|
1316
|
+
cleanup();
|
|
1317
|
+
return () => {};
|
|
1318
|
+
}
|
|
1319
|
+
this.cleanupTasks.add(cleanup);
|
|
1320
|
+
return () => this.cleanupTasks.delete(cleanup);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
private assertActive(method: string): void {
|
|
1324
|
+
if (this.destroyed) {
|
|
1325
|
+
throw new Error(`Flow.${method}: instance has been destroyed.`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private publicStep(step: FlowStep | null | undefined): PublicFlowStep | null {
|
|
1330
|
+
if (!step) return null;
|
|
1331
|
+
const {
|
|
1332
|
+
onEnter: _onEnter,
|
|
1333
|
+
onLeave: _onLeave,
|
|
1334
|
+
onNext: _onNext,
|
|
1335
|
+
onBack: _onBack,
|
|
1336
|
+
canEnter: _canEnter,
|
|
1337
|
+
canLeave: _canLeave,
|
|
1338
|
+
...publicStep
|
|
1339
|
+
} = step;
|
|
1340
|
+
return publicStep;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
private buildRoot(): HTMLElement {
|
|
1344
|
+
return jsx('div', {
|
|
1345
|
+
className: this.options.className.root,
|
|
1346
|
+
id: this.options.id,
|
|
1347
|
+
role: 'group',
|
|
1348
|
+
'data-flow': 'root',
|
|
1349
|
+
'aria-busy': () => (this.state.loading ? 'true' : 'false'),
|
|
1350
|
+
'aria-labelledby': `${this.options.id}-title`,
|
|
1351
|
+
ref: (element: HTMLElement) => {
|
|
1352
|
+
this.nodes.root = element;
|
|
1353
|
+
},
|
|
1354
|
+
}) as HTMLElement;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private mountView(): void {
|
|
1358
|
+
if (this.renderDispose || !this.root) return;
|
|
1359
|
+
this.renderDispose = createRoot((dispose: FlowCleanup) => {
|
|
1360
|
+
const viewDispose = render(() => this.view(), this.root as HTMLElement);
|
|
1361
|
+
onCleanup(viewDispose);
|
|
1362
|
+
return dispose;
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
private view(): RenderableContent<FlowRenderContext> {
|
|
1367
|
+
const snapshot = this.snapshot();
|
|
1368
|
+
return [
|
|
1369
|
+
this.renderSlot('renderHeader', snapshot, () =>
|
|
1370
|
+
this.options.showHeader ? this.headerView(snapshot) : null
|
|
1371
|
+
),
|
|
1372
|
+
this.renderSlot('renderSteps', snapshot, () =>
|
|
1373
|
+
this.options.showSteps ? this.stepsView(snapshot) : null
|
|
1374
|
+
),
|
|
1375
|
+
this.renderSlot('renderBody', snapshot, () => this.bodyView(snapshot)),
|
|
1376
|
+
this.renderSlot('renderFooter', snapshot, () =>
|
|
1377
|
+
this.options.showFooter ? this.footerView(snapshot) : null
|
|
1378
|
+
),
|
|
1379
|
+
];
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
private renderSlot(
|
|
1383
|
+
name: FlowSlotName,
|
|
1384
|
+
snapshot: FlowSnapshot,
|
|
1385
|
+
fallback: () => RenderableContent<FlowRenderContext>
|
|
1386
|
+
): RenderableContent<FlowRenderContext> {
|
|
1387
|
+
const slot = this.options[name];
|
|
1388
|
+
if (slot === false) return null;
|
|
1389
|
+
if (typeof slot === 'function') {
|
|
1390
|
+
return slot(this.createRenderContext(snapshot, fallback));
|
|
1391
|
+
}
|
|
1392
|
+
return fallback();
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
private createRenderContext(
|
|
1396
|
+
snapshot: FlowSnapshot,
|
|
1397
|
+
fallback: () => RenderableContent<FlowRenderContext>
|
|
1398
|
+
): FlowRenderContext {
|
|
1399
|
+
return {
|
|
1400
|
+
flow: this,
|
|
1401
|
+
snapshot,
|
|
1402
|
+
state: this.state,
|
|
1403
|
+
steps: this.steps.map((step) => this.publicStep(step)),
|
|
1404
|
+
currentStep: snapshot.currentStep,
|
|
1405
|
+
currentData: snapshot.currentData,
|
|
1406
|
+
data: snapshot.data,
|
|
1407
|
+
fallback,
|
|
1408
|
+
next: (payload) => this.next(payload),
|
|
1409
|
+
back: (payload) => this.back(payload),
|
|
1410
|
+
goTo: (target, payload, options) => this.goTo(target, payload, options),
|
|
1411
|
+
reset: () => this.reset(),
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
private headerView(snapshot: FlowSnapshot): HTMLElement {
|
|
1416
|
+
return jsx('div', {
|
|
1417
|
+
className: this.options.className.header,
|
|
1418
|
+
'data-flow-header': '',
|
|
1419
|
+
children: [
|
|
1420
|
+
jsx('div', {
|
|
1421
|
+
id: `${this.options.id}-title`,
|
|
1422
|
+
className: this.options.className.title,
|
|
1423
|
+
'data-flow-title': '',
|
|
1424
|
+
children:
|
|
1425
|
+
snapshot.currentStep?.title || snapshot.currentStep?.id || '',
|
|
1426
|
+
}),
|
|
1427
|
+
snapshot.currentStep?.description
|
|
1428
|
+
? jsx('div', {
|
|
1429
|
+
className: this.options.className.description,
|
|
1430
|
+
'data-flow-description': '',
|
|
1431
|
+
children: snapshot.currentStep.description,
|
|
1432
|
+
})
|
|
1433
|
+
: null,
|
|
1434
|
+
],
|
|
1435
|
+
}) as HTMLElement;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private stepsView(snapshot: FlowSnapshot): HTMLOListElement {
|
|
1439
|
+
return jsx('ol', {
|
|
1440
|
+
className: this.options.className.steps,
|
|
1441
|
+
'data-flow-steps': '',
|
|
1442
|
+
role: 'list',
|
|
1443
|
+
'aria-label': 'Flow steps',
|
|
1444
|
+
children: this.steps.map((step, index) =>
|
|
1445
|
+
jsx('li', {
|
|
1446
|
+
className: this.stepClass(index, snapshot.currentIndex),
|
|
1447
|
+
'data-flow-step': step.id,
|
|
1448
|
+
'data-flow-step-index': String(index),
|
|
1449
|
+
'aria-current': index === snapshot.currentIndex ? 'step' : undefined,
|
|
1450
|
+
children: [
|
|
1451
|
+
jsx('button', {
|
|
1452
|
+
type: 'button',
|
|
1453
|
+
className: this.options.className.stepButton,
|
|
1454
|
+
disabled: this.options.linear && index > snapshot.currentIndex,
|
|
1455
|
+
'aria-current':
|
|
1456
|
+
index === snapshot.currentIndex ? 'step' : undefined,
|
|
1457
|
+
'aria-disabled':
|
|
1458
|
+
this.options.linear && index > snapshot.currentIndex
|
|
1459
|
+
? 'true'
|
|
1460
|
+
: 'false',
|
|
1461
|
+
'aria-label': `${index + 1}. ${step.title || step.id}`,
|
|
1462
|
+
'data-flow-step-action': 'goTo',
|
|
1463
|
+
onClick: () => {
|
|
1464
|
+
if (index === snapshot.currentIndex) return;
|
|
1465
|
+
void this.goTo(index);
|
|
1466
|
+
},
|
|
1467
|
+
children: [
|
|
1468
|
+
jsx('span', {
|
|
1469
|
+
className: this.options.className.stepIndex,
|
|
1470
|
+
'data-flow-step-number': '',
|
|
1471
|
+
children: index + 1,
|
|
1472
|
+
}),
|
|
1473
|
+
jsx('span', {
|
|
1474
|
+
className: this.options.className.stepTitle,
|
|
1475
|
+
'data-flow-step-title': '',
|
|
1476
|
+
children: step.title || step.id,
|
|
1477
|
+
}),
|
|
1478
|
+
],
|
|
1479
|
+
}),
|
|
1480
|
+
],
|
|
1481
|
+
})
|
|
1482
|
+
),
|
|
1483
|
+
}) as HTMLOListElement;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private bodyView(snapshot: FlowSnapshot): HTMLElement {
|
|
1487
|
+
return jsx('div', {
|
|
1488
|
+
className: this.options.className.body,
|
|
1489
|
+
'data-flow-body': '',
|
|
1490
|
+
role: 'region',
|
|
1491
|
+
'aria-live': 'polite',
|
|
1492
|
+
'aria-busy': snapshot.loading ? 'true' : 'false',
|
|
1493
|
+
children: this.contentView(this.currentStep.content),
|
|
1494
|
+
}) as HTMLElement;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
private footerView(snapshot: FlowSnapshot): HTMLElement {
|
|
1498
|
+
const ghostButton = joinClasses(
|
|
1499
|
+
this.options.className.button,
|
|
1500
|
+
this.options.className.buttonGhost
|
|
1501
|
+
);
|
|
1502
|
+
const primaryButton = joinClasses(
|
|
1503
|
+
this.options.className.button,
|
|
1504
|
+
this.options.className.buttonPrimary
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
return jsx('div', {
|
|
1508
|
+
className: this.options.className.footer,
|
|
1509
|
+
'data-flow-footer': '',
|
|
1510
|
+
children: [
|
|
1511
|
+
this.options.showReset
|
|
1512
|
+
? jsx('button', {
|
|
1513
|
+
type: 'button',
|
|
1514
|
+
className: joinClasses(ghostButton, this.options.className.reset),
|
|
1515
|
+
'data-action': 'reset',
|
|
1516
|
+
onClick: () => this.reset(),
|
|
1517
|
+
disabled: snapshot.loading,
|
|
1518
|
+
'aria-disabled': snapshot.loading ? 'true' : 'false',
|
|
1519
|
+
children: this.options.text.reset,
|
|
1520
|
+
})
|
|
1521
|
+
: null,
|
|
1522
|
+
this.options.showBack
|
|
1523
|
+
? jsx('button', {
|
|
1524
|
+
type: 'button',
|
|
1525
|
+
className: joinClasses(ghostButton, this.options.className.back),
|
|
1526
|
+
'data-action': 'back',
|
|
1527
|
+
onClick: () => void this.back(),
|
|
1528
|
+
disabled: !snapshot.canBack,
|
|
1529
|
+
'aria-disabled': !snapshot.canBack ? 'true' : 'false',
|
|
1530
|
+
children: this.options.text.back,
|
|
1531
|
+
})
|
|
1532
|
+
: null,
|
|
1533
|
+
this.options.showNext
|
|
1534
|
+
? jsx('button', {
|
|
1535
|
+
type: 'button',
|
|
1536
|
+
className: joinClasses(
|
|
1537
|
+
primaryButton,
|
|
1538
|
+
this.options.className.next
|
|
1539
|
+
),
|
|
1540
|
+
'data-action': 'next',
|
|
1541
|
+
onClick: () => void this.next(),
|
|
1542
|
+
disabled: snapshot.loading,
|
|
1543
|
+
'aria-disabled': snapshot.loading ? 'true' : 'false',
|
|
1544
|
+
children: snapshot.isLast
|
|
1545
|
+
? this.options.text.finish
|
|
1546
|
+
: this.options.text.next,
|
|
1547
|
+
})
|
|
1548
|
+
: null,
|
|
1549
|
+
],
|
|
1550
|
+
}) as HTMLElement;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
private contentView(
|
|
1554
|
+
content: FlowStep['content']
|
|
1555
|
+
): RenderableContent<FlowContext> {
|
|
1556
|
+
if (typeof content === 'function') return content(this.createContext());
|
|
1557
|
+
return content ?? '';
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
private stepClass(
|
|
1561
|
+
index: number,
|
|
1562
|
+
currentIndex = this.state.currentIndex
|
|
1563
|
+
): string {
|
|
1564
|
+
return joinClasses(
|
|
1565
|
+
this.options.className.step,
|
|
1566
|
+
index === currentIndex && this.options.className.active,
|
|
1567
|
+
index < currentIndex && this.options.className.complete
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* 创建 Flow 实例。
|
|
1574
|
+
* @param {FlowOptions} options Flow 配置。
|
|
1575
|
+
* @returns {Flow}
|
|
1576
|
+
*/
|
|
1577
|
+
export function createFlow(options: FlowOptions = {}): Flow {
|
|
1578
|
+
return new Flow(options);
|
|
1579
|
+
}
|