@hkdigital/lib-core 0.5.57 → 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,159 +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) and validate no duplicates
199
- for (const [state, path] of Object.entries(routeMap)) {
200
- // Check if this path is already mapped to a different state
201
- const existingState = this.#pathToStateMap[path];
202
- if (existingState && existingState !== state) {
203
- throw new Error(
204
- `PageMachine: Duplicate route mapping detected. ` +
205
- `Path "${path}" is mapped to both "${existingState}" and "${state}". ` +
206
- `Each route path must map to exactly one state.`
207
- );
208
- }
209
- this.#pathToStateMap[path] = state;
210
- }
211
-
212
- // Derive initial state from startPath
213
- const initialState = this.#pathToStateMap[startPath];
214
- if (!initialState) {
215
- throw new Error(
216
- `PageMachine: startPath "${startPath}" not found in routeMap`
217
- );
218
- }
123
+ this.#current = startPath;
219
124
 
220
- this.#startState = initialState;
221
- this.#current = initialState;
222
-
223
- // Mark initial state as visited
224
- this.#visitedStates.add(initialState);
125
+ // Mark start path as visited
126
+ this.#visitedRoutes.add(startPath);
225
127
  }
226
128
 
227
129
  /**
228
- * Normalize onEnterHooks to ensure consistent format
229
- * Converts function to {onEnter: function} object
130
+ * Synchronize machine with URL path
230
131
  *
231
- * @param {Record<string, Function|{onEnter: Function}>} hooks - Raw hooks configuration
232
- * @returns {Record<string, {onEnter: Function}>} Normalized hooks
233
- */
234
- #normalizeOnEnterHooks(hooks) {
235
- /** @type {Record<string, {onEnter: Function} */
236
- const normalized = {};
237
-
238
- for (const [state, hook] of Object.entries(hooks)) {
239
- if (typeof hook === 'function') {
240
- // Simple function -> wrap in object
241
- normalized[state] = { onEnter: hook };
242
- } else if (hook?.onEnter) {
243
- // Already an object with onEnter
244
- normalized[state] = hook;
245
- }
246
- }
247
-
248
- return normalized;
249
- }
250
-
251
- /**
252
- * Synchronize machine state with URL path
253
- *
254
- * Call this in a $effect that watches $page.url.pathname
255
- * Automatically tracks visited states and triggers onEnter hooks
132
+ * Call this in a $effect that watches $page.url.pathname.
133
+ * Automatically tracks visited routes.
256
134
  *
257
135
  * @param {string} currentPath - Current URL pathname
258
136
  *
259
- * @returns {boolean} True if state was changed
137
+ * @returns {boolean} True if route was changed
260
138
  */
261
139
  syncFromPath(currentPath) {
262
- 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}`);
263
145
 
264
- if (targetState && targetState !== this.#current) {
265
- // Log state transition from URL sync
266
- this.logger?.debug(
267
- `syncFromPath: ${currentPath} → targetState: ${targetState}`
268
- );
146
+ const oldRoute = this.#current;
147
+ this.#current = matchedRoute;
148
+ this.#visitedRoutes.add(matchedRoute);
149
+ this.#revision++;
269
150
 
270
- // Use #setState to handle onEnter hooks
271
- this.#setState(targetState);
151
+ this.logger?.debug(`Route changed: ${oldRoute} ${matchedRoute}`);
272
152
 
273
153
  return true;
274
154
  }
@@ -277,154 +157,50 @@ export default class PageMachine {
277
157
  }
278
158
 
279
159
  /**
280
- * Set the current state directly (internal use only)
281
- * Handles onEnter hooks and auto-transitions
282
- *
283
- * Note: This is private to enforce URL-first navigation.
284
- * To change state, navigate to the URL using switchToPage()
285
- * and let syncFromPath() update the state.
286
- *
287
- * @param {string} newState - Target state
288
- */
289
- async #setState(newState) {
290
- if (newState === this.#current || this.#isTransitioning) {
291
- return;
292
- }
293
-
294
- // Abort previous state's onEnter handler
295
- if (this.#currentOnEnterHandler?.abort) {
296
- this.#currentOnEnterHandler.abort();
297
- }
298
- this.#currentOnEnterHandler = null;
299
- this.#currentOnEnterDone = null;
300
-
301
- const oldState = this.#current;
302
- this.#isTransitioning = true;
303
- this.#current = newState;
304
- this.#visitedStates.add(newState);
305
-
306
- // Log state transition
307
- this.logger?.debug(`setState: ${oldState} → ${newState}`);
308
-
309
- // Check if this state has an onEnter hook
310
- const hookConfig = this.#onEnterHooks[newState];
311
- if (hookConfig?.onEnter) {
312
- // Create done callback for auto-transition
313
- let doneCalled = false;
314
-
315
- const done = (/** @type {string} */ nextState) => {
316
- if (!doneCalled && nextState && nextState !== newState) {
317
- doneCalled = true;
318
- this.#isTransitioning = false;
319
- this.#setState(nextState);
320
- }
321
- };
322
-
323
- this.#currentOnEnterDone = done;
324
-
325
- // Call the onEnter hook
326
- try {
327
- const handler = hookConfig.onEnter(done);
328
-
329
- // Store abort/complete handlers if provided
330
- if (handler && typeof handler === 'object') {
331
- if (handler.abort || handler.complete) {
332
- this.#currentOnEnterHandler = {
333
- abort: handler.abort,
334
- complete: handler.complete
335
- };
336
- }
337
- }
338
-
339
- // If hook returned a promise, await it
340
- if (handler?.then) {
341
- await handler;
342
- }
343
- } catch (error) {
344
- const logger = this.logger ?? console;
345
- logger.error(`Error in onEnter hook for state ${newState}:`, error);
346
- }
347
- }
348
-
349
- this.#isTransitioning = false;
350
- this.#revision++;
351
- }
352
-
353
- /**
354
- * Get state name from URL path
160
+ * Find matching route from path
355
161
  *
356
162
  * @param {string} path - URL pathname
357
163
  *
358
- * @returns {string|null} State name or null
164
+ * @returns {string|null} Matched route or null
359
165
  */
360
- #getStateFromPath(path) {
361
- // Try exact match first
362
- if (this.#pathToStateMap[path]) {
363
- return this.#pathToStateMap[path];
364
- }
365
-
366
- // Try partial match (path includes route)
367
- for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
368
- if (path.includes(routePath)) {
369
- 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;
370
172
  }
371
- }
372
-
373
- return null;
374
- }
375
173
 
376
- /**
377
- * Get route path for a given state
378
- *
379
- * @param {string} state - State name
380
- *
381
- * @returns {string} Route path or null if no mapping
382
- */
383
- getPathForState(state) {
384
- 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
+ }
385
180
 
386
- if (!path) {
387
- throw new Error(`No path found for state [${state}]`);
181
+ return null;
388
182
  }
389
183
 
184
+ // No routes list - accept any path
390
185
  return path;
391
186
  }
392
187
 
393
188
  /**
394
- * Navigate to the route path for a given state
395
- *
396
- * @param {string} state - State name
397
- */
398
- navigateToState(state) {
399
- const path = this.getPathForState(state);
400
- switchToPage(path);
401
- }
402
-
403
- /**
404
- * Get route path for current state
189
+ * Get current route
405
190
  *
406
- * @returns {string|null} Route path or null if no mapping
407
- */
408
- getCurrentPath() {
409
- return this.getPathForState(this.#current);
410
- }
411
-
412
- /**
413
- * Get current state
414
- *
415
- * @returns {string} Current state name
191
+ * @returns {string} Current route path
416
192
  */
417
193
  get current() {
418
194
  return this.#current;
419
195
  }
420
196
 
421
197
  /**
422
- * Get the route map
198
+ * Get the routes list
423
199
  *
424
- * @returns {Record<string, string>} Copy of route map
200
+ * @returns {string[]} Copy of routes list
425
201
  */
426
- get routeMap() {
427
- return { ...this.#routeMap };
202
+ get routes() {
203
+ return [...this.#routes];
428
204
  }
429
205
 
430
206
  /* ===== Data Properties (Business/Domain State) ===== */
@@ -485,7 +261,8 @@ export default class PageMachine {
485
261
  /**
486
262
  * Update multiple data properties at once
487
263
  *
488
- * @param {Record<string, any>} dataUpdates - Object with key-value pairs
264
+ * @param {Record<string, any>} dataUpdates
265
+ * Object with key-value pairs
489
266
  *
490
267
  * @example
491
268
  * ```javascript
@@ -503,32 +280,32 @@ export default class PageMachine {
503
280
  this.#revision++;
504
281
  }
505
282
 
506
- /* ===== Visited States Tracking ===== */
283
+ /* ===== Visited Routes Tracking ===== */
507
284
 
508
285
  /**
509
- * Check if a state has been visited
286
+ * Check if a route has been visited
510
287
  *
511
- * @param {string} state - State name to check
288
+ * @param {string} route - Route path to check
512
289
  *
513
- * @returns {boolean} True if the state has been visited
290
+ * @returns {boolean} True if the route has been visited
514
291
  *
515
292
  * @example
516
293
  * ```javascript
517
- * if (machine.hasVisited(STATE_PROFILE)) {
294
+ * if (machine.hasVisited('/intro/profile')) {
518
295
  * // User has seen profile page, skip intro
519
296
  * }
520
297
  * ```
521
298
  */
522
- hasVisited(state) {
299
+ hasVisited(route) {
523
300
  // Access revision to ensure reactivity
524
301
  this.#revision;
525
- return this.#visitedStates.has(state);
302
+ return this.#visitedRoutes.has(route);
526
303
  }
527
304
 
528
305
  /**
529
- * Check if the start state has been visited
306
+ * Check if the start route has been visited
530
307
  *
531
- * @returns {boolean} True if the start state has been visited
308
+ * @returns {boolean} True if the start route has been visited
532
309
  *
533
310
  * @example
534
311
  * ```javascript
@@ -538,27 +315,27 @@ export default class PageMachine {
538
315
  * ```
539
316
  */
540
317
  get hasVisitedStart() {
541
- return this.hasVisited(this.#startState);
318
+ return this.hasVisited(this.#startPath);
542
319
  }
543
320
 
544
321
  /**
545
- * Get all visited states
322
+ * Get all visited routes
546
323
  *
547
- * @returns {string[]} Array of visited state names
324
+ * @returns {string[]} Array of visited route paths
548
325
  */
549
- getVisitedStates() {
326
+ getVisitedRoutes() {
550
327
  // Access revision to ensure reactivity
551
328
  this.#revision;
552
- return Array.from(this.#visitedStates);
329
+ return Array.from(this.#visitedRoutes);
553
330
  }
554
331
 
555
332
  /**
556
- * Reset visited states tracking
333
+ * Reset visited routes tracking
557
334
  * Useful for testing or resetting experience
558
335
  */
559
- resetVisitedStates() {
560
- this.#visitedStates.clear();
561
- this.#visitedStates.add(this.#current);
336
+ resetVisitedRoutes() {
337
+ this.#visitedRoutes.clear();
338
+ this.#visitedRoutes.add(this.#current);
562
339
  this.#revision++;
563
340
  }
564
341
 
@@ -573,15 +350,6 @@ export default class PageMachine {
573
350
  return this.#startPath;
574
351
  }
575
352
 
576
- /**
577
- * Get the start state
578
- *
579
- * @returns {string} Start state name
580
- */
581
- get startState() {
582
- return this.#startState;
583
- }
584
-
585
353
  /**
586
354
  * Check if the supplied path matches the start path
587
355
  *
@@ -601,19 +369,19 @@ export default class PageMachine {
601
369
  }
602
370
 
603
371
  /**
604
- * Check if currently on the start state
372
+ * Check if currently on the start path
605
373
  *
606
- * @returns {boolean} True if current state is the start state
374
+ * @returns {boolean} True if current route is the start path
607
375
  *
608
376
  * @example
609
377
  * ```javascript
610
- * if (machine.isOnStartState) {
378
+ * if (machine.isOnStartPath) {
611
379
  * // Show onboarding
612
380
  * }
613
381
  * ```
614
382
  */
615
- get isOnStartState() {
616
- return this.#current === this.#startState;
383
+ get isOnStartPath() {
384
+ return this.#current === this.#startPath;
617
385
  }
618
386
 
619
387
  /**
@@ -631,78 +399,4 @@ export default class PageMachine {
631
399
  switchToPage(this.#startPath);
632
400
  });
633
401
  }
634
-
635
- /* ===== Transition Control Methods ===== */
636
-
637
- /**
638
- * Abort current state's transitions
639
- * Cancels animations/operations immediately (incomplete state)
640
- *
641
- * @example
642
- * ```javascript
643
- * // User clicks "Cancel" button
644
- * machine.abortTransitions();
645
- * ```
646
- */
647
- abortTransitions() {
648
- if (this.#currentOnEnterHandler?.abort) {
649
- this.#currentOnEnterHandler.abort();
650
- this.#currentOnEnterHandler = null;
651
- this.#currentOnEnterDone = null;
652
- this.#revision++;
653
- }
654
- }
655
-
656
- /**
657
- * Complete current state's transitions immediately
658
- * Fast-forwards animations/operations to completion (complete state)
659
- *
660
- * @example
661
- * ```javascript
662
- * // User clicks "Skip" or "Next" button
663
- * machine.completeTransitions();
664
- * ```
665
- */
666
- completeTransitions() {
667
- if (this.#currentOnEnterHandler?.complete) {
668
- this.#currentOnEnterHandler.complete();
669
- this.#currentOnEnterHandler = null;
670
- this.#currentOnEnterDone = null;
671
- this.#revision++;
672
- }
673
- }
674
-
675
- /**
676
- * Check if current state has transitions that can be completed
677
- *
678
- * @returns {boolean} True if completeTransitions() can be called
679
- *
680
- * @example
681
- * ```svelte
682
- * {#if machine.canCompleteTransitions}
683
- * <button onclick={() => machine.completeTransitions()}>Skip</button>
684
- * {/if}
685
- * ```
686
- */
687
- get canCompleteTransitions() {
688
- this.#revision; // Ensure reactivity
689
- return !!this.#currentOnEnterHandler?.complete;
690
- }
691
-
692
- /**
693
- * Check if current state has transitions that can be aborted
694
- *
695
- * @returns {boolean} True if abortTransitions() can be called
696
- *
697
- * @example
698
- * ```svelte
699
- * {#if machine.canAbortTransitions}
700
- * <button onclick={() => machine.abortTransitions()}>Cancel</button>
701
- * {/if}
702
- * ```
703
- */
704
- get canAbortTransitions() {
705
- this.#revision; // Ensure reactivity
706
- return !!this.#currentOnEnterHandler?.abort;
707
- }
708
402
  }