@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/CUSTOMIZATION.md +150 -0
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/dist/css/core.css +1 -0
- package/dist/css/fade.css +1 -0
- package/dist/css/flip.css +1 -0
- package/dist/css/motus.css +1 -0
- package/dist/css/slide.css +1 -0
- package/dist/css/zoom.css +1 -0
- package/dist/motus.cjs +800 -0
- package/dist/motus.d.cts +102 -0
- package/dist/motus.d.ts +102 -0
- package/dist/motus.js +791 -0
- package/dist/motus.umd.js +2 -0
- package/package.json +115 -0
- package/scss/animations/fade.scss +60 -0
- package/scss/animations/flip.scss +42 -0
- package/scss/animations/slide.scss +34 -0
- package/scss/animations/zoom.scss +58 -0
- package/scss/config.scss +3 -0
- package/scss/core.scss +18 -0
- package/scss/motus.scss +8 -0
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;
|