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.
Files changed (71) hide show
  1. package/dist/index.d.ts +2927 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.umd.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +4 -3
  6. package/src/components/accordion.ts +522 -0
  7. package/src/components/drop.ts +497 -0
  8. package/src/components/flow.ts +1579 -0
  9. package/src/components/form.ts +953 -0
  10. package/src/components/{icons.js → icons.ts} +24 -11
  11. package/src/components/menu.ts +615 -0
  12. package/src/components/{modal.js → modal.ts} +560 -237
  13. package/src/components/offcanvas.ts +496 -0
  14. package/src/components/{pagination.js → pagination.ts} +251 -76
  15. package/src/components/parabola.ts +335 -0
  16. package/src/components/{sticky.js → sticky.ts} +123 -98
  17. package/src/components/{swiper.js → swiper.ts} +510 -232
  18. package/src/components/tabs.ts +984 -0
  19. package/src/components/theme.ts +437 -0
  20. package/src/components/toast.ts +371 -0
  21. package/src/components/toc.ts +385 -0
  22. package/src/components/tooltip.ts +179 -0
  23. package/src/components/validator.ts +596 -0
  24. package/src/core/{Component.js → Component.ts} +134 -27
  25. package/src/css/animation.css +31 -1
  26. package/src/css/card.css +1 -1
  27. package/src/css/color.css +863 -1013
  28. package/src/css/color_p3.css +822 -975
  29. package/src/css/components.css +1 -0
  30. package/src/css/form.css +1 -1
  31. package/src/css/icon.css +17 -1
  32. package/src/css/index.css +2 -2
  33. package/src/css/menu.css +1 -1
  34. package/src/css/tabs.css +1 -0
  35. package/src/css/tokens.css +1 -1
  36. package/src/index.ts +37 -0
  37. package/src/locales/{index.js → index.ts} +1 -2
  38. package/src/types.d.ts +1 -0
  39. package/src/utilities/browser.ts +131 -0
  40. package/src/utilities/cache.ts +57 -0
  41. package/src/utilities/core.ts +614 -0
  42. package/src/utilities/dom.ts +468 -0
  43. package/src/utilities/events.ts +106 -0
  44. package/src/utilities/http.ts +35 -0
  45. package/src/utilities/storage.ts +46 -0
  46. package/src/components/accordion.js +0 -373
  47. package/src/components/drop.js +0 -437
  48. package/src/components/flow.js +0 -1133
  49. package/src/components/form.js +0 -585
  50. package/src/components/menu.js +0 -422
  51. package/src/components/offcanvas.js +0 -332
  52. package/src/components/parabola.js +0 -295
  53. package/src/components/tabs.js +0 -746
  54. package/src/components/theme.js +0 -353
  55. package/src/components/toast.js +0 -329
  56. package/src/components/toc.js +0 -226
  57. package/src/components/tooltip.js +0 -86
  58. package/src/components/validator.js +0 -586
  59. package/src/index.js +0 -45
  60. package/src/locales/index.min.js +0 -1
  61. package/src/utilities/browser.js +0 -111
  62. package/src/utilities/cache.js +0 -159
  63. package/src/utilities/core.js +0 -502
  64. package/src/utilities/debug.js +0 -103
  65. package/src/utilities/dom.js +0 -313
  66. package/src/utilities/events.js +0 -69
  67. package/src/utilities/http.js +0 -30
  68. package/src/utilities/legacy-dom.js +0 -179
  69. package/src/utilities/storage.js +0 -46
  70. /package/src/{components/components.js → css/themes/index.css} +0 -0
  71. /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
+ }