vanilla-jui 1.4.7 → 1.5.1

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