@hkdigital/lib-core 0.5.57 → 0.5.59
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.
- package/dist/logging/internal/logger/Logger.js +1 -1
- package/dist/state/machines/page-machine/PageMachine.svelte.d.ts +146 -138
- package/dist/state/machines/page-machine/PageMachine.svelte.js +263 -445
- package/dist/state/machines/page-machine/PageMachine.svelte.js__ +708 -0
- package/dist/state/machines/page-machine/README.md +121 -69
- package/package.json +1 -1
|
@@ -1,144 +1,108 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Route-aware data manager for page groups
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Does NOT enforce
|
|
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
|
-
* -
|
|
8
|
+
* - Current route tracking and synchronization
|
|
10
9
|
* - Start path management
|
|
11
|
-
* - Data properties for business/domain state
|
|
12
|
-
* - Visited
|
|
13
|
-
* -
|
|
10
|
+
* - Data properties for business/domain state (using SvelteMap)
|
|
11
|
+
* - Visited routes tracking (using SvelteSet)
|
|
12
|
+
* - Fine-grained reactivity without manual revision tracking
|
|
13
|
+
*
|
|
14
|
+
* Best practices:
|
|
15
|
+
* - Use constants for routes: `const ROUTE_INTRO = '/intro/start'`
|
|
16
|
+
* - Use KEY_ constants for data: `const KEY_SCORE = 'score'`
|
|
14
17
|
*
|
|
15
18
|
* Basic usage:
|
|
16
19
|
* ```javascript
|
|
20
|
+
* // Define constants
|
|
21
|
+
* const ROUTE_INTRO = '/intro/start';
|
|
22
|
+
* const KEY_SCORE = 'score';
|
|
23
|
+
* const KEY_TUTORIAL_SEEN = 'tutorial-seen';
|
|
24
|
+
*
|
|
17
25
|
* const machine = new PageMachine({
|
|
18
|
-
* startPath:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* [
|
|
26
|
+
* startPath: ROUTE_INTRO,
|
|
27
|
+
* routes: [ROUTE_INTRO, '/intro/profile', '/intro/complete'],
|
|
28
|
+
* initialData: {
|
|
29
|
+
* [KEY_SCORE]: 0,
|
|
30
|
+
* [KEY_TUTORIAL_SEEN]: false
|
|
22
31
|
* }
|
|
23
32
|
* });
|
|
24
33
|
*
|
|
25
|
-
* // Sync machine
|
|
34
|
+
* // Sync machine with URL changes
|
|
26
35
|
* $effect(() => {
|
|
27
36
|
* machine.syncFromPath($page.url.pathname);
|
|
28
37
|
* });
|
|
38
|
+
*
|
|
39
|
+
* // Check current route (reactive)
|
|
40
|
+
* $effect(() => {
|
|
41
|
+
* if (machine.current === ROUTE_INTRO) {
|
|
42
|
+
* console.log('On intro page');
|
|
43
|
+
* }
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* // Access data (reactive, fine-grained)
|
|
47
|
+
* $effect(() => {
|
|
48
|
+
* const score = machine.getData(KEY_SCORE);
|
|
49
|
+
* console.log('Score changed:', score);
|
|
50
|
+
* });
|
|
29
51
|
* ```
|
|
30
52
|
*
|
|
31
|
-
*
|
|
53
|
+
* Animations and page-specific logic should use $effect in pages:
|
|
32
54
|
* ```javascript
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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));
|
|
55
|
+
* // In +page.svelte
|
|
56
|
+
* const animations = new PageAnimations();
|
|
43
57
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* };
|
|
48
|
-
* }
|
|
58
|
+
* $effect(() => {
|
|
59
|
+
* if (someCondition) {
|
|
60
|
+
* animations.start();
|
|
49
61
|
* }
|
|
50
62
|
* });
|
|
51
|
-
*
|
|
52
|
-
* // Fast-forward animation
|
|
53
|
-
* machine.completeTransitions();
|
|
54
|
-
*
|
|
55
|
-
* // Cancel animation
|
|
56
|
-
* machine.abortTransitions();
|
|
57
63
|
* ```
|
|
58
64
|
*/
|
|
59
|
-
import {
|
|
65
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
60
66
|
|
|
61
67
|
export default class PageMachine {
|
|
62
68
|
/**
|
|
63
|
-
* Logger instance
|
|
69
|
+
* Logger instance
|
|
64
70
|
* @type {import('../../../logging/client.js').Logger}
|
|
65
71
|
*/
|
|
66
72
|
logger;
|
|
73
|
+
|
|
67
74
|
/**
|
|
68
|
-
* Current
|
|
75
|
+
* Current route path
|
|
69
76
|
* @type {string}
|
|
70
77
|
*/
|
|
71
78
|
// @ts-ignore
|
|
72
79
|
#current = $state();
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
|
-
* Start path for this
|
|
82
|
+
* Start path for this route group
|
|
76
83
|
* @type {string}
|
|
77
84
|
*/
|
|
78
85
|
#startPath = '';
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
|
-
*
|
|
82
|
-
* @type {string}
|
|
83
|
-
*/
|
|
84
|
-
#startState = '';
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Map of states to route paths
|
|
88
|
-
* @type {Record<string, string>}
|
|
89
|
-
*/
|
|
90
|
-
#routeMap = {};
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Reverse map of route paths to states
|
|
94
|
-
* @type {Record<string, string>}
|
|
95
|
-
*/
|
|
96
|
-
#pathToStateMap = {};
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Data properties for business/domain state
|
|
100
|
-
* Can be initialized from server and synced back
|
|
101
|
-
* @type {Record<string, any>}
|
|
102
|
-
*/
|
|
103
|
-
#data = $state({});
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Track which states have been visited during this session
|
|
107
|
-
* Useful for showing first-time hints/tips
|
|
108
|
-
* @type {Set<string>}
|
|
109
|
-
*/
|
|
110
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
111
|
-
#visitedStates = new Set();
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Revision counter for triggering reactivity
|
|
115
|
-
* @type {number}
|
|
116
|
-
*/
|
|
117
|
-
#revision = $state(0);
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Map of state names to onEnter hook configurations
|
|
121
|
-
* @type {Record<string, {onEnter: Function}>}
|
|
88
|
+
* Optional list of valid routes for this group
|
|
89
|
+
* @type {string[]}
|
|
122
90
|
*/
|
|
123
|
-
#
|
|
91
|
+
#routes = [];
|
|
124
92
|
|
|
125
93
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
94
|
+
* Reactive map for business/domain data
|
|
95
|
+
* Uses SvelteMap for fine-grained reactivity
|
|
96
|
+
* @type {SvelteMap<string, any>}
|
|
128
97
|
*/
|
|
129
|
-
#
|
|
98
|
+
#data;
|
|
130
99
|
|
|
131
100
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
101
|
+
* Reactive set for visited routes
|
|
102
|
+
* Uses SvelteSet for automatic reactivity
|
|
103
|
+
* @type {SvelteSet<string>}
|
|
134
104
|
*/
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Flag to prevent concurrent state transitions
|
|
139
|
-
* @type {boolean}
|
|
140
|
-
*/
|
|
141
|
-
#isTransitioning = false;
|
|
105
|
+
#visitedRoutes;
|
|
142
106
|
|
|
143
107
|
/**
|
|
144
108
|
* Constructor
|
|
@@ -146,129 +110,74 @@ export default class PageMachine {
|
|
|
146
110
|
* @param {Object} config - Configuration object
|
|
147
111
|
* @param {string} config.startPath
|
|
148
112
|
* Start path for this route group (e.g., '/game/play')
|
|
149
|
-
* @param {
|
|
150
|
-
*
|
|
113
|
+
* @param {string[]} [config.routes=[]]
|
|
114
|
+
* Optional list of valid routes (for validation/dev tools)
|
|
151
115
|
* @param {Record<string, any>} [config.initialData={}]
|
|
152
|
-
* Initial data properties (
|
|
153
|
-
* @param {Record<string, Function>} [config.onEnterHooks={}]
|
|
154
|
-
* Map of states to onEnter hook functions
|
|
116
|
+
* Initial data properties (use KEY_ constants for keys)
|
|
155
117
|
* @param {import('../../../logging/client.js').Logger} [config.logger]
|
|
156
|
-
* Logger instance (optional
|
|
118
|
+
* Logger instance (optional)
|
|
157
119
|
*
|
|
158
120
|
* @example
|
|
159
121
|
* ```javascript
|
|
122
|
+
* const ROUTE_INTRO = '/intro/start';
|
|
123
|
+
* const KEY_INTRO_COMPLETED = 'intro-completed';
|
|
124
|
+
* const KEY_SCORE = 'score';
|
|
125
|
+
*
|
|
160
126
|
* const machine = new PageMachine({
|
|
161
|
-
* startPath:
|
|
162
|
-
*
|
|
163
|
-
* [STATE_START]: '/intro/start',
|
|
164
|
-
* [STATE_ANIMATE]: '/intro/animate'
|
|
165
|
-
* },
|
|
127
|
+
* startPath: ROUTE_INTRO,
|
|
128
|
+
* routes: [ROUTE_INTRO, '/intro/profile'],
|
|
166
129
|
* initialData: {
|
|
167
|
-
*
|
|
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
|
-
* }
|
|
130
|
+
* [KEY_INTRO_COMPLETED]: false,
|
|
131
|
+
* [KEY_SCORE]: 0
|
|
177
132
|
* }
|
|
178
133
|
* });
|
|
179
134
|
* ```
|
|
180
135
|
*/
|
|
181
|
-
constructor({
|
|
182
|
-
startPath,
|
|
183
|
-
routeMap = {},
|
|
184
|
-
initialData = {},
|
|
185
|
-
onEnterHooks = {},
|
|
186
|
-
logger = null
|
|
187
|
-
}) {
|
|
136
|
+
constructor({ startPath, routes = [], initialData = {}, logger = null }) {
|
|
188
137
|
if (!startPath) {
|
|
189
138
|
throw new Error('PageMachine requires startPath parameter');
|
|
190
139
|
}
|
|
191
140
|
|
|
192
141
|
this.logger = logger;
|
|
193
142
|
this.#startPath = startPath;
|
|
194
|
-
this.#
|
|
195
|
-
this.#
|
|
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
|
-
}
|
|
143
|
+
this.#routes = routes;
|
|
144
|
+
this.#current = startPath;
|
|
219
145
|
|
|
220
|
-
|
|
221
|
-
this.#
|
|
146
|
+
// Initialize reactive data structures
|
|
147
|
+
this.#data = new SvelteMap();
|
|
148
|
+
this.#visitedRoutes = new SvelteSet();
|
|
222
149
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Normalize onEnterHooks to ensure consistent format
|
|
229
|
-
* Converts function to {onEnter: function} object
|
|
230
|
-
*
|
|
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
|
-
}
|
|
150
|
+
// Populate initial data
|
|
151
|
+
for (const [key, value] of Object.entries(initialData)) {
|
|
152
|
+
this.#data.set(key, value);
|
|
246
153
|
}
|
|
247
154
|
|
|
248
|
-
|
|
155
|
+
// Mark start path as visited
|
|
156
|
+
this.#visitedRoutes.add(startPath);
|
|
249
157
|
}
|
|
250
158
|
|
|
251
159
|
/**
|
|
252
|
-
* Synchronize machine
|
|
160
|
+
* Synchronize machine with URL path
|
|
253
161
|
*
|
|
254
|
-
* Call this in a $effect that watches $page.url.pathname
|
|
255
|
-
* Automatically tracks visited
|
|
162
|
+
* Call this in a $effect that watches $page.url.pathname.
|
|
163
|
+
* Automatically tracks visited routes.
|
|
256
164
|
*
|
|
257
165
|
* @param {string} currentPath - Current URL pathname
|
|
258
166
|
*
|
|
259
|
-
* @returns {boolean} True if
|
|
167
|
+
* @returns {boolean} True if route was changed
|
|
260
168
|
*/
|
|
261
169
|
syncFromPath(currentPath) {
|
|
262
|
-
|
|
170
|
+
// Find matching route
|
|
171
|
+
const matchedRoute = this.#findMatchingRoute(currentPath);
|
|
172
|
+
|
|
173
|
+
if (matchedRoute && matchedRoute !== this.#current) {
|
|
174
|
+
this.logger?.debug(`syncFromPath: ${currentPath} → ${matchedRoute}`);
|
|
263
175
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
this.
|
|
267
|
-
`syncFromPath: ${currentPath} → targetState: ${targetState}`
|
|
268
|
-
);
|
|
176
|
+
const oldRoute = this.#current;
|
|
177
|
+
this.#current = matchedRoute;
|
|
178
|
+
this.#visitedRoutes.add(matchedRoute);
|
|
269
179
|
|
|
270
|
-
|
|
271
|
-
this.#setState(targetState);
|
|
180
|
+
this.logger?.debug(`Route changed: ${oldRoute} → ${matchedRoute}`);
|
|
272
181
|
|
|
273
182
|
return true;
|
|
274
183
|
}
|
|
@@ -277,154 +186,50 @@ export default class PageMachine {
|
|
|
277
186
|
}
|
|
278
187
|
|
|
279
188
|
/**
|
|
280
|
-
*
|
|
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
|
|
189
|
+
* Find matching route from path
|
|
355
190
|
*
|
|
356
191
|
* @param {string} path - URL pathname
|
|
357
192
|
*
|
|
358
|
-
* @returns {string|null}
|
|
193
|
+
* @returns {string|null} Matched route or null
|
|
359
194
|
*/
|
|
360
|
-
#
|
|
361
|
-
//
|
|
362
|
-
if (this.#
|
|
363
|
-
|
|
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;
|
|
195
|
+
#findMatchingRoute(path) {
|
|
196
|
+
// If routes list provided, try to match against it
|
|
197
|
+
if (this.#routes.length > 0) {
|
|
198
|
+
// Try exact match first
|
|
199
|
+
if (this.#routes.includes(path)) {
|
|
200
|
+
return path;
|
|
370
201
|
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
202
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
*/
|
|
383
|
-
getPathForState(state) {
|
|
384
|
-
const path = this.#routeMap[state];
|
|
203
|
+
// Try partial match (path starts with route)
|
|
204
|
+
for (const route of this.#routes) {
|
|
205
|
+
if (path.startsWith(route)) {
|
|
206
|
+
return route;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
385
209
|
|
|
386
|
-
|
|
387
|
-
throw new Error(`No path found for state [${state}]`);
|
|
210
|
+
return null;
|
|
388
211
|
}
|
|
389
212
|
|
|
213
|
+
// No routes list - accept any path
|
|
390
214
|
return path;
|
|
391
215
|
}
|
|
392
216
|
|
|
393
217
|
/**
|
|
394
|
-
*
|
|
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
|
|
218
|
+
* Get current route
|
|
405
219
|
*
|
|
406
|
-
* @returns {string
|
|
407
|
-
*/
|
|
408
|
-
getCurrentPath() {
|
|
409
|
-
return this.getPathForState(this.#current);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Get current state
|
|
414
|
-
*
|
|
415
|
-
* @returns {string} Current state name
|
|
220
|
+
* @returns {string} Current route path
|
|
416
221
|
*/
|
|
417
222
|
get current() {
|
|
418
223
|
return this.#current;
|
|
419
224
|
}
|
|
420
225
|
|
|
421
226
|
/**
|
|
422
|
-
* Get the
|
|
227
|
+
* Get the routes list
|
|
423
228
|
*
|
|
424
|
-
* @returns {
|
|
229
|
+
* @returns {string[]} Copy of routes list
|
|
425
230
|
*/
|
|
426
|
-
get
|
|
427
|
-
return
|
|
231
|
+
get routes() {
|
|
232
|
+
return [...this.#routes];
|
|
428
233
|
}
|
|
429
234
|
|
|
430
235
|
/* ===== Data Properties (Business/Domain State) ===== */
|
|
@@ -432,43 +237,59 @@ export default class PageMachine {
|
|
|
432
237
|
/**
|
|
433
238
|
* Set a data property value
|
|
434
239
|
*
|
|
435
|
-
*
|
|
240
|
+
* Automatically reactive - effects watching this key will re-run.
|
|
241
|
+
* Uses fine-grained reactivity, so only effects watching this specific
|
|
242
|
+
* key will be triggered.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} key - Property key (use KEY_ constant)
|
|
436
245
|
* @param {any} value - Property value
|
|
437
246
|
*
|
|
438
247
|
* @example
|
|
439
248
|
* ```javascript
|
|
440
|
-
*
|
|
441
|
-
*
|
|
249
|
+
* const KEY_HAS_STRONG_PROFILE = 'has-strong-profile';
|
|
250
|
+
* const KEY_PROFILE_SCORE = 'profile-score';
|
|
251
|
+
*
|
|
252
|
+
* machine.setData(KEY_HAS_STRONG_PROFILE, true);
|
|
253
|
+
* machine.setData(KEY_PROFILE_SCORE, 85);
|
|
442
254
|
* ```
|
|
443
255
|
*/
|
|
444
256
|
setData(key, value) {
|
|
445
|
-
this.#data
|
|
446
|
-
this.#revision++;
|
|
257
|
+
this.#data.set(key, value);
|
|
447
258
|
}
|
|
448
259
|
|
|
449
260
|
/**
|
|
450
261
|
* Get a data property value
|
|
451
262
|
*
|
|
452
|
-
*
|
|
263
|
+
* Automatically reactive - creates a dependency on this specific key.
|
|
264
|
+
* The effect will only re-run when THIS key changes, not when other
|
|
265
|
+
* keys change.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} key - Property key (use KEY_ constant)
|
|
453
268
|
*
|
|
454
269
|
* @returns {any} Property value or undefined
|
|
455
270
|
*
|
|
456
271
|
* @example
|
|
457
272
|
* ```javascript
|
|
458
|
-
* const
|
|
459
|
-
*
|
|
273
|
+
* const KEY_SCORE = 'score';
|
|
274
|
+
*
|
|
275
|
+
* // Reactive - re-runs only when KEY_SCORE changes
|
|
276
|
+
* $effect(() => {
|
|
277
|
+
* const score = machine.getData(KEY_SCORE);
|
|
278
|
+
* console.log('Score:', score);
|
|
279
|
+
* });
|
|
460
280
|
* ```
|
|
461
281
|
*/
|
|
462
282
|
getData(key) {
|
|
463
|
-
|
|
464
|
-
this.#revision;
|
|
465
|
-
return this.#data[key];
|
|
283
|
+
return this.#data.get(key);
|
|
466
284
|
}
|
|
467
285
|
|
|
468
286
|
/**
|
|
469
|
-
* Get all data properties
|
|
287
|
+
* Get all data properties as plain object
|
|
288
|
+
*
|
|
289
|
+
* Note: This returns a snapshot (plain object), not a reactive map.
|
|
290
|
+
* Use this for serialization or server sync, not for reactive tracking.
|
|
470
291
|
*
|
|
471
|
-
* @returns {Record<string, any>}
|
|
292
|
+
* @returns {Record<string, any>} Plain object with all data
|
|
472
293
|
*
|
|
473
294
|
* @example
|
|
474
295
|
* ```javascript
|
|
@@ -477,58 +298,124 @@ export default class PageMachine {
|
|
|
477
298
|
* ```
|
|
478
299
|
*/
|
|
479
300
|
getAllData() {
|
|
480
|
-
|
|
481
|
-
this.#revision;
|
|
482
|
-
return { ...this.#data };
|
|
301
|
+
return Object.fromEntries(this.#data);
|
|
483
302
|
}
|
|
484
303
|
|
|
485
304
|
/**
|
|
486
305
|
* Update multiple data properties at once
|
|
487
306
|
*
|
|
488
|
-
*
|
|
307
|
+
* Each property update triggers fine-grained reactivity.
|
|
308
|
+
*
|
|
309
|
+
* @param {Record<string, any>} dataUpdates
|
|
310
|
+
* Object with key-value pairs (use KEY_ constants for keys)
|
|
489
311
|
*
|
|
490
312
|
* @example
|
|
491
313
|
* ```javascript
|
|
314
|
+
* const KEY_HAS_STRONG_PROFILE = 'has-strong-profile';
|
|
315
|
+
* const KEY_PROFILE_SCORE = 'profile-score';
|
|
316
|
+
* const KEY_MATCHED_SECTOR = 'matched-sector';
|
|
317
|
+
*
|
|
492
318
|
* machine.updateData({
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
319
|
+
* [KEY_HAS_STRONG_PROFILE]: true,
|
|
320
|
+
* [KEY_PROFILE_SCORE]: 85,
|
|
321
|
+
* [KEY_MATCHED_SECTOR]: 'technology'
|
|
496
322
|
* });
|
|
497
323
|
* ```
|
|
498
324
|
*/
|
|
499
325
|
updateData(dataUpdates) {
|
|
500
326
|
for (const [key, value] of Object.entries(dataUpdates)) {
|
|
501
|
-
this.#data
|
|
327
|
+
this.#data.set(key, value);
|
|
502
328
|
}
|
|
503
|
-
this.#revision++;
|
|
504
329
|
}
|
|
505
330
|
|
|
506
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Delete a data property
|
|
333
|
+
*
|
|
334
|
+
* @param {string} key - Property key to delete (use KEY_ constant)
|
|
335
|
+
*
|
|
336
|
+
* @returns {boolean} True if the key existed and was deleted
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```javascript
|
|
340
|
+
* const KEY_TEMPORARY_FLAG = 'temporary-flag';
|
|
341
|
+
*
|
|
342
|
+
* machine.deleteData(KEY_TEMPORARY_FLAG);
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
deleteData(key) {
|
|
346
|
+
return this.#data.delete(key);
|
|
347
|
+
}
|
|
507
348
|
|
|
508
349
|
/**
|
|
509
|
-
* Check if
|
|
350
|
+
* Check if data property exists
|
|
510
351
|
*
|
|
511
|
-
* @param {string}
|
|
352
|
+
* @param {string} key - Property key to check (use KEY_ constant)
|
|
512
353
|
*
|
|
513
|
-
* @returns {boolean} True if the
|
|
354
|
+
* @returns {boolean} True if the key exists
|
|
514
355
|
*
|
|
515
356
|
* @example
|
|
516
357
|
* ```javascript
|
|
517
|
-
*
|
|
518
|
-
*
|
|
358
|
+
* const KEY_TUTORIAL_SEEN = 'tutorial-seen';
|
|
359
|
+
*
|
|
360
|
+
* if (machine.hasData(KEY_TUTORIAL_SEEN)) {
|
|
361
|
+
* // Skip tutorial
|
|
519
362
|
* }
|
|
520
363
|
* ```
|
|
521
364
|
*/
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
365
|
+
hasData(key) {
|
|
366
|
+
return this.#data.has(key);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Clear all data properties
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* ```javascript
|
|
374
|
+
* machine.clearData(); // Reset all game data
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
clearData() {
|
|
378
|
+
this.#data.clear();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Get number of data properties
|
|
383
|
+
*
|
|
384
|
+
* @returns {number} Number of data entries
|
|
385
|
+
*/
|
|
386
|
+
get dataSize() {
|
|
387
|
+
return this.#data.size;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ===== Visited Routes Tracking ===== */
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Check if a route has been visited
|
|
394
|
+
*
|
|
395
|
+
* Automatically reactive - creates a dependency on the visited routes set.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} route - Route path to check
|
|
398
|
+
*
|
|
399
|
+
* @returns {boolean} True if the route has been visited
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* ```javascript
|
|
403
|
+
* // Reactive - re-runs when visited routes change
|
|
404
|
+
* $effect(() => {
|
|
405
|
+
* if (machine.hasVisited('/intro/profile')) {
|
|
406
|
+
* console.log('User has seen profile page');
|
|
407
|
+
* }
|
|
408
|
+
* });
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
hasVisited(route) {
|
|
412
|
+
return this.#visitedRoutes.has(route);
|
|
526
413
|
}
|
|
527
414
|
|
|
528
415
|
/**
|
|
529
|
-
* Check if the start
|
|
416
|
+
* Check if the start route has been visited
|
|
530
417
|
*
|
|
531
|
-
* @returns {boolean} True if the start
|
|
418
|
+
* @returns {boolean} True if the start route has been visited
|
|
532
419
|
*
|
|
533
420
|
* @example
|
|
534
421
|
* ```javascript
|
|
@@ -538,28 +425,42 @@ export default class PageMachine {
|
|
|
538
425
|
* ```
|
|
539
426
|
*/
|
|
540
427
|
get hasVisitedStart() {
|
|
541
|
-
return this.hasVisited(this.#
|
|
428
|
+
return this.hasVisited(this.#startPath);
|
|
542
429
|
}
|
|
543
430
|
|
|
544
431
|
/**
|
|
545
|
-
* Get all visited
|
|
432
|
+
* Get all visited routes as array
|
|
433
|
+
*
|
|
434
|
+
* Note: Returns a snapshot (plain array), not reactive.
|
|
546
435
|
*
|
|
547
|
-
* @returns {string[]} Array of visited
|
|
436
|
+
* @returns {string[]} Array of visited route paths
|
|
548
437
|
*/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
this.#revision;
|
|
552
|
-
return Array.from(this.#visitedStates);
|
|
438
|
+
getVisitedRoutes() {
|
|
439
|
+
return Array.from(this.#visitedRoutes);
|
|
553
440
|
}
|
|
554
441
|
|
|
555
442
|
/**
|
|
556
|
-
* Reset visited
|
|
557
|
-
*
|
|
443
|
+
* Reset visited routes tracking
|
|
444
|
+
*
|
|
445
|
+
* Clears all visited routes and marks only the current route as visited.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```javascript
|
|
449
|
+
* machine.resetVisitedRoutes(); // Reset progress tracking
|
|
450
|
+
* ```
|
|
558
451
|
*/
|
|
559
|
-
|
|
560
|
-
this.#
|
|
561
|
-
this.#
|
|
562
|
-
|
|
452
|
+
resetVisitedRoutes() {
|
|
453
|
+
this.#visitedRoutes.clear();
|
|
454
|
+
this.#visitedRoutes.add(this.#current);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get number of visited routes
|
|
459
|
+
*
|
|
460
|
+
* @returns {number} Number of routes visited
|
|
461
|
+
*/
|
|
462
|
+
get visitedRoutesCount() {
|
|
463
|
+
return this.#visitedRoutes.size;
|
|
563
464
|
}
|
|
564
465
|
|
|
565
466
|
/* ===== Start Path Methods ===== */
|
|
@@ -573,15 +474,6 @@ export default class PageMachine {
|
|
|
573
474
|
return this.#startPath;
|
|
574
475
|
}
|
|
575
476
|
|
|
576
|
-
/**
|
|
577
|
-
* Get the start state
|
|
578
|
-
*
|
|
579
|
-
* @returns {string} Start state name
|
|
580
|
-
*/
|
|
581
|
-
get startState() {
|
|
582
|
-
return this.#startState;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
477
|
/**
|
|
586
478
|
* Check if the supplied path matches the start path
|
|
587
479
|
*
|
|
@@ -601,19 +493,19 @@ export default class PageMachine {
|
|
|
601
493
|
}
|
|
602
494
|
|
|
603
495
|
/**
|
|
604
|
-
* Check if currently on the start
|
|
496
|
+
* Check if currently on the start path
|
|
605
497
|
*
|
|
606
|
-
* @returns {boolean} True if current
|
|
498
|
+
* @returns {boolean} True if current route is the start path
|
|
607
499
|
*
|
|
608
500
|
* @example
|
|
609
501
|
* ```javascript
|
|
610
|
-
* if (machine.
|
|
502
|
+
* if (machine.isOnStartPath) {
|
|
611
503
|
* // Show onboarding
|
|
612
504
|
* }
|
|
613
505
|
* ```
|
|
614
506
|
*/
|
|
615
|
-
get
|
|
616
|
-
return this.#current === this.#
|
|
507
|
+
get isOnStartPath() {
|
|
508
|
+
return this.#current === this.#startPath;
|
|
617
509
|
}
|
|
618
510
|
|
|
619
511
|
/**
|
|
@@ -631,78 +523,4 @@ export default class PageMachine {
|
|
|
631
523
|
switchToPage(this.#startPath);
|
|
632
524
|
});
|
|
633
525
|
}
|
|
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
526
|
}
|