@hkdigital/lib-core 0.5.56 → 0.5.58

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.
@@ -1,99 +1,70 @@
1
1
  /**
2
- * Base class for page state machines with URL route mapping
2
+ * Route-aware data manager for page groups
3
3
  *
4
- * Simple state tracker that maps states to URL routes.
5
- * Does NOT enforce FSM transitions - allows free navigation
6
- * (because users can navigate to any URL via browser).
4
+ * Tracks navigation within a route group and manages business/domain data.
5
+ * Does NOT enforce transitions - allows free navigation via browser.
7
6
  *
8
7
  * Features:
9
- * - State-to-route mapping and sync
8
+ * - Current route tracking and synchronization
10
9
  * - Start path management
11
10
  * - Data properties for business/domain state
12
- * - Visited states tracking
13
- * - onEnter hooks with abort/complete handlers for animations
11
+ * - Visited routes tracking
14
12
  *
15
13
  * Basic usage:
16
14
  * ```javascript
17
15
  * const machine = new PageMachine({
18
16
  * startPath: '/intro/start',
19
- * routeMap: {
20
- * [STATE_START]: '/intro/start',
21
- * [STATE_PROFILE]: '/intro/profile'
22
- * }
17
+ * routes: ['/intro/start', '/intro/profile', '/intro/complete']
23
18
  * });
24
19
  *
25
- * // Sync machine state with URL changes
20
+ * // Sync machine with URL changes
26
21
  * $effect(() => {
27
22
  * machine.syncFromPath($page.url.pathname);
28
23
  * });
24
+ *
25
+ * // Check current route
26
+ * if (machine.current === '/intro/profile') {
27
+ * // Do something
28
+ * }
29
29
  * ```
30
30
  *
31
- * With onEnter hooks (for animations):
31
+ * Animations and page-specific logic should use $effect in pages:
32
32
  * ```javascript
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));
33
+ * // In +page.svelte
34
+ * const animations = new PageAnimations();
43
35
  *
44
- * return {
45
- * abort: () => animation.cancel(),
46
- * complete: () => animation.finish()
47
- * };
48
- * }
36
+ * $effect(() => {
37
+ * if (someCondition) {
38
+ * animations.start();
49
39
  * }
50
40
  * });
51
- *
52
- * // Fast-forward animation
53
- * machine.completeTransitions();
54
- *
55
- * // Cancel animation
56
- * machine.abortTransitions();
57
41
  * ```
58
42
  */
59
- import { switchToPage } from '../../../util/sveltekit.js';
60
-
61
43
  export default class PageMachine {
62
44
  /**
63
- * Logger instance for state machine
45
+ * Logger instance
64
46
  * @type {import('../../../logging/client.js').Logger}
65
47
  */
66
48
  logger;
49
+
67
50
  /**
68
- * Current state
51
+ * Current route path
69
52
  * @type {string}
70
53
  */
71
54
  // @ts-ignore
72
55
  #current = $state();
73
56
 
74
57
  /**
75
- * Start path for this page machine
58
+ * Start path for this route group
76
59
  * @type {string}
77
60
  */
78
61
  #startPath = '';
79
62
 
80
63
  /**
81
- * Initial/start state (derived from startPath)
82
- * @type {string}
83
- */
84
- #startState = '';
85
-
86
- /**
87
- * Map of states to route paths
88
- * @type {Record<string, string>}
64
+ * Optional list of valid routes for this group
65
+ * @type {string[]}
89
66
  */
90
- #routeMap = {};
91
-
92
- /**
93
- * Reverse map of route paths to states
94
- * @type {Record<string, string>}
95
- */
96
- #pathToStateMap = {};
67
+ #routes = [];
97
68
 
98
69
  /**
99
70
  * Data properties for business/domain state
@@ -103,12 +74,12 @@ export default class PageMachine {
103
74
  #data = $state({});
104
75
 
105
76
  /**
106
- * Track which states have been visited during this session
77
+ * Track which routes have been visited during this session
107
78
  * Useful for showing first-time hints/tips
108
79
  * @type {Set<string>}
109
80
  */
110
81
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
111
- #visitedStates = new Set();
82
+ #visitedRoutes = new Set();
112
83
 
113
84
  /**
114
85
  * Revision counter for triggering reactivity
@@ -116,150 +87,68 @@ export default class PageMachine {
116
87
  */
117
88
  #revision = $state(0);
118
89
 
119
- /**
120
- * Map of state names to onEnter hook configurations
121
- * @type {Record<string, {onEnter: Function}>}
122
- */
123
- #onEnterHooks = {};
124
-
125
- /**
126
- * Current state's onEnter handler (abort/complete functions)
127
- * @type {{abort?: Function, complete?: Function} | null}
128
- */
129
- #currentOnEnterHandler = null;
130
-
131
- /**
132
- * Current state's done callback
133
- * @type {Function | null}
134
- */
135
- #currentOnEnterDone = null;
136
-
137
- /**
138
- * Flag to prevent concurrent state transitions
139
- * @type {boolean}
140
- */
141
- #isTransitioning = false;
142
-
143
90
  /**
144
91
  * Constructor
145
92
  *
146
93
  * @param {Object} config - Configuration object
147
94
  * @param {string} config.startPath
148
95
  * Start path for this route group (e.g., '/game/play')
149
- * @param {Record<string, string>} [config.routeMap={}]
150
- * Map of states to route paths
96
+ * @param {string[]} [config.routes=[]]
97
+ * Optional list of valid routes (for validation/dev tools)
151
98
  * @param {Record<string, any>} [config.initialData={}]
152
99
  * Initial data properties (from server)
153
- * @param {Record<string, Function>} [config.onEnterHooks={}]
154
- * Map of states to onEnter hook functions
155
100
  * @param {import('../../../logging/client.js').Logger} [config.logger]
156
- * Logger instance (optional, if not provided no logging occurs)
101
+ * Logger instance (optional)
157
102
  *
158
103
  * @example
159
104
  * ```javascript
160
105
  * const machine = new PageMachine({
161
106
  * startPath: '/intro/start',
162
- * routeMap: {
163
- * [STATE_START]: '/intro/start',
164
- * [STATE_ANIMATE]: '/intro/animate'
165
- * },
107
+ * routes: ['/intro/start', '/intro/profile'],
166
108
  * initialData: {
167
109
  * INTRO_COMPLETED: false
168
- * },
169
- * onEnterHooks: {
170
- * [STATE_ANIMATE]: (done) => {
171
- * setTimeout(() => done(STATE_START), 1000);
172
- * return {
173
- * abort: () => clearTimeout(...),
174
- * complete: () => done(STATE_START)
175
- * };
176
- * }
177
110
  * }
178
111
  * });
179
112
  * ```
180
113
  */
181
- constructor({
182
- startPath,
183
- routeMap = {},
184
- initialData = {},
185
- onEnterHooks = {},
186
- logger = null
187
- }) {
114
+ constructor({ startPath, routes = [], initialData = {}, logger = null }) {
188
115
  if (!startPath) {
189
116
  throw new Error('PageMachine requires startPath parameter');
190
117
  }
191
118
 
192
119
  this.logger = logger;
193
120
  this.#startPath = startPath;
194
- this.#routeMap = routeMap;
121
+ this.#routes = routes;
195
122
  this.#data = initialData;
196
- this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
197
-
198
- // Build reverse map (path -> state)
199
- for (const [state, path] of Object.entries(routeMap)) {
200
- this.#pathToStateMap[path] = state;
201
- }
202
-
203
- // Derive initial state from startPath
204
- const initialState = this.#pathToStateMap[startPath];
205
- if (!initialState) {
206
- throw new Error(
207
- `PageMachine: startPath "${startPath}" not found in routeMap`
208
- );
209
- }
123
+ this.#current = startPath;
210
124
 
211
- this.#startState = initialState;
212
- this.#current = initialState;
213
-
214
- // Mark initial state as visited
215
- this.#visitedStates.add(initialState);
125
+ // Mark start path as visited
126
+ this.#visitedRoutes.add(startPath);
216
127
  }
217
128
 
218
129
  /**
219
- * Normalize onEnterHooks to ensure consistent format
220
- * Converts function to {onEnter: function} object
130
+ * Synchronize machine with URL path
221
131
  *
222
- * @param {Record<string, Function|{onEnter: Function}>} hooks - Raw hooks configuration
223
- * @returns {Record<string, {onEnter: Function}>} Normalized hooks
224
- */
225
- #normalizeOnEnterHooks(hooks) {
226
- /** @type {Record<string, {onEnter: Function} */
227
- const normalized = {};
228
-
229
- for (const [state, hook] of Object.entries(hooks)) {
230
- if (typeof hook === 'function') {
231
- // Simple function -> wrap in object
232
- normalized[state] = { onEnter: hook };
233
- } else if (hook?.onEnter) {
234
- // Already an object with onEnter
235
- normalized[state] = hook;
236
- }
237
- }
238
-
239
- return normalized;
240
- }
241
-
242
- /**
243
- * Synchronize machine state with URL path
244
- *
245
- * Call this in a $effect that watches $page.url.pathname
246
- * Automatically tracks visited states and triggers onEnter hooks
132
+ * Call this in a $effect that watches $page.url.pathname.
133
+ * Automatically tracks visited routes.
247
134
  *
248
135
  * @param {string} currentPath - Current URL pathname
249
136
  *
250
- * @returns {boolean} True if state was changed
137
+ * @returns {boolean} True if route was changed
251
138
  */
252
139
  syncFromPath(currentPath) {
253
- const targetState = this.#getStateFromPath(currentPath);
140
+ // Find matching route
141
+ const matchedRoute = this.#findMatchingRoute(currentPath);
142
+
143
+ if (matchedRoute && matchedRoute !== this.#current) {
144
+ this.logger?.debug(`syncFromPath: ${currentPath} → ${matchedRoute}`);
254
145
 
255
- if (targetState && targetState !== this.#current) {
256
- // Log state transition from URL sync
257
- this.logger?.debug(
258
- `syncFromPath: ${currentPath} → targetState: ${targetState}`
259
- );
146
+ const oldRoute = this.#current;
147
+ this.#current = matchedRoute;
148
+ this.#visitedRoutes.add(matchedRoute);
149
+ this.#revision++;
260
150
 
261
- // Use #setState to handle onEnter hooks
262
- this.#setState(targetState);
151
+ this.logger?.debug(`Route changed: ${oldRoute} ${matchedRoute}`);
263
152
 
264
153
  return true;
265
154
  }
@@ -268,154 +157,50 @@ export default class PageMachine {
268
157
  }
269
158
 
270
159
  /**
271
- * Set the current state directly (internal use only)
272
- * Handles onEnter hooks and auto-transitions
273
- *
274
- * Note: This is private to enforce URL-first navigation.
275
- * To change state, navigate to the URL using switchToPage()
276
- * and let syncFromPath() update the state.
277
- *
278
- * @param {string} newState - Target state
279
- */
280
- async #setState(newState) {
281
- if (newState === this.#current || this.#isTransitioning) {
282
- return;
283
- }
284
-
285
- // Abort previous state's onEnter handler
286
- if (this.#currentOnEnterHandler?.abort) {
287
- this.#currentOnEnterHandler.abort();
288
- }
289
- this.#currentOnEnterHandler = null;
290
- this.#currentOnEnterDone = null;
291
-
292
- const oldState = this.#current;
293
- this.#isTransitioning = true;
294
- this.#current = newState;
295
- this.#visitedStates.add(newState);
296
-
297
- // Log state transition
298
- this.logger?.debug(`setState: ${oldState} → ${newState}`);
299
-
300
- // Check if this state has an onEnter hook
301
- const hookConfig = this.#onEnterHooks[newState];
302
- if (hookConfig?.onEnter) {
303
- // Create done callback for auto-transition
304
- let doneCalled = false;
305
-
306
- const done = (/** @type {string} */ nextState) => {
307
- if (!doneCalled && nextState && nextState !== newState) {
308
- doneCalled = true;
309
- this.#isTransitioning = false;
310
- this.#setState(nextState);
311
- }
312
- };
313
-
314
- this.#currentOnEnterDone = done;
315
-
316
- // Call the onEnter hook
317
- try {
318
- const handler = hookConfig.onEnter(done);
319
-
320
- // Store abort/complete handlers if provided
321
- if (handler && typeof handler === 'object') {
322
- if (handler.abort || handler.complete) {
323
- this.#currentOnEnterHandler = {
324
- abort: handler.abort,
325
- complete: handler.complete
326
- };
327
- }
328
- }
329
-
330
- // If hook returned a promise, await it
331
- if (handler?.then) {
332
- await handler;
333
- }
334
- } catch (error) {
335
- const logger = this.logger ?? console;
336
- logger.error(`Error in onEnter hook for state ${newState}:`, error);
337
- }
338
- }
339
-
340
- this.#isTransitioning = false;
341
- this.#revision++;
342
- }
343
-
344
- /**
345
- * Get state name from URL path
160
+ * Find matching route from path
346
161
  *
347
162
  * @param {string} path - URL pathname
348
163
  *
349
- * @returns {string|null} State name or null
164
+ * @returns {string|null} Matched route or null
350
165
  */
351
- #getStateFromPath(path) {
352
- // Try exact match first
353
- if (this.#pathToStateMap[path]) {
354
- return this.#pathToStateMap[path];
355
- }
356
-
357
- // Try partial match (path includes route)
358
- for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
359
- if (path.includes(routePath)) {
360
- return state;
166
+ #findMatchingRoute(path) {
167
+ // If routes list provided, try to match against it
168
+ if (this.#routes.length > 0) {
169
+ // Try exact match first
170
+ if (this.#routes.includes(path)) {
171
+ return path;
361
172
  }
362
- }
363
-
364
- return null;
365
- }
366
173
 
367
- /**
368
- * Get route path for a given state
369
- *
370
- * @param {string} state - State name
371
- *
372
- * @returns {string} Route path or null if no mapping
373
- */
374
- getPathForState(state) {
375
- const path = this.#routeMap[state];
174
+ // Try partial match (path starts with route)
175
+ for (const route of this.#routes) {
176
+ if (path.startsWith(route)) {
177
+ return route;
178
+ }
179
+ }
376
180
 
377
- if (!path) {
378
- throw new Error(`No path found for state [${state}]`);
181
+ return null;
379
182
  }
380
183
 
184
+ // No routes list - accept any path
381
185
  return path;
382
186
  }
383
187
 
384
188
  /**
385
- * Navigate to the route path for a given state
386
- *
387
- * @param {string} state - State name
388
- */
389
- navigateToState(state) {
390
- const path = this.getPathForState(state);
391
- switchToPage(path);
392
- }
393
-
394
- /**
395
- * Get route path for current state
189
+ * Get current route
396
190
  *
397
- * @returns {string|null} Route path or null if no mapping
398
- */
399
- getCurrentPath() {
400
- return this.getPathForState(this.#current);
401
- }
402
-
403
- /**
404
- * Get current state
405
- *
406
- * @returns {string} Current state name
191
+ * @returns {string} Current route path
407
192
  */
408
193
  get current() {
409
194
  return this.#current;
410
195
  }
411
196
 
412
197
  /**
413
- * Get the route map
198
+ * Get the routes list
414
199
  *
415
- * @returns {Record<string, string>} Copy of route map
200
+ * @returns {string[]} Copy of routes list
416
201
  */
417
- get routeMap() {
418
- return { ...this.#routeMap };
202
+ get routes() {
203
+ return [...this.#routes];
419
204
  }
420
205
 
421
206
  /* ===== Data Properties (Business/Domain State) ===== */
@@ -476,7 +261,8 @@ export default class PageMachine {
476
261
  /**
477
262
  * Update multiple data properties at once
478
263
  *
479
- * @param {Record<string, any>} dataUpdates - Object with key-value pairs
264
+ * @param {Record<string, any>} dataUpdates
265
+ * Object with key-value pairs
480
266
  *
481
267
  * @example
482
268
  * ```javascript
@@ -494,32 +280,32 @@ export default class PageMachine {
494
280
  this.#revision++;
495
281
  }
496
282
 
497
- /* ===== Visited States Tracking ===== */
283
+ /* ===== Visited Routes Tracking ===== */
498
284
 
499
285
  /**
500
- * Check if a state has been visited
286
+ * Check if a route has been visited
501
287
  *
502
- * @param {string} state - State name to check
288
+ * @param {string} route - Route path to check
503
289
  *
504
- * @returns {boolean} True if the state has been visited
290
+ * @returns {boolean} True if the route has been visited
505
291
  *
506
292
  * @example
507
293
  * ```javascript
508
- * if (machine.hasVisited(STATE_PROFILE)) {
294
+ * if (machine.hasVisited('/intro/profile')) {
509
295
  * // User has seen profile page, skip intro
510
296
  * }
511
297
  * ```
512
298
  */
513
- hasVisited(state) {
299
+ hasVisited(route) {
514
300
  // Access revision to ensure reactivity
515
301
  this.#revision;
516
- return this.#visitedStates.has(state);
302
+ return this.#visitedRoutes.has(route);
517
303
  }
518
304
 
519
305
  /**
520
- * Check if the start state has been visited
306
+ * Check if the start route has been visited
521
307
  *
522
- * @returns {boolean} True if the start state has been visited
308
+ * @returns {boolean} True if the start route has been visited
523
309
  *
524
310
  * @example
525
311
  * ```javascript
@@ -529,27 +315,27 @@ export default class PageMachine {
529
315
  * ```
530
316
  */
531
317
  get hasVisitedStart() {
532
- return this.hasVisited(this.#startState);
318
+ return this.hasVisited(this.#startPath);
533
319
  }
534
320
 
535
321
  /**
536
- * Get all visited states
322
+ * Get all visited routes
537
323
  *
538
- * @returns {string[]} Array of visited state names
324
+ * @returns {string[]} Array of visited route paths
539
325
  */
540
- getVisitedStates() {
326
+ getVisitedRoutes() {
541
327
  // Access revision to ensure reactivity
542
328
  this.#revision;
543
- return Array.from(this.#visitedStates);
329
+ return Array.from(this.#visitedRoutes);
544
330
  }
545
331
 
546
332
  /**
547
- * Reset visited states tracking
333
+ * Reset visited routes tracking
548
334
  * Useful for testing or resetting experience
549
335
  */
550
- resetVisitedStates() {
551
- this.#visitedStates.clear();
552
- this.#visitedStates.add(this.#current);
336
+ resetVisitedRoutes() {
337
+ this.#visitedRoutes.clear();
338
+ this.#visitedRoutes.add(this.#current);
553
339
  this.#revision++;
554
340
  }
555
341
 
@@ -564,15 +350,6 @@ export default class PageMachine {
564
350
  return this.#startPath;
565
351
  }
566
352
 
567
- /**
568
- * Get the start state
569
- *
570
- * @returns {string} Start state name
571
- */
572
- get startState() {
573
- return this.#startState;
574
- }
575
-
576
353
  /**
577
354
  * Check if the supplied path matches the start path
578
355
  *
@@ -592,19 +369,19 @@ export default class PageMachine {
592
369
  }
593
370
 
594
371
  /**
595
- * Check if currently on the start state
372
+ * Check if currently on the start path
596
373
  *
597
- * @returns {boolean} True if current state is the start state
374
+ * @returns {boolean} True if current route is the start path
598
375
  *
599
376
  * @example
600
377
  * ```javascript
601
- * if (machine.isOnStartState) {
378
+ * if (machine.isOnStartPath) {
602
379
  * // Show onboarding
603
380
  * }
604
381
  * ```
605
382
  */
606
- get isOnStartState() {
607
- return this.#current === this.#startState;
383
+ get isOnStartPath() {
384
+ return this.#current === this.#startPath;
608
385
  }
609
386
 
610
387
  /**
@@ -622,78 +399,4 @@ export default class PageMachine {
622
399
  switchToPage(this.#startPath);
623
400
  });
624
401
  }
625
-
626
- /* ===== Transition Control Methods ===== */
627
-
628
- /**
629
- * Abort current state's transitions
630
- * Cancels animations/operations immediately (incomplete state)
631
- *
632
- * @example
633
- * ```javascript
634
- * // User clicks "Cancel" button
635
- * machine.abortTransitions();
636
- * ```
637
- */
638
- abortTransitions() {
639
- if (this.#currentOnEnterHandler?.abort) {
640
- this.#currentOnEnterHandler.abort();
641
- this.#currentOnEnterHandler = null;
642
- this.#currentOnEnterDone = null;
643
- this.#revision++;
644
- }
645
- }
646
-
647
- /**
648
- * Complete current state's transitions immediately
649
- * Fast-forwards animations/operations to completion (complete state)
650
- *
651
- * @example
652
- * ```javascript
653
- * // User clicks "Skip" or "Next" button
654
- * machine.completeTransitions();
655
- * ```
656
- */
657
- completeTransitions() {
658
- if (this.#currentOnEnterHandler?.complete) {
659
- this.#currentOnEnterHandler.complete();
660
- this.#currentOnEnterHandler = null;
661
- this.#currentOnEnterDone = null;
662
- this.#revision++;
663
- }
664
- }
665
-
666
- /**
667
- * Check if current state has transitions that can be completed
668
- *
669
- * @returns {boolean} True if completeTransitions() can be called
670
- *
671
- * @example
672
- * ```svelte
673
- * {#if machine.canCompleteTransitions}
674
- * <button onclick={() => machine.completeTransitions()}>Skip</button>
675
- * {/if}
676
- * ```
677
- */
678
- get canCompleteTransitions() {
679
- this.#revision; // Ensure reactivity
680
- return !!this.#currentOnEnterHandler?.complete;
681
- }
682
-
683
- /**
684
- * Check if current state has transitions that can be aborted
685
- *
686
- * @returns {boolean} True if abortTransitions() can be called
687
- *
688
- * @example
689
- * ```svelte
690
- * {#if machine.canAbortTransitions}
691
- * <button onclick={() => machine.abortTransitions()}>Cancel</button>
692
- * {/if}
693
- * ```
694
- */
695
- get canAbortTransitions() {
696
- this.#revision; // Ensure reactivity
697
- return !!this.#currentOnEnterHandler?.abort;
698
- }
699
402
  }