@hkdigital/lib-core 0.5.44 → 0.5.46
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/state/context/README.md +226 -0
- package/dist/state/context/RouteStateContext.svelte.d.ts +44 -0
- package/dist/state/context/RouteStateContext.svelte.js +119 -0
- package/dist/state/context.d.ts +2 -1
- package/dist/state/context.js +2 -1
- package/dist/state/machines/page-machine/PageMachine.svelte.d.ts +270 -0
- package/dist/state/machines/page-machine/PageMachine.svelte.js +537 -0
- package/dist/state/machines/page-machine/README.md +157 -0
- package/dist/state/machines.d.ts +1 -0
- package/dist/state/machines.js +2 -0
- package/dist/ui/components/game-box/ScaledContainer.svelte +1 -1
- package/dist/util/time/index.js +19 -8
- package/package.json +1 -1
- /package/dist/state/context/{state-context.d.ts → util.d.ts} +0 -0
- /package/dist/state/context/{state-context.js → util.js} +0 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for page state machines with URL route mapping
|
|
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).
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - State-to-route mapping and sync
|
|
10
|
+
* - Data properties for business/domain state
|
|
11
|
+
* - Visited states tracking
|
|
12
|
+
* - onEnter hooks with abort/complete handlers for animations
|
|
13
|
+
*
|
|
14
|
+
* Basic usage:
|
|
15
|
+
* ```javascript
|
|
16
|
+
* const machine = new PageMachine({
|
|
17
|
+
* initialState: STATE_START,
|
|
18
|
+
* routeMap: {
|
|
19
|
+
* [STATE_START]: '/intro/start',
|
|
20
|
+
* [STATE_PROFILE]: '/intro/profile'
|
|
21
|
+
* }
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Sync machine state with URL changes
|
|
25
|
+
* $effect(() => {
|
|
26
|
+
* machine.syncFromPath($page.url.pathname);
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* With onEnter hooks (for animations):
|
|
31
|
+
* ```javascript
|
|
32
|
+
* const machine = new PageMachine({
|
|
33
|
+
* initialState: STATE_ANIMATE,
|
|
34
|
+
* routeMap: {
|
|
35
|
+
* [STATE_ANIMATE]: '/game/animate',
|
|
36
|
+
* [STATE_PLAY]: '/game/play'
|
|
37
|
+
* },
|
|
38
|
+
* onEnterHooks: {
|
|
39
|
+
* [STATE_ANIMATE]: (done) => {
|
|
40
|
+
* const animation = playAnimation(1000);
|
|
41
|
+
* animation.finished.then(() => done(STATE_PLAY));
|
|
42
|
+
*
|
|
43
|
+
* return {
|
|
44
|
+
* abort: () => animation.cancel(),
|
|
45
|
+
* complete: () => animation.finish()
|
|
46
|
+
* };
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* // Fast-forward animation
|
|
52
|
+
* machine.completeTransitions();
|
|
53
|
+
*
|
|
54
|
+
* // Cancel animation
|
|
55
|
+
* machine.abortTransitions();
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export default class PageMachine {
|
|
59
|
+
/**
|
|
60
|
+
* Current state
|
|
61
|
+
* @type {string}
|
|
62
|
+
*/
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
#current = $state();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Map of states to route paths
|
|
68
|
+
* @type {Record<string, string>}
|
|
69
|
+
*/
|
|
70
|
+
#routeMap = {};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reverse map of route paths to states
|
|
74
|
+
* @type {Record<string, string>}
|
|
75
|
+
*/
|
|
76
|
+
#pathToStateMap = {};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Data properties for business/domain state
|
|
80
|
+
* Can be initialized from server and synced back
|
|
81
|
+
* @type {Record<string, any>}
|
|
82
|
+
*/
|
|
83
|
+
#data = $state({});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Track which states have been visited during this session
|
|
87
|
+
* Useful for showing first-time hints/tips
|
|
88
|
+
* @type {Set<string>}
|
|
89
|
+
*/
|
|
90
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
91
|
+
#visitedStates = new Set();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Revision counter for triggering reactivity
|
|
95
|
+
* @type {number}
|
|
96
|
+
*/
|
|
97
|
+
#revision = $state(0);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Map of state names to onEnter hook configurations
|
|
101
|
+
* @type {Record<string, {onEnter: Function}>}
|
|
102
|
+
*/
|
|
103
|
+
#onEnterHooks = {};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Current state's onEnter handler (abort/complete functions)
|
|
107
|
+
* @type {{abort?: Function, complete?: Function} | null}
|
|
108
|
+
*/
|
|
109
|
+
#currentOnEnterHandler = null;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Current state's done callback
|
|
113
|
+
* @type {Function | null}
|
|
114
|
+
*/
|
|
115
|
+
#currentOnEnterDone = null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flag to prevent concurrent state transitions
|
|
119
|
+
* @type {boolean}
|
|
120
|
+
*/
|
|
121
|
+
#isTransitioning = false;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Constructor
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} config - Configuration object
|
|
127
|
+
* @param {string} config.initialState - Initial state name
|
|
128
|
+
* @param {Record<string, string>} [config.routeMap={}] - Map of states to route paths
|
|
129
|
+
* @param {Record<string, any>} [config.initialData={}] - Initial data properties (from server)
|
|
130
|
+
* @param {Record<string, Function>} [config.onEnterHooks={}] - Map of states to onEnter hook functions
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```javascript
|
|
134
|
+
* const machine = new PageMachine({
|
|
135
|
+
* initialState: STATE_START,
|
|
136
|
+
* routeMap: {
|
|
137
|
+
* [STATE_START]: '/intro/start',
|
|
138
|
+
* [STATE_ANIMATE]: '/intro/animate'
|
|
139
|
+
* },
|
|
140
|
+
* initialData: {
|
|
141
|
+
* INTRO_COMPLETED: false
|
|
142
|
+
* },
|
|
143
|
+
* onEnterHooks: {
|
|
144
|
+
* [STATE_ANIMATE]: (done) => {
|
|
145
|
+
* setTimeout(() => done(STATE_START), 1000);
|
|
146
|
+
* return {
|
|
147
|
+
* abort: () => clearTimeout(...),
|
|
148
|
+
* complete: () => done(STATE_START)
|
|
149
|
+
* };
|
|
150
|
+
* }
|
|
151
|
+
* }
|
|
152
|
+
* });
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
constructor({ initialState, routeMap = {}, initialData = {}, onEnterHooks = {} }) {
|
|
156
|
+
if (!initialState) {
|
|
157
|
+
throw new Error('PageMachine requires initialState parameter');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.#current = initialState;
|
|
161
|
+
this.#routeMap = routeMap;
|
|
162
|
+
this.#data = initialData;
|
|
163
|
+
this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
|
|
164
|
+
|
|
165
|
+
// Build reverse map (path -> state)
|
|
166
|
+
for (const [state, path] of Object.entries(routeMap)) {
|
|
167
|
+
this.#pathToStateMap[path] = state;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Mark initial state as visited
|
|
171
|
+
this.#visitedStates.add(initialState);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Normalize onEnterHooks to ensure consistent format
|
|
176
|
+
* Converts function to {onEnter: function} object
|
|
177
|
+
*
|
|
178
|
+
* @param {Record<string, Function|Object>} hooks - Raw hooks configuration
|
|
179
|
+
* @returns {Record<string, {onEnter: Function}>} Normalized hooks
|
|
180
|
+
*/
|
|
181
|
+
#normalizeOnEnterHooks(hooks) {
|
|
182
|
+
const normalized = {};
|
|
183
|
+
|
|
184
|
+
for (const [state, hook] of Object.entries(hooks)) {
|
|
185
|
+
if (typeof hook === 'function') {
|
|
186
|
+
// Simple function -> wrap in object
|
|
187
|
+
normalized[state] = { onEnter: hook };
|
|
188
|
+
} else if (hook && typeof hook === 'object' && hook.onEnter) {
|
|
189
|
+
// Already an object with onEnter
|
|
190
|
+
normalized[state] = hook;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return normalized;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Synchronize machine state with URL path
|
|
199
|
+
*
|
|
200
|
+
* Call this in a $effect that watches $page.url.pathname
|
|
201
|
+
* Automatically tracks visited states
|
|
202
|
+
*
|
|
203
|
+
* @param {string} currentPath - Current URL pathname
|
|
204
|
+
*
|
|
205
|
+
* @returns {boolean} True if state was changed
|
|
206
|
+
*/
|
|
207
|
+
syncFromPath(currentPath) {
|
|
208
|
+
const targetState = this.#getStateFromPath(currentPath);
|
|
209
|
+
|
|
210
|
+
if (targetState && targetState !== this.#current) {
|
|
211
|
+
this.#current = targetState;
|
|
212
|
+
this.#visitedStates.add(targetState);
|
|
213
|
+
this.#revision++;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Set the current state directly
|
|
222
|
+
* Handles onEnter hooks and auto-transitions
|
|
223
|
+
*
|
|
224
|
+
* @param {string} newState - Target state
|
|
225
|
+
*/
|
|
226
|
+
async setState(newState) {
|
|
227
|
+
if (newState === this.#current || this.#isTransitioning) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Abort previous state's onEnter handler
|
|
232
|
+
if (this.#currentOnEnterHandler?.abort) {
|
|
233
|
+
this.#currentOnEnterHandler.abort();
|
|
234
|
+
}
|
|
235
|
+
this.#currentOnEnterHandler = null;
|
|
236
|
+
this.#currentOnEnterDone = null;
|
|
237
|
+
|
|
238
|
+
this.#isTransitioning = true;
|
|
239
|
+
this.#current = newState;
|
|
240
|
+
this.#visitedStates.add(newState);
|
|
241
|
+
|
|
242
|
+
// Check if this state has an onEnter hook
|
|
243
|
+
const hookConfig = this.#onEnterHooks[newState];
|
|
244
|
+
if (hookConfig?.onEnter) {
|
|
245
|
+
// Create done callback for auto-transition
|
|
246
|
+
let doneCalled = false;
|
|
247
|
+
const done = (nextState) => {
|
|
248
|
+
if (!doneCalled && nextState && nextState !== newState) {
|
|
249
|
+
doneCalled = true;
|
|
250
|
+
this.#isTransitioning = false;
|
|
251
|
+
this.setState(nextState);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
this.#currentOnEnterDone = done;
|
|
256
|
+
|
|
257
|
+
// Call the onEnter hook
|
|
258
|
+
try {
|
|
259
|
+
const handler = hookConfig.onEnter(done);
|
|
260
|
+
|
|
261
|
+
// Store abort/complete handlers if provided
|
|
262
|
+
if (handler && typeof handler === 'object') {
|
|
263
|
+
if (handler.abort || handler.complete) {
|
|
264
|
+
this.#currentOnEnterHandler = {
|
|
265
|
+
abort: handler.abort,
|
|
266
|
+
complete: handler.complete
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If hook returned a promise, await it
|
|
272
|
+
if (handler?.then) {
|
|
273
|
+
await handler;
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error(`Error in onEnter hook for state ${newState}:`, error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.#isTransitioning = false;
|
|
281
|
+
this.#revision++;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get state name from URL path
|
|
286
|
+
*
|
|
287
|
+
* @param {string} path - URL pathname
|
|
288
|
+
*
|
|
289
|
+
* @returns {string|null} State name or null
|
|
290
|
+
*/
|
|
291
|
+
#getStateFromPath(path) {
|
|
292
|
+
// Try exact match first
|
|
293
|
+
if (this.#pathToStateMap[path]) {
|
|
294
|
+
return this.#pathToStateMap[path];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Try partial match (path includes route)
|
|
298
|
+
for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
|
|
299
|
+
if (path.includes(routePath)) {
|
|
300
|
+
return state;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get route path for a given state
|
|
309
|
+
*
|
|
310
|
+
* @param {string} state - State name
|
|
311
|
+
*
|
|
312
|
+
* @returns {string|null} Route path or null if no mapping
|
|
313
|
+
*/
|
|
314
|
+
getPathForState(state) {
|
|
315
|
+
return this.#routeMap[state] || null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get route path for current state
|
|
320
|
+
*
|
|
321
|
+
* @returns {string|null} Route path or null if no mapping
|
|
322
|
+
*/
|
|
323
|
+
getCurrentPath() {
|
|
324
|
+
return this.getPathForState(this.#current);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get current state
|
|
329
|
+
*
|
|
330
|
+
* @returns {string} Current state name
|
|
331
|
+
*/
|
|
332
|
+
get current() {
|
|
333
|
+
return this.#current;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get the route map
|
|
338
|
+
*
|
|
339
|
+
* @returns {Record<string, string>} Copy of route map
|
|
340
|
+
*/
|
|
341
|
+
get routeMap() {
|
|
342
|
+
return { ...this.#routeMap };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ===== Data Properties (Business/Domain State) ===== */
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Set a data property value
|
|
349
|
+
*
|
|
350
|
+
* @param {string} key - Property key
|
|
351
|
+
* @param {any} value - Property value
|
|
352
|
+
*
|
|
353
|
+
* @example
|
|
354
|
+
* ```javascript
|
|
355
|
+
* machine.setData('HAS_STRONG_PROFILE', true);
|
|
356
|
+
* machine.setData('PROFILE_SCORE', 85);
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
setData(key, value) {
|
|
360
|
+
this.#data[key] = value;
|
|
361
|
+
this.#revision++;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Get a data property value
|
|
366
|
+
*
|
|
367
|
+
* @param {string} key - Property key
|
|
368
|
+
*
|
|
369
|
+
* @returns {any} Property value or undefined
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* ```javascript
|
|
373
|
+
* const hasProfile = machine.getData('HAS_STRONG_PROFILE');
|
|
374
|
+
* const score = machine.getData('PROFILE_SCORE');
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
getData(key) {
|
|
378
|
+
// Access revision to ensure reactivity
|
|
379
|
+
this.#revision;
|
|
380
|
+
return this.#data[key];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get all data properties
|
|
385
|
+
*
|
|
386
|
+
* @returns {Record<string, any>} Copy of all data
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* ```javascript
|
|
390
|
+
* const allData = machine.getAllData();
|
|
391
|
+
* await playerService.saveData(allData);
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
getAllData() {
|
|
395
|
+
// Access revision to ensure reactivity
|
|
396
|
+
this.#revision;
|
|
397
|
+
return { ...this.#data };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Update multiple data properties at once
|
|
402
|
+
*
|
|
403
|
+
* @param {Record<string, any>} dataUpdates - Object with key-value pairs
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* ```javascript
|
|
407
|
+
* machine.updateData({
|
|
408
|
+
* HAS_STRONG_PROFILE: true,
|
|
409
|
+
* PROFILE_SCORE: 85,
|
|
410
|
+
* MATCHED_SECTOR: 'technology'
|
|
411
|
+
* });
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
updateData(dataUpdates) {
|
|
415
|
+
for (const [key, value] of Object.entries(dataUpdates)) {
|
|
416
|
+
this.#data[key] = value;
|
|
417
|
+
}
|
|
418
|
+
this.#revision++;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* ===== Visited States Tracking ===== */
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Check if a state has been visited
|
|
425
|
+
*
|
|
426
|
+
* @param {string} state - State name to check
|
|
427
|
+
*
|
|
428
|
+
* @returns {boolean} True if the state has been visited
|
|
429
|
+
*
|
|
430
|
+
* @example
|
|
431
|
+
* ```javascript
|
|
432
|
+
* if (machine.hasVisited(STATE_PROFILE)) {
|
|
433
|
+
* // User has seen profile page, skip intro
|
|
434
|
+
* }
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
hasVisited(state) {
|
|
438
|
+
// Access revision to ensure reactivity
|
|
439
|
+
this.#revision;
|
|
440
|
+
return this.#visitedStates.has(state);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Get all visited states
|
|
445
|
+
*
|
|
446
|
+
* @returns {string[]} Array of visited state names
|
|
447
|
+
*/
|
|
448
|
+
getVisitedStates() {
|
|
449
|
+
// Access revision to ensure reactivity
|
|
450
|
+
this.#revision;
|
|
451
|
+
return Array.from(this.#visitedStates);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Reset visited states tracking
|
|
456
|
+
* Useful for testing or resetting experience
|
|
457
|
+
*/
|
|
458
|
+
resetVisitedStates() {
|
|
459
|
+
this.#visitedStates.clear();
|
|
460
|
+
this.#visitedStates.add(this.#current);
|
|
461
|
+
this.#revision++;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/* ===== Transition Control Methods ===== */
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Abort current state's transitions
|
|
468
|
+
* Cancels animations/operations immediately (incomplete state)
|
|
469
|
+
*
|
|
470
|
+
* @example
|
|
471
|
+
* ```javascript
|
|
472
|
+
* // User clicks "Cancel" button
|
|
473
|
+
* machine.abortTransitions();
|
|
474
|
+
* ```
|
|
475
|
+
*/
|
|
476
|
+
abortTransitions() {
|
|
477
|
+
if (this.#currentOnEnterHandler?.abort) {
|
|
478
|
+
this.#currentOnEnterHandler.abort();
|
|
479
|
+
this.#currentOnEnterHandler = null;
|
|
480
|
+
this.#currentOnEnterDone = null;
|
|
481
|
+
this.#revision++;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Complete current state's transitions immediately
|
|
487
|
+
* Fast-forwards animations/operations to completion (complete state)
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* ```javascript
|
|
491
|
+
* // User clicks "Skip" or "Next" button
|
|
492
|
+
* machine.completeTransitions();
|
|
493
|
+
* ```
|
|
494
|
+
*/
|
|
495
|
+
completeTransitions() {
|
|
496
|
+
if (this.#currentOnEnterHandler?.complete) {
|
|
497
|
+
this.#currentOnEnterHandler.complete();
|
|
498
|
+
this.#currentOnEnterHandler = null;
|
|
499
|
+
this.#currentOnEnterDone = null;
|
|
500
|
+
this.#revision++;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Check if current state has transitions that can be completed
|
|
506
|
+
*
|
|
507
|
+
* @returns {boolean} True if completeTransitions() can be called
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* ```svelte
|
|
511
|
+
* {#if machine.canCompleteTransitions}
|
|
512
|
+
* <button onclick={() => machine.completeTransitions()}>Skip</button>
|
|
513
|
+
* {/if}
|
|
514
|
+
* ```
|
|
515
|
+
*/
|
|
516
|
+
get canCompleteTransitions() {
|
|
517
|
+
this.#revision; // Ensure reactivity
|
|
518
|
+
return !!this.#currentOnEnterHandler?.complete;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Check if current state has transitions that can be aborted
|
|
523
|
+
*
|
|
524
|
+
* @returns {boolean} True if abortTransitions() can be called
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* ```svelte
|
|
528
|
+
* {#if machine.canAbortTransitions}
|
|
529
|
+
* <button onclick={() => machine.abortTransitions()}>Cancel</button>
|
|
530
|
+
* {/if}
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
get canAbortTransitions() {
|
|
534
|
+
this.#revision; // Ensure reactivity
|
|
535
|
+
return !!this.#currentOnEnterHandler?.abort;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# PageMachine
|
|
2
|
+
|
|
3
|
+
State machine for managing page view states with URL route mapping.
|
|
4
|
+
|
|
5
|
+
## How it connects
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────┐
|
|
9
|
+
│ MyFlowPageMachine (extends PageMachine) │
|
|
10
|
+
│ - Maps states to URL routes │
|
|
11
|
+
│ - Tracks current state and visited states │
|
|
12
|
+
│ - Provides computed properties (inIntro, inStep1, etc.) │
|
|
13
|
+
└────────────────┬────────────────────────────────────────┘
|
|
14
|
+
│
|
|
15
|
+
│ Contained in state
|
|
16
|
+
│
|
|
17
|
+
┌────────────────▼────────────────────────────────────────┐
|
|
18
|
+
│ MyFlowState (extends RouteStateContext) │
|
|
19
|
+
│ get pageMachine() { return this.#pageMachine; } │
|
|
20
|
+
└────────────────┬────────────────────────────────────────┘
|
|
21
|
+
│
|
|
22
|
+
│ Context provided to layout
|
|
23
|
+
│
|
|
24
|
+
┌────────────────▼────────────────────────────────────────┐
|
|
25
|
+
│ +layout.svelte │
|
|
26
|
+
│ IMPORTANT: Must sync URL with state: │
|
|
27
|
+
│ $effect(() => { │
|
|
28
|
+
│ pageMachine.syncFromPath($page.url.pathname); │
|
|
29
|
+
│ }); │
|
|
30
|
+
└────────────────┬────────────────────────────────────────┘
|
|
31
|
+
│
|
|
32
|
+
│ Pages use machine state
|
|
33
|
+
│
|
|
34
|
+
┌────────┴─────────┬────────────────┐
|
|
35
|
+
▼ ▼ ▼
|
|
36
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
37
|
+
│ +page │ │ +page │ │ Component│
|
|
38
|
+
│ │ │ │ │ │
|
|
39
|
+
└──────────┘ └──────────┘ └──────────┘
|
|
40
|
+
Access via: pageMachine.current, pageMachine.inIntro, etc.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Main Purposes
|
|
44
|
+
|
|
45
|
+
1. **Track current view/step** - Which page is active
|
|
46
|
+
2. **Map states to URL paths** - Connect state names to routes
|
|
47
|
+
3. **Sync with browser navigation** - Keep state in sync with URL
|
|
48
|
+
4. **Track visited states** - Know which pages user has seen
|
|
49
|
+
|
|
50
|
+
## Basic Usage
|
|
51
|
+
|
|
52
|
+
### 1. Create a page machine class
|
|
53
|
+
|
|
54
|
+
```javascript
|
|
55
|
+
// my-flow.machine.svelte.js
|
|
56
|
+
import PageMachine from '$lib/state/machines/PageMachine.svelte.js';
|
|
57
|
+
|
|
58
|
+
export const STATE_INTRO = 'intro';
|
|
59
|
+
export const STATE_STEP1 = 'step1';
|
|
60
|
+
export const STATE_STEP2 = 'step2';
|
|
61
|
+
|
|
62
|
+
export default class MyFlowPageMachine extends PageMachine {
|
|
63
|
+
constructor(initialData = {}) {
|
|
64
|
+
const routeMap = {
|
|
65
|
+
[STATE_INTRO]: '/my-flow/intro',
|
|
66
|
+
[STATE_STEP1]: '/my-flow/step1',
|
|
67
|
+
[STATE_STEP2]: '/my-flow/step2'
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
super(STATE_INTRO, routeMap, initialData);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Computed properties for convenience
|
|
74
|
+
get inIntro() {
|
|
75
|
+
return this.current === STATE_INTRO;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get inStep1() {
|
|
79
|
+
return this.current === STATE_STEP1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Use in state container
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
// my-flow.state.svelte.js
|
|
88
|
+
import { RouteStateContext } from '$lib/state/context.js';
|
|
89
|
+
import MyFlowPageMachine from './my-flow.machine.svelte.js';
|
|
90
|
+
|
|
91
|
+
export class MyFlowState extends RouteStateContext {
|
|
92
|
+
#pageMachine;
|
|
93
|
+
|
|
94
|
+
constructor() {
|
|
95
|
+
super();
|
|
96
|
+
this.#pageMachine = new MyFlowPageMachine();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get pageMachine() {
|
|
100
|
+
return this.#pageMachine;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 3. Sync with route in +layout.svelte component
|
|
106
|
+
|
|
107
|
+
**This is IMPORTANT for url path to be connected to the page machine**
|
|
108
|
+
|
|
109
|
+
```svelte
|
|
110
|
+
<script>
|
|
111
|
+
import { page } from '$app/stores';
|
|
112
|
+
import { getMyFlowState } from '../my-flow.state.svelte.js';
|
|
113
|
+
|
|
114
|
+
const flowState = getMyFlowState();
|
|
115
|
+
const pageMachine = flowState.pageMachine;
|
|
116
|
+
|
|
117
|
+
// Sync machine with URL changes
|
|
118
|
+
$effect(() => {
|
|
119
|
+
pageMachine.syncFromPath($page.url.pathname);
|
|
120
|
+
});
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
{#if pageMachine.inIntro}
|
|
124
|
+
<IntroView />
|
|
125
|
+
{:else if pageMachine.inStep1}
|
|
126
|
+
<Step1View />
|
|
127
|
+
{/if}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Key Methods
|
|
131
|
+
|
|
132
|
+
```javascript
|
|
133
|
+
// Sync with URL path
|
|
134
|
+
machine.syncFromPath(currentPath)
|
|
135
|
+
|
|
136
|
+
// Get current state
|
|
137
|
+
machine.current
|
|
138
|
+
|
|
139
|
+
// Get route for state
|
|
140
|
+
machine.getPathForState(stateName)
|
|
141
|
+
|
|
142
|
+
// Data properties (for business logic)
|
|
143
|
+
machine.setData('KEY', value)
|
|
144
|
+
machine.getData('KEY')
|
|
145
|
+
|
|
146
|
+
// Visited states tracking
|
|
147
|
+
machine.hasVisited(stateName)
|
|
148
|
+
machine.getVisitedStates()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Important Notes
|
|
152
|
+
|
|
153
|
+
- Not a finite state machine - allows free navigation
|
|
154
|
+
- States map 1:1 with routes
|
|
155
|
+
- Use state constants instead of magic strings
|
|
156
|
+
- Always sync in `$effect` watching `$page.url.pathname`
|
|
157
|
+
- Data properties are for business logic, not UI state
|
package/dist/state/machines.d.ts
CHANGED
package/dist/state/machines.js
CHANGED