@jsenv/dom 0.6.0 → 0.7.0

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.
Files changed (109) hide show
  1. package/dist/jsenv_dom.js +262 -330
  2. package/package.json +2 -4
  3. package/index.js +0 -124
  4. package/src/attr/add_attribute_effect.js +0 -93
  5. package/src/attr/attributes.js +0 -32
  6. package/src/color/color_constrast.js +0 -69
  7. package/src/color/color_parsing.js +0 -319
  8. package/src/color/color_scheme.js +0 -28
  9. package/src/color/pick_light_or_dark.js +0 -34
  10. package/src/color/resolve_css_color.js +0 -60
  11. package/src/demos/3_columns_resize_demo.html +0 -84
  12. package/src/demos/3_rows_resize_demo.html +0 -89
  13. package/src/demos/aside_and_main_demo.html +0 -93
  14. package/src/demos/coordinates_demo.html +0 -450
  15. package/src/demos/document_autoscroll_demo.html +0 -517
  16. package/src/demos/drag_gesture_constraints_demo.html +0 -701
  17. package/src/demos/drag_gesture_demo.html +0 -1047
  18. package/src/demos/drag_gesture_element_to_impact_demo.html +0 -445
  19. package/src/demos/drag_reference_element_demo.html +0 -480
  20. package/src/demos/flex_details_set_demo.html +0 -302
  21. package/src/demos/flex_details_set_demo_2.html +0 -315
  22. package/src/demos/visible_rect_demo.html +0 -525
  23. package/src/element_signature.js +0 -100
  24. package/src/interaction/drag/constraint_feedback_line.js +0 -92
  25. package/src/interaction/drag/drag_constraint.js +0 -659
  26. package/src/interaction/drag/drag_debug_markers.js +0 -635
  27. package/src/interaction/drag/drag_element_positioner.js +0 -382
  28. package/src/interaction/drag/drag_gesture.js +0 -566
  29. package/src/interaction/drag/drag_resize_demo.html +0 -571
  30. package/src/interaction/drag/drag_to_move.js +0 -301
  31. package/src/interaction/drag/drag_to_resize_gesture.js +0 -68
  32. package/src/interaction/drag/drop_target_detection.js +0 -148
  33. package/src/interaction/drag/sticky_frontiers.js +0 -160
  34. package/src/interaction/event_marker.js +0 -14
  35. package/src/interaction/focus/active_element.js +0 -33
  36. package/src/interaction/focus/arrow_navigation.js +0 -599
  37. package/src/interaction/focus/element_is_focusable.js +0 -57
  38. package/src/interaction/focus/element_visibility.js +0 -111
  39. package/src/interaction/focus/find_focusable.js +0 -21
  40. package/src/interaction/focus/focus_group.js +0 -91
  41. package/src/interaction/focus/focus_group_registry.js +0 -12
  42. package/src/interaction/focus/focus_nav.js +0 -12
  43. package/src/interaction/focus/focus_nav_event_marker.js +0 -14
  44. package/src/interaction/focus/focus_trap.js +0 -105
  45. package/src/interaction/focus/tab_navigation.js +0 -128
  46. package/src/interaction/focus/tests/focus_group_skip_tab_test.html +0 -206
  47. package/src/interaction/focus/tests/tree_focus_test.html +0 -304
  48. package/src/interaction/focus/tests/tree_focus_test.jsx +0 -261
  49. package/src/interaction/focus/tests/tree_focus_test_preact.html +0 -13
  50. package/src/interaction/isolate_interactions.js +0 -161
  51. package/src/interaction/keyboard.js +0 -26
  52. package/src/interaction/scroll/capture_scroll.js +0 -47
  53. package/src/interaction/scroll/is_scrollable.js +0 -159
  54. package/src/interaction/scroll/scroll_container.js +0 -110
  55. package/src/interaction/scroll/scroll_trap.js +0 -44
  56. package/src/interaction/scroll/scrollbar_size.js +0 -20
  57. package/src/interaction/scroll/wheel_through.js +0 -138
  58. package/src/iterable_weak_set.js +0 -66
  59. package/src/position/dom_coords.js +0 -340
  60. package/src/position/offset_parent.js +0 -15
  61. package/src/position/position_fixed.js +0 -15
  62. package/src/position/position_sticky.js +0 -213
  63. package/src/position/sticky_rect.js +0 -79
  64. package/src/position/visible_rect.js +0 -486
  65. package/src/pub_sub.js +0 -31
  66. package/src/size/can_take_size.js +0 -11
  67. package/src/size/details_content_full_height.js +0 -63
  68. package/src/size/flex_details_set.js +0 -974
  69. package/src/size/get_available_height.js +0 -22
  70. package/src/size/get_available_width.js +0 -22
  71. package/src/size/get_border_sizes.js +0 -14
  72. package/src/size/get_height.js +0 -4
  73. package/src/size/get_inner_height.js +0 -15
  74. package/src/size/get_inner_width.js +0 -15
  75. package/src/size/get_margin_sizes.js +0 -10
  76. package/src/size/get_max_height.js +0 -57
  77. package/src/size/get_max_width.js +0 -47
  78. package/src/size/get_min_height.js +0 -14
  79. package/src/size/get_min_width.js +0 -14
  80. package/src/size/get_padding_sizes.js +0 -10
  81. package/src/size/get_width.js +0 -4
  82. package/src/size/hooks/use_available_height.js +0 -27
  83. package/src/size/hooks/use_available_width.js +0 -27
  84. package/src/size/hooks/use_max_height.js +0 -10
  85. package/src/size/hooks/use_max_width.js +0 -10
  86. package/src/size/hooks/use_resize_status.js +0 -62
  87. package/src/size/resize.js +0 -695
  88. package/src/size/resolve_css_size.js +0 -32
  89. package/src/style/dom_styles.js +0 -97
  90. package/src/style/style_composition.js +0 -121
  91. package/src/style/style_controller.js +0 -345
  92. package/src/style/style_default.js +0 -153
  93. package/src/style/style_default_demo.html +0 -128
  94. package/src/style/style_parsing.js +0 -375
  95. package/src/transition/demos/animation_resumption_test.xhtml +0 -500
  96. package/src/transition/demos/height_toggle_test.xhtml +0 -515
  97. package/src/transition/dom_transition.js +0 -254
  98. package/src/transition/easing.js +0 -48
  99. package/src/transition/group_transition.js +0 -261
  100. package/src/transition/transform_style_parser.js +0 -32
  101. package/src/transition/transition_playback.js +0 -366
  102. package/src/transition/transition_timeline.js +0 -79
  103. package/src/traversal.js +0 -247
  104. package/src/ui_transition/demos/content_states_transition_demo.html +0 -628
  105. package/src/ui_transition/demos/smooth_height_transition_demo.html +0 -149
  106. package/src/ui_transition/demos/transition_testing.html +0 -354
  107. package/src/ui_transition/ui_transition.js +0 -1491
  108. package/src/utils.js +0 -69
  109. package/src/value_effect.js +0 -35
@@ -1,1491 +0,0 @@
1
- /**
2
- * Required HTML structure for UI transitions with smooth size and phase/content animations:
3
- *
4
- * <div class="ui_transition_container"
5
- * data-size-transition <!-- Optional: enable size animations -->
6
- * data-size-transition-duration <!-- Optional: size transition duration, default 300ms -->
7
- * data-content-transition <!-- Content transition type: cross-fade, slide-left -->
8
- * data-content-transition-duration <!-- Content transition duration -->
9
- * data-phase-transition <!-- Phase transition type: cross-fade only -->
10
- * data-phase-transition-duration <!-- Phase transition duration -->
11
- * >
12
- * <!-- Main container with relative positioning and overflow hidden -->
13
- *
14
- * <div class="ui_transition_outer_wrapper">
15
- * <!-- Size animation target: width/height constraints are applied here during transitions -->
16
- *
17
- * <div class="ui_transition_measure_wrapper">
18
- * <!-- Content measurement layer: ResizeObserver watches this to detect natural content size changes -->
19
- *
20
- * <div class="ui_transition_slot" data-content-key>
21
- * <!-- Content slot: actual content is inserted here via children -->
22
- * </div>
23
- *
24
- * <div class="ui_transition_phase_overlay">
25
- * <!-- Phase transition overlay: clone old content phase is positioned here for content phase transitions (loading/error) -->
26
- * </div>
27
- * </div>
28
- * </div>
29
- *
30
- * <div class="ui_transition_content_overlay">
31
- * <!-- Content transition overlay: cloned old content is positioned here for slide/fade animations -->
32
- * </div>
33
- * </div>
34
- *
35
- * This separation allows:
36
- * - Optional smooth size transitions by constraining outer-wrapper dimensions (when data-size-transition is present)
37
- * - Instant size updates by default
38
- * - Accurate content measurement via measure-wrapper ResizeObserver
39
- * - Content transitions (slide, etc.) that operate at container level and can outlive content phase changes
40
- * - Phase transitions (cross-fade only) that operate on individual elements for loading/error states
41
- * - Independent content updates in the slot without affecting ongoing animations
42
- */
43
-
44
- import { getElementSignature } from "../element_signature.js";
45
- import { getHeight } from "../size/get_height.js";
46
- import { getInnerWidth } from "../size/get_inner_width.js";
47
- import { getWidth } from "../size/get_width.js";
48
- import {
49
- createHeightTransition,
50
- createOpacityTransition,
51
- createTranslateXTransition,
52
- createWidthTransition,
53
- getOpacity,
54
- getOpacityWithoutTransition,
55
- getTranslateX,
56
- getTranslateXWithoutTransition,
57
- } from "../transition/dom_transition.js";
58
- import { createGroupTransitionController } from "../transition/group_transition.js";
59
-
60
- import.meta.css = /* css */ `
61
- .ui_transition_container {
62
- position: relative;
63
- display: inline-flex;
64
- flex: 1;
65
- }
66
-
67
- .ui_transition_outer_wrapper {
68
- display: inline-flex;
69
- flex: 1;
70
- }
71
-
72
- .ui_transition_measure_wrapper {
73
- display: inline-flex;
74
- flex: 1;
75
- }
76
-
77
- .ui_transition_slot {
78
- position: relative;
79
- display: inline-flex;
80
- flex: 1;
81
- }
82
-
83
- .ui_transition_phase_overlay {
84
- position: absolute;
85
- inset: 0;
86
- pointer-events: none;
87
- }
88
-
89
- .ui_transition_content_overlay {
90
- position: absolute;
91
- inset: 0;
92
- pointer-events: none;
93
- }
94
- `;
95
-
96
- const DEBUG = {
97
- size: false,
98
- transition: false,
99
- transition_updates: false,
100
- };
101
-
102
- // Utility function to format content key states consistently for debug logs
103
- const formatContentKeyState = (contentKey, hasChild, hasTextNode = false) => {
104
- if (hasTextNode) {
105
- return "[text]";
106
- }
107
- if (!hasChild) {
108
- return "[empty]";
109
- }
110
- if (contentKey === null || contentKey === undefined) {
111
- return "[unkeyed]";
112
- }
113
- return `[data-content-key="${contentKey}"]`;
114
- };
115
-
116
- const SIZE_TRANSITION_DURATION = 150; // Default size transition duration
117
- const SIZE_DIFF_EPSILON = 0.5; // Ignore size transition when difference below this (px)
118
- const CONTENT_TRANSITION = "cross-fade"; // Default content transition type
119
- const CONTENT_TRANSITION_DURATION = 300; // Default content transition duration
120
- const PHASE_TRANSITION = "cross-fade";
121
- const PHASE_TRANSITION_DURATION = 300; // Default phase transition duration
122
-
123
- export const initUITransition = (container) => {
124
- const localDebug = {
125
- ...DEBUG,
126
- transition: container.hasAttribute("data-debug-transition"),
127
- };
128
-
129
- const debug = (type, ...args) => {
130
- if (localDebug[type]) {
131
- console.debug(`[${type}]`, ...args);
132
- }
133
- };
134
-
135
- if (!container.classList.contains("ui_transition_container")) {
136
- console.error("Element must have ui_transition_container class");
137
- return { cleanup: () => {} };
138
- }
139
-
140
- const outerWrapper = container.querySelector(".ui_transition_outer_wrapper");
141
- const measureWrapper = container.querySelector(
142
- ".ui_transition_measure_wrapper",
143
- );
144
- const slot = container.querySelector(".ui_transition_slot");
145
- let phaseOverlay = measureWrapper.querySelector(
146
- ".ui_transition_phase_overlay",
147
- );
148
- let contentOverlay = container.querySelector(
149
- ".ui_transition_content_overlay",
150
- );
151
-
152
- if (!phaseOverlay) {
153
- phaseOverlay = document.createElement("div");
154
- phaseOverlay.className = "ui_transition_phase_overlay";
155
- measureWrapper.appendChild(phaseOverlay);
156
- }
157
- if (!contentOverlay) {
158
- contentOverlay = document.createElement("div");
159
- contentOverlay.className = "ui_transition_content_overlay";
160
- container.appendChild(contentOverlay);
161
- }
162
-
163
- if (
164
- !outerWrapper ||
165
- !measureWrapper ||
166
- !slot ||
167
- !phaseOverlay ||
168
- !contentOverlay
169
- ) {
170
- console.error("Missing required ui-transition structure");
171
- return { cleanup: () => {} };
172
- }
173
-
174
- const transitionController = createGroupTransitionController();
175
-
176
- // Transition state
177
- let activeContentTransition = null;
178
- let activeContentTransitionType = null;
179
- let activePhaseTransition = null;
180
- let activePhaseTransitionType = null;
181
- let isPaused = false;
182
-
183
- // Size state
184
- let naturalContentWidth = 0; // Natural size of actual content (not loading/error states)
185
- let naturalContentHeight = 0;
186
- let constrainedWidth = 0; // Current constrained dimensions (what outer wrapper is set to)
187
- let constrainedHeight = 0;
188
- let sizeTransition = null;
189
- let resizeObserver = null;
190
- let sizeHoldActive = false; // Hold previous dimensions during content transitions when size transitions are disabled
191
-
192
- // Prevent reacting to our own constrained size changes while animating
193
- let suppressResizeObserver = false;
194
- let pendingResizeSync = false; // ensure one measurement after suppression ends
195
-
196
- // Handle size updates based on content state
197
- let hasSizeTransitions = container.hasAttribute("data-size-transition");
198
- const initialTransitionEnabled = container.hasAttribute(
199
- "data-initial-transition",
200
- );
201
- let hasPopulatedOnce = false; // track if we've already populated once (null → something)
202
-
203
- // Child state
204
- let lastContentKey = null;
205
- let previousChild = null;
206
- let isContentPhase = false; // Current state: true when showing content phase (loading/error)
207
- let wasContentPhase = false; // Previous state for comparison
208
-
209
- const measureContentSize = () => {
210
- return [getWidth(measureWrapper), getHeight(measureWrapper)];
211
- };
212
-
213
- const updateContentDimensions = () => {
214
- const [newWidth, newHeight] = measureContentSize();
215
- debug("size", "Content size changed:", {
216
- width: `${naturalContentWidth} → ${newWidth}`,
217
- height: `${naturalContentHeight} → ${newHeight}`,
218
- });
219
-
220
- updateNaturalContentSize(newWidth, newHeight);
221
-
222
- if (sizeTransition) {
223
- debug("size", "Updating animation target:", newHeight);
224
- updateToSize(newWidth, newHeight);
225
- } else {
226
- constrainedWidth = newWidth;
227
- constrainedHeight = newHeight;
228
- }
229
- };
230
-
231
- const stopResizeObserver = () => {
232
- if (resizeObserver) {
233
- resizeObserver.disconnect();
234
- resizeObserver = null;
235
- }
236
- };
237
-
238
- const startResizeObserver = () => {
239
- resizeObserver = new ResizeObserver(() => {
240
- if (!hasSizeTransitions) {
241
- return;
242
- }
243
- if (suppressResizeObserver) {
244
- pendingResizeSync = true;
245
- debug("size", "Resize ignored (suppressed during size transition)");
246
- return;
247
- }
248
- updateContentDimensions();
249
- });
250
- resizeObserver.observe(measureWrapper);
251
- };
252
-
253
- const releaseConstraints = (reason) => {
254
- debug("size", `Releasing constraints (${reason})`);
255
- const [beforeWidth, beforeHeight] = measureContentSize();
256
- outerWrapper.style.width = "";
257
- outerWrapper.style.height = "";
258
- outerWrapper.style.overflow = "";
259
- const [afterWidth, afterHeight] = measureContentSize();
260
- debug("size", "Size after release:", {
261
- width: `${beforeWidth} → ${afterWidth}`,
262
- height: `${beforeHeight} → ${afterHeight}`,
263
- });
264
- constrainedWidth = afterWidth;
265
- constrainedHeight = afterHeight;
266
- naturalContentWidth = afterWidth;
267
- naturalContentHeight = afterHeight;
268
- // Defer a sync if suppression just ended; actual dispatch will come from resize observer
269
- if (!suppressResizeObserver && pendingResizeSync) {
270
- pendingResizeSync = false;
271
- updateContentDimensions();
272
- }
273
- };
274
-
275
- const updateToSize = (targetWidth, targetHeight) => {
276
- if (
277
- constrainedWidth === targetWidth &&
278
- constrainedHeight === targetHeight
279
- ) {
280
- return;
281
- }
282
-
283
- const shouldAnimate = container.hasAttribute("data-size-transition");
284
- const widthDiff = Math.abs(targetWidth - constrainedWidth);
285
- const heightDiff = Math.abs(targetHeight - constrainedHeight);
286
-
287
- if (widthDiff <= SIZE_DIFF_EPSILON && heightDiff <= SIZE_DIFF_EPSILON) {
288
- // Both diffs negligible; just sync styles if changed and bail
289
- if (widthDiff > 0) {
290
- outerWrapper.style.width = `${targetWidth}px`;
291
- constrainedWidth = targetWidth;
292
- }
293
- if (heightDiff > 0) {
294
- outerWrapper.style.height = `${targetHeight}px`;
295
- constrainedHeight = targetHeight;
296
- }
297
- debug(
298
- "size",
299
- `Skip size animation entirely (diffs width:${widthDiff.toFixed(4)}px height:${heightDiff.toFixed(4)}px)`,
300
- );
301
- return;
302
- }
303
-
304
- if (!shouldAnimate) {
305
- // No size transitions - just update dimensions instantly
306
- debug("size", "Updating size instantly:", {
307
- width: `${constrainedWidth} → ${targetWidth}`,
308
- height: `${constrainedHeight} → ${targetHeight}`,
309
- });
310
- suppressResizeObserver = true;
311
- outerWrapper.style.width = `${targetWidth}px`;
312
- outerWrapper.style.height = `${targetHeight}px`;
313
- constrainedWidth = targetWidth;
314
- constrainedHeight = targetHeight;
315
- // allow any resize notifications to settle then re-enable
316
- requestAnimationFrame(() => {
317
- suppressResizeObserver = false;
318
- if (pendingResizeSync) {
319
- pendingResizeSync = false;
320
- updateContentDimensions();
321
- }
322
- });
323
- return;
324
- }
325
-
326
- // Animated size transition
327
- debug("size", "Animating size:", {
328
- width: `${constrainedWidth} → ${targetWidth}`,
329
- height: `${constrainedHeight} → ${targetHeight}`,
330
- });
331
-
332
- const duration = parseInt(
333
- container.getAttribute("data-size-transition-duration") ||
334
- SIZE_TRANSITION_DURATION,
335
- );
336
-
337
- outerWrapper.style.overflow = "hidden";
338
- const transitions = [];
339
-
340
- // heightDiff & widthDiff already computed earlier in updateToSize when deciding to skip entirely
341
- if (heightDiff <= SIZE_DIFF_EPSILON) {
342
- // Treat as identical
343
- if (heightDiff > 0) {
344
- debug(
345
- "size",
346
- `Skip height transition (negligible diff ${heightDiff.toFixed(4)}px)`,
347
- );
348
- }
349
- outerWrapper.style.height = `${targetHeight}px`;
350
- constrainedHeight = targetHeight;
351
- } else if (targetHeight !== constrainedHeight) {
352
- transitions.push(
353
- createHeightTransition(outerWrapper, targetHeight, {
354
- duration,
355
- onUpdate: ({ value }) => {
356
- constrainedHeight = value;
357
- },
358
- }),
359
- );
360
- }
361
-
362
- if (widthDiff <= SIZE_DIFF_EPSILON) {
363
- if (widthDiff > 0) {
364
- debug(
365
- "size",
366
- `Skip width transition (negligible diff ${widthDiff.toFixed(4)}px)`,
367
- );
368
- }
369
- outerWrapper.style.width = `${targetWidth}px`;
370
- constrainedWidth = targetWidth;
371
- } else if (targetWidth !== constrainedWidth) {
372
- transitions.push(
373
- createWidthTransition(outerWrapper, targetWidth, {
374
- duration,
375
- onUpdate: ({ value }) => {
376
- constrainedWidth = value;
377
- },
378
- }),
379
- );
380
- }
381
-
382
- if (transitions.length > 0) {
383
- suppressResizeObserver = true;
384
- sizeTransition = transitionController.animate(transitions, {
385
- onFinish: () => {
386
- releaseConstraints("animated size transition completed");
387
- // End suppression next frame to avoid RO loop warnings
388
- requestAnimationFrame(() => {
389
- suppressResizeObserver = false;
390
- if (pendingResizeSync) {
391
- pendingResizeSync = false;
392
- updateContentDimensions();
393
- }
394
- });
395
- },
396
- });
397
- sizeTransition.play();
398
- } else {
399
- debug(
400
- "size",
401
- "No size transitions created (identical or negligible differences)",
402
- );
403
- }
404
- };
405
-
406
- const applySizeConstraints = (targetWidth, targetHeight) => {
407
- debug("size", "Applying size constraints:", {
408
- width: `${constrainedWidth} → ${targetWidth}`,
409
- height: `${constrainedHeight} → ${targetHeight}`,
410
- });
411
-
412
- outerWrapper.style.width = `${targetWidth}px`;
413
- outerWrapper.style.height = `${targetHeight}px`;
414
- outerWrapper.style.overflow = "hidden";
415
- constrainedWidth = targetWidth;
416
- constrainedHeight = targetHeight;
417
- };
418
-
419
- const updateNaturalContentSize = (newWidth, newHeight) => {
420
- debug("size", "Updating natural content size:", {
421
- width: `${naturalContentWidth} → ${newWidth}`,
422
- height: `${naturalContentHeight} → ${newHeight}`,
423
- });
424
- naturalContentWidth = newWidth;
425
- naturalContentHeight = newHeight;
426
- };
427
-
428
- let isUpdating = false;
429
-
430
- // Shared transition setup function
431
- const setupTransition = ({
432
- isPhaseTransition = false,
433
- overlay,
434
- existingOldContents,
435
- needsOldChildClone,
436
- previousChild,
437
- firstChild,
438
- attributeToRemove = [],
439
- }) => {
440
- let oldChild = null;
441
- let cleanup = () => {};
442
- const currentTransitionElement = existingOldContents[0];
443
-
444
- if (currentTransitionElement) {
445
- oldChild = currentTransitionElement;
446
- debug(
447
- "transition",
448
- `Continuing from current ${isPhaseTransition ? "phase" : "content"} transition element`,
449
- );
450
- cleanup = () => oldChild.remove();
451
- } else if (needsOldChildClone) {
452
- overlay.innerHTML = "";
453
-
454
- // Clone the individual element for the transition
455
- oldChild = previousChild.cloneNode(true);
456
-
457
- // Remove specified attributes
458
- attributeToRemove.forEach((attr) => oldChild.removeAttribute(attr));
459
-
460
- oldChild.setAttribute("data-ui-transition-old", "");
461
- overlay.appendChild(oldChild);
462
- debug(
463
- "transition",
464
- `Cloned previous child for ${isPhaseTransition ? "phase" : "content"} transition:`,
465
- getElementSignature(previousChild),
466
- );
467
- cleanup = () => oldChild.remove();
468
- } else {
469
- overlay.innerHTML = "";
470
- debug(
471
- "transition",
472
- `No old child to clone for ${isPhaseTransition ? "phase" : "content"} transition`,
473
- );
474
- }
475
-
476
- // Determine which elements to return based on transition type:
477
- // - Phase transitions: operate on individual elements (cross-fade between specific elements)
478
- // - Content transitions: operate at container level (slide entire containers, outlive content phases)
479
- let oldElement;
480
- let newElement;
481
- if (isPhaseTransition) {
482
- // Phase transitions work on individual elements
483
- oldElement = oldChild;
484
- newElement = firstChild;
485
- } else {
486
- // Content transitions work at container level and can outlive content phase changes
487
- oldElement = oldChild ? overlay : null;
488
- newElement = firstChild ? measureWrapper : null;
489
- }
490
-
491
- return {
492
- oldChild,
493
- cleanup,
494
- oldElement,
495
- newElement,
496
- };
497
- };
498
-
499
- // Initialize with current size
500
- [constrainedWidth, constrainedHeight] = measureContentSize();
501
-
502
- const handleChildSlotMutation = (reason = "mutation") => {
503
- if (isUpdating) {
504
- debug("transition", "Preventing recursive update");
505
- return;
506
- }
507
-
508
- hasSizeTransitions = container.hasAttribute("data-size-transition");
509
-
510
- try {
511
- isUpdating = true;
512
- const firstChild = slot.children[0] || null;
513
- const childUIName = firstChild?.getAttribute("data-ui-name");
514
- if (localDebug.transition) {
515
- const updateLabel =
516
- childUIName ||
517
- (firstChild ? getElementSignature(firstChild) : "cleared/empty");
518
- console.group(`UI Update: ${updateLabel} (reason: ${reason})`);
519
- }
520
-
521
- // Check for text nodes in the slot (not supported)
522
- const hasTextNode = Array.from(slot.childNodes).some(
523
- (node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim(),
524
- );
525
- if (hasTextNode) {
526
- console.warn(
527
- "UI Transition: Text nodes in transition slots are not supported. Please wrap text content in an element.",
528
- { slot, textContent: slot.textContent.trim() },
529
- );
530
- }
531
-
532
- // Check for multiple elements in the slot (not supported yet)
533
- const hasMultipleElements = slot.children.length > 1;
534
- if (hasMultipleElements) {
535
- console.warn(
536
- "UI Transition: Multiple elements in transition slots are not supported yet. Please use a single container element.",
537
- { slot, elementCount: slot.children.length },
538
- );
539
- }
540
-
541
- // Prefer data-content-key on child, fallback to slot
542
- let currentContentKey = null;
543
- let slotContentKey = slot.getAttribute("data-content-key");
544
- let childContentKey = firstChild?.getAttribute("data-content-key");
545
- if (childContentKey && slotContentKey) {
546
- console.warn(
547
- "Both data-content-key found on child and ui_transition_slot. Using child value.",
548
- { childContentKey, slotContentKey },
549
- );
550
- }
551
- currentContentKey = childContentKey || slotContentKey || null;
552
-
553
- // Determine transition scenarios early for early registration check
554
- const hadChild = previousChild !== null;
555
- const hasChild = firstChild !== null;
556
-
557
- // Check for text nodes in previous state (reconstruct from previousChild)
558
- const hadTextNode =
559
- previousChild && previousChild.nodeType === Node.TEXT_NODE;
560
-
561
- // Compute formatted content key states ONCE per mutation (requirement: max 2 calls)
562
- const previousContentKeyState = formatContentKeyState(
563
- lastContentKey,
564
- hadChild,
565
- hadTextNode,
566
- );
567
- const currentContentKeyState = formatContentKeyState(
568
- currentContentKey,
569
- hasChild,
570
- hasTextNode,
571
- );
572
-
573
- // Track previous key before any potential early registration update
574
- const prevKeyBeforeRegistration = lastContentKey;
575
-
576
- // Prepare phase info early so logging can be unified (even for early return)
577
- wasContentPhase = isContentPhase;
578
- isContentPhase = firstChild
579
- ? firstChild.hasAttribute("data-content-phase")
580
- : true; // empty (no child) is treated as content phase
581
-
582
- const previousIsContentPhase = !hadChild || wasContentPhase;
583
- const currentIsContentPhase = !hasChild || isContentPhase;
584
-
585
- // Early conceptual registration path: empty slot, text nodes, or multiple elements (no visual transition)
586
- const shouldGiveUpEarlyAndJustRegister =
587
- (!hadChild && !hasChild && !hasTextNode) ||
588
- hasTextNode ||
589
- hasMultipleElements;
590
- let earlyAction = null;
591
- if (shouldGiveUpEarlyAndJustRegister) {
592
- if (hasTextNode) {
593
- earlyAction = "text_nodes_unsupported";
594
- } else if (hasMultipleElements) {
595
- earlyAction = "multiple_elements_unsupported";
596
- } else {
597
- const prevKey = prevKeyBeforeRegistration;
598
- const keyChanged = prevKey !== currentContentKey;
599
- if (!keyChanged) {
600
- earlyAction = "unchanged";
601
- } else if (prevKey === null && currentContentKey !== null) {
602
- earlyAction = "registered";
603
- } else if (prevKey !== null && currentContentKey === null) {
604
- earlyAction = "cleared";
605
- } else {
606
- earlyAction = "changed";
607
- }
608
- }
609
- // Will update lastContentKey after unified logging
610
- }
611
-
612
- // Decide which representation to display for previous/current in early case
613
- const conceptualPrevDisplay =
614
- prevKeyBeforeRegistration === null
615
- ? "[unkeyed]"
616
- : `[data-content-key="${prevKeyBeforeRegistration}"]`;
617
- const conceptualCurrentDisplay =
618
- currentContentKey === null
619
- ? "[unkeyed]"
620
- : `[data-content-key="${currentContentKey}"]`;
621
- const previousDisplay = shouldGiveUpEarlyAndJustRegister
622
- ? conceptualPrevDisplay
623
- : previousContentKeyState;
624
- const currentDisplay = shouldGiveUpEarlyAndJustRegister
625
- ? conceptualCurrentDisplay
626
- : currentContentKeyState;
627
-
628
- // Build a simple descriptive sentence
629
- let contentKeysSentence = `Content key: ${previousDisplay} → ${currentDisplay}`;
630
- debug("transition", contentKeysSentence);
631
-
632
- if (shouldGiveUpEarlyAndJustRegister) {
633
- // Log decision explicitly (was previously embedded)
634
- debug("transition", `Decision: EARLY_RETURN (${earlyAction})`);
635
- // Register new conceptual key & return early (skip rest of transition logic)
636
- lastContentKey = currentContentKey;
637
- if (localDebug.transition) {
638
- console.groupEnd();
639
- }
640
- return;
641
- }
642
- debug(
643
- "size",
644
- `Update triggered, size: ${constrainedWidth}x${constrainedHeight}`,
645
- );
646
-
647
- if (sizeTransition) {
648
- sizeTransition.cancel();
649
- }
650
-
651
- const [newWidth, newHeight] = measureContentSize();
652
- debug("size", `Measured size: ${newWidth}x${newHeight}`);
653
- outerWrapper.style.width = `${constrainedWidth}px`;
654
- outerWrapper.style.height = `${constrainedHeight}px`;
655
-
656
- // Handle resize observation
657
- stopResizeObserver();
658
- if (firstChild && !isContentPhase) {
659
- startResizeObserver();
660
- debug("size", "Observing child resize");
661
- }
662
-
663
- // Determine transition scenarios (hadChild/hasChild already computed above for logging)
664
-
665
- /**
666
- * Content Phase Logic: Why empty slots are treated as content phases
667
- *
668
- * When there is no child element (React component returns null), it is considered
669
- * that the component does not render anything temporarily. This might be because:
670
- * - The component is loading but does not have a loading state
671
- * - The component has an error but does not have an error state
672
- * - The component is conceptually unloaded (underlying content was deleted/is not accessible)
673
- *
674
- * This represents a phase of the given content: having nothing to display.
675
- *
676
- * We support transitions between different contents via the ability to set
677
- * [data-content-key] on the ".ui_transition_slot". This is also useful when you want
678
- * all children of a React component to inherit the same data-content-key without
679
- * explicitly setting the attribute on each child element.
680
- */
681
-
682
- // Content key change when either slot or child has data-content-key and it changed
683
- let shouldDoContentTransition = false;
684
- if (
685
- (slot.getAttribute("data-content-key") ||
686
- firstChild?.getAttribute("data-content-key")) &&
687
- lastContentKey !== null
688
- ) {
689
- shouldDoContentTransition = currentContentKey !== lastContentKey;
690
- }
691
-
692
- const becomesEmpty = hadChild && !hasChild;
693
- const becomesPopulated = !hadChild && hasChild;
694
- const isInitialPopulationWithoutTransition =
695
- becomesPopulated && !hasPopulatedOnce && !initialTransitionEnabled;
696
-
697
- // Content phase change: any transition between content/content-phase/null except when slot key changes
698
- // This includes: null→loading, loading→content, content→loading, loading→null, etc.
699
- const shouldDoPhaseTransition =
700
- !shouldDoContentTransition &&
701
- (becomesPopulated ||
702
- becomesEmpty ||
703
- (hadChild &&
704
- hasChild &&
705
- (previousIsContentPhase !== currentIsContentPhase ||
706
- (previousIsContentPhase && currentIsContentPhase))));
707
-
708
- const contentChange = hadChild && hasChild && shouldDoContentTransition;
709
- const phaseChange = hadChild && hasChild && shouldDoPhaseTransition;
710
-
711
- // Determine if we only need to preserve an existing content transition (no new change)
712
- const preserveOnlyContentTransition =
713
- activeContentTransition !== null &&
714
- !shouldDoContentTransition &&
715
- !shouldDoPhaseTransition &&
716
- !becomesPopulated &&
717
- !becomesEmpty;
718
-
719
- // Include becomesPopulated in content transition only if it's not a phase transition
720
- const shouldDoContentTransitionIncludingPopulation =
721
- shouldDoContentTransition ||
722
- (becomesPopulated && !shouldDoPhaseTransition);
723
-
724
- const decisions = [];
725
- if (shouldDoContentTransition) decisions.push("CONTENT TRANSITION");
726
- if (shouldDoPhaseTransition) decisions.push("PHASE TRANSITION");
727
- if (preserveOnlyContentTransition)
728
- decisions.push("PRESERVE CONTENT TRANSITION");
729
- if (decisions.length === 0) decisions.push("NO TRANSITION");
730
-
731
- debug("transition", `Decision: ${decisions.join(" + ")}`);
732
- if (preserveOnlyContentTransition) {
733
- const progress = (activeContentTransition.progress * 100).toFixed(1);
734
- debug(
735
- "transition",
736
- `Preserving existing content transition (progress ${progress}%)`,
737
- );
738
- }
739
-
740
- // Early return optimization: if no transition decision and we are not continuing
741
- // an existing active content transition (animationProgress > 0), we can skip
742
- // all transition setup logic below.
743
- if (
744
- decisions.length === 1 &&
745
- decisions[0] === "NO TRANSITION" &&
746
- activeContentTransition === null &&
747
- activePhaseTransition === null
748
- ) {
749
- debug(
750
- "transition",
751
- `Early return: no transition or continuation required`,
752
- );
753
- // Still ensure size logic executes below (so do not return before size alignment)
754
- }
755
-
756
- // Handle initial population skip (first null → something): no content or size animations
757
- if (isInitialPopulationWithoutTransition) {
758
- debug(
759
- "transition",
760
- "Initial population detected: skipping transitions (opt-in with data-initial-transition)",
761
- );
762
-
763
- // Apply sizes instantly, no animation
764
- if (isContentPhase) {
765
- applySizeConstraints(newWidth, newHeight);
766
- } else {
767
- updateNaturalContentSize(newWidth, newHeight);
768
- releaseConstraints("initial population - skip transitions");
769
- }
770
-
771
- // Register state and mark initial population done
772
- previousChild = firstChild;
773
- lastContentKey = currentContentKey;
774
- hasPopulatedOnce = true;
775
- if (localDebug.transition) {
776
- console.groupEnd();
777
- }
778
- return;
779
- }
780
-
781
- // Plan size transition upfront; execution will happen after content/phase transitions
782
- let sizePlan = {
783
- action: "none",
784
- targetWidth: constrainedWidth,
785
- targetHeight: constrainedHeight,
786
- };
787
-
788
- size_transition: {
789
- const getTargetDimensions = () => {
790
- if (!isContentPhase) {
791
- return [newWidth, newHeight];
792
- }
793
- const shouldUseNewDimensions =
794
- naturalContentWidth === 0 && naturalContentHeight === 0;
795
- const targetWidth = shouldUseNewDimensions
796
- ? newWidth
797
- : naturalContentWidth || newWidth;
798
- const targetHeight = shouldUseNewDimensions
799
- ? newHeight
800
- : naturalContentHeight || newHeight;
801
- return [targetWidth, targetHeight];
802
- };
803
-
804
- const [targetWidth, targetHeight] = getTargetDimensions();
805
- sizePlan.targetWidth = targetWidth;
806
- sizePlan.targetHeight = targetHeight;
807
-
808
- if (
809
- targetWidth === constrainedWidth &&
810
- targetHeight === constrainedHeight
811
- ) {
812
- debug("size", "No size change required");
813
- // We'll handle potential constraint release in final section (if not holding)
814
- break size_transition;
815
- }
816
-
817
- debug("size", "Size change needed:", {
818
- width: `${constrainedWidth} → ${targetWidth}`,
819
- height: `${constrainedHeight} → ${targetHeight}`,
820
- });
821
-
822
- if (isContentPhase) {
823
- // Content phases (loading/error) always use size constraints for consistent sizing
824
- sizePlan.action = hasSizeTransitions ? "animate" : "applyConstraints";
825
- } else {
826
- // Actual content: update natural content dimensions for future content phases
827
- updateNaturalContentSize(targetWidth, targetHeight);
828
- sizePlan.action = hasSizeTransitions ? "animate" : "release";
829
- }
830
- }
831
-
832
- content_transition: {
833
- // Handle content transitions (slide-left, cross-fade for content key changes)
834
- if (
835
- decisions.length === 1 &&
836
- decisions[0] === "NO TRANSITION" &&
837
- activeContentTransition === null &&
838
- activePhaseTransition === null
839
- ) {
840
- // Skip creating any new transitions entirely
841
- } else if (
842
- shouldDoContentTransitionIncludingPopulation &&
843
- !preserveOnlyContentTransition
844
- ) {
845
- const existingOldContents = contentOverlay.querySelectorAll(
846
- "[data-ui-transition-old]",
847
- );
848
- const animationProgress = activeContentTransition?.progress || 0;
849
-
850
- if (animationProgress > 0) {
851
- debug(
852
- "transition",
853
- `Preserving content transition progress: ${(animationProgress * 100).toFixed(1)}%`,
854
- );
855
- }
856
-
857
- const newTransitionType =
858
- container.getAttribute("data-content-transition") ||
859
- CONTENT_TRANSITION;
860
- const canContinueSmoothly =
861
- activeContentTransitionType === newTransitionType &&
862
- activeContentTransition;
863
-
864
- if (canContinueSmoothly) {
865
- debug(
866
- "transition",
867
- "Continuing with same content transition type (restarting due to actual change)",
868
- );
869
- activeContentTransition.cancel();
870
- } else if (
871
- activeContentTransition &&
872
- activeContentTransitionType !== newTransitionType
873
- ) {
874
- debug(
875
- "transition",
876
- "Different content transition type, keeping both",
877
- `${activeContentTransitionType} → ${newTransitionType}`,
878
- );
879
- } else if (activeContentTransition) {
880
- debug("transition", "Cancelling current content transition");
881
- activeContentTransition.cancel();
882
- }
883
-
884
- const needsOldChildClone =
885
- (contentChange || becomesEmpty) &&
886
- previousChild &&
887
- !existingOldContents[0];
888
-
889
- const duration = parseInt(
890
- container.getAttribute("data-content-transition-duration") ||
891
- CONTENT_TRANSITION_DURATION,
892
- );
893
- const type =
894
- container.getAttribute("data-content-transition") ||
895
- CONTENT_TRANSITION;
896
-
897
- const setupContentTransition = () =>
898
- setupTransition({
899
- isPhaseTransition: false,
900
- overlay: contentOverlay,
901
- existingOldContents,
902
- needsOldChildClone,
903
- previousChild,
904
- firstChild,
905
- attributeToRemove: ["data-content-key"],
906
- });
907
-
908
- // If size transitions are disabled and the new content is smaller,
909
- // hold the previous size to avoid cropping during the transition.
910
- if (!hasSizeTransitions) {
911
- const willShrinkWidth = constrainedWidth > newWidth;
912
- const willShrinkHeight = constrainedHeight > newHeight;
913
- sizeHoldActive = willShrinkWidth || willShrinkHeight;
914
- if (sizeHoldActive) {
915
- debug(
916
- "size",
917
- `Holding previous size during content transition: ${constrainedWidth}x${constrainedHeight}`,
918
- );
919
- applySizeConstraints(constrainedWidth, constrainedHeight);
920
- }
921
- }
922
-
923
- activeContentTransition = animateTransition(
924
- transitionController,
925
- firstChild,
926
- setupContentTransition,
927
- {
928
- duration,
929
- type,
930
- animationProgress,
931
- isPhaseTransition: false,
932
- fromContentKeyState: previousContentKeyState,
933
- toContentKeyState: currentContentKeyState,
934
- onComplete: () => {
935
- activeContentTransition = null;
936
- activeContentTransitionType = null;
937
- if (sizeHoldActive) {
938
- // Release the hold after the content transition completes
939
- releaseConstraints(
940
- "content transition completed - release size hold",
941
- );
942
- sizeHoldActive = false;
943
- }
944
- },
945
- debug,
946
- },
947
- );
948
-
949
- if (activeContentTransition) {
950
- activeContentTransition.play();
951
- }
952
- activeContentTransitionType = type;
953
- } else if (
954
- !shouldDoContentTransition &&
955
- !preserveOnlyContentTransition
956
- ) {
957
- // Clean up content overlay if no content transition needed and nothing to preserve
958
- contentOverlay.innerHTML = "";
959
- activeContentTransition = null;
960
- activeContentTransitionType = null;
961
- }
962
-
963
- // Handle phase transitions (cross-fade for content phase changes)
964
- if (shouldDoPhaseTransition) {
965
- const phaseTransitionType =
966
- container.getAttribute("data-phase-transition") || PHASE_TRANSITION;
967
-
968
- const existingOldPhaseContents = phaseOverlay.querySelectorAll(
969
- "[data-ui-transition-old]",
970
- );
971
- const phaseAnimationProgress = activePhaseTransition?.progress || 0;
972
-
973
- if (phaseAnimationProgress > 0) {
974
- debug(
975
- "transition",
976
- `Preserving phase transition progress: ${(phaseAnimationProgress * 100).toFixed(1)}%`,
977
- );
978
- }
979
-
980
- const canContinueSmoothly =
981
- activePhaseTransitionType === phaseTransitionType &&
982
- activePhaseTransition;
983
-
984
- if (canContinueSmoothly) {
985
- debug("transition", "Continuing with same phase transition type");
986
- activePhaseTransition.cancel();
987
- } else if (
988
- activePhaseTransition &&
989
- activePhaseTransitionType !== phaseTransitionType
990
- ) {
991
- debug(
992
- "transition",
993
- "Different phase transition type, keeping both",
994
- `${activePhaseTransitionType} → ${phaseTransitionType}`,
995
- );
996
- } else if (activePhaseTransition) {
997
- debug("transition", "Cancelling current phase transition");
998
- activePhaseTransition.cancel();
999
- }
1000
-
1001
- const needsOldPhaseClone =
1002
- (becomesEmpty || becomesPopulated || phaseChange) &&
1003
- previousChild &&
1004
- !existingOldPhaseContents[0];
1005
-
1006
- const phaseDuration = parseInt(
1007
- container.getAttribute("data-phase-transition-duration") ||
1008
- PHASE_TRANSITION_DURATION,
1009
- );
1010
-
1011
- const setupPhaseTransition = () =>
1012
- setupTransition({
1013
- isPhaseTransition: true,
1014
- overlay: phaseOverlay,
1015
- existingOldContents: existingOldPhaseContents,
1016
- needsOldChildClone: needsOldPhaseClone,
1017
- previousChild,
1018
- firstChild,
1019
- attributeToRemove: ["data-content-key", "data-content-phase"],
1020
- });
1021
-
1022
- const fromPhase = !hadChild
1023
- ? "null"
1024
- : wasContentPhase
1025
- ? "content-phase"
1026
- : "content";
1027
- const toPhase = !hasChild
1028
- ? "null"
1029
- : isContentPhase
1030
- ? "content-phase"
1031
- : "content";
1032
-
1033
- debug(
1034
- "transition",
1035
- `Starting phase transition: ${fromPhase} → ${toPhase}`,
1036
- );
1037
-
1038
- activePhaseTransition = animateTransition(
1039
- transitionController,
1040
- firstChild,
1041
- setupPhaseTransition,
1042
- {
1043
- duration: phaseDuration,
1044
- type: phaseTransitionType,
1045
- animationProgress: phaseAnimationProgress,
1046
- isPhaseTransition: true,
1047
- fromContentKeyState: previousContentKeyState,
1048
- toContentKeyState: currentContentKeyState,
1049
- onComplete: () => {
1050
- activePhaseTransition = null;
1051
- activePhaseTransitionType = null;
1052
- debug("transition", "Phase transition complete");
1053
- },
1054
- debug,
1055
- },
1056
- );
1057
-
1058
- if (activePhaseTransition) {
1059
- activePhaseTransition.play();
1060
- }
1061
- activePhaseTransitionType = phaseTransitionType;
1062
- }
1063
- }
1064
-
1065
- // Store current child for next transition
1066
- previousChild = firstChild;
1067
- lastContentKey = currentContentKey;
1068
- if (becomesPopulated) {
1069
- hasPopulatedOnce = true;
1070
- }
1071
-
1072
- // Execute planned size action, unless holding size during a content transition
1073
- if (!sizeHoldActive) {
1074
- if (
1075
- sizePlan.targetWidth === constrainedWidth &&
1076
- sizePlan.targetHeight === constrainedHeight
1077
- ) {
1078
- // no size changes planned; possibly release constraints
1079
- if (!isContentPhase) {
1080
- releaseConstraints("no size change needed");
1081
- }
1082
- } else if (sizePlan.action === "animate") {
1083
- updateToSize(sizePlan.targetWidth, sizePlan.targetHeight);
1084
- } else if (sizePlan.action === "applyConstraints") {
1085
- applySizeConstraints(sizePlan.targetWidth, sizePlan.targetHeight);
1086
- } else if (sizePlan.action === "release") {
1087
- releaseConstraints("actual content - no size transitions needed");
1088
- }
1089
- }
1090
- } finally {
1091
- isUpdating = false;
1092
- if (localDebug.transition) {
1093
- console.groupEnd();
1094
- }
1095
- }
1096
- };
1097
-
1098
- // Run once at init to process current slot content (warnings, sizing, transitions)
1099
- handleChildSlotMutation("init");
1100
-
1101
- // Watch for child changes and attribute changes on children
1102
- const mutationObserver = new MutationObserver((mutations) => {
1103
- let childListMutation = false;
1104
- const attributeMutationSet = new Set();
1105
-
1106
- for (const mutation of mutations) {
1107
- if (mutation.type === "childList") {
1108
- childListMutation = true;
1109
- continue;
1110
- }
1111
- if (mutation.type === "attributes") {
1112
- const { attributeName, target } = mutation;
1113
- if (
1114
- attributeName === "data-content-key" ||
1115
- attributeName === "data-content-phase"
1116
- ) {
1117
- attributeMutationSet.add(attributeName);
1118
- debug(
1119
- "transition",
1120
- `Attribute change detected: ${attributeName} on`,
1121
- getElementSignature(target),
1122
- );
1123
- }
1124
- }
1125
- }
1126
-
1127
- if (!childListMutation && attributeMutationSet.size === 0) {
1128
- return;
1129
- }
1130
- const reasonParts = [];
1131
- if (childListMutation) {
1132
- reasonParts.push("childList change");
1133
- }
1134
- if (attributeMutationSet.size) {
1135
- for (const attr of attributeMutationSet) {
1136
- reasonParts.push(`[${attr}] change`);
1137
- }
1138
- }
1139
- const reason = reasonParts.join("+");
1140
- handleChildSlotMutation(reason);
1141
- });
1142
-
1143
- mutationObserver.observe(slot, {
1144
- childList: true,
1145
- attributes: true,
1146
- attributeFilter: ["data-content-key", "data-content-phase"],
1147
- characterData: false,
1148
- });
1149
-
1150
- // Return API
1151
- return {
1152
- slot,
1153
-
1154
- cleanup: () => {
1155
- mutationObserver.disconnect();
1156
- stopResizeObserver();
1157
- if (sizeTransition) {
1158
- sizeTransition.cancel();
1159
- }
1160
- if (activeContentTransition) {
1161
- activeContentTransition.cancel();
1162
- }
1163
- if (activePhaseTransition) {
1164
- activePhaseTransition.cancel();
1165
- }
1166
- },
1167
- pause: () => {
1168
- if (activeContentTransition?.pause) {
1169
- activeContentTransition.pause();
1170
- isPaused = true;
1171
- }
1172
- if (activePhaseTransition?.pause) {
1173
- activePhaseTransition.pause();
1174
- isPaused = true;
1175
- }
1176
- },
1177
- resume: () => {
1178
- if (activeContentTransition?.play && isPaused) {
1179
- activeContentTransition.play();
1180
- isPaused = false;
1181
- }
1182
- if (activePhaseTransition?.play && isPaused) {
1183
- activePhaseTransition.play();
1184
- isPaused = false;
1185
- }
1186
- },
1187
- getState: () => ({
1188
- isPaused,
1189
- contentTransitionInProgress: activeContentTransition !== null,
1190
- phaseTransitionInProgress: activePhaseTransition !== null,
1191
- }),
1192
- };
1193
- };
1194
-
1195
- const animateTransition = (
1196
- transitionController,
1197
- newChild,
1198
- setupTransition,
1199
- {
1200
- type,
1201
- duration,
1202
- animationProgress = 0,
1203
- isPhaseTransition,
1204
- onComplete,
1205
- fromContentKeyState,
1206
- toContentKeyState,
1207
- debug,
1208
- },
1209
- ) => {
1210
- let transitionType;
1211
- if (type === "cross-fade") {
1212
- transitionType = crossFade;
1213
- } else if (type === "slide-left") {
1214
- transitionType = slideLeft;
1215
- } else {
1216
- return null;
1217
- }
1218
-
1219
- const { cleanup, oldElement, newElement } = setupTransition();
1220
- // Use precomputed content key states (expected to be provided by caller)
1221
- const fromContentKey = fromContentKeyState;
1222
- const toContentKey = toContentKeyState;
1223
-
1224
- debug("transition", "Setting up animation:", {
1225
- type,
1226
- from: fromContentKey,
1227
- to: toContentKey,
1228
- progress: `${(animationProgress * 100).toFixed(1)}%`,
1229
- });
1230
-
1231
- const remainingDuration = Math.max(100, duration * (1 - animationProgress));
1232
- debug("transition", `Animation duration: ${remainingDuration}ms`);
1233
-
1234
- const transitions = transitionType.apply(oldElement, newElement, {
1235
- duration: remainingDuration,
1236
- startProgress: animationProgress,
1237
- isPhaseTransition,
1238
- debug,
1239
- });
1240
-
1241
- debug(
1242
- "transition",
1243
- `Created ${transitions.length} transition(s) for animation`,
1244
- );
1245
-
1246
- if (transitions.length === 0) {
1247
- debug("transition", "No transitions to animate, cleaning up immediately");
1248
- cleanup();
1249
- onComplete?.();
1250
- return null;
1251
- }
1252
-
1253
- const groupTransition = transitionController.animate(transitions, {
1254
- onFinish: () => {
1255
- groupTransition.cancel();
1256
- cleanup();
1257
- onComplete?.();
1258
- },
1259
- });
1260
-
1261
- return groupTransition;
1262
- };
1263
-
1264
- const slideLeft = {
1265
- name: "slide-left",
1266
- apply: (
1267
- oldElement,
1268
- newElement,
1269
- { duration, startProgress = 0, isPhaseTransition = false, debug },
1270
- ) => {
1271
- if (!oldElement && !newElement) {
1272
- return [];
1273
- }
1274
-
1275
- if (!newElement) {
1276
- // Content -> Empty (slide out left only)
1277
- const currentPosition = getTranslateX(oldElement);
1278
- const containerWidth = getInnerWidth(oldElement.parentElement);
1279
- const from = currentPosition;
1280
- const to = -containerWidth;
1281
- debug("transition", "Slide out to empty:", { from, to });
1282
-
1283
- return [
1284
- createTranslateXTransition(oldElement, to, {
1285
- from,
1286
- duration,
1287
- startProgress,
1288
- onUpdate: ({ value, timing }) => {
1289
- debug("transition_updates", "Slide out progress:", value);
1290
- if (timing === "end") {
1291
- debug("transition", "Slide out complete");
1292
- }
1293
- },
1294
- }),
1295
- ];
1296
- }
1297
-
1298
- if (!oldElement) {
1299
- // Empty -> Content (slide in from right)
1300
- const containerWidth = getInnerWidth(newElement.parentElement);
1301
- const from = containerWidth; // Start from right edge for slide-in effect
1302
- const to = getTranslateXWithoutTransition(newElement);
1303
- debug("transition", "Slide in from empty:", { from, to });
1304
- return [
1305
- createTranslateXTransition(newElement, to, {
1306
- from,
1307
- duration,
1308
- startProgress,
1309
- onUpdate: ({ value, timing }) => {
1310
- debug("transition_updates", "Slide in progress:", value);
1311
- if (timing === "end") {
1312
- debug("transition", "Slide in complete");
1313
- }
1314
- },
1315
- }),
1316
- ];
1317
- }
1318
-
1319
- // Content -> Content (slide left)
1320
- // The old content (oldElement) slides OUT to the left
1321
- // The new content (newElement) slides IN from the right
1322
-
1323
- // Get positions for the slide animation
1324
- const containerWidth = getInnerWidth(newElement.parentElement);
1325
- const oldContentPosition = getTranslateX(oldElement);
1326
- const currentNewPosition = getTranslateX(newElement);
1327
- const naturalNewPosition = getTranslateXWithoutTransition(newElement);
1328
-
1329
- // For smooth continuation: if newElement is mid-transition,
1330
- // calculate new position to maintain seamless sliding
1331
- let startNewPosition;
1332
- if (currentNewPosition !== 0 && naturalNewPosition === 0) {
1333
- startNewPosition = currentNewPosition + containerWidth;
1334
- debug(
1335
- "transition",
1336
- "Calculated seamless position:",
1337
- `${currentNewPosition} + ${containerWidth} = ${startNewPosition}`,
1338
- );
1339
- } else {
1340
- startNewPosition = naturalNewPosition || containerWidth;
1341
- }
1342
-
1343
- // For phase transitions, force new content to start from right edge for proper slide-in
1344
- const effectiveFromPosition = isPhaseTransition
1345
- ? containerWidth
1346
- : startNewPosition;
1347
-
1348
- debug("transition", "Slide transition:", {
1349
- oldContent: `${oldContentPosition} → ${-containerWidth}`,
1350
- newContent: `${effectiveFromPosition} → ${naturalNewPosition}`,
1351
- });
1352
-
1353
- const transitions = [];
1354
-
1355
- // Slide old content out
1356
- transitions.push(
1357
- createTranslateXTransition(oldElement, -containerWidth, {
1358
- from: oldContentPosition,
1359
- duration,
1360
- startProgress,
1361
- onUpdate: ({ value }) => {
1362
- debug("transition_updates", "Old content slide out:", value);
1363
- },
1364
- }),
1365
- );
1366
-
1367
- // Slide new content in
1368
- transitions.push(
1369
- createTranslateXTransition(newElement, naturalNewPosition, {
1370
- from: effectiveFromPosition,
1371
- duration,
1372
- startProgress,
1373
- onUpdate: ({ value, timing }) => {
1374
- debug("transition_updates", "New content slide in:", value);
1375
- if (timing === "end") {
1376
- debug("transition", "Slide complete");
1377
- }
1378
- },
1379
- }),
1380
- );
1381
-
1382
- return transitions;
1383
- },
1384
- };
1385
-
1386
- const crossFade = {
1387
- name: "cross-fade",
1388
- apply: (
1389
- oldElement,
1390
- newElement,
1391
- { duration, startProgress = 0, isPhaseTransition = false, debug },
1392
- ) => {
1393
- if (!oldElement && !newElement) {
1394
- return [];
1395
- }
1396
-
1397
- if (!newElement) {
1398
- // Content -> Empty (fade out only)
1399
- const from = getOpacity(oldElement);
1400
- const to = 0;
1401
- debug("transition", "Fade out to empty:", { from, to });
1402
- return [
1403
- createOpacityTransition(oldElement, to, {
1404
- from,
1405
- duration,
1406
- startProgress,
1407
- onUpdate: ({ value, timing }) => {
1408
- debug("transition_updates", "Content fade out:", value.toFixed(3));
1409
- if (timing === "end") {
1410
- debug("transition", "Fade out complete");
1411
- }
1412
- },
1413
- }),
1414
- ];
1415
- }
1416
-
1417
- if (!oldElement) {
1418
- // Empty -> Content (fade in only)
1419
- const from = 0;
1420
- const to = getOpacityWithoutTransition(newElement);
1421
- debug("transition", "Fade in from empty:", { from, to });
1422
- return [
1423
- createOpacityTransition(newElement, to, {
1424
- from,
1425
- duration,
1426
- startProgress,
1427
- onUpdate: ({ value, timing }) => {
1428
- debug("transition_updates", "Fade in progress:", value.toFixed(3));
1429
- if (timing === "end") {
1430
- debug("transition", "Fade in complete");
1431
- }
1432
- },
1433
- }),
1434
- ];
1435
- }
1436
-
1437
- // Content -> Content (cross-fade)
1438
- // Get current opacity for both elements
1439
- const oldOpacity = getOpacity(oldElement);
1440
- const newOpacity = getOpacity(newElement);
1441
- const newNaturalOpacity = getOpacityWithoutTransition(newElement);
1442
-
1443
- // For phase transitions, always start new content from 0 for clean visual transition
1444
- // For content transitions, check for ongoing transitions to continue smoothly
1445
- let effectiveFromOpacity;
1446
- if (isPhaseTransition) {
1447
- effectiveFromOpacity = 0; // Always start fresh for phase transitions (loading → content, etc.)
1448
- } else {
1449
- // For content transitions: if new element has ongoing opacity transition
1450
- // (indicated by non-zero opacity when natural opacity is different),
1451
- // start from current opacity to continue smoothly, otherwise start from 0
1452
- const hasOngoingTransition =
1453
- newOpacity !== newNaturalOpacity && newOpacity > 0;
1454
- effectiveFromOpacity = hasOngoingTransition ? newOpacity : 0;
1455
- }
1456
-
1457
- debug("transition", "Cross-fade transition:", {
1458
- oldOpacity: `${oldOpacity} → 0`,
1459
- newOpacity: `${effectiveFromOpacity} → ${newNaturalOpacity}`,
1460
- isPhaseTransition,
1461
- });
1462
-
1463
- return [
1464
- createOpacityTransition(oldElement, 0, {
1465
- from: oldOpacity,
1466
- duration,
1467
- startProgress,
1468
- onUpdate: ({ value }) => {
1469
- if (value > 0) {
1470
- debug(
1471
- "transition_updates",
1472
- "Old content fade out:",
1473
- value.toFixed(3),
1474
- );
1475
- }
1476
- },
1477
- }),
1478
- createOpacityTransition(newElement, newNaturalOpacity, {
1479
- from: effectiveFromOpacity,
1480
- duration,
1481
- startProgress: isPhaseTransition ? 0 : startProgress, // Phase transitions: new content always starts fresh
1482
- onUpdate: ({ value, timing }) => {
1483
- debug("transition_updates", "New content fade in:", value.toFixed(3));
1484
- if (timing === "end") {
1485
- debug("transition", "Cross-fade complete");
1486
- }
1487
- },
1488
- }),
1489
- ];
1490
- },
1491
- };