@hkdigital/lib-core 0.5.45 → 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.
@@ -9,10 +9,17 @@
9
9
  * - State-to-route mapping and sync
10
10
  * - Data properties for business/domain state
11
11
  * - Visited states tracking
12
+ * - onEnter hooks with abort/complete handlers for animations
12
13
  *
13
14
  * Basic usage:
14
15
  * ```javascript
15
- * const machine = cityState.getOrCreatePageMachine('intro', IntroPageMachine);
16
+ * const machine = new PageMachine({
17
+ * initialState: STATE_START,
18
+ * routeMap: {
19
+ * [STATE_START]: '/intro/start',
20
+ * [STATE_PROFILE]: '/intro/profile'
21
+ * }
22
+ * });
16
23
  *
17
24
  * // Sync machine state with URL changes
18
25
  * $effect(() => {
@@ -20,54 +27,73 @@
20
27
  * });
21
28
  * ```
22
29
  *
23
- * With data properties (for business logic):
30
+ * With onEnter hooks (for animations):
24
31
  * ```javascript
25
- * // Initialize with server data
26
- * const initialData = {
27
- * HAS_STRONG_PROFILE: false,
28
- * PROFILE_COMPLETED: false,
29
- * MATCHED_SECTOR: null
30
- * };
31
- * const machine = new CircuitPageMachine(initialState, routeMap, initialData);
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));
32
42
  *
33
- * // Read data
34
- * if (machine.getData('HAS_STRONG_PROFILE')) {
35
- * // Show advanced content
36
- * }
43
+ * return {
44
+ * abort: () => animation.cancel(),
45
+ * complete: () => animation.finish()
46
+ * };
47
+ * }
48
+ * }
49
+ * });
37
50
  *
38
- * // Update data (triggers reactivity)
39
- * machine.setData('HAS_STRONG_PROFILE', true);
51
+ * // Fast-forward animation
52
+ * machine.completeTransitions();
40
53
  *
41
- * // Check visited states
42
- * if (machine.hasVisited(STATE_PROFILE)) {
43
- * // User has seen profile page before
44
- * }
54
+ * // Cancel animation
55
+ * machine.abortTransitions();
45
56
  * ```
46
57
  */
47
58
  export default class PageMachine {
48
59
  /**
49
60
  * Constructor
50
61
  *
51
- * @param {string} initialState - Initial state name
52
- * @param {Record<string, string>} routeMap - Map of states to route paths
53
- * @param {Record<string, any>} [initialData={}] - Initial data properties (from server)
62
+ * @param {Object} config - Configuration object
63
+ * @param {string} config.initialState - Initial state name
64
+ * @param {Record<string, string>} [config.routeMap={}] - Map of states to route paths
65
+ * @param {Record<string, any>} [config.initialData={}] - Initial data properties (from server)
66
+ * @param {Record<string, Function>} [config.onEnterHooks={}] - Map of states to onEnter hook functions
54
67
  *
55
68
  * @example
56
69
  * ```javascript
57
- * const routeMap = {
58
- * [STATE_MATCH]: '/city/intro/match',
59
- * [STATE_CIRCUIT]: '/city/intro/racecircuit'
60
- * };
61
- *
62
- * const initialData = {
63
- * INTRO_COMPLETED: false,
64
- * PROFILE_SCORE: 0
65
- * };
66
- *
67
- * const machine = new CityIntroPageMachine(STATE_START, routeMap, initialData);
70
+ * const machine = new PageMachine({
71
+ * initialState: STATE_START,
72
+ * routeMap: {
73
+ * [STATE_START]: '/intro/start',
74
+ * [STATE_ANIMATE]: '/intro/animate'
75
+ * },
76
+ * initialData: {
77
+ * INTRO_COMPLETED: false
78
+ * },
79
+ * onEnterHooks: {
80
+ * [STATE_ANIMATE]: (done) => {
81
+ * setTimeout(() => done(STATE_START), 1000);
82
+ * return {
83
+ * abort: () => clearTimeout(...),
84
+ * complete: () => done(STATE_START)
85
+ * };
86
+ * }
87
+ * }
88
+ * });
68
89
  * ```
69
90
  */
70
- constructor(initialState: string, routeMap?: Record<string, string>, initialData?: Record<string, any>);
91
+ constructor({ initialState, routeMap, initialData, onEnterHooks }: {
92
+ initialState: string;
93
+ routeMap?: Record<string, string> | undefined;
94
+ initialData?: Record<string, any> | undefined;
95
+ onEnterHooks?: Record<string, Function> | undefined;
96
+ });
71
97
  /**
72
98
  * Synchronize machine state with URL path
73
99
  *
@@ -81,10 +107,11 @@ export default class PageMachine {
81
107
  syncFromPath(currentPath: string): boolean;
82
108
  /**
83
109
  * Set the current state directly
110
+ * Handles onEnter hooks and auto-transitions
84
111
  *
85
112
  * @param {string} newState - Target state
86
113
  */
87
- setState(newState: string): void;
114
+ setState(newState: string): Promise<void>;
88
115
  /**
89
116
  * Get route path for a given state
90
117
  *
@@ -191,5 +218,53 @@ export default class PageMachine {
191
218
  * Useful for testing or resetting experience
192
219
  */
193
220
  resetVisitedStates(): void;
221
+ /**
222
+ * Abort current state's transitions
223
+ * Cancels animations/operations immediately (incomplete state)
224
+ *
225
+ * @example
226
+ * ```javascript
227
+ * // User clicks "Cancel" button
228
+ * machine.abortTransitions();
229
+ * ```
230
+ */
231
+ abortTransitions(): void;
232
+ /**
233
+ * Complete current state's transitions immediately
234
+ * Fast-forwards animations/operations to completion (complete state)
235
+ *
236
+ * @example
237
+ * ```javascript
238
+ * // User clicks "Skip" or "Next" button
239
+ * machine.completeTransitions();
240
+ * ```
241
+ */
242
+ completeTransitions(): void;
243
+ /**
244
+ * Check if current state has transitions that can be completed
245
+ *
246
+ * @returns {boolean} True if completeTransitions() can be called
247
+ *
248
+ * @example
249
+ * ```svelte
250
+ * {#if machine.canCompleteTransitions}
251
+ * <button onclick={() => machine.completeTransitions()}>Skip</button>
252
+ * {/if}
253
+ * ```
254
+ */
255
+ get canCompleteTransitions(): boolean;
256
+ /**
257
+ * Check if current state has transitions that can be aborted
258
+ *
259
+ * @returns {boolean} True if abortTransitions() can be called
260
+ *
261
+ * @example
262
+ * ```svelte
263
+ * {#if machine.canAbortTransitions}
264
+ * <button onclick={() => machine.abortTransitions()}>Cancel</button>
265
+ * {/if}
266
+ * ```
267
+ */
268
+ get canAbortTransitions(): boolean;
194
269
  #private;
195
270
  }
@@ -9,10 +9,17 @@
9
9
  * - State-to-route mapping and sync
10
10
  * - Data properties for business/domain state
11
11
  * - Visited states tracking
12
+ * - onEnter hooks with abort/complete handlers for animations
12
13
  *
13
14
  * Basic usage:
14
15
  * ```javascript
15
- * const machine = cityState.getOrCreatePageMachine('intro', IntroPageMachine);
16
+ * const machine = new PageMachine({
17
+ * initialState: STATE_START,
18
+ * routeMap: {
19
+ * [STATE_START]: '/intro/start',
20
+ * [STATE_PROFILE]: '/intro/profile'
21
+ * }
22
+ * });
16
23
  *
17
24
  * // Sync machine state with URL changes
18
25
  * $effect(() => {
@@ -20,28 +27,32 @@
20
27
  * });
21
28
  * ```
22
29
  *
23
- * With data properties (for business logic):
30
+ * With onEnter hooks (for animations):
24
31
  * ```javascript
25
- * // Initialize with server data
26
- * const initialData = {
27
- * HAS_STRONG_PROFILE: false,
28
- * PROFILE_COMPLETED: false,
29
- * MATCHED_SECTOR: null
30
- * };
31
- * const machine = new CircuitPageMachine(initialState, routeMap, initialData);
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));
32
42
  *
33
- * // Read data
34
- * if (machine.getData('HAS_STRONG_PROFILE')) {
35
- * // Show advanced content
36
- * }
43
+ * return {
44
+ * abort: () => animation.cancel(),
45
+ * complete: () => animation.finish()
46
+ * };
47
+ * }
48
+ * }
49
+ * });
37
50
  *
38
- * // Update data (triggers reactivity)
39
- * machine.setData('HAS_STRONG_PROFILE', true);
51
+ * // Fast-forward animation
52
+ * machine.completeTransitions();
40
53
  *
41
- * // Check visited states
42
- * if (machine.hasVisited(STATE_PROFILE)) {
43
- * // User has seen profile page before
44
- * }
54
+ * // Cancel animation
55
+ * machine.abortTransitions();
45
56
  * ```
46
57
  */
47
58
  export default class PageMachine {
@@ -85,32 +96,71 @@ export default class PageMachine {
85
96
  */
86
97
  #revision = $state(0);
87
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
+
88
123
  /**
89
124
  * Constructor
90
125
  *
91
- * @param {string} initialState - Initial state name
92
- * @param {Record<string, string>} routeMap - Map of states to route paths
93
- * @param {Record<string, any>} [initialData={}] - Initial data properties (from server)
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
94
131
  *
95
132
  * @example
96
133
  * ```javascript
97
- * const routeMap = {
98
- * [STATE_MATCH]: '/city/intro/match',
99
- * [STATE_CIRCUIT]: '/city/intro/racecircuit'
100
- * };
101
- *
102
- * const initialData = {
103
- * INTRO_COMPLETED: false,
104
- * PROFILE_SCORE: 0
105
- * };
106
- *
107
- * const machine = new CityIntroPageMachine(STATE_START, routeMap, initialData);
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
+ * });
108
153
  * ```
109
154
  */
110
- constructor(initialState, routeMap = {}, initialData = {}) {
155
+ constructor({ initialState, routeMap = {}, initialData = {}, onEnterHooks = {} }) {
156
+ if (!initialState) {
157
+ throw new Error('PageMachine requires initialState parameter');
158
+ }
159
+
111
160
  this.#current = initialState;
112
161
  this.#routeMap = routeMap;
113
162
  this.#data = initialData;
163
+ this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
114
164
 
115
165
  // Build reverse map (path -> state)
116
166
  for (const [state, path] of Object.entries(routeMap)) {
@@ -121,6 +171,29 @@ export default class PageMachine {
121
171
  this.#visitedStates.add(initialState);
122
172
  }
123
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
+
124
197
  /**
125
198
  * Synchronize machine state with URL path
126
199
  *
@@ -146,13 +219,66 @@ export default class PageMachine {
146
219
 
147
220
  /**
148
221
  * Set the current state directly
222
+ * Handles onEnter hooks and auto-transitions
149
223
  *
150
224
  * @param {string} newState - Target state
151
225
  */
152
- setState(newState) {
153
- if (newState !== this.#current) {
154
- this.#current = newState;
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();
155
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++;
156
282
  }
157
283
 
158
284
  /**
@@ -334,4 +460,78 @@ export default class PageMachine {
334
460
  this.#visitedStates.add(this.#current);
335
461
  this.#revision++;
336
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
+ }
337
537
  }
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { clamp, enableContainerScaling } from '../../../design/index.js';
2
+ import { enableContainerScaling } from '../../../design/index.js';
3
3
 
4
4
  /**
5
5
  * Wrapper component that applies container scaling to its children
@@ -185,12 +185,16 @@ export function getWeekNumber(dateOrTimestamp) {
185
185
  //
186
186
  // Create a copy of this date object
187
187
  //
188
- const target = new Date(date.valueOf());
188
+ const target = new Date(Date.UTC(
189
+ date.getUTCFullYear(),
190
+ date.getUTCMonth(),
191
+ date.getUTCDate()
192
+ ));
189
193
 
190
194
  //
191
195
  // ISO week date weeks start on Monday, so correct the day number
192
196
  //
193
- const dayNumber = (date.getDay() + 6) % 7;
197
+ const dayNumber = (date.getUTCDay() + 6) % 7;
194
198
 
195
199
  //
196
200
  // ISO 8601 states that week 1 is the week with the first Thursday
@@ -198,22 +202,29 @@ export function getWeekNumber(dateOrTimestamp) {
198
202
  //
199
203
  // Set the target date to the Thursday in the target week
200
204
  //
201
- target.setDate(target.getDate() - dayNumber + 3);
205
+ target.setUTCDate(target.getUTCDate() - dayNumber + 3);
202
206
 
203
207
  //
204
208
  // Store the millisecond value of the target date
205
209
  //
206
210
  const firstThursday = target.valueOf();
207
211
 
208
- // Set the target to the first Thursday of the year
209
- // First, set the target to January 1st
210
- target.setMonth(0, 1);
212
+ //
213
+ // Get the year of the Thursday in the target week
214
+ // (This is important for dates near year boundaries)
215
+ //
216
+ const yearOfThursday = target.getUTCFullYear();
217
+
218
+ // Set the target to the first Thursday of that year
219
+ // First, set the target to January 1st of that year
220
+ target.setUTCFullYear(yearOfThursday);
221
+ target.setUTCMonth(0, 1);
211
222
 
212
223
  //
213
224
  // Not a Thursday? Correct the date to the next Thursday
214
225
  //
215
- if (target.getDay() !== 4) {
216
- target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7));
226
+ if (target.getUTCDay() !== 4) {
227
+ target.setUTCMonth(0, 1 + ((4 - target.getUTCDay() + 7) % 7));
217
228
  }
218
229
 
219
230
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hkdigital/lib-core",
3
- "version": "0.5.45",
3
+ "version": "0.5.46",
4
4
  "author": {
5
5
  "name": "HKdigital",
6
6
  "url": "https://hkdigital.nl"