@hkdigital/lib-core 0.5.45 → 0.5.47

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.
@@ -7,12 +7,20 @@
7
7
  *
8
8
  * Features:
9
9
  * - State-to-route mapping and sync
10
+ * - Start path management
10
11
  * - Data properties for business/domain state
11
12
  * - Visited states tracking
13
+ * - onEnter hooks with abort/complete handlers for animations
12
14
  *
13
15
  * Basic usage:
14
16
  * ```javascript
15
- * const machine = cityState.getOrCreatePageMachine('intro', IntroPageMachine);
17
+ * const machine = new PageMachine({
18
+ * startPath: '/intro/start',
19
+ * routeMap: {
20
+ * [STATE_START]: '/intro/start',
21
+ * [STATE_PROFILE]: '/intro/profile'
22
+ * }
23
+ * });
16
24
  *
17
25
  * // Sync machine state with URL changes
18
26
  * $effect(() => {
@@ -20,28 +28,32 @@
20
28
  * });
21
29
  * ```
22
30
  *
23
- * With data properties (for business logic):
31
+ * With onEnter hooks (for animations):
24
32
  * ```javascript
25
- * // Initialize with server data
26
- * const initialData = {
27
- * HAS_STRONG_PROFILE: false,
28
- * PROFILE_COMPLETED: false,
29
- * MATCHED_SECTOR: null
30
- * };
31
- * const machine = new CircuitPageMachine(initialState, routeMap, initialData);
33
+ * const machine = new PageMachine({
34
+ * startPath: '/game/animate',
35
+ * routeMap: {
36
+ * [STATE_ANIMATE]: '/game/animate',
37
+ * [STATE_PLAY]: '/game/play'
38
+ * },
39
+ * onEnterHooks: {
40
+ * [STATE_ANIMATE]: (done) => {
41
+ * const animation = playAnimation(1000);
42
+ * animation.finished.then(() => done(STATE_PLAY));
32
43
  *
33
- * // Read data
34
- * if (machine.getData('HAS_STRONG_PROFILE')) {
35
- * // Show advanced content
36
- * }
44
+ * return {
45
+ * abort: () => animation.cancel(),
46
+ * complete: () => animation.finish()
47
+ * };
48
+ * }
49
+ * }
50
+ * });
37
51
  *
38
- * // Update data (triggers reactivity)
39
- * machine.setData('HAS_STRONG_PROFILE', true);
52
+ * // Fast-forward animation
53
+ * machine.completeTransitions();
40
54
  *
41
- * // Check visited states
42
- * if (machine.hasVisited(STATE_PROFILE)) {
43
- * // User has seen profile page before
44
- * }
55
+ * // Cancel animation
56
+ * machine.abortTransitions();
45
57
  * ```
46
58
  */
47
59
  export default class PageMachine {
@@ -52,6 +64,18 @@ export default class PageMachine {
52
64
  // @ts-ignore
53
65
  #current = $state();
54
66
 
67
+ /**
68
+ * Start path for this page machine
69
+ * @type {string}
70
+ */
71
+ #startPath = '';
72
+
73
+ /**
74
+ * Initial/start state (derived from startPath)
75
+ * @type {string}
76
+ */
77
+ #startState = '';
78
+
55
79
  /**
56
80
  * Map of states to route paths
57
81
  * @type {Record<string, string>}
@@ -85,42 +109,119 @@ export default class PageMachine {
85
109
  */
86
110
  #revision = $state(0);
87
111
 
112
+ /**
113
+ * Map of state names to onEnter hook configurations
114
+ * @type {Record<string, {onEnter: Function}>}
115
+ */
116
+ #onEnterHooks = {};
117
+
118
+ /**
119
+ * Current state's onEnter handler (abort/complete functions)
120
+ * @type {{abort?: Function, complete?: Function} | null}
121
+ */
122
+ #currentOnEnterHandler = null;
123
+
124
+ /**
125
+ * Current state's done callback
126
+ * @type {Function | null}
127
+ */
128
+ #currentOnEnterDone = null;
129
+
130
+ /**
131
+ * Flag to prevent concurrent state transitions
132
+ * @type {boolean}
133
+ */
134
+ #isTransitioning = false;
135
+
88
136
  /**
89
137
  * Constructor
90
138
  *
91
- * @param {string} initialState - Initial state name
92
- * @param {Record<string, string>} routeMap - Map of states to route paths
93
- * @param {Record<string, any>} [initialData={}] - Initial data properties (from server)
139
+ * @param {Object} config - Configuration object
140
+ * @param {string} config.startPath
141
+ * Start path for this route group (e.g., '/game/play')
142
+ * @param {Record<string, string>} [config.routeMap={}]
143
+ * Map of states to route paths
144
+ * @param {Record<string, any>} [config.initialData={}]
145
+ * Initial data properties (from server)
146
+ * @param {Record<string, Function>} [config.onEnterHooks={}]
147
+ * Map of states to onEnter hook functions
94
148
  *
95
149
  * @example
96
150
  * ```javascript
97
- * const routeMap = {
98
- * [STATE_MATCH]: '/city/intro/match',
99
- * [STATE_CIRCUIT]: '/city/intro/racecircuit'
100
- * };
101
- *
102
- * const initialData = {
103
- * INTRO_COMPLETED: false,
104
- * PROFILE_SCORE: 0
105
- * };
106
- *
107
- * const machine = new CityIntroPageMachine(STATE_START, routeMap, initialData);
151
+ * const machine = new PageMachine({
152
+ * startPath: '/intro/start',
153
+ * routeMap: {
154
+ * [STATE_START]: '/intro/start',
155
+ * [STATE_ANIMATE]: '/intro/animate'
156
+ * },
157
+ * initialData: {
158
+ * INTRO_COMPLETED: false
159
+ * },
160
+ * onEnterHooks: {
161
+ * [STATE_ANIMATE]: (done) => {
162
+ * setTimeout(() => done(STATE_START), 1000);
163
+ * return {
164
+ * abort: () => clearTimeout(...),
165
+ * complete: () => done(STATE_START)
166
+ * };
167
+ * }
168
+ * }
169
+ * });
108
170
  * ```
109
171
  */
110
- constructor(initialState, routeMap = {}, initialData = {}) {
111
- this.#current = initialState;
172
+ constructor({ startPath, routeMap = {}, initialData = {}, onEnterHooks = {} }) {
173
+ if (!startPath) {
174
+ throw new Error('PageMachine requires startPath parameter');
175
+ }
176
+
177
+ this.#startPath = startPath;
112
178
  this.#routeMap = routeMap;
113
179
  this.#data = initialData;
180
+ this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
114
181
 
115
182
  // Build reverse map (path -> state)
116
183
  for (const [state, path] of Object.entries(routeMap)) {
117
184
  this.#pathToStateMap[path] = state;
118
185
  }
119
186
 
187
+ // Derive initial state from startPath
188
+ const initialState = this.#pathToStateMap[startPath];
189
+ if (!initialState) {
190
+ throw new Error(
191
+ `PageMachine: startPath "${startPath}" not found in routeMap`
192
+ );
193
+ }
194
+
195
+ this.#startState = initialState;
196
+ this.#current = initialState;
197
+
120
198
  // Mark initial state as visited
121
199
  this.#visitedStates.add(initialState);
122
200
  }
123
201
 
202
+ /**
203
+ * Normalize onEnterHooks to ensure consistent format
204
+ * Converts function to {onEnter: function} object
205
+ *
206
+ * @param {Record<string, Function|Object>} hooks - Raw hooks configuration
207
+ * @returns {Record<string, {onEnter: Function}>} Normalized hooks
208
+ */
209
+ #normalizeOnEnterHooks(hooks) {
210
+ const normalized = {};
211
+
212
+ for (const [state, hook] of Object.entries(hooks)) {
213
+ if (typeof hook === 'function') {
214
+ // Simple function -> wrap in object
215
+ normalized[state] = { onEnter: hook };
216
+ } else if (hook && typeof hook === 'object' && hook.onEnter) {
217
+ // Already an object with onEnter
218
+ normalized[state] = hook;
219
+ }
220
+ }
221
+
222
+ return normalized;
223
+ }
224
+
124
225
  /**
125
226
  * Synchronize machine state with URL path
126
227
  *
@@ -146,13 +247,66 @@ export default class PageMachine {
146
247
 
147
248
  /**
148
249
  * Set the current state directly
250
+ * Handles onEnter hooks and auto-transitions
149
251
  *
150
252
  * @param {string} newState - Target state
151
253
  */
152
- setState(newState) {
153
- if (newState !== this.#current) {
154
- this.#current = newState;
254
+ async setState(newState) {
255
+ if (newState === this.#current || this.#isTransitioning) {
256
+ return;
257
+ }
258
+
259
+ // Abort previous state's onEnter handler
260
+ if (this.#currentOnEnterHandler?.abort) {
261
+ this.#currentOnEnterHandler.abort();
262
+ }
263
+ this.#currentOnEnterHandler = null;
264
+ this.#currentOnEnterDone = null;
265
+
266
+ this.#isTransitioning = true;
267
+ this.#current = newState;
268
+ this.#visitedStates.add(newState);
269
+
270
+ // Check if this state has an onEnter hook
271
+ const hookConfig = this.#onEnterHooks[newState];
272
+ if (hookConfig?.onEnter) {
273
+ // Create done callback for auto-transition
274
+ let doneCalled = false;
275
+ const done = (nextState) => {
276
+ if (!doneCalled && nextState && nextState !== newState) {
277
+ doneCalled = true;
278
+ this.#isTransitioning = false;
279
+ this.setState(nextState);
280
+ }
281
+ };
282
+
283
+ this.#currentOnEnterDone = done;
284
+
285
+ // Call the onEnter hook
286
+ try {
287
+ const handler = hookConfig.onEnter(done);
288
+
289
+ // Store abort/complete handlers if provided
290
+ if (handler && typeof handler === 'object') {
291
+ if (handler.abort || handler.complete) {
292
+ this.#currentOnEnterHandler = {
293
+ abort: handler.abort,
294
+ complete: handler.complete
295
+ };
296
+ }
297
+ }
298
+
299
+ // If hook returned a promise, await it
300
+ if (handler?.then) {
301
+ await handler;
302
+ }
303
+ } catch (error) {
304
+ console.error(`Error in onEnter hook for state ${newState}:`, error);
305
+ }
155
306
  }
307
+
308
+ this.#isTransitioning = false;
309
+ this.#revision++;
156
310
  }
157
311
 
158
312
  /**
@@ -334,4 +488,148 @@ export default class PageMachine {
334
488
  this.#visitedStates.add(this.#current);
335
489
  this.#revision++;
336
490
  }
491
+
492
+ /* ===== Start Path Methods ===== */
493
+
494
+ /**
495
+ * Get the start path
496
+ *
497
+ * @returns {string} Start path
498
+ */
499
+ get startPath() {
500
+ return this.#startPath;
501
+ }
502
+
503
+ /**
504
+ * Get the start state
505
+ *
506
+ * @returns {string} Start state name
507
+ */
508
+ get startState() {
509
+ return this.#startState;
510
+ }
511
+
512
+ /**
513
+ * Check if the supplied path matches the start path
514
+ *
515
+ * @param {string} path - Path to check
516
+ *
517
+ * @returns {boolean} True if path matches start path
518
+ *
519
+ * @example
520
+ * ```javascript
521
+ * if (machine.isStartPath('/game/play')) {
522
+ * // User is on the start page
523
+ * }
524
+ * ```
525
+ */
526
+ isStartPath(path) {
527
+ return path === this.#startPath;
528
+ }
529
+
530
+ /**
531
+ * Check if currently on the start state
532
+ *
533
+ * @returns {boolean} True if current state is the start state
534
+ *
535
+ * @example
536
+ * ```javascript
537
+ * if (machine.isOnStartState) {
538
+ * // Show onboarding
539
+ * }
540
+ * ```
541
+ */
542
+ get isOnStartState() {
543
+ return this.#current === this.#startState;
544
+ }
545
+
546
+ /**
547
+ * Navigate to the start path
548
+ *
549
+ * @example
550
+ * ```javascript
551
+ * // Redirect user to start
552
+ * machine.redirectToStartPath();
553
+ * ```
554
+ */
555
+ redirectToStartPath() {
556
+ // Import dynamically to avoid circular dependencies
557
+ import('../../../util/sveltekit.js').then(({ switchToPage }) => {
558
+ switchToPage(this.#startPath);
559
+ });
560
+ }
561
+
562
+ /* ===== Transition Control Methods ===== */
563
+
564
+ /**
565
+ * Abort current state's transitions
566
+ * Cancels animations/operations immediately (incomplete state)
567
+ *
568
+ * @example
569
+ * ```javascript
570
+ * // User clicks "Cancel" button
571
+ * machine.abortTransitions();
572
+ * ```
573
+ */
574
+ abortTransitions() {
575
+ if (this.#currentOnEnterHandler?.abort) {
576
+ this.#currentOnEnterHandler.abort();
577
+ this.#currentOnEnterHandler = null;
578
+ this.#currentOnEnterDone = null;
579
+ this.#revision++;
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Complete current state's transitions immediately
585
+ * Fast-forwards animations/operations to completion (complete state)
586
+ *
587
+ * @example
588
+ * ```javascript
589
+ * // User clicks "Skip" or "Next" button
590
+ * machine.completeTransitions();
591
+ * ```
592
+ */
593
+ completeTransitions() {
594
+ if (this.#currentOnEnterHandler?.complete) {
595
+ this.#currentOnEnterHandler.complete();
596
+ this.#currentOnEnterHandler = null;
597
+ this.#currentOnEnterDone = null;
598
+ this.#revision++;
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Check if current state has transitions that can be completed
604
+ *
605
+ * @returns {boolean} True if completeTransitions() can be called
606
+ *
607
+ * @example
608
+ * ```svelte
609
+ * {#if machine.canCompleteTransitions}
610
+ * <button onclick={() => machine.completeTransitions()}>Skip</button>
611
+ * {/if}
612
+ * ```
613
+ */
614
+ get canCompleteTransitions() {
615
+ this.#revision; // Ensure reactivity
616
+ return !!this.#currentOnEnterHandler?.complete;
617
+ }
618
+
619
+ /**
620
+ * Check if current state has transitions that can be aborted
621
+ *
622
+ * @returns {boolean} True if abortTransitions() can be called
623
+ *
624
+ * @example
625
+ * ```svelte
626
+ * {#if machine.canAbortTransitions}
627
+ * <button onclick={() => machine.abortTransitions()}>Cancel</button>
628
+ * {/if}
629
+ * ```
630
+ */
631
+ get canAbortTransitions() {
632
+ this.#revision; // Ensure reactivity
633
+ return !!this.#currentOnEnterHandler?.abort;
634
+ }
337
635
  }