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