@inc2734/unitone-css 1.4.0 → 1.4.1

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/src/library.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createObserverScope } from './observer-scope';
2
+
1
3
  const layoutAttributeName = 'data-unitone-layout';
2
4
  const layoutIntersectionMargin = 200;
3
5
  const layoutIntersectionRootMargin = `${layoutIntersectionMargin}px 0px`;
@@ -40,31 +42,38 @@ const setLayoutTokens = (element, tokens) => {
40
42
  * Observes target resizes and invokes the callback when a relevant change is detected.
41
43
  *
42
44
  * @param {Element} target Target element.
43
- * @param {(target: Element, entry?: ResizeObserverEntry) => void} callback Callback to run.
44
- * @param {{ getValue?: (entry: ResizeObserverEntry) => unknown, delay?: number }} [options]
45
+ * @param {(target: Element) => void} callback Callback to run.
46
+ * @param {ReturnType<typeof createObserverScope>} scope Observer lifetime.
47
+ * @param {(entry: ResizeObserverEntry) => unknown} [getValue] Value to compare.
45
48
  * @returns {ResizeObserver} ResizeObserver instance.
46
49
  */
47
- const createResizeObserver = (target, callback, { getValue, delay = 250 } = {}) => {
48
- let prevValue;
49
- let isFirstEntry = true;
50
-
51
- const observer = new ResizeObserver(
52
- debounce((entries) => {
53
- for (const entry of entries) {
54
- const currentValue = getValue?.(entry);
55
- if (isFirstEntry) {
56
- prevValue = currentValue;
57
- isFirstEntry = false;
58
- continue;
59
- }
50
+ const createResizeObserver = (target, callback, scope, getValue) => {
51
+ const prevValues = new WeakMap();
52
+ const onResize = debounce(() => callback(target), 250);
53
+ const observer = new ResizeObserver((entries) => {
54
+ if (scope.disposed) {
55
+ return;
56
+ }
60
57
 
61
- if (undefined === currentValue || currentValue !== prevValue) {
62
- callback(entry.target, entry);
63
- prevValue = currentValue;
64
- }
58
+ let changed = false;
59
+ for (const entry of entries) {
60
+ const currentValue = getValue?.(entry);
61
+ if (
62
+ !prevValues.has(entry.target) ||
63
+ undefined === currentValue ||
64
+ currentValue !== prevValues.get(entry.target)
65
+ ) {
66
+ changed = true;
65
67
  }
66
- }, delay),
67
- );
68
+ prevValues.set(entry.target, currentValue);
69
+ }
70
+
71
+ if (changed) {
72
+ onResize();
73
+ }
74
+ });
75
+ scope.addCleanup(onResize.cancel);
76
+ scope.addCleanup(() => observer.disconnect());
68
77
 
69
78
  observer.observe(target);
70
79
 
@@ -77,22 +86,18 @@ const createResizeObserver = (target, callback, { getValue, delay = 250 } = {})
77
86
  * @param {Node} target Target node.
78
87
  * @param {MutationObserverInit} options Observer options.
79
88
  * @param {(entries: MutationRecord[]) => void} callback Callback to run.
80
- * @returns {MutationObserver} MutationObserver instance.
89
+ * @param {ReturnType<typeof createObserverScope>} scope Observer lifetime.
90
+ * @returns {void}
81
91
  */
82
- const createMutationObserver = (target, options, callback) => {
92
+ const createMutationObserver = (target, options, callback, scope) => {
83
93
  const observer = new MutationObserver((entries) => {
84
- requestAnimationFrame(() => {
85
- if (!target?.isConnected) {
86
- return;
87
- }
88
-
94
+ if (!scope.disposed && target.isConnected) {
89
95
  callback(entries);
90
- });
96
+ }
91
97
  });
92
98
 
93
99
  observer.observe(target, options);
94
-
95
- return observer;
100
+ scope.addCleanup(() => observer.disconnect());
96
101
  };
97
102
 
98
103
  /**
@@ -100,12 +105,13 @@ const createMutationObserver = (target, options, callback) => {
100
105
  *
101
106
  * @param {Element} target Target element.
102
107
  * @param {(entry: IntersectionObserverEntry) => void} callback Callback to run.
103
- * @returns {IntersectionObserver} IntersectionObserver instance.
108
+ * @param {ReturnType<typeof createObserverScope>} scope Observer lifetime.
109
+ * @returns {void}
104
110
  */
105
- const createIntersectionObserver = (target, callback) => {
111
+ const createIntersectionObserver = (target, callback, scope) => {
106
112
  const observer = new IntersectionObserver(
107
113
  ([entry]) => {
108
- if (entry) {
114
+ if (entry && !scope.disposed) {
109
115
  callback(entry);
110
116
  }
111
117
  },
@@ -113,8 +119,7 @@ const createIntersectionObserver = (target, callback) => {
113
119
  );
114
120
 
115
121
  observer.observe(target);
116
-
117
- return observer;
122
+ scope.addCleanup(() => observer.disconnect());
118
123
  };
119
124
 
120
125
  /**
@@ -122,27 +127,19 @@ const createIntersectionObserver = (target, callback) => {
122
127
  *
123
128
  * @param {Element} target Target element.
124
129
  * @param {(target: Element) => void} callback Callback to run.
130
+ * @param {ReturnType<typeof createObserverScope>} scope Observer lifetime.
125
131
  * @returns {() => void} Schedule function.
126
132
  */
127
- const createScheduledTargetCallback = (target, callback) => {
133
+ const createScheduledTargetCallback = (target, callback, scope) => {
128
134
  let rafId = 0;
129
- let defaultView;
130
135
 
131
136
  return () => {
132
- defaultView = target?.ownerDocument?.defaultView;
133
- if (!defaultView?.requestAnimationFrame) {
134
- callback(target);
137
+ if (scope.disposed || rafId) {
135
138
  return;
136
139
  }
137
140
 
138
- if (rafId) {
139
- return;
140
- }
141
-
142
- rafId = defaultView.requestAnimationFrame(() => {
141
+ rafId = scope.requestAnimationFrame(() => {
143
142
  rafId = 0;
144
- defaultView = null;
145
-
146
143
  if (target?.isConnected) {
147
144
  callback(target);
148
145
  }
@@ -151,269 +148,169 @@ const createScheduledTargetCallback = (target, callback) => {
151
148
  };
152
149
 
153
150
  /**
154
- * Observes resizes on the target and its direct children.
151
+ * Keeps resize and optional attribute observation in sync with direct children.
155
152
  *
156
153
  * @param {Element} target Target element.
154
+ * @param {ResizeObserver} resizeObserver Shared size observer.
157
155
  * @param {(target: Element) => void} callback Callback to run.
158
- * @param {{ getValue?: (entry: ResizeObserverEntry) => unknown, delay?: number, onChildList?: (entries: MutationRecord[]) => void }} [options]
159
- * @returns {{ resizeObserver: ResizeObserver, mutationObserver: MutationObserver }}
156
+ * @param {ReturnType<typeof createObserverScope>} scope Observer lifetime.
157
+ * @param {{ attributeFilter: string[], shouldApply: (entry: MutationRecord) => boolean }} [attributes]
158
+ * @returns {void}
160
159
  */
161
- const createDirectChildrenResizeObserver = (
162
- target,
163
- callback,
164
- { getValue, delay = 250, onChildList } = {},
165
- ) => {
166
- const prevValues = new WeakMap();
160
+ const observeLayoutChildren = (target, resizeObserver, callback, scope, attributes) => {
167
161
  const observedChildren = new Set();
168
-
169
- const observer = new ResizeObserver(
170
- debounce((entries) => {
171
- let shouldApply = false;
172
-
173
- for (const entry of entries) {
174
- const currentValue = getValue?.(entry);
175
- if (!prevValues.has(entry.target)) {
176
- prevValues.set(entry.target, currentValue);
177
- continue;
178
- }
179
-
180
- if (undefined === currentValue || currentValue !== prevValues.get(entry.target)) {
181
- shouldApply = true;
162
+ const attributeObserver = attributes
163
+ ? new MutationObserver((entries) => {
164
+ if (
165
+ !scope.disposed &&
166
+ target.isConnected &&
167
+ hasAttributeMutation(
168
+ entries,
169
+ (entry) => entry.target.parentElement === target && attributes.shouldApply(entry),
170
+ )
171
+ ) {
172
+ callback(target);
182
173
  }
174
+ })
175
+ : null;
176
+ scope.addCleanup(() => {
177
+ observedChildren.clear();
178
+ attributeObserver?.disconnect();
179
+ });
183
180
 
184
- prevValues.set(entry.target, currentValue);
185
- }
186
-
187
- if (shouldApply) {
188
- callback(target);
189
- }
190
- }, delay),
191
- );
192
-
193
- const syncObservedChildren = () => {
194
- Array.from(observedChildren).forEach((child) => {
181
+ const syncChildren = () => {
182
+ for (const child of observedChildren) {
195
183
  if (child.parentElement !== target) {
196
- observer.unobserve(child);
184
+ resizeObserver.unobserve(child);
197
185
  observedChildren.delete(child);
198
- prevValues.delete(child);
199
- }
200
- });
201
-
202
- Array.from(target?.children ?? []).forEach((child) => {
203
- if (observedChildren.has(child)) {
204
- return;
205
186
  }
206
-
207
- observer.observe(child);
208
- observedChildren.add(child);
209
- });
210
- };
211
-
212
- observer.observe(target);
213
- syncObservedChildren();
214
-
215
- const mutationObserver = createMutationObserver(target, { childList: true }, (entries) => {
216
- if (!entries.some((entry) => 'childList' === entry.type)) {
217
- return;
218
187
  }
219
188
 
220
- syncObservedChildren();
221
- onChildList?.(entries);
222
- callback(target);
223
- });
224
-
225
- return {
226
- resizeObserver: observer,
227
- mutationObserver,
228
- };
229
- };
230
-
231
- /**
232
- * Observes attribute changes on direct children.
233
- *
234
- * @param {Element} target Target element.
235
- * @param {(target: Element) => void} callback Callback to run.
236
- * @param {{ attributeFilter: string[], shouldApply: (entry: MutationRecord) => boolean, attributeOldValue?: boolean }} options
237
- * @returns {{ observer: MutationObserver, syncObservedChildren: () => void }}
238
- */
239
- const createDirectChildrenAttributeObserver = (
240
- target,
241
- callback,
242
- { attributeFilter, shouldApply, attributeOldValue = true } = {},
243
- ) => {
244
- const observer = new MutationObserver((entries) => {
245
- requestAnimationFrame(() => {
246
- if (!target?.isConnected) {
247
- return;
248
- }
249
-
250
- if (
251
- entries.some(
252
- (entry) =>
253
- 'attributes' === entry.type &&
254
- entry.target.parentElement === target &&
255
- shouldApply(entry),
256
- )
257
- ) {
258
- callback(target);
189
+ attributeObserver?.disconnect();
190
+ for (const child of target.children) {
191
+ if (!observedChildren.has(child)) {
192
+ resizeObserver.observe(child);
193
+ observedChildren.add(child);
259
194
  }
260
- });
261
- });
262
-
263
- const syncObservedChildren = () => {
264
- observer.disconnect();
265
- Array.from(target?.children ?? []).forEach((child) => {
266
- observer.observe(child, {
195
+ attributeObserver?.observe(child, {
267
196
  attributes: true,
268
- attributeFilter,
269
- attributeOldValue,
197
+ attributeFilter: attributes.attributeFilter,
198
+ attributeOldValue: true,
270
199
  });
271
- });
200
+ }
272
201
  };
273
202
 
274
- syncObservedChildren();
275
-
276
- return {
277
- observer,
278
- syncObservedChildren,
279
- };
203
+ syncChildren();
204
+ createMutationObserver(
205
+ target,
206
+ { childList: true },
207
+ () => {
208
+ syncChildren();
209
+ callback(target);
210
+ },
211
+ scope,
212
+ );
280
213
  };
281
214
 
282
215
  /**
283
216
  * Creates a bundled observer setup for layout re-application.
284
217
  *
285
218
  * @param {Element} target Target element.
286
- * @param {(target: Element) => void} apply Apply function.
219
+ * @param {(target: Element, scope: ReturnType<typeof createObserverScope>) => void} apply Apply function.
287
220
  * @param {{
288
221
  * getResizeValue?: (entry: ResizeObserverEntry) => unknown,
289
- * delay?: number,
290
- * observeResize?: boolean,
291
- * observeIntersection?: boolean,
292
222
  * observeDirectChildrenResize?: boolean,
293
- * targetMutation?: { options: MutationObserverInit, shouldApply?: (entries: MutationRecord[]) => boolean },
294
- * directChildMutation?: { attributeFilter: string[], shouldApply: (entry: MutationRecord) => boolean, attributeOldValue?: boolean }
223
+ * targetMutation?: { options: MutationObserverInit, shouldApply: (entries: MutationRecord[]) => boolean },
224
+ * directChildMutation?: { attributeFilter: string[], shouldApply: (entry: MutationRecord) => boolean }
295
225
  * }} [options]
296
- * @returns {void}
226
+ * @returns {() => void} Stops observation and cancels queued work.
297
227
  */
298
228
  const createLayoutObserver = (
299
229
  target,
300
230
  apply,
301
- {
302
- getResizeValue,
303
- delay = 250,
304
- observeResize = true,
305
- observeIntersection = false,
306
- observeDirectChildrenResize = false,
307
- targetMutation,
308
- directChildMutation,
309
- } = {},
231
+ { getResizeValue, observeDirectChildrenResize = false, targetMutation, directChildMutation } = {},
310
232
  ) => {
311
- const shouldObserveIntersection =
312
- observeIntersection && 'undefined' !== typeof IntersectionObserver;
233
+ const scope = createObserverScope(target);
234
+ const shouldObserveIntersection = 'undefined' !== typeof IntersectionObserver;
313
235
  let isIntersecting = !shouldObserveIntersection || isNearViewport(target);
314
- let needsApply = shouldObserveIntersection && !isIntersecting;
236
+ let needsApply = !isIntersecting;
315
237
 
316
- const runApply = (element = target) => {
317
- if (!element?.isConnected) {
238
+ const runApply = () => {
239
+ if (scope.disposed || !target.isConnected) {
318
240
  return;
319
241
  }
320
242
 
321
- if (shouldObserveIntersection && !isIntersecting) {
243
+ if (!isIntersecting) {
322
244
  needsApply = true;
323
245
  return;
324
246
  }
325
247
 
326
248
  needsApply = false;
327
- apply(element);
249
+ apply(target, scope);
328
250
  };
329
251
 
330
- const schedule = createScheduledTargetCallback(target, runApply);
252
+ const schedule = createScheduledTargetCallback(target, runApply, scope);
331
253
  const scheduleApply = () => {
332
- if (!target?.isConnected) {
333
- return;
334
- }
335
-
336
- if (shouldObserveIntersection && !isIntersecting) {
337
- needsApply = true;
254
+ if (scope.disposed || !target?.isConnected) {
338
255
  return;
339
256
  }
340
257
 
341
258
  needsApply = true;
342
- schedule();
259
+ if (isIntersecting) {
260
+ schedule();
261
+ }
343
262
  };
344
263
 
345
- let syncDirectChildAttributes = () => {};
346
-
347
- const resizeBundle = !observeResize
348
- ? {
349
- resizeObserver: null,
350
- mutationObserver: null,
351
- }
352
- : observeDirectChildrenResize
353
- ? createDirectChildrenResizeObserver(target, scheduleApply, {
354
- getValue: getResizeValue,
355
- delay,
356
- onChildList: () => {
357
- syncDirectChildAttributes();
358
- },
359
- })
360
- : {
361
- resizeObserver: createResizeObserver(target, scheduleApply, {
362
- getValue: getResizeValue,
363
- delay,
364
- }),
365
- };
366
-
367
- if (shouldObserveIntersection) {
368
- createIntersectionObserver(target, (entry) => {
369
- isIntersecting = entry.isIntersecting;
370
- if (isIntersecting && needsApply) {
371
- scheduleApply();
372
- }
373
- });
264
+ const resizeObserver = createResizeObserver(target, scheduleApply, scope, getResizeValue);
265
+ if (observeDirectChildrenResize) {
266
+ observeLayoutChildren(target, resizeObserver, scheduleApply, scope, directChildMutation);
374
267
  }
375
268
 
376
- if (targetMutation?.options) {
377
- createMutationObserver(target, targetMutation.options, (entries) => {
378
- if (targetMutation.shouldApply?.(entries) ?? 0 < entries.length) {
379
- scheduleApply();
380
- }
381
- });
269
+ if (shouldObserveIntersection) {
270
+ createIntersectionObserver(
271
+ target,
272
+ (entry) => {
273
+ isIntersecting = entry.isIntersecting;
274
+ if (isIntersecting && needsApply) {
275
+ scheduleApply();
276
+ }
277
+ },
278
+ scope,
279
+ );
382
280
  }
383
281
 
384
- const directChildAttributeBundle =
385
- directChildMutation?.attributeFilter && directChildMutation.shouldApply
386
- ? createDirectChildrenAttributeObserver(target, scheduleApply, {
387
- attributeFilter: directChildMutation.attributeFilter,
388
- shouldApply: directChildMutation.shouldApply,
389
- attributeOldValue: directChildMutation.attributeOldValue,
390
- })
391
- : null;
392
-
393
- if (directChildAttributeBundle) {
394
- syncDirectChildAttributes = directChildAttributeBundle.syncObservedChildren;
282
+ if (targetMutation) {
283
+ createMutationObserver(
284
+ target,
285
+ targetMutation.options,
286
+ (entries) => {
287
+ if (targetMutation.shouldApply(entries)) {
288
+ scheduleApply();
289
+ }
290
+ },
291
+ scope,
292
+ );
395
293
  }
396
294
 
397
- if (!resizeBundle.mutationObserver && directChildAttributeBundle) {
398
- createMutationObserver(target, { childList: true }, (entries) => {
399
- if (!entries.some((entry) => 'childList' === entry.type)) {
400
- return;
401
- }
402
-
403
- syncDirectChildAttributes();
404
- scheduleApply();
405
- });
295
+ if (isIntersecting) {
296
+ runApply();
406
297
  }
407
298
 
408
- if (!shouldObserveIntersection || isIntersecting) {
409
- runApply(target);
410
- }
299
+ return scope.dispose;
411
300
  };
412
301
 
413
302
  const getBorderBoxInlineSize = (entry) => entry.borderBoxSize?.[0].inlineSize;
414
303
 
415
304
  const getContentRectWidth = (entry) => parseInt(entry.contentRect?.width);
416
305
 
306
+ const getElementStyle = (element) =>
307
+ (element.ownerDocument.defaultView ?? window).getComputedStyle(element);
308
+
309
+ const isInFlow = (element) => {
310
+ const { position, display } = getElementStyle(element);
311
+ return !['absolute', 'fixed'].includes(position) && 'none' !== display;
312
+ };
313
+
417
314
  const hasLayoutBox = (element) => !!element?.isConnected && 0 < element.getClientRects().length;
418
315
 
419
316
  const isNearViewport = (element) => {
@@ -468,32 +365,51 @@ const getNormalizedInlineRect = (
468
365
  };
469
366
 
470
367
  /**
471
- * Returns whether the mutation list contains a matching attributes record.
368
+ * Compares each attribute before the batch with its final value, ignoring intermediate writes.
472
369
  *
473
370
  * @param {MutationRecord[]} entries Mutation records.
474
371
  * @param {(entry: MutationRecord) => boolean} predicate Match predicate.
475
372
  * @returns {boolean} Whether a matching attributes mutation exists.
476
373
  */
477
- const hasAttributeMutation = (entries, predicate) =>
478
- entries.some((entry) => 'attributes' === entry.type && predicate(entry));
374
+ const hasAttributeMutation = (entries, predicate) => {
375
+ const seen = new Map();
376
+ return entries.some((entry) => {
377
+ if ('attributes' !== entry.type) {
378
+ return false;
379
+ }
380
+ const attributes = seen.get(entry.target) ?? new Set();
381
+ if (attributes.has(entry.attributeName)) {
382
+ return false;
383
+ }
384
+ attributes.add(entry.attributeName);
385
+ seen.set(entry.target, attributes);
386
+ return predicate(entry);
387
+ });
388
+ };
479
389
 
480
390
  /**
481
391
  * Coalesces repeated calls into the final call within the delay window.
482
392
  *
483
393
  * @param {Function} fn Function to wrap.
484
394
  * @param {number} delay Delay in milliseconds.
485
- * @returns {Function} Debounced function.
395
+ * @returns {Function & { cancel: () => void }} Debounced function with cancellation.
486
396
  */
487
397
  export function debounce(fn, delay) {
488
398
  let timer;
489
399
 
490
- return function (...args) {
400
+ const debounced = function (...args) {
491
401
  const context = this;
492
402
  clearTimeout(timer);
493
403
  timer = setTimeout(() => {
404
+ timer = undefined;
494
405
  fn.apply(context, args);
495
406
  }, delay);
496
407
  };
408
+ debounced.cancel = () => {
409
+ clearTimeout(timer);
410
+ timer = undefined;
411
+ };
412
+ return debounced;
497
413
  }
498
414
 
499
415
  /**
@@ -515,14 +431,14 @@ export const setDividerLinewrap = (target) => {
515
431
  layoutTokens: withoutLayoutTokens(getLayoutTokens(child), ['-bol', '-linewrap']),
516
432
  }));
517
433
 
518
- const resetChildLayouts = () => {
434
+ const applyChildLayouts = () => {
519
435
  childLayouts.forEach(({ child, layoutTokens }) => {
520
436
  setLayoutTokens(child, layoutTokens);
521
437
  });
522
438
  };
523
439
 
524
440
  if (!currentLayoutArray.some((value) => value.startsWith('-divider:'))) {
525
- resetChildLayouts();
441
+ applyChildLayouts();
526
442
  return;
527
443
  }
528
444
 
@@ -532,41 +448,34 @@ export const setDividerLinewrap = (target) => {
532
448
  }
533
449
 
534
450
  if (!hasLayoutBox(target)) {
535
- resetChildLayouts();
451
+ applyChildLayouts();
536
452
  return;
537
453
  }
538
454
 
539
455
  const defaultView =
540
456
  target?.ownerDocument?.defaultView ?? ('undefined' !== typeof window ? window : undefined);
541
457
  if (!defaultView?.getComputedStyle) {
542
- resetChildLayouts();
458
+ applyChildLayouts();
543
459
  return;
544
460
  }
545
461
 
546
- const targetStyle = defaultView.getComputedStyle(target);
462
+ const targetStyle = getElementStyle(target);
547
463
  const flow = {
548
464
  direction: targetStyle.getPropertyValue('direction'),
549
465
  flexDirection: targetStyle.getPropertyValue('flex-direction'),
550
466
  writingMode: targetStyle.getPropertyValue('writing-mode'),
551
467
  };
552
468
 
553
- const targetChildren = childLayouts.reduce((accumulator, { child, layoutTokens }) => {
554
- const style = defaultView.getComputedStyle(child);
555
- const position = style.getPropertyValue('position');
556
- const display = style.getPropertyValue('display');
557
- if ('absolute' !== position && 'fixed' !== position && 'none' !== display) {
558
- const rect = child.getBoundingClientRect();
559
- accumulator.push({
560
- child,
561
- layoutTokens,
562
- inlineRect: getNormalizedInlineRect(rect, flow),
563
- });
469
+ const targetChildren = [];
470
+ for (const entry of childLayouts) {
471
+ if (isInFlow(entry.child)) {
472
+ entry.inlineRect = getNormalizedInlineRect(entry.child.getBoundingClientRect(), flow);
473
+ targetChildren.push(entry);
564
474
  }
565
- return accumulator;
566
- }, []);
475
+ }
567
476
 
568
477
  if (0 === targetChildren.length) {
569
- resetChildLayouts();
478
+ applyChildLayouts();
570
479
  setLayoutTokens(target, [...currentLayoutArray, 'divider:initialized']);
571
480
  return;
572
481
  }
@@ -574,15 +483,14 @@ export const setDividerLinewrap = (target) => {
574
483
  let prevInlineRect;
575
484
  let hasWrapped = false;
576
485
  let isStack = true;
577
- const nextChildLayouts = targetChildren.map(({ child, layoutTokens, inlineRect }, index) => {
578
- const nextLayoutTokens = [...layoutTokens];
486
+ targetChildren.forEach(({ layoutTokens, inlineRect }, index) => {
579
487
  const isBeginningOfLine =
580
488
  0 === index ||
581
489
  inlineRect.start < prevInlineRect.end - layoutPositionTolerance ||
582
490
  inlineRect.start <= prevInlineRect.start + layoutPositionTolerance;
583
491
 
584
492
  if (isBeginningOfLine) {
585
- nextLayoutTokens.push('-bol');
493
+ layoutTokens.push('-bol');
586
494
  if (0 < index) {
587
495
  hasWrapped = true;
588
496
  }
@@ -591,19 +499,12 @@ export const setDividerLinewrap = (target) => {
591
499
  }
592
500
 
593
501
  if (hasWrapped) {
594
- nextLayoutTokens.push('-linewrap');
502
+ layoutTokens.push('-linewrap');
595
503
  }
596
504
 
597
505
  prevInlineRect = inlineRect;
598
- return { child, layoutTokens: nextLayoutTokens };
599
- });
600
-
601
- const nextChildLayoutMap = new Map(
602
- nextChildLayouts.map(({ child, layoutTokens }) => [child, layoutTokens]),
603
- );
604
- childLayouts.forEach(({ child, layoutTokens }) => {
605
- setLayoutTokens(child, nextChildLayoutMap.get(child) ?? layoutTokens);
606
506
  });
507
+ applyChildLayouts();
607
508
 
608
509
  const nextTargetLayout = [...currentLayoutArray];
609
510
  if (isStack) {
@@ -614,56 +515,39 @@ export const setDividerLinewrap = (target) => {
614
515
  setLayoutTokens(target, nextTargetLayout);
615
516
  };
616
517
 
518
+ const withoutAttributeTokens = (value, ignoredTokens) =>
519
+ withoutLayoutTokens((value ?? '').split(' '), ignoredTokens).join(' ');
520
+
617
521
  /**
618
522
  * Creates the observer bundle for divider layouts.
619
523
  *
620
524
  * @param {Element} target Target element.
621
525
  * @param {{ ignore?: { layout?: string[], className?: string[] } }} [args]
622
- * @returns {void}
526
+ * @returns {() => void} Stops observation and cancels queued work.
623
527
  */
624
528
  export const dividersResizeObserver = (target, args = {}) => {
625
529
  const shouldRecalculateByAttributeMutation = (entry) => {
626
- if ('data-unitone-layout' === entry.attributeName) {
627
- const ignoreUnitoneLayouts = [
628
- ...(args?.ignore?.layout ?? []),
629
- ...['divider:initialized', '-bol', '-linewrap', '-stack'],
630
- ];
631
-
632
- const current = (entry.target.getAttribute(entry.attributeName) ?? '')
633
- .split(' ')
634
- .filter((v) => !ignoreUnitoneLayouts.includes(v))
635
- .join(' ');
636
-
637
- const old = (entry.oldValue ?? '')
638
- .split(' ')
639
- .filter((v) => !ignoreUnitoneLayouts.includes(v))
640
- .join(' ');
641
-
642
- return current !== old;
643
- }
644
-
645
- if ('class' === entry.attributeName) {
646
- const ignoreClassNames = [...(args?.ignore?.className ?? [])];
647
-
648
- const current = (entry.target.getAttribute(entry.attributeName) ?? '')
649
- .split(' ')
650
- .filter((v) => !ignoreClassNames.includes(v))
651
- .join(' ');
652
-
653
- const old = (entry.oldValue ?? '')
654
- .split(' ')
655
- .filter((v) => !ignoreClassNames.includes(v))
656
- .join(' ');
657
-
658
- return current !== old;
530
+ const { attributeName, oldValue } = entry;
531
+ const currentValue = entry.target.getAttribute(attributeName);
532
+ const ignoredTokens =
533
+ layoutAttributeName === attributeName
534
+ ? [...(args?.ignore?.layout ?? []), 'divider:initialized', '-bol', '-linewrap', '-stack']
535
+ : 'class' === attributeName
536
+ ? [...(args?.ignore?.className ?? [])]
537
+ : null;
538
+
539
+ if (ignoredTokens) {
540
+ return (
541
+ withoutAttributeTokens(currentValue, ignoredTokens) !==
542
+ withoutAttributeTokens(oldValue, ignoredTokens)
543
+ );
659
544
  }
660
545
 
661
- return ['style', 'dir'].includes(entry.attributeName);
546
+ return ['style', 'dir'].includes(attributeName) && (currentValue ?? '') !== (oldValue ?? '');
662
547
  };
663
548
 
664
- createLayoutObserver(target, setDividerLinewrap, {
549
+ return createLayoutObserver(target, setDividerLinewrap, {
665
550
  getResizeValue: getBorderBoxInlineSize,
666
- observeIntersection: true,
667
551
  observeDirectChildrenResize: true,
668
552
  targetMutation: {
669
553
  options: {
@@ -679,7 +563,6 @@ export const dividersResizeObserver = (target, args = {}) => {
679
563
  },
680
564
  directChildMutation: {
681
565
  attributeFilter: ['style', 'data-unitone-layout', 'class'],
682
- attributeOldValue: true,
683
566
  shouldApply: shouldRecalculateByAttributeMutation,
684
567
  },
685
568
  });
@@ -696,13 +579,12 @@ export const setStairsStep = (target) => {
696
579
  const currentLayoutArray = withoutLayoutTokens(getLayoutTokens(target), ['stairs:initialized']);
697
580
  setLayoutTokens(target, currentLayoutArray);
698
581
 
699
- const firstChild = children[0];
700
- if (!firstChild) {
582
+ if (0 === children.length) {
701
583
  setLayoutTokens(target, [...currentLayoutArray, 'stairs:initialized']);
702
584
  return;
703
585
  }
704
586
 
705
- // Reset
587
+ // Measure the unshifted layout before applying steps and measuring their overflow.
706
588
  target.style.removeProperty('--unitone--stairs-step-overflow-volume');
707
589
  target.style.removeProperty('--unitone--max-stairs-step');
708
590
  children.forEach((child) => {
@@ -720,12 +602,10 @@ export const setStairsStep = (target) => {
720
602
 
721
603
  const isAlternatingStairs = ['up-down', 'down-up'].includes(stairsUp);
722
604
 
723
- const direction = window.getComputedStyle(target).getPropertyValue('flex-direction');
605
+ const direction = getElementStyle(target).getPropertyValue('flex-direction');
724
606
  const targetBottom = target.getBoundingClientRect().bottom;
725
607
  const filteredChildren = children.reduce((accumulator, child) => {
726
- const position = window.getComputedStyle(child).getPropertyValue('position');
727
- const display = window.getComputedStyle(child).getPropertyValue('display');
728
- if ('absolute' === position || 'fixed' === position || 'none' === display) {
608
+ if (!isInFlow(child)) {
729
609
  return accumulator;
730
610
  }
731
611
 
@@ -744,7 +624,7 @@ export const setStairsStep = (target) => {
744
624
  const isBol =
745
625
  'row-reverse' === direction ? prevRect?.left <= rect.left : prevRect?.left >= rect.left;
746
626
 
747
- if (0 === index || (firstChild === child && !prevRect) || isBol) {
627
+ if (0 === index || isBol) {
748
628
  stairsStep = 0;
749
629
  } else if (isAlternatingStairs) {
750
630
  stairsStep = 0 === stairsStep ? 1 : 0;
@@ -783,11 +663,10 @@ export const setStairsStep = (target) => {
783
663
  * Creates the observer bundle for stairs layouts.
784
664
  *
785
665
  * @param {Element} target Target element.
786
- * @returns {void}
666
+ * @returns {() => void} Stops observation and cancels queued work.
787
667
  */
788
668
  export const stairsResizeObserver = (target) => {
789
- createLayoutObserver(target, setStairsStep, {
790
- observeIntersection: true,
669
+ return createLayoutObserver(target, setStairsStep, {
791
670
  observeDirectChildrenResize: true,
792
671
  });
793
672
  };
@@ -800,11 +679,7 @@ export const stairsResizeObserver = (target) => {
800
679
  */
801
680
  const isIgnoredVerticalWritingMutationNode = (node) =>
802
681
  node?.nodeType === Node.ELEMENT_NODE &&
803
- [
804
- 'vertical-writing__thresholder',
805
- 'vertical-writing:initialized',
806
- 'vertical-writing:safari',
807
- ].includes(node.getAttribute('data-unitone-layout'));
682
+ 'vertical-writing__thresholder' === node.getAttribute(layoutAttributeName);
808
683
 
809
684
  /**
810
685
  * Returns whether vertical-writing mutations require re-application.
@@ -831,9 +706,10 @@ const shouldApplyVerticalWritingMutation = (entries) =>
831
706
  * Recalculates column count and height for vertical-writing layouts.
832
707
  *
833
708
  * @param {Element} target Target element.
709
+ * @param {ReturnType<typeof createObserverScope>} [scope] Observer lifetime.
834
710
  * @returns {void}
835
711
  */
836
- export const setColumnCountForVertical = (target) => {
712
+ const updateColumnCountForVertical = (target, scope) => {
837
713
  if (!target) {
838
714
  return;
839
715
  }
@@ -854,10 +730,7 @@ export const setColumnCountForVertical = (target) => {
854
730
  Array.from(target.children)
855
731
  .reverse()
856
732
  .some((child) => {
857
- if (
858
- !['absolute', 'fixed'].includes(getComputedStyle(child).position) &&
859
- 'none' !== getComputedStyle(child).display
860
- ) {
733
+ if (isInFlow(child)) {
861
734
  lastChild = child;
862
735
  return true;
863
736
  }
@@ -868,11 +741,11 @@ export const setColumnCountForVertical = (target) => {
868
741
  return;
869
742
  }
870
743
 
871
- const computedStyle = getComputedStyle(target);
744
+ const computedStyle = getElementStyle(target);
872
745
  const threshold = String(computedStyle.getPropertyValue('--unitone--threshold')).trim();
873
746
  let forceSwitch = false;
874
747
 
875
- if (threshold) {
748
+ if (threshold && !/^[+-]?(?:0+\.?0*|\.0+)(?:[a-z]+|%)?$/i.test(threshold)) {
876
749
  const thresholder = target.ownerDocument.createElement('div');
877
750
  thresholder.setAttribute(layoutAttributeName, 'vertical-writing__thresholder');
878
751
  target.appendChild(thresholder);
@@ -892,7 +765,10 @@ export const setColumnCountForVertical = (target) => {
892
765
 
893
766
  setLayoutTokens(target, [...nextLayoutTokens, 'vertical-writing:initialized']);
894
767
 
895
- requestAnimationFrame(() => {
768
+ const schedule = scope
769
+ ? (callback) => scope.requestAnimationFrame(callback)
770
+ : (callback) => target.ownerDocument.defaultView.requestAnimationFrame(callback);
771
+ schedule(() => {
896
772
  if (!target?.isConnected) {
897
773
  return;
898
774
  }
@@ -915,23 +791,30 @@ export const setColumnCountForVertical = (target) => {
915
791
  };
916
792
 
917
793
  /**
918
- * Creates the observer bundle for vertical-writing layouts.
794
+ * Recalculates vertical-writing columns without creating persistent observers.
919
795
  *
920
796
  * @param {Element} target Target element.
921
797
  * @returns {void}
922
798
  */
799
+ export const setColumnCountForVertical = (target) => updateColumnCountForVertical(target);
800
+
801
+ /**
802
+ * Creates the observer bundle for vertical-writing layouts.
803
+ *
804
+ * @param {Element} target Target element.
805
+ * @returns {() => void} Stops observation and cancels queued work.
806
+ */
923
807
  export const verticalsResizeObserver = (target) => {
924
- const applyVerticalColumns = (element) => {
808
+ const applyVerticalColumns = (element, scope) => {
925
809
  if (element.parentNode?.style) {
926
810
  element.parentNode.style.height = '';
927
811
  }
928
812
 
929
- setColumnCountForVertical(element);
813
+ updateColumnCountForVertical(element, scope);
930
814
  };
931
815
 
932
- createLayoutObserver(target, applyVerticalColumns, {
816
+ return createLayoutObserver(target, applyVerticalColumns, {
933
817
  getResizeValue: getContentRectWidth,
934
- observeIntersection: true,
935
818
  targetMutation: {
936
819
  options: {
937
820
  attributes: true,
@@ -944,111 +827,301 @@ export const verticalsResizeObserver = (target) => {
944
827
  });
945
828
  };
946
829
 
830
+ const marqueeClones = new WeakSet();
831
+ const marqueeStates = new WeakMap();
832
+
833
+ const getMarquees = (target) =>
834
+ Array.from(target.querySelectorAll(':scope > [data-unitone-layout~="marquee"]'));
835
+
836
+ const getMarqueeAnimation = (element) =>
837
+ element
838
+ ?.getAnimations()
839
+ .find(({ animationName }) => ['marquee', 'marquee-reverse'].includes(animationName));
840
+
947
841
  /**
948
- * Creates duplicated marquee content and refreshes initialization markers.
842
+ * Measures layout width without including transforms or rounding fractional pixels.
949
843
  *
950
- * @param {Element} target Target element.
951
- * @returns {Element | undefined} The duplicated marquee element, if created.
844
+ * @param {CSSStyleDeclaration} style Computed style.
845
+ * @param {boolean} borderBox Whether to include padding and borders.
846
+ * @returns {number} Width in CSS pixels.
952
847
  */
953
- export const setMarquee = (target) => {
954
- let clonedMarquee;
955
-
956
- const addInitializedToken = (element) => {
957
- const layout = element.getAttribute('data-unitone-layout') ?? '';
958
- if (layout.split(/\s+/).includes('marquee:initialized')) {
959
- return;
960
- }
961
- element.setAttribute('data-unitone-layout', `${layout} marquee:initialized`.trim());
962
- };
848
+ const getMarqueeWidth = (style, borderBox) => {
849
+ const edges = ['paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth'].reduce(
850
+ (total, property) => total + (parseFloat(style[property]) || 0),
851
+ 0,
852
+ );
853
+ const width = parseFloat(style.width) || 0;
854
+ return width + (borderBox ? edges : 0) - ('border-box' === style.boxSizing ? edges : 0);
855
+ };
963
856
 
964
- const removeInitializedToken = (element) => {
965
- const layout = element.getAttribute('data-unitone-layout') ?? '';
966
- const next = layout
967
- .split(/\s+/)
968
- .filter((value) => value && 'marquee:initialized' !== value)
969
- .join(' ');
970
- element.setAttribute('data-unitone-layout', next);
971
- };
857
+ /**
858
+ * Synchronizes existing animations without measuring or rebuilding copies.
859
+ *
860
+ * @param {Element[]} marquees Original and copied marquees.
861
+ * @param {ReturnType<typeof createObserverScope>} [scope] Observer lifetime.
862
+ * @returns {void}
863
+ */
864
+ const syncMarqueeAnimations = ([original, ...copies], scope) => {
865
+ const animation = getMarqueeAnimation(original);
866
+ if (animation) {
867
+ const syncAnimations = () => {
868
+ if (scope?.disposed || !original.isConnected || getMarqueeAnimation(original) !== animation) {
869
+ return;
870
+ }
871
+ copies.forEach((element) => {
872
+ const copyAnimation = getMarqueeAnimation(element);
873
+ if (!element.isConnected || !copyAnimation) {
874
+ return;
875
+ }
972
876
 
973
- const getMarquees = () => target.querySelectorAll(':scope > [data-unitone-layout~="marquee"]');
974
- let marquees = getMarquees();
975
- if (0 === marquees.length) {
976
- return;
877
+ const syncTime = () => {
878
+ if (
879
+ !scope?.disposed &&
880
+ element.isConnected &&
881
+ getMarqueeAnimation(original) === animation
882
+ ) {
883
+ copyAnimation.currentTime = animation.currentTime;
884
+ }
885
+ };
886
+ syncTime();
887
+ // A pending play or pause task can otherwise apply an outdated hold time.
888
+ if (copyAnimation.pending) {
889
+ copyAnimation.ready.then(syncTime, () => {});
890
+ }
891
+ });
892
+ };
893
+ syncAnimations();
894
+ if (animation.pending) {
895
+ animation.ready.then(syncAnimations, () => {});
896
+ }
977
897
  }
898
+ };
978
899
 
979
- const shouldRestartAnimation = Array.from(marquees).some((marquee) =>
980
- (marquee.getAttribute(layoutAttributeName) ?? '').split(/\s+/).includes('marquee:initialized'),
981
- );
900
+ /**
901
+ * Updates generated copies and synchronizes them with the original animation.
902
+ *
903
+ * @param {Element} target Marquee wrapper.
904
+ * @param {boolean} [refreshClones] Whether source content has changed; omitted for manual detection.
905
+ * @param {ReturnType<typeof createObserverScope>} [scope] Observer lifetime.
906
+ * @returns {{ originals: Element[], firstClone?: Element }} Sources and the first new copy.
907
+ */
908
+ const updateMarquee = (target, refreshClones, scope) => {
909
+ const originals = [];
910
+ const clones = [];
911
+ for (const child of target.children) {
912
+ if (marqueeClones.has(child)) {
913
+ clones.push(child);
914
+ } else if (child.matches('[data-unitone-layout~="marquee"]')) {
915
+ originals.push(child);
916
+ }
917
+ }
918
+ const original = originals[0];
919
+ const state = marqueeStates.get(target);
920
+ // Observer calls know whether content changed; manual calls compare the saved markup.
921
+ // Form control state can change without changing its HTML attributes.
922
+ refreshClones ??=
923
+ state?.source !== original?.outerHTML || !!original?.querySelector('input, textarea, select');
924
+ refreshClones ||= state?.original !== original;
925
+
926
+ if (refreshClones || 1 !== originals.length) {
927
+ clones.forEach((clone) => clone.remove());
928
+ clones.length = 0;
929
+ }
982
930
 
983
- if (1 === target.childElementCount && 1 === marquees.length) {
984
- const marquee = marquees[0];
985
- clonedMarquee = marquee.cloneNode(true);
986
- clonedMarquee.setAttribute('aria-hidden', 'true');
987
- marquee.after(clonedMarquee);
931
+ if (!original || !hasLayoutBox(target)) {
932
+ marqueeStates.delete(target);
933
+ return { originals };
988
934
  }
989
935
 
990
- marquees = getMarquees();
936
+ const wrapperStyle = getElementStyle(target);
937
+ const originalStyle = getElementStyle(original);
938
+ const wrapperWidth = getMarqueeWidth(wrapperStyle, false);
939
+ const width = getMarqueeWidth(originalStyle, true);
940
+ const columnGap = wrapperStyle.columnGap;
941
+ const gap = (parseFloat(columnGap) || 0) * (columnGap.endsWith('%') ? wrapperWidth / 100 : 1);
942
+ const gapValue = `${gap}px`;
943
+ if (target.style.getPropertyValue('--unitone--marquee-gap') !== gapValue) {
944
+ target.style.setProperty('--unitone--marquee-gap', gapValue);
945
+ }
991
946
 
992
- if (!shouldRestartAnimation) {
993
- marquees.forEach((marquee) => {
994
- addInitializedToken(marquee);
995
- });
996
- return clonedMarquee;
947
+ // Keep author-provided sibling content intact instead of treating it as a generated copy.
948
+ const canClone = 1 === originals.length && target.childElementCount - clones.length === 1;
949
+ const count =
950
+ canClone && 0 < wrapperWidth && 0 < width ? Math.ceil((wrapperWidth + gap) / (width + gap)) : 0;
951
+
952
+ clones.splice(count).forEach((clone) => clone.remove());
953
+
954
+ const lastMarquee = clones.at(-1) ?? original;
955
+ let firstClone;
956
+ const fragment = target.ownerDocument.createDocumentFragment();
957
+ while (clones.length < count) {
958
+ const clone = original.cloneNode(true);
959
+ clone.setAttribute('aria-hidden', 'true');
960
+ clone.setAttribute('inert', '');
961
+ marqueeClones.add(clone);
962
+ clones.push(clone);
963
+ fragment.append(clone);
964
+ firstClone ??= clone;
965
+ }
966
+ if (firstClone) {
967
+ lastMarquee.after(fragment);
997
968
  }
998
969
 
999
- marquees.forEach((marquee) => {
1000
- removeInitializedToken(marquee);
970
+ [...originals, ...clones].forEach((element) => {
971
+ const tokens = getLayoutTokens(element);
972
+ if (!tokens.includes('marquee:initialized')) {
973
+ setLayoutTokens(element, [...tokens, 'marquee:initialized']);
974
+ }
1001
975
  });
1002
976
 
1003
- requestAnimationFrame(() => {
1004
- if (!target?.isConnected) {
1005
- return;
1006
- }
1007
- getMarquees().forEach((marquee) => {
1008
- addInitializedToken(marquee);
1009
- });
977
+ marqueeStates.set(target, {
978
+ original,
979
+ wrapperWidth,
980
+ width,
981
+ vertical: !originalStyle.writingMode.startsWith('horizontal'),
982
+ source: refreshClones || !state ? original.outerHTML : state.source,
1010
983
  });
984
+ syncMarqueeAnimations([...originals, ...clones], scope);
1011
985
 
1012
- return clonedMarquee;
986
+ return { originals, firstClone };
1013
987
  };
1014
988
 
1015
989
  /**
1016
- * Creates the observer bundle for marquee layouts.
990
+ * Refreshes marquee copies while retaining the original animation's progress.
1017
991
  *
1018
992
  * @param {Element} target Target element.
1019
- * @returns {void}
993
+ * @returns {Element | undefined} The first newly created copy, if any.
994
+ */
995
+ export const setMarquee = (target) => updateMarquee(target).firstClone;
996
+
997
+ /**
998
+ * Observes marquee size and content changes without delaying resize updates.
999
+ *
1000
+ * @param {Element} target Target element.
1001
+ * @returns {() => void} Stops observation and cancels queued work.
1020
1002
  */
1021
1003
  export const marqueeResizeObserver = (target) => {
1022
- let clonedMarquee;
1004
+ const scope = createObserverScope(target);
1005
+ let isIntersecting = 'undefined' === typeof IntersectionObserver || isNearViewport(target);
1006
+ let refreshClones = true;
1007
+ const observedOriginals = new Set();
1008
+ scope.addCleanup(() => {
1009
+ observedOriginals.clear();
1010
+ marqueeStates.delete(target);
1011
+ });
1023
1012
 
1024
- const applyMarquee = (element) => {
1025
- clonedMarquee = setMarquee(element);
1013
+ const observeMutations = () => {
1014
+ mutationObserver.observe(target, { attributes: true, childList: true });
1015
+ observedOriginals.forEach((element) => {
1016
+ mutationObserver.observe(element, {
1017
+ attributes: true,
1018
+ childList: true,
1019
+ characterData: true,
1020
+ subtree: true,
1021
+ });
1022
+ });
1026
1023
  };
1027
1024
 
1028
- createLayoutObserver(target, applyMarquee, {
1029
- observeResize: false,
1030
- observeIntersection: true,
1031
- targetMutation: {
1032
- options: {
1033
- childList: true,
1034
- },
1035
- shouldApply: (entries) => {
1036
- const addedNodes = entries.flatMap((entry) => Array.from(entry.addedNodes ?? []));
1037
- const removedNodes = entries.flatMap((entry) => Array.from(entry.removedNodes ?? []));
1025
+ const changesSource = (entry) =>
1026
+ entry.target !== target ||
1027
+ ('childList' === entry.type &&
1028
+ [...entry.addedNodes, ...entry.removedNodes].some((node) => !marqueeClones.has(node)));
1038
1029
 
1039
- if (
1040
- clonedMarquee?.isConnected &&
1041
- 1 === addedNodes.length &&
1042
- 0 === removedNodes.length &&
1043
- addedNodes[0] === clonedMarquee
1044
- ) {
1045
- clonedMarquee = null;
1046
- return false;
1047
- }
1030
+ const apply = () => {
1031
+ if (scope.disposed || !target.isConnected || !isIntersecting) {
1032
+ return;
1033
+ }
1048
1034
 
1049
- clonedMarquee = null;
1050
- return true;
1035
+ // Preserve pending author edits, then exclude our own DOM writes from observation.
1036
+ refreshClones ||= mutationObserver.takeRecords().some(changesSource);
1037
+ mutationObserver.disconnect();
1038
+ const { originals } = updateMarquee(target, refreshClones, scope);
1039
+ refreshClones = false;
1040
+
1041
+ observedOriginals.forEach((element) => {
1042
+ if (!originals.includes(element)) {
1043
+ resizeObserver.unobserve(element);
1044
+ observedOriginals.delete(element);
1045
+ }
1046
+ });
1047
+ originals.forEach((element) => {
1048
+ if (!observedOriginals.has(element)) {
1049
+ resizeObserver.observe(element, { box: 'border-box' });
1050
+ observedOriginals.add(element);
1051
+ }
1052
+ });
1053
+ observeMutations();
1054
+ };
1055
+
1056
+ const scheduleApply = createScheduledTargetCallback(target, apply, scope);
1057
+ const mutationObserver = new MutationObserver((entries) => {
1058
+ if (!entries.some((entry) => 'childList' !== entry.type || changesSource(entry))) {
1059
+ return;
1060
+ }
1061
+ refreshClones ||= entries.some(changesSource);
1062
+ scheduleApply();
1063
+ });
1064
+ const resizeObserver = new ResizeObserver((entries) => {
1065
+ // Measurements already applied by a mutation callback need no second update.
1066
+ const state = marqueeStates.get(target);
1067
+ if (
1068
+ !state ||
1069
+ entries.some((entry) => {
1070
+ if (entry.target === target) {
1071
+ return Math.abs(entry.contentRect.width - state.wrapperWidth) > 0.01;
1072
+ }
1073
+ const box = entry.borderBoxSize?.[0];
1074
+ const width = state.vertical ? box?.blockSize : box?.inlineSize;
1075
+ return (
1076
+ entry.target !== state.original ||
1077
+ undefined === width ||
1078
+ Math.abs(width - state.width) > 0.01
1079
+ );
1080
+ })
1081
+ ) {
1082
+ apply();
1083
+ }
1084
+ });
1085
+ scope.addCleanup(() => mutationObserver.disconnect());
1086
+ scope.addCleanup(() => resizeObserver.disconnect());
1087
+ resizeObserver.observe(target);
1088
+ observeMutations();
1089
+
1090
+ if ('undefined' !== typeof IntersectionObserver) {
1091
+ createIntersectionObserver(
1092
+ target,
1093
+ (entry) => {
1094
+ const wasIntersecting = isIntersecting;
1095
+ isIntersecting = entry.isIntersecting;
1096
+ if (isIntersecting && !wasIntersecting) {
1097
+ apply();
1098
+ }
1051
1099
  },
1052
- },
1100
+ scope,
1101
+ );
1102
+ }
1103
+
1104
+ // Re-sync copies created while CSS has paused the original animation.
1105
+ const scheduleSync = createScheduledTargetCallback(
1106
+ target,
1107
+ () => syncMarqueeAnimations(getMarquees(target), scope),
1108
+ scope,
1109
+ );
1110
+ const onPauseChange = () => {
1111
+ if (getLayoutTokens(target).includes('-pause-on-hover')) {
1112
+ scheduleSync();
1113
+ }
1114
+ };
1115
+ ['pointerenter', 'pointerleave', 'focusin', 'focusout'].forEach((eventName) => {
1116
+ target.addEventListener(eventName, onPauseChange);
1117
+ scope.addCleanup(() => target.removeEventListener(eventName, onPauseChange));
1053
1118
  });
1119
+
1120
+ // Viewport media queries can change only the gap, without resizing either marquee box.
1121
+ const defaultView = target.ownerDocument.defaultView;
1122
+ defaultView.addEventListener('resize', scheduleApply);
1123
+ scope.addCleanup(() => defaultView.removeEventListener('resize', scheduleApply));
1124
+ apply();
1125
+
1126
+ return scope.dispose;
1054
1127
  };