@duboseweb/motus 1.0.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/dist/motus.cjs ADDED
@@ -0,0 +1,800 @@
1
+ /*! dwg-motus | MIT License | https://github.com/dubose-web/dwg-motus */
2
+ 'use strict';
3
+
4
+ Object.defineProperty(exports, '__esModule', { value: true });
5
+
6
+ /**
7
+ * Every literal string the library writes to or reads from the DOM.
8
+ *
9
+ * Single source of truth so the attribute/class/event prefix can never end up
10
+ * half-renamed across modules.
11
+ */
12
+ /** Attribute that marks an element for animation: `data-motus="fade-up"`. */
13
+ const ATTR = 'data-motus';
14
+ /** Attribute on `<html>` that disables the library in both CSS and JS. Set by the consumer. */
15
+ const DISABLED_ATTR = 'data-motus-disabled';
16
+ /**
17
+ * Attribute on `<html>` that the library sets on itself when it is not running
18
+ * — disabled by option, unsupported browser, or torn down.
19
+ *
20
+ * Deliberately separate from `DISABLED_ATTR`: the CSS hides `[data-motus]`
21
+ * elements until they animate, so something has to tell it to reveal them when
22
+ * no JS will ever arrive to do it. Reusing `DISABLED_ATTR` would be read back
23
+ * by `isDisabled()` on the next `init()` and wedge the library off for good.
24
+ */
25
+ const INACTIVE_ATTR = 'data-motus-inactive';
26
+ /** Builds a per-option attribute name, e.g. `attr('delay')` -> `data-motus-delay`. */
27
+ const attr = (key) => `${ATTR}-${key}`;
28
+ const CLASS_READY = 'motus-ready';
29
+ const VAR_DURATION = '--motus-duration';
30
+ const VAR_DELAY = '--motus-delay';
31
+ const VAR_EASING = '--motus-easing';
32
+ const EVENT_IN = 'motus:in';
33
+ const EVENT_OUT = 'motus:out';
34
+ /** Prefix for every console warning the library emits. */
35
+ const LOG_PREFIX = '[dwg-motus]';
36
+
37
+ /**
38
+ * Bootstrap 5's scale, so the tier names mean what they mean everywhere else.
39
+ * `xs` is implicit at 0 and is deliberately not a valid `disable` target —
40
+ * "disable below 0px" would never match.
41
+ */
42
+ const BREAKPOINTS = Object.freeze({
43
+ sm: 576,
44
+ md: 768,
45
+ lg: 992,
46
+ xl: 1200,
47
+ xxl: 1400,
48
+ });
49
+ const BREAKPOINT_NAMES = ['sm', 'md', 'lg', 'xl', 'xxl'];
50
+ /**
51
+ * Frozen so a stray `Object.assign(DEFAULTS, settings)` can never poison
52
+ * subsequent `init()` calls. Always merge into a fresh object.
53
+ */
54
+ const DEFAULTS = Object.freeze({
55
+ offset: 120,
56
+ delay: 0,
57
+ easing: 'ease',
58
+ duration: 400,
59
+ disable: 'lg',
60
+ breakpoints: BREAKPOINTS,
61
+ once: false,
62
+ mirror: false,
63
+ anchorPlacement: 'top-bottom',
64
+ startEvent: 'DOMContentLoaded',
65
+ animatedClassName: 'motus-animate',
66
+ initClassName: 'motus-init',
67
+ useClassNames: false,
68
+ disableMutationObserver: false,
69
+ debounceDelay: 50,
70
+ });
71
+ const ANCHOR_PLACEMENTS = [
72
+ 'top-bottom',
73
+ 'top-center',
74
+ 'top-top',
75
+ 'center-bottom',
76
+ 'center-center',
77
+ 'center-top',
78
+ 'bottom-bottom',
79
+ 'bottom-center',
80
+ 'bottom-top',
81
+ ];
82
+ /** The legacy device-class keywords. Touch-based, exclusive, not configurable. */
83
+ const DISABLE_KEYWORDS = ['phone', 'tablet', 'mobile'];
84
+ const DEBOUNCE_MIN = 16;
85
+ const DEBOUNCE_MAX = 500;
86
+
87
+ /** Trailing-edge debounce. Inlined to keep the package dependency-free. */
88
+ function debounce(fn, wait) {
89
+ let timeout;
90
+ return (...args) => {
91
+ clearTimeout(timeout);
92
+ timeout = setTimeout(() => fn(...args), wait);
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Viewport and device detection via `matchMedia` — no user-agent sniffing.
98
+ *
99
+ * `below()` is the width-based path behind the `disable` tier names. The
100
+ * `phone` / `mobile` / `tablet` trio is the older device-class path:
101
+ * `pointer: coarse` plus `hover: none` is the standard signal for a touch
102
+ * device, and the width cut-off is what separates a phone from a tablet.
103
+ */
104
+ const below = (width) =>
105
+ /**
106
+ * `- 0.02` rather than `- 1`: a max-width derived from a min-width
107
+ * breakpoint must not leave a dead zone on fractional viewport widths,
108
+ * which a 991.5px window would otherwise land in.
109
+ */
110
+ matchMedia(`(max-width: ${width - 0.02}px)`).matches;
111
+ const phone = () => matchMedia('(pointer: coarse) and (hover: none) and (max-width: 767px)').matches;
112
+ const mobile = () => matchMedia('(pointer: coarse) and (hover: none)').matches;
113
+ const tablet = () => mobile() && !phone();
114
+
115
+ /**
116
+ * The CSS-native keywords (`ease`, `linear`, `ease-in`, `ease-out`,
117
+ * `ease-in-out`) are deliberately absent — the fallthrough returns them
118
+ * unchanged, and that same fallthrough is what lets a raw `cubic-bezier(...)`
119
+ * value through untouched.
120
+ */
121
+ const EASING_MAP = {
122
+ 'ease-in-back': 'cubic-bezier(.6, -.28, .735, .045)',
123
+ 'ease-out-back': 'cubic-bezier(.175, .885, .32, 1.275)',
124
+ 'ease-in-out-back': 'cubic-bezier(.68, -.55, .265, 1.55)',
125
+ 'ease-in-sine': 'cubic-bezier(.47, 0, .745, .715)',
126
+ 'ease-out-sine': 'cubic-bezier(.39, .575, .565, 1)',
127
+ 'ease-in-out-sine': 'cubic-bezier(.445, .05, .55, .95)',
128
+ 'ease-in-quad': 'cubic-bezier(.55, .085, .68, .53)',
129
+ 'ease-out-quad': 'cubic-bezier(.25, .46, .45, .94)',
130
+ 'ease-in-out-quad': 'cubic-bezier(.455, .03, .515, .955)',
131
+ 'ease-in-cubic': 'cubic-bezier(.55, .055, .675, .19)',
132
+ 'ease-out-cubic': 'cubic-bezier(.215, .61, .355, 1)',
133
+ 'ease-in-out-cubic': 'cubic-bezier(.645, .045, .355, 1)',
134
+ 'ease-in-quart': 'cubic-bezier(.895, .03, .685, .22)',
135
+ 'ease-out-quart': 'cubic-bezier(.165, .84, .44, 1)',
136
+ 'ease-in-out-quart': 'cubic-bezier(.77, 0, .175, 1)',
137
+ };
138
+ const resolveEasing = (name) => { var _a; return (_a = EASING_MAP[name]) !== null && _a !== void 0 ? _a : name; };
139
+
140
+ const addClasses = (node, classes) => {
141
+ for (const className of classes)
142
+ node.classList.add(className);
143
+ };
144
+ const removeClasses = (node, classes) => {
145
+ for (const className of classes)
146
+ node.classList.remove(className);
147
+ };
148
+ /**
149
+ * Dispatches on `document`. The `:<id>` variant fires *in addition to* the base
150
+ * event, never instead of it.
151
+ */
152
+ const fireEvent = (eventName, node, id) => {
153
+ const detail = { node };
154
+ document.dispatchEvent(new CustomEvent(eventName, { detail }));
155
+ if (id) {
156
+ document.dispatchEvent(new CustomEvent(`${eventName}:${id}`, { detail }));
157
+ }
158
+ };
159
+ /** The three custom properties this library owns, global and per element alike. */
160
+ const VARS = [VAR_DURATION, VAR_DELAY, VAR_EASING];
161
+ const clearVars = (target) => {
162
+ if (!target)
163
+ return;
164
+ for (const name of VARS)
165
+ target.style.removeProperty(name);
166
+ };
167
+ /** Global duration/delay/easing, read by the core stylesheet. */
168
+ const setGlobalVars = (options) => {
169
+ const { body } = document;
170
+ if (!body)
171
+ return;
172
+ body.style.setProperty(VAR_DURATION, `${options.duration}ms`);
173
+ body.style.setProperty(VAR_DELAY, `${options.delay}ms`);
174
+ body.style.setProperty(VAR_EASING, resolveEasing(options.easing));
175
+ };
176
+ // `document.body` is null before the parser reaches it; the element variant
177
+ // always has a node.
178
+ const clearGlobalVars = () => clearVars(document.body);
179
+ const clearElementVars = (el) => clearVars(el);
180
+ /**
181
+ * Per-element overrides. Only written when the attribute is actually present —
182
+ * note `"0"` is a truthy string, so `data-motus-duration="0"` correctly yields
183
+ * `--motus-duration: 0ms`.
184
+ */
185
+ const setElementVars = (el, values) => {
186
+ if (values.duration)
187
+ el.style.setProperty(VAR_DURATION, `${values.duration}ms`);
188
+ if (values.delay)
189
+ el.style.setProperty(VAR_DELAY, `${values.delay}ms`);
190
+ if (values.easing)
191
+ el.style.setProperty(VAR_EASING, resolveEasing(values.easing));
192
+ };
193
+
194
+ /**
195
+ * True when the browser supports IntersectionObserver natively.
196
+ *
197
+ * Checks the constructor type rather than `'IntersectionObserver' in window`,
198
+ * because a stubbed-but-undefined global would otherwise pass the `in` test and
199
+ * then throw on construction.
200
+ */
201
+ const isSupported = () => {
202
+ if (typeof window === 'undefined')
203
+ return false;
204
+ if (typeof window.IntersectionObserver !== 'function')
205
+ return false;
206
+ if (typeof window.IntersectionObserverEntry !== 'function')
207
+ return false;
208
+ return 'intersectionRatio' in window.IntersectionObserverEntry.prototype;
209
+ };
210
+
211
+ /**
212
+ * Per-node "has this already animated in" state, needed because every
213
+ * `rebuild()` — `refresh()`, a height-changing resize, a MutationObserver
214
+ * batch — throws the configs away and then calls `activate()`, which replays
215
+ * the observers' initial records. A config that starts at `false` re-fires
216
+ * `motus:in` for everything already on screen.
217
+ *
218
+ * The animated class on the element is the primary source, because that class
219
+ * is what the stylesheet keys on: if the remembered state and the DOM ever
220
+ * disagree, the DOM is the one that decides whether the user sees anything.
221
+ * This map is only the fallback for `animatedClassName: false`, where no
222
+ * marker is written and there is nothing to read back.
223
+ *
224
+ * Keyed by `node`, not `observeTarget`: several configs can share one
225
+ * `observeTarget` (`data-motus-anchor`), but `buildConfigs` emits exactly one
226
+ * config per element, so `node` is unique.
227
+ *
228
+ * A WeakMap cannot be cleared, so the reset rebinds a fresh one. That is why
229
+ * callers go through these functions rather than importing the map.
230
+ */
231
+ let animated = new WeakMap();
232
+ const setAnimated = (node, value) => {
233
+ animated.set(node, value);
234
+ };
235
+ /**
236
+ * Resolves the starting `animated` for a rebuilt config.
237
+ *
238
+ * Reads the class whenever there is one to read. A remembered `true` with the
239
+ * class gone — a framework re-render resetting `className`, a consumer
240
+ * stripping it — would otherwise leave the element hidden for good, since the
241
+ * CSS keeps every `[data-motus]` element invisible until it animates.
242
+ */
243
+ const seedAnimated = (node, animatedClassName) => animatedClassName ? node.classList.contains(animatedClassName) : animated.get(node) === true;
244
+ /** Called from `disable()`, which strips the animated class from every element. */
245
+ const resetAnimatedState = () => {
246
+ animated = new WeakMap();
247
+ };
248
+
249
+ function getInlineOption(el, key, fallback) {
250
+ const value = el.getAttribute(attr(key));
251
+ if (value === 'true')
252
+ return true;
253
+ if (value === 'false')
254
+ return false;
255
+ return value !== null && value !== void 0 ? value : fallback;
256
+ }
257
+
258
+ /**
259
+ * Reads an inline option that is only meaningful as a string. `getInlineOption`
260
+ * coerces `"true"`/`"false"` to booleans, which none of these callers want.
261
+ */
262
+ const getInlineString = (node, key) => {
263
+ const value = getInlineOption(node, key);
264
+ return typeof value === 'string' ? value : undefined;
265
+ };
266
+ /**
267
+ * Resolves the anchor element for `data-motus-anchor`.
268
+ *
269
+ * An invalid CSS selector makes `querySelector` throw, which would otherwise
270
+ * take down the whole refresh; fall back to the node itself.
271
+ *
272
+ * `resolved` memoises the lookup for the duration of one `buildConfigs` pass,
273
+ * so a page where 50 elements share an anchor runs one query per rebuild
274
+ * rather than 50. It also means a bad selector warns once, not once per node.
275
+ */
276
+ const resolveAnchor = (node, resolved) => {
277
+ var _a;
278
+ const selector = getInlineString(node, 'anchor');
279
+ if (!selector)
280
+ return node;
281
+ if (resolved.has(selector))
282
+ return (_a = resolved.get(selector)) !== null && _a !== void 0 ? _a : node;
283
+ let target = null;
284
+ try {
285
+ target = document.querySelector(selector);
286
+ }
287
+ catch (_b) {
288
+ console.warn(`${LOG_PREFIX} Invalid data-motus-anchor selector: ${selector}`);
289
+ }
290
+ resolved.set(selector, target);
291
+ return target !== null && target !== void 0 ? target : node;
292
+ };
293
+ /**
294
+ * Builds the resolved per-element settings, and performs the only DOM writes
295
+ * that happen at setup time: the init class and the per-element CSS variables.
296
+ */
297
+ const buildConfigs = (elements, options) => {
298
+ const anchors = new Map();
299
+ // Shared, because with the default `useClassNames: false` every element ends
300
+ // up with the same list. An empty array is what makes
301
+ // `animatedClassName: false` skip the class entirely.
302
+ const baseClassNames = options.animatedClassName ? [options.animatedClassName] : [];
303
+ return elements.map((node) => {
304
+ var _a, _b;
305
+ const mirror = Boolean(getInlineOption(node, 'mirror', options.mirror));
306
+ const once = Boolean(getInlineOption(node, 'once', options.once));
307
+ const id = (_a = getInlineString(node, 'id')) !== null && _a !== void 0 ? _a : null;
308
+ const anchorPlacement = ((_b = getInlineString(node, 'anchor-placement')) !== null && _b !== void 0 ? _b : options.anchorPlacement);
309
+ // A non-numeric offset would otherwise poison the observer pool key
310
+ // (`"top-bottom-NaN"`) and every comparison in activate().
311
+ const rawOffset = Number(getInlineOption(node, 'offset', options.offset));
312
+ const offset = Number.isFinite(rawOffset) ? rawOffset : options.offset;
313
+ setElementVars(node, {
314
+ duration: getInlineString(node, 'duration'),
315
+ delay: getInlineString(node, 'delay'),
316
+ easing: getInlineString(node, 'easing'),
317
+ });
318
+ if (options.initClassName) {
319
+ node.classList.add(options.initClassName);
320
+ }
321
+ // `useClassNames` also applies the data-motus value itself, which is how
322
+ // the Animate.css integration works.
323
+ const custom = options.useClassNames ? node.getAttribute(ATTR) : null;
324
+ const animatedClassNames = custom
325
+ ? baseClassNames.concat(custom.split(' ').filter((name) => name !== ''))
326
+ : baseClassNames;
327
+ return {
328
+ node,
329
+ observeTarget: resolveAnchor(node, anchors),
330
+ mirror,
331
+ once,
332
+ id,
333
+ animatedClassNames,
334
+ // Seeded, not reset: rebuild() calls activate() as soon as the page is
335
+ // ready, which replays an "intersecting" record for everything on screen.
336
+ // A fresh `false` here makes that replay re-fire `motus:in`.
337
+ animated: seedAnimated(node, options.animatedClassName),
338
+ anchorPlacement,
339
+ offset,
340
+ };
341
+ });
342
+ };
343
+
344
+ /**
345
+ * Translates an anchor placement into an IntersectionObserver `rootMargin`.
346
+ *
347
+ * Pure and exported so the arithmetic can be unit-tested without a DOM.
348
+ */
349
+ const getRootMargin = (anchorPlacement, offset, windowHeight = window.innerHeight) => {
350
+ switch (anchorPlacement) {
351
+ case 'top-center':
352
+ case 'center-center':
353
+ case 'bottom-center': {
354
+ // Clamped to at least 1px: a margin that collapses the root to zero
355
+ // height would never intersect anything.
356
+ const centerMargin = Math.max(Math.round(windowHeight / 2) - offset, 1);
357
+ return `${-centerMargin}px 0px ${-centerMargin}px 0px`;
358
+ }
359
+ case 'top-top':
360
+ case 'center-top':
361
+ case 'bottom-top': {
362
+ const topExpand = Math.max(offset, 1);
363
+ return `${topExpand}px 0px ${-(windowHeight - offset)}px 0px`;
364
+ }
365
+ case 'top-bottom':
366
+ case 'center-bottom':
367
+ case 'bottom-bottom':
368
+ default:
369
+ return `0px 0px ${-offset}px 0px`;
370
+ }
371
+ };
372
+ /**
373
+ * `center-*` placements wait until the element is half visible. The `bottom-*`
374
+ * placements stay at 0 because `rootMargin` already compensates for element
375
+ * height.
376
+ */
377
+ const getThreshold = (anchorPlacement) => {
378
+ switch (anchorPlacement) {
379
+ case 'center-bottom':
380
+ case 'center-center':
381
+ case 'center-top':
382
+ return 0.5;
383
+ default:
384
+ return 0;
385
+ }
386
+ };
387
+
388
+ /**
389
+ * Creates the IntersectionObservers for a set of resolved configs.
390
+ *
391
+ * Observers are pooled by `anchorPlacement` + `offset`, because those two
392
+ * values are the only inputs to `rootMargin` and `threshold`. A page with 200
393
+ * elements sharing one configuration gets one observer, not 200.
394
+ */
395
+ const createObserver = (configs,
396
+ /**
397
+ * Passed in by `rebuild()`, which has already read it. Every pool would
398
+ * otherwise re-read `window.innerHeight` through `getRootMargin`'s default.
399
+ */
400
+ windowHeight = window.innerHeight) => {
401
+ /**
402
+ * IntersectionObserver fires its first callback immediately on `observe()`,
403
+ * before the stylesheet's `motus-ready` gate is in place. Holding callbacks
404
+ * back until `activate()` is what stops above-the-fold elements from jumping
405
+ * straight to their final state with no visible transition.
406
+ */
407
+ let activated = false;
408
+ const pools = new Map();
409
+ const handleEntry = (entry, pool) => {
410
+ const targets = pool.targets.get(entry.target);
411
+ if (!targets)
412
+ return;
413
+ for (const config of targets) {
414
+ if (entry.isIntersecting) {
415
+ if (!config.animated) {
416
+ addClasses(config.node, config.animatedClassNames);
417
+ fireEvent(EVENT_IN, config.node, config.id);
418
+ config.animated = true;
419
+ setAnimated(config.node, true);
420
+ }
421
+ }
422
+ else if (config.animated && config.mirror && !config.once) {
423
+ removeClasses(config.node, config.animatedClassNames);
424
+ fireEvent(EVENT_OUT, config.node, config.id);
425
+ config.animated = false;
426
+ setAnimated(config.node, false);
427
+ }
428
+ }
429
+ // Only stop observing once *every* config on this target is finished —
430
+ // with a shared anchor, unobserving on the first one strands the rest.
431
+ if (targets.every((config) => config.once && config.animated)) {
432
+ pool.observer.unobserve(entry.target);
433
+ }
434
+ };
435
+ for (const config of configs) {
436
+ // A finished `once` config can only produce callbacks it would ignore.
437
+ // Skipping before pooling leaves the unobserve rule intact — the config
438
+ // simply never enters the target Map.
439
+ if (config.once && config.animated)
440
+ continue;
441
+ const key = `${config.anchorPlacement}-${config.offset}`;
442
+ let pool = pools.get(key);
443
+ if (!pool) {
444
+ const targets = new Map();
445
+ const observer = new IntersectionObserver((entries) => {
446
+ if (!activated) {
447
+ pool.buffered.push(...entries);
448
+ return;
449
+ }
450
+ for (const entry of entries)
451
+ handleEntry(entry, pool);
452
+ }, {
453
+ rootMargin: getRootMargin(config.anchorPlacement, config.offset, windowHeight),
454
+ threshold: getThreshold(config.anchorPlacement),
455
+ });
456
+ pool = { observer, targets, buffered: [] };
457
+ pools.set(key, pool);
458
+ }
459
+ const existing = pool.targets.get(config.observeTarget);
460
+ if (existing) {
461
+ existing.push(config);
462
+ }
463
+ else {
464
+ pool.targets.set(config.observeTarget, [config]);
465
+ pool.observer.observe(config.observeTarget);
466
+ }
467
+ }
468
+ return {
469
+ disconnect: () => {
470
+ for (const pool of pools.values())
471
+ pool.observer.disconnect();
472
+ pools.clear();
473
+ },
474
+ /**
475
+ * Opens the gate and settles whatever the observers already know.
476
+ *
477
+ * Replays the entries that arrived while gated, plus any the browser has
478
+ * computed but not yet dispatched. That is what makes an element which was
479
+ * already on screen at init animate, without re-deriving the trigger
480
+ * geometry by hand: every decision here comes from the browser, using each
481
+ * pool's own rootMargin and threshold.
482
+ *
483
+ * Entries are processed oldest-first so the final state reflects the most
484
+ * recent observation.
485
+ */
486
+ activate: () => {
487
+ activated = true;
488
+ for (const pool of pools.values()) {
489
+ const entries = pool.buffered.concat(pool.observer.takeRecords());
490
+ pool.buffered.length = 0;
491
+ for (const entry of entries)
492
+ handleEntry(entry, pool);
493
+ }
494
+ },
495
+ };
496
+ };
497
+
498
+ /**
499
+ * Iterates the live NodeList directly rather than spreading it into an array.
500
+ * This runs for every mutation record of every DOM change anywhere on the
501
+ * page, most of which have nothing to do with motus, so the allocation is not
502
+ * worth it.
503
+ */
504
+ const containsMotusNode = (nodes) => {
505
+ for (const node of nodes) {
506
+ if (node.nodeType !== Node.ELEMENT_NODE)
507
+ continue;
508
+ const el = node;
509
+ if (el.hasAttribute(ATTR) || el.querySelector(`[${ATTR}]`))
510
+ return true;
511
+ }
512
+ return false;
513
+ };
514
+ /**
515
+ * Watches the document for dynamically added `[data-motus]` elements.
516
+ *
517
+ * Only `addedNodes` are considered. Reacting to removals causes a rebuild storm
518
+ * on SPA teardown for no benefit.
519
+ *
520
+ * Callbacks are batched into a single frame: framework hydration that appends
521
+ * 200 elements should rebuild the observers once, not 200 times.
522
+ */
523
+ const watch = (callback) => {
524
+ let scheduled = false;
525
+ const observer = new MutationObserver((mutations) => {
526
+ if (scheduled)
527
+ return;
528
+ const hasNewElements = mutations.some((mutation) => containsMotusNode(mutation.addedNodes));
529
+ if (!hasNewElements)
530
+ return;
531
+ scheduled = true;
532
+ requestAnimationFrame(() => {
533
+ scheduled = false;
534
+ callback();
535
+ });
536
+ });
537
+ observer.observe(document.documentElement, { childList: true, subtree: true });
538
+ return observer;
539
+ };
540
+
541
+ /** Tier names first — they are the documented path; the device keywords are legacy. */
542
+ const DISABLE_VALUES = [...BREAKPOINT_NAMES, ...DISABLE_KEYWORDS];
543
+ const isNonNegativeNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
544
+ /**
545
+ * Merges user settings over the defaults, clamps what needs clamping, and
546
+ * reports every problem in a single grouped warning so one typo does not
547
+ * produce a wall of console noise.
548
+ */
549
+ const normalizeOptions = (settings = {}) => {
550
+ const problems = [];
551
+ for (const key of Object.keys(settings)) {
552
+ if (!(key in DEFAULTS)) {
553
+ problems.push(`Unknown option "${key}".`);
554
+ }
555
+ }
556
+ const options = Object.assign({}, DEFAULTS, settings);
557
+ for (const key of ['duration', 'delay', 'offset']) {
558
+ if (!isNonNegativeNumber(options[key])) {
559
+ problems.push(`"${key}" must be a non-negative number, received ${String(options[key])}. Using ${DEFAULTS[key]}.`);
560
+ options[key] = DEFAULTS[key];
561
+ }
562
+ }
563
+ if (!ANCHOR_PLACEMENTS.includes(options.anchorPlacement)) {
564
+ problems.push(`"anchorPlacement" must be one of ${ANCHOR_PLACEMENTS.join(', ')}. Using ${DEFAULTS.anchorPlacement}.`);
565
+ options.anchorPlacement = DEFAULTS.anchorPlacement;
566
+ }
567
+ if (typeof options.disable === 'string' && !DISABLE_VALUES.includes(options.disable)) {
568
+ problems.push(`"disable" must be a boolean, a function, or one of ${DISABLE_VALUES.join(', ')}. Using ${String(DEFAULTS.disable)}.`);
569
+ // Falls back to the default, not to `false` — a typo'd tier name must not
570
+ // silently re-enable animations on every viewport.
571
+ options.disable = DEFAULTS.disable;
572
+ }
573
+ /**
574
+ * `Object.assign` above copied the frozen default map by reference, so a
575
+ * partial override would otherwise drop the tiers it did not mention.
576
+ * Re-merge into a fresh object; `DEFAULTS.breakpoints` is never the target.
577
+ */
578
+ options.breakpoints = Object.assign({}, DEFAULTS.breakpoints, settings.breakpoints);
579
+ for (const name of BREAKPOINT_NAMES) {
580
+ const width = options.breakpoints[name];
581
+ if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) {
582
+ problems.push(`"breakpoints.${name}" must be a positive number, received ${String(width)}. Using ${DEFAULTS.breakpoints[name]}.`);
583
+ options.breakpoints[name] = DEFAULTS.breakpoints[name];
584
+ }
585
+ }
586
+ if (typeof options.startEvent !== 'string' || options.startEvent === '') {
587
+ problems.push(`"startEvent" must be a non-empty string. Using ${DEFAULTS.startEvent}.`);
588
+ options.startEvent = DEFAULTS.startEvent;
589
+ }
590
+ const requested = options.debounceDelay;
591
+ const clamped = Number.isFinite(requested)
592
+ ? Math.min(DEBOUNCE_MAX, Math.max(DEBOUNCE_MIN, requested))
593
+ : DEFAULTS.debounceDelay;
594
+ if (clamped !== requested) {
595
+ problems.push(`"debounceDelay" clamped from ${String(requested)} to ${clamped} (allowed range ${DEBOUNCE_MIN}–${DEBOUNCE_MAX}ms).`);
596
+ }
597
+ options.debounceDelay = clamped;
598
+ if (problems.length > 0) {
599
+ console.warn(`${LOG_PREFIX} Invalid options:\n - ${problems.join('\n - ')}`);
600
+ }
601
+ return options;
602
+ };
603
+
604
+ let elements = [];
605
+ let observers = null;
606
+ let mutationObs = null;
607
+ let listeners = [];
608
+ let initialized = false;
609
+ let lastWindowHeight = null;
610
+ let options = Object.assign({}, DEFAULTS);
611
+ /** Every listener goes through here so `destroy()` can remove all of them. */
612
+ const listen = (target, event, fn) => {
613
+ target.addEventListener(event, fn);
614
+ listeners.push({ target, event, fn });
615
+ };
616
+ const collectElements = () => [
617
+ ...document.querySelectorAll(`[${ATTR}]`),
618
+ ];
619
+ /**
620
+ * Takes the whole options object rather than just `disable`, because a tier
621
+ * name is meaningless without the breakpoint map it indexes into.
622
+ */
623
+ const isDisabled = (opts) => {
624
+ const { disable } = opts;
625
+ return (document.documentElement.hasAttribute(DISABLED_ATTR) ||
626
+ disable === true ||
627
+ // A tier name means *below* that tier, so `'lg'` covers everything narrower.
628
+ (typeof disable === 'string' &&
629
+ disable in opts.breakpoints &&
630
+ below(opts.breakpoints[disable])) ||
631
+ (disable === 'mobile' && mobile()) ||
632
+ (disable === 'phone' && phone()) ||
633
+ (disable === 'tablet' && tablet()) ||
634
+ (typeof disable === 'function' && disable() === true));
635
+ };
636
+ /**
637
+ * Rebuilds the observers from the current DOM.
638
+ *
639
+ * Unconditional by design — the width-only-resize optimisation lives in
640
+ * `handleResize()`, not here, so that dynamically added content is always
641
+ * picked up even though the viewport has not changed size.
642
+ */
643
+ const rebuild = () => {
644
+ if (!initialized)
645
+ return;
646
+ lastWindowHeight = window.innerHeight;
647
+ elements = collectElements();
648
+ observers === null || observers === void 0 ? void 0 : observers.disconnect();
649
+ observers = createObserver(buildConfigs(elements, options), lastWindowHeight);
650
+ if (document.body.classList.contains(CLASS_READY)) {
651
+ // Already painted once — the new observers just need un-gating.
652
+ observers === null || observers === void 0 ? void 0 : observers.activate();
653
+ return;
654
+ }
655
+ /**
656
+ * Two frames, not one. The first frame must paint the elements in their
657
+ * initial hidden state; only then does `motus-ready` enable transitions and
658
+ * `activate()` add the animate class. Collapsing this makes every
659
+ * above-the-fold element snap to its final position with no animation.
660
+ */
661
+ requestAnimationFrame(() => {
662
+ requestAnimationFrame(() => {
663
+ document.body.classList.add(CLASS_READY);
664
+ observers === null || observers === void 0 ? void 0 : observers.activate();
665
+ });
666
+ });
667
+ };
668
+ /** First run: flips the initialised flag, then builds. */
669
+ const start = () => {
670
+ initialized = true;
671
+ rebuild();
672
+ };
673
+ /**
674
+ * `rootMargin` is vertical-only, so a width-only resize needs no rebuild.
675
+ * Mobile browsers fire `resize` on every URL-bar show/hide, which would
676
+ * otherwise tear down and recreate every observer mid-scroll.
677
+ */
678
+ const handleResize = () => {
679
+ if (!initialized)
680
+ return;
681
+ if (lastWindowHeight === window.innerHeight)
682
+ return;
683
+ rebuild();
684
+ };
685
+ const refresh = () => rebuild();
686
+ const refreshHard = () => {
687
+ if (isDisabled(options)) {
688
+ disable();
689
+ return;
690
+ }
691
+ /**
692
+ * Coming back from disabled is not a rebuild. When `init()` bailed at the
693
+ * gate it returned before setting `initialized`, installing the mutation
694
+ * observer or binding any listener, so `rebuild()` would no-op here. Re-run
695
+ * `init()` with the options already in hand — they are normalised, so the
696
+ * second pass revalidates cleanly and warns about nothing.
697
+ */
698
+ if (!initialized) {
699
+ init(options);
700
+ return;
701
+ }
702
+ document.documentElement.removeAttribute(INACTIVE_ATTR);
703
+ rebuild();
704
+ };
705
+ /**
706
+ * Tears down observers and removes the classes and custom properties this
707
+ * library added. Deliberately leaves `data-motus*` attributes alone so the
708
+ * markup survives and `init()` works again afterwards.
709
+ */
710
+ const disable = () => {
711
+ var _a;
712
+ // The CSS hides every [data-motus] element until it animates. With the
713
+ // library off, nothing will ever add that class, so this attribute is what
714
+ // stops the page from rendering blank.
715
+ document.documentElement.setAttribute(INACTIVE_ATTR, '');
716
+ mutationObs === null || mutationObs === void 0 ? void 0 : mutationObs.disconnect();
717
+ mutationObs = null;
718
+ observers === null || observers === void 0 ? void 0 : observers.disconnect();
719
+ observers = null;
720
+ (_a = document.body) === null || _a === void 0 ? void 0 : _a.classList.remove(CLASS_READY);
721
+ for (const el of elements) {
722
+ clearElementVars(el);
723
+ if (options.initClassName)
724
+ el.classList.remove(options.initClassName);
725
+ if (options.animatedClassName)
726
+ el.classList.remove(options.animatedClassName);
727
+ }
728
+ // The classes are gone, so the remembered state is now a lie. Reset here
729
+ // rather than in destroy() so the disable -> refreshHard() round trip
730
+ // re-animates. destroy() calls disable() first, so it inherits this.
731
+ resetAnimatedState();
732
+ };
733
+ /**
734
+ * Full teardown. Note `options` is intentionally left in place — `disable()`
735
+ * needs the last-used class names to remove them.
736
+ */
737
+ const destroy = () => {
738
+ disable();
739
+ for (const { target, event, fn } of listeners) {
740
+ target.removeEventListener(event, fn);
741
+ }
742
+ listeners = [];
743
+ clearGlobalVars();
744
+ elements = [];
745
+ initialized = false;
746
+ lastWindowHeight = null;
747
+ };
748
+ const init = (settings) => {
749
+ // Repeated init() is common in SPA route handlers; without this, every call
750
+ // leaks another set of listeners and observers.
751
+ if (initialized)
752
+ destroy();
753
+ options = normalizeOptions(settings);
754
+ // Cleared before the gate below can set it again, so re-initialising with
755
+ // different options is not permanently poisoned by the last run.
756
+ document.documentElement.removeAttribute(INACTIVE_ATTR);
757
+ if (!isSupported()) {
758
+ console.warn(`${LOG_PREFIX} IntersectionObserver is not supported in this browser; animations are disabled.`);
759
+ document.documentElement.setAttribute(INACTIVE_ATTR, '');
760
+ return undefined;
761
+ }
762
+ elements = collectElements();
763
+ // Checked before the MutationObserver is installed: on a disabled page there
764
+ // is no reason for every DOM mutation to run the disable path again.
765
+ if (isDisabled(options)) {
766
+ disable();
767
+ return undefined;
768
+ }
769
+ if (!options.disableMutationObserver) {
770
+ mutationObs = watch(refreshHard);
771
+ }
772
+ setGlobalVars(options);
773
+ if (options.startEvent === 'DOMContentLoaded' || options.startEvent === 'load') {
774
+ listen(window, 'load', () => {
775
+ // Guarded so the first run does not happen twice when DOMContentLoaded
776
+ // already fired.
777
+ if (!initialized)
778
+ start();
779
+ });
780
+ }
781
+ else {
782
+ listen(document, options.startEvent, () => start());
783
+ }
784
+ if (options.startEvent === 'DOMContentLoaded' &&
785
+ (document.readyState === 'complete' || document.readyState === 'interactive')) {
786
+ start();
787
+ }
788
+ listen(window, 'resize', debounce(handleResize, options.debounceDelay));
789
+ return elements;
790
+ };
791
+
792
+ /** Frozen so consuming code cannot monkey-patch the API. */
793
+ const Motus = Object.freeze({ init, refresh, refreshHard, destroy });
794
+
795
+ exports.DEFAULTS = DEFAULTS;
796
+ exports.default = Motus;
797
+ exports.destroy = destroy;
798
+ exports.init = init;
799
+ exports.refresh = refresh;
800
+ exports.refreshHard = refreshHard;