wake-marquee 0.1.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.
package/src/marquee.js ADDED
@@ -0,0 +1,716 @@
1
+ /**
2
+ * wake-marquee — an endless marquee that answers to the scroll.
3
+ *
4
+ * Two movements run at once on the same row:
5
+ *
6
+ * 1. The loop. Lanes are translated together by up to one lane width, then
7
+ * snap back. The snap is invisible because it is exactly one repeat of
8
+ * the content.
9
+ * 2. The wake. The layer holding those lanes is given `wake` percent of
10
+ * overhang on each side, and the scroll spends precisely that reserve.
11
+ * The row is dragged against its own direction of travel as the element
12
+ * crosses the viewport, so it reads as something being pulled through
13
+ * water rather than a banner playing on a loop.
14
+ *
15
+ * On top of that the loop reverses when the reader scrolls back up, eased
16
+ * rather than switched, so the row appears to have momentum of its own.
17
+ *
18
+ * Every instance on the page shares one `requestAnimationFrame` loop, and
19
+ * that loop reads all geometry before it writes any transform. Interleaving
20
+ * the two would force a layout between every read and every write, which is
21
+ * the difference between one reflow per frame and one per marquee.
22
+ *
23
+ * @module wake-marquee
24
+ */
25
+
26
+ import { advance, clamp, easeDirection, frameDelta, laneCount, viewProgress, wakeOffset } from './motion.js';
27
+
28
+ /**
29
+ * @typedef {object} MarqueeOptions
30
+ * @property {'left' | 'right'} [direction='left'] Travel direction while the
31
+ * reader scrolls down. Scrolling up reverses it unless `reverse` is off.
32
+ * @property {number} [speed=60] Travel speed in pixels per second.
33
+ * @property {number} [wake=8] Scroll-driven displacement, as a percentage of
34
+ * the container width. `0` turns the wake off and leaves a plain loop.
35
+ * @property {boolean} [reverse=true] Whether scrolling up reverses travel.
36
+ * @property {number} [ease=5] How sharply a reversal settles, per second.
37
+ * Higher is more abrupt; around `1` reads as a long, heavy turn.
38
+ * @property {string} [gap] Space between two items, any CSS length. Defaults
39
+ * to the stylesheet's `2rem`.
40
+ * @property {string | false} [fade=false] Soft edges left and right, any CSS
41
+ * length, e.g. `'6rem'`.
42
+ * @property {boolean} [pauseOnHover=false] Hold still while a real pointer
43
+ * rests on the row. Ignored on touch, where there is no hover to leave.
44
+ * @property {Window | HTMLElement} [scroller=window] What to read the scroll
45
+ * direction from. Pass the scrolling element when the page is inside an
46
+ * overflow container.
47
+ * @property {boolean} [respectMotionPreference=true] Stay still under
48
+ * `prefers-reduced-motion: reduce`. Turning this off is almost always the
49
+ * wrong call.
50
+ */
51
+
52
+ /** @type {Required<Omit<MarqueeOptions, 'gap' | 'fade' | 'scroller'>> & {gap: string | null, fade: string | false, scroller: Window | HTMLElement | null}} */
53
+ const DEFAULTS = Object.freeze({
54
+ direction: 'left',
55
+ speed: 60,
56
+ wake: 8,
57
+ reverse: true,
58
+ ease: 5,
59
+ gap: null,
60
+ fade: false,
61
+ pauseOnHover: false,
62
+ scroller: null, // resolved to `window` at construction, so this stays SSR-safe
63
+ respectMotionPreference: true,
64
+ });
65
+
66
+ const ATTRIBUTE = 'data-wake-marquee';
67
+ const LANE_CLASS = 'wake-lane';
68
+ const TRACK_CLASS = 'wake-track';
69
+
70
+ /** Extra lanes held in reserve against sub-pixel rounding at the right edge. */
71
+ const LANE_BUFFER = 1;
72
+
73
+ /** How far outside the viewport an instance starts running, in px. */
74
+ const ROOT_MARGIN = '200px 0px';
75
+
76
+ /**
77
+ * Has this element already been turned into a marquee?
78
+ *
79
+ * The presence of `data-wake-marquee` is not the answer. That attribute is
80
+ * also the stylesheet's hook, so it belongs in the markup for the sake of the
81
+ * unenhanced page, and it is there long before any script runs. The structure
82
+ * is the honest signal: an initialised root holds a `.wake-track`.
83
+ *
84
+ * @param {Element} el
85
+ * @returns {boolean}
86
+ */
87
+ function isInitialised(el) {
88
+ return el.firstElementChild?.classList.contains(TRACK_CLASS) === true;
89
+ }
90
+
91
+ /** @type {Set<Marquee>} Every live instance, driven by the one shared loop. */
92
+ const registry = new Set();
93
+
94
+ /** @type {Map<Window | HTMLElement, {last: number, sign: number}>} */
95
+ const scrollers = new Map();
96
+
97
+ let rafId = 0;
98
+ let lastFrame = 0;
99
+
100
+ /**
101
+ * Read the scroll offset of either the window or an overflow container.
102
+ * @param {Window | HTMLElement} scroller
103
+ * @returns {number}
104
+ */
105
+ function scrollOffset(scroller) {
106
+ return scroller === window ? window.scrollY : /** @type {HTMLElement} */ (scroller).scrollTop;
107
+ }
108
+
109
+ /**
110
+ * Sample which way a scroller last moved: +1 down, -1 up.
111
+ *
112
+ * Reading `scrollY` or `scrollTop` is a layout read, so this belongs in the
113
+ * frame's read phase and nowhere else. It samples once per scroller rather
114
+ * than once per instance, and only reacts past half a pixel: without that
115
+ * threshold the sub-pixel jitter of a smooth-scrolling library flips the sign
116
+ * every few frames and shakes every marquee on the page.
117
+ *
118
+ * @param {Window | HTMLElement} scroller
119
+ */
120
+ function sampleScroll(scroller) {
121
+ const state = scrollers.get(scroller);
122
+ if (!state) {
123
+ scrollers.set(scroller, { last: scrollOffset(scroller), sign: 1 });
124
+ return;
125
+ }
126
+ const now = scrollOffset(scroller);
127
+ if (Math.abs(now - state.last) > 0.5) {
128
+ state.sign = now > state.last ? 1 : -1;
129
+ state.last = now;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The last sampled direction of a scroller. Pure map lookup, no layout.
135
+ * @param {Window | HTMLElement} scroller
136
+ * @returns {number}
137
+ */
138
+ function scrollSign(scroller) {
139
+ return scrollers.get(scroller)?.sign ?? 1;
140
+ }
141
+
142
+ /**
143
+ * @returns {boolean}
144
+ */
145
+ function prefersReducedMotion() {
146
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
147
+ }
148
+
149
+ /**
150
+ * Options in, validated options out. Explicit `undefined` counts as absent,
151
+ * because framework wrappers hand every prop through whether it was set or
152
+ * not, and `speed: undefined` has to mean the default rather than `NaN`.
153
+ *
154
+ * Exported so a wrapper can reject bad options where the caller wrote them,
155
+ * rather than at the first frame of an animation that then silently does
156
+ * nothing.
157
+ *
158
+ * @param {MarqueeOptions} options
159
+ * @returns {typeof DEFAULTS}
160
+ */
161
+ export function normalizeOptions(options = {}) {
162
+ const config = { ...DEFAULTS };
163
+ for (const [key, value] of Object.entries(options)) {
164
+ if (value !== undefined) config[key] = value;
165
+ }
166
+
167
+ if (config.direction !== 'left' && config.direction !== 'right') {
168
+ throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${config.direction}"`);
169
+ }
170
+ if (!Number.isFinite(config.speed) || config.speed < 0) {
171
+ throw new RangeError(`wake-marquee: speed must be a non-negative number, received ${config.speed}`);
172
+ }
173
+ if (!Number.isFinite(config.wake) || config.wake < 0) {
174
+ throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${config.wake}`);
175
+ }
176
+ if (!Number.isFinite(config.ease) || config.ease <= 0) {
177
+ throw new RangeError(`wake-marquee: ease must be a positive number, received ${config.ease}`);
178
+ }
179
+
180
+ // `?? window` would throw where there is no window at all, and a bad
181
+ // option should be reportable from a test runner or a server render.
182
+ config.scroller = config.scroller ?? (typeof window === 'undefined' ? null : window);
183
+ return config;
184
+ }
185
+
186
+ /**
187
+ * Read options declared on the element as `data-wake-*` attributes, so the
188
+ * markup can carry its own configuration and the auto-init entry point needs
189
+ * no JavaScript from the caller at all.
190
+ *
191
+ * @param {HTMLElement} el
192
+ * @returns {MarqueeOptions}
193
+ */
194
+ export function readOptions(el) {
195
+ const d = el.dataset;
196
+ /** @type {MarqueeOptions} */
197
+ const options = {};
198
+
199
+ if (d.wakeDirection) options.direction = /** @type {'left' | 'right'} */ (d.wakeDirection);
200
+ if (d.wakeSpeed) options.speed = Number(d.wakeSpeed);
201
+ if (d.wake) options.wake = Number(d.wake);
202
+ if (d.wakeEase) options.ease = Number(d.wakeEase);
203
+ if (d.wakeGap) options.gap = d.wakeGap;
204
+ if (d.wakeFade) options.fade = d.wakeFade;
205
+ // Presence is the value: `data-wake-pause-on-hover` reads as an empty
206
+ // string, which is falsy, so these cannot be tested by truthiness.
207
+ if (d.wakePauseOnHover !== undefined) options.pauseOnHover = true;
208
+ if (d.wakeReverse !== undefined) options.reverse = d.wakeReverse !== 'false';
209
+
210
+ return options;
211
+ }
212
+
213
+ /**
214
+ * One marquee. Built by `createMarquee`, never constructed directly.
215
+ */
216
+ class Marquee {
217
+ /**
218
+ * @param {HTMLElement} root
219
+ * @param {MarqueeOptions} options
220
+ */
221
+ constructor(root, options) {
222
+ /** @type {HTMLElement} The element handed to `createMarquee`. */
223
+ this.element = root;
224
+ /** @type {typeof DEFAULTS} */
225
+ this.options = normalizeOptions(options);
226
+
227
+ this.dirSign = this.options.direction === 'right' ? 1 : -1;
228
+ /** Eased travel direction in `[-1, 1]`; starts already up to speed. */
229
+ this.dirFactor = this.dirSign;
230
+ /** Running loop offset, always inside `[0, period)`. */
231
+ this.offset = 0;
232
+ /** Width of one lane in px: the loop's repeat distance. */
233
+ this.period = 0;
234
+ /** Last written wake displacement in px, kept so a paused frame holds. */
235
+ this.wakePx = 0;
236
+ /** Last measured wake amplitude in px. */
237
+ this.amplitude = 0;
238
+ /** Has the first frame been lined up with the static row it replaces? */
239
+ this.aligned = false;
240
+
241
+ this.visible = false;
242
+ this.measured = false;
243
+ this.paused = false;
244
+ this.hovered = false;
245
+ this.destroyed = false;
246
+ /** Last written status, so an unchanged frame writes no attribute. */
247
+ this.status = '';
248
+
249
+ this.#build();
250
+ this.#observe();
251
+
252
+ registry.add(this);
253
+ this.#applyMotionPreference();
254
+ }
255
+
256
+ /**
257
+ * Wrap the caller's children into the two layers the animation needs.
258
+ *
259
+ * root [data-wake-marquee] clips, and is the geometry the wake reads
260
+ * track .wake-track overhung by `wake` percent on either side
261
+ * lane .wake-lane the original children
262
+ * lane .wake-lane clones, added once measured
263
+ *
264
+ * Building this here rather than asking for it in the markup keeps the
265
+ * unenhanced page honest: without JavaScript the children sit in a plain
266
+ * clipped flex row, which is the resting state the stylesheet describes and
267
+ * exactly what stays on screen under reduced motion.
268
+ */
269
+ #build() {
270
+ const root = this.element;
271
+ const doc = root.ownerDocument;
272
+
273
+ this.track = doc.createElement('div');
274
+ this.track.className = TRACK_CLASS;
275
+
276
+ this.lane = doc.createElement('div');
277
+ this.lane.className = LANE_CLASS;
278
+
279
+ // Move the children before the track is attached, so this costs one
280
+ // layout rather than one per child.
281
+ while (root.firstChild) this.lane.appendChild(root.firstChild);
282
+
283
+ this.track.appendChild(this.lane);
284
+ root.appendChild(this.track);
285
+
286
+ /** @type {HTMLElement[]} Lane 0 is the original; the rest are clones. */
287
+ this.lanes = [this.lane];
288
+
289
+ /**
290
+ * How to put the element back. Every attribute and custom property below
291
+ * doubles as configuration the markup may have declared itself, so
292
+ * `destroy()` may only take back what it actually added. Removing
293
+ * `data-wake-fade` because we saw it would delete the caller's option and
294
+ * a later `initMarquees()` would come back without it.
295
+ *
296
+ * @type {Array<() => void>}
297
+ */
298
+ this.undo = [];
299
+
300
+ /** @param {string} name @param {string} value */
301
+ const setAttribute = (name, value) => {
302
+ if (root.hasAttribute(name)) return;
303
+ root.setAttribute(name, value);
304
+ this.undo.push(() => root.removeAttribute(name));
305
+ };
306
+
307
+ /** @param {string} name @param {string} value */
308
+ const setProperty = (name, value) => {
309
+ if (root.style.getPropertyValue(name)) return;
310
+ root.style.setProperty(name, value);
311
+ this.undo.push(() => root.style.removeProperty(name));
312
+ };
313
+
314
+ setAttribute(ATTRIBUTE, '');
315
+ if (this.options.gap) setProperty('--wake-gap', this.options.gap);
316
+ if (this.options.fade) {
317
+ setProperty('--wake-fade', this.options.fade);
318
+ setAttribute('data-wake-fade', '');
319
+ }
320
+
321
+ }
322
+
323
+ /**
324
+ * Give the track the overhang the wake spends, half of it on each side.
325
+ *
326
+ * Deliberately not done at construction. The overhang shifts the row left
327
+ * by a full amplitude on its own, and the transform that cancels it is only
328
+ * written once the row is measured. Set them at different times and the
329
+ * reader sees the row slide sideways and back: at construction for a row on
330
+ * screen, and on the way in for a row below the fold, which is worse
331
+ * because it happens right where they are looking.
332
+ *
333
+ * Idempotent, so refresh() can call it on every resize.
334
+ */
335
+ #setOverhang() {
336
+ this.track.style.marginInlineStart = `-${this.options.wake}%`;
337
+ this.track.style.width = `${100 + this.options.wake * 2}%`;
338
+ }
339
+
340
+ /**
341
+ * Take the row from its static state to its running one inside a single
342
+ * task: overhang, measurement, clones and the first transform together.
343
+ * Anything left over for the next frame gets painted on its own.
344
+ */
345
+ #activate() {
346
+ this.refresh();
347
+ if (this.period > 0 && !this.paused) {
348
+ this.read(window.innerHeight);
349
+ this.write(0, scrollSign(this.options.scroller));
350
+ }
351
+ }
352
+
353
+ #observe() {
354
+ const root = this.element;
355
+
356
+ this.intersection = new IntersectionObserver(
357
+ (entries) => {
358
+ for (const entry of entries) {
359
+ this.visible = entry.isIntersecting;
360
+ root.toggleAttribute('data-wake-active', entry.isIntersecting);
361
+ // Clone on first sight, not at construction. Images below the fold
362
+ // keep their `loading="lazy"` meaning that way, and a page with a
363
+ // dozen marquees does no work for the eleven nobody has reached.
364
+ // Activating here rather than leaving it to the next frame matters
365
+ // when the reader arrives by anchor link or a restored scroll
366
+ // position: the row is already fully in view when this fires.
367
+ if (entry.isIntersecting && !this.measured) this.#activate();
368
+ }
369
+ schedule();
370
+ },
371
+ { rootMargin: ROOT_MARGIN, threshold: 0 },
372
+ );
373
+ this.intersection.observe(root);
374
+
375
+ // Content decides the period, the container decides how many lanes cover
376
+ // it, so both are watched. A late web font or a decoded image changes the
377
+ // first; a rotation changes the second.
378
+ this.resize = new ResizeObserver(() => {
379
+ if (this.measured) this.refresh();
380
+ });
381
+ this.resize.observe(this.lane);
382
+ this.resize.observe(root);
383
+
384
+ if (this.options.pauseOnHover && matchMedia('(hover: hover)').matches) {
385
+ this.onEnter = () => {
386
+ this.hovered = true;
387
+ };
388
+ this.onLeave = () => {
389
+ this.hovered = false;
390
+ schedule();
391
+ };
392
+ root.addEventListener('pointerenter', this.onEnter);
393
+ root.addEventListener('pointerleave', this.onLeave);
394
+ }
395
+
396
+ if (this.options.respectMotionPreference && typeof matchMedia === 'function') {
397
+ this.motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
398
+ this.onMotionChange = () => this.#applyMotionPreference();
399
+ this.motionQuery.addEventListener('change', this.onMotionChange);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Honour the reader's motion preference, now and whenever they change it.
405
+ * Stopping resets the transforms, so the row settles back into the same
406
+ * static, clipped state a page without JavaScript would show.
407
+ */
408
+ #applyMotionPreference() {
409
+ if (!this.options.respectMotionPreference) {
410
+ schedule();
411
+ return;
412
+ }
413
+ if (prefersReducedMotion()) {
414
+ this.paused = true;
415
+ this.#reset();
416
+ } else {
417
+ this.paused = false;
418
+ schedule();
419
+ }
420
+ }
421
+
422
+ #reset() {
423
+ this.offset = 0;
424
+ this.wakePx = 0;
425
+ // Resuming is a first frame again: it has to line up with the static row
426
+ // that a reduced-motion reader has been looking at until now.
427
+ this.aligned = false;
428
+ for (const lane of this.lanes) lane.style.transform = '';
429
+ this.track.style.transform = '';
430
+ }
431
+
432
+ /**
433
+ * Build the running state now, in the caller's own task, for a row that is
434
+ * already on screen.
435
+ *
436
+ * Left to the observers, the three steps land in three different frames:
437
+ * the overhang in one, the clones in the next, the first transform in the
438
+ * one after. The browser paints all three, and the row visibly jumps twice
439
+ * before it settles. Doing them together means the reader goes straight
440
+ * from the static row to the moving one with nothing in between.
441
+ *
442
+ * Rows below the fold are left to the IntersectionObserver, which is what
443
+ * keeps a page of ten marquees from measuring ten of them at load.
444
+ */
445
+ prime() {
446
+ if (this.destroyed || this.measured) return this;
447
+
448
+ const viewport = window.innerHeight;
449
+ const rect = this.element.getBoundingClientRect();
450
+ const margin = 200; // matches ROOT_MARGIN
451
+ if (rect.bottom < -margin || rect.top > viewport + margin) return this;
452
+
453
+ this.visible = true;
454
+ this.element.toggleAttribute('data-wake-active', true);
455
+ // dt of zero advances nothing: this only puts the row on screen already
456
+ // running, aligned to the static row it replaces.
457
+ this.#activate();
458
+ return this;
459
+ }
460
+
461
+ /**
462
+ * Re-measure and top up the clones. Called on first sight, on resize, and
463
+ * available to callers who change the content themselves.
464
+ */
465
+ refresh() {
466
+ if (this.destroyed) return;
467
+
468
+ const period = this.lane.getBoundingClientRect().width;
469
+ // A display:none ancestor, or a lane whose images have not laid out yet,
470
+ // measures zero. Bailing leaves `measured` false so the next observer
471
+ // callback tries again, rather than locking in a broken period.
472
+ if (!(period > 0)) return;
473
+
474
+ // The period is the unit the offset is expressed in, so when it changes
475
+ // the offset has to be restated in the new one or the row jumps by the
476
+ // difference. This is not a rare edge: an image without width and height
477
+ // attributes, or a web font swapping in, resizes the lane under a row
478
+ // that is already running. Holding the phase keeps that invisible.
479
+ if (this.period > 0 && period !== this.period) {
480
+ this.offset = (this.offset / this.period) * period;
481
+ }
482
+
483
+ this.period = period;
484
+ this.measured = true;
485
+ this.#setOverhang();
486
+
487
+ const needed = laneCount(this.track.getBoundingClientRect().width, period, LANE_BUFFER);
488
+
489
+ while (this.lanes.length < needed) {
490
+ const clone = /** @type {HTMLElement} */ (this.lane.cloneNode(true));
491
+ // A clone is decoration. `inert` takes it out of the focus order and the
492
+ // accessibility tree in one attribute; `aria-hidden` and the tabindex
493
+ // sweep cover browsers that do not have `inert` yet, where a clone would
494
+ // otherwise put a dozen invisible tab stops in the reader's way.
495
+ clone.inert = true;
496
+ clone.setAttribute('aria-hidden', 'true');
497
+ if (!('inert' in clone)) {
498
+ clone
499
+ .querySelectorAll('a, button, input, select, textarea, [tabindex]')
500
+ .forEach((el) => el.setAttribute('tabindex', '-1'));
501
+ }
502
+ // Clones start far off to the right and are translated in. A lazy image
503
+ // there would never reach its loading threshold and would arrive as a
504
+ // hole in the row. The source is identical to the original, so this is
505
+ // a cache hit rather than a second download.
506
+ clone.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'));
507
+ this.track.appendChild(clone);
508
+ this.lanes.push(clone);
509
+ }
510
+
511
+ while (this.lanes.length > needed) {
512
+ this.lanes.pop()?.remove();
513
+ }
514
+
515
+ schedule();
516
+ }
517
+
518
+ /** Read geometry. Never writes, so it cannot force a layout mid-frame. */
519
+ read(viewport) {
520
+ if (!this.#running()) return;
521
+ const rect = this.element.getBoundingClientRect();
522
+ const progress = viewProgress(rect.top, rect.height, viewport);
523
+ // Kept because the first frame's alignment has to undo exactly the
524
+ // overhang the track was given, and that is measured in these same px.
525
+ this.amplitude = (this.options.wake * rect.width) / 100;
526
+ this.wakePx = wakeOffset(progress, this.amplitude, this.dirSign);
527
+ }
528
+
529
+ /**
530
+ * Land the first animated frame on the pixel the static row already
531
+ * occupies, instead of wherever `offset: 0` happens to fall.
532
+ *
533
+ * Before the script runs, item 1 sits flush at the container's left edge.
534
+ * A running row puts lane 0 at `-period` so a clone covers that edge, and
535
+ * shifts the whole track by `-amplitude` of overhang plus the wake. Left
536
+ * alone, those add up to a visible jump on the first frame, of anything up
537
+ * to one full amplitude depending on where the row happens to be on screen.
538
+ *
539
+ * Solving `-amplitude + wake + (offset - period) ≡ 0 (mod period)` for the
540
+ * offset lines the two up exactly, whatever the scroll position. From the
541
+ * next frame on it advances normally: nothing else ever touches it.
542
+ */
543
+ #align() {
544
+ this.aligned = true;
545
+ const shift = this.amplitude - this.wakePx;
546
+ this.offset = ((shift % this.period) + this.period) % this.period;
547
+ }
548
+
549
+ /** Write transforms. Never reads geometry. */
550
+ write(dt, scrollDir) {
551
+ if (!this.#running()) return;
552
+ if (!this.aligned) this.#align();
553
+
554
+ const target = this.hovered ? 0 : this.options.reverse ? this.dirSign * scrollDir : this.dirSign;
555
+ this.dirFactor = easeDirection(this.dirFactor, target, dt, this.options.ease);
556
+
557
+ this.offset = advance(this.offset, this.dirFactor * this.options.speed, dt, this.period);
558
+
559
+ // One period of lead, so travelling left never exposes the origin.
560
+ const x = this.offset - this.period;
561
+ const transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
562
+ for (const lane of this.lanes) lane.style.transform = transform;
563
+
564
+ this.track.style.transform = `translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;
565
+
566
+ // Deliberately not `data-wake-direction`: that attribute is the *option*
567
+ // the markup declares, and a re-run of initMarquees() reads it back. A
568
+ // status written into it would turn "right" into "forward" and the next
569
+ // read would reject it.
570
+ const status = scrollDir === 1 ? 'forward' : 'reversed';
571
+ if (status !== this.status) {
572
+ this.status = status;
573
+ this.element.setAttribute('data-wake-travel', status);
574
+ }
575
+ }
576
+
577
+ #running() {
578
+ return !this.destroyed && !this.paused && this.visible && this.period > 0;
579
+ }
580
+
581
+ /** Resume after `pause()`. No effect under a reduced-motion preference. */
582
+ play() {
583
+ if (this.destroyed) return this;
584
+ if (this.options.respectMotionPreference && prefersReducedMotion()) return this;
585
+ this.paused = false;
586
+ schedule();
587
+ return this;
588
+ }
589
+
590
+ /** Hold the row where it is. The wake stops with it. */
591
+ pause() {
592
+ this.paused = true;
593
+ return this;
594
+ }
595
+
596
+ /** Undo everything: clones, wrappers, observers, listeners, attributes. */
597
+ destroy() {
598
+ if (this.destroyed) return;
599
+ this.destroyed = true;
600
+ registry.delete(this);
601
+
602
+ this.intersection?.disconnect();
603
+ this.resize?.disconnect();
604
+ if (this.onEnter) this.element.removeEventListener('pointerenter', this.onEnter);
605
+ if (this.onLeave) this.element.removeEventListener('pointerleave', this.onLeave);
606
+ if (this.motionQuery && this.onMotionChange) {
607
+ this.motionQuery.removeEventListener('change', this.onMotionChange);
608
+ }
609
+
610
+ for (const lane of this.lanes.slice(1)) lane.remove();
611
+ // Hand the original children back exactly where they came from.
612
+ while (this.lane.firstChild) this.element.appendChild(this.lane.firstChild);
613
+ this.track.remove();
614
+
615
+ // Written by the running loop, never by the caller, so always ours.
616
+ this.element.removeAttribute('data-wake-travel');
617
+ this.element.removeAttribute('data-wake-active');
618
+ for (const undo of this.undo) undo();
619
+ this.undo = [];
620
+
621
+ this.lanes = [];
622
+ }
623
+ }
624
+
625
+ /**
626
+ * The one loop for every marquee on the page.
627
+ *
628
+ * Reads first, writes second, both across all instances. Interleaving them
629
+ * would put a forced reflow between each pair, so a page with six marquees
630
+ * would pay six layouts a frame instead of one.
631
+ *
632
+ * @param {number} time
633
+ */
634
+ function frame(time) {
635
+ const dt = lastFrame === 0 ? 0 : frameDelta(time - lastFrame);
636
+ lastFrame = time;
637
+
638
+ const viewport = window.innerHeight;
639
+
640
+ for (const marquee of registry) {
641
+ if (marquee.options.reverse) sampleScroll(marquee.options.scroller);
642
+ marquee.read(viewport);
643
+ }
644
+
645
+ for (const marquee of registry) {
646
+ marquee.write(dt, marquee.options.reverse ? scrollSign(marquee.options.scroller) : 1);
647
+ }
648
+
649
+ // Stop the moment nothing is on screen. A page scrolled past its marquees
650
+ // should cost nothing at all, and an idle rAF loop is not nothing: it keeps
651
+ // the compositor awake and shows up on a battery.
652
+ rafId = 0;
653
+ schedule();
654
+ }
655
+
656
+ /** Start the shared loop if any instance needs it and it is not already up. */
657
+ function schedule() {
658
+ if (rafId !== 0) return;
659
+ let wanted = false;
660
+ for (const marquee of registry) {
661
+ if (!marquee.destroyed && !marquee.paused && marquee.visible) {
662
+ wanted = true;
663
+ break;
664
+ }
665
+ }
666
+ if (!wanted) {
667
+ lastFrame = 0; // so the first frame after a gap integrates zero, not seconds
668
+ return;
669
+ }
670
+ rafId = requestAnimationFrame(frame);
671
+ }
672
+
673
+ /**
674
+ * Turn an element and its children into a marquee.
675
+ *
676
+ * @param {HTMLElement} element Container. Its direct children become the items.
677
+ * @param {MarqueeOptions} [options]
678
+ * @returns {Marquee}
679
+ */
680
+ export function createMarquee(element, options = {}) {
681
+ if (!element || element.nodeType !== 1) {
682
+ throw new TypeError('wake-marquee: createMarquee expects an element');
683
+ }
684
+ if (isInitialised(element)) {
685
+ throw new Error('wake-marquee: this element is already a marquee, call destroy() first');
686
+ }
687
+ return new Marquee(element, options).prime();
688
+ }
689
+
690
+ /**
691
+ * Find every `[data-wake-marquee]` in `root` and start it, reading each
692
+ * element's own `data-wake-*` attributes for its options.
693
+ *
694
+ * Already-initialised elements are skipped, so this is safe to call again
695
+ * after new content arrives.
696
+ *
697
+ * @param {object} [init]
698
+ * @param {ParentNode} [init.root=document] Where to look.
699
+ * @param {MarqueeOptions} [init.defaults] Applied under each element's own
700
+ * attributes, so the markup always wins.
701
+ * @returns {Marquee[]} Only the instances this call created.
702
+ */
703
+ export function initMarquees({ root = document, defaults = {} } = {}) {
704
+ const created = [];
705
+ for (const el of root.querySelectorAll(`[${ATTRIBUTE}]`)) {
706
+ if (isInitialised(el)) continue;
707
+ created.push(new Marquee(/** @type {HTMLElement} */ (el), { ...defaults, ...readOptions(el) }));
708
+ }
709
+ // Every row is built before any row is measured. Interleaving the two would
710
+ // make each construction invalidate the layout the next one has to read,
711
+ // which is one forced reflow per marquee at the worst possible moment.
712
+ for (const marquee of created) marquee.prime();
713
+ return created;
714
+ }
715
+
716
+ export { DEFAULTS as defaults, Marquee };