keeptrack-css 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/LICENSE +21 -0
- package/README.md +367 -0
- package/keepTrack.esm.js +775 -0
- package/keepTrack.js +780 -0
- package/keepTrack.min.js +1 -0
- package/package.json +38 -0
package/keepTrack.js
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof define === 'function' && define.amd) {
|
|
3
|
+
define([], function () {
|
|
4
|
+
return factory(root);
|
|
5
|
+
});
|
|
6
|
+
} else if (typeof exports === 'object') {
|
|
7
|
+
module.exports = factory(root);
|
|
8
|
+
} else {
|
|
9
|
+
root.KeepTrack = factory(root);
|
|
10
|
+
}
|
|
11
|
+
})(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this, function (window) {
|
|
12
|
+
|
|
13
|
+
const defaults = {
|
|
14
|
+
scrollbarWidth: true,
|
|
15
|
+
scrollbarHeight: false,
|
|
16
|
+
debounceTime: 250,
|
|
17
|
+
poll: false,
|
|
18
|
+
detectSticky: false,
|
|
19
|
+
stickyTopDynamic: false,
|
|
20
|
+
onChange: null
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function debounce(fn, delay) {
|
|
24
|
+
let timeout;
|
|
25
|
+
return function (...args) {
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
timeout = setTimeout(() => {
|
|
28
|
+
requestAnimationFrame(() => fn.apply(this, args));
|
|
29
|
+
}, delay);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getTarget(el, value) {
|
|
34
|
+
if (!value) return false;
|
|
35
|
+
|
|
36
|
+
const level = parseInt(value, 10);
|
|
37
|
+
if (!isNaN(level) && level > 0) {
|
|
38
|
+
let node = el;
|
|
39
|
+
for (let i = 0; i < level; i++) {
|
|
40
|
+
if (!node.parentElement) return false;
|
|
41
|
+
node = node.parentElement;
|
|
42
|
+
}
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return el.closest(value) || document.querySelector(value) || false;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getStickyContainer(el) {
|
|
54
|
+
let parent = el.parentElement;
|
|
55
|
+
while (parent && parent !== document.documentElement) {
|
|
56
|
+
if (window.getComputedStyle(parent).display !== 'contents') return parent;
|
|
57
|
+
parent = parent.parentElement;
|
|
58
|
+
}
|
|
59
|
+
return document.documentElement;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function measureUnstuckDocTop(el) {
|
|
63
|
+
const prevPosition = el.style.getPropertyValue('position');
|
|
64
|
+
const prevPositionPriority = el.style.getPropertyPriority('position');
|
|
65
|
+
const prevTop = el.style.getPropertyValue('top');
|
|
66
|
+
const prevTopPriority = el.style.getPropertyPriority('top');
|
|
67
|
+
el.style.setProperty('position', 'static', 'important');
|
|
68
|
+
el.style.setProperty('top', 'auto', 'important');
|
|
69
|
+
const top = el.getBoundingClientRect().top + window.scrollY;
|
|
70
|
+
if (prevPosition) {
|
|
71
|
+
el.style.setProperty('position', prevPosition, prevPositionPriority || '');
|
|
72
|
+
} else {
|
|
73
|
+
el.style.removeProperty('position');
|
|
74
|
+
}
|
|
75
|
+
if (prevTop) {
|
|
76
|
+
el.style.setProperty('top', prevTop, prevTopPriority || '');
|
|
77
|
+
} else {
|
|
78
|
+
el.style.removeProperty('top');
|
|
79
|
+
}
|
|
80
|
+
return top;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getContainerDocTop(container) {
|
|
84
|
+
if (container === document.documentElement) return 0;
|
|
85
|
+
const computed = window.getComputedStyle(container);
|
|
86
|
+
const needsUnstuck = computed.position === 'sticky' || computed.position === 'fixed';
|
|
87
|
+
if (needsUnstuck) {
|
|
88
|
+
return measureUnstuckDocTop(container);
|
|
89
|
+
}
|
|
90
|
+
const rect = container.getBoundingClientRect();
|
|
91
|
+
return rect.top + window.scrollY;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createTopMeasurerTools() {
|
|
95
|
+
const topMeasurers = new WeakMap();
|
|
96
|
+
function getTopMeasurer(container) {
|
|
97
|
+
let measurer = topMeasurers.get(container);
|
|
98
|
+
if (!measurer) {
|
|
99
|
+
measurer = document.createElement('div');
|
|
100
|
+
measurer.style.position = 'absolute';
|
|
101
|
+
measurer.style.left = '0';
|
|
102
|
+
measurer.style.width = '0';
|
|
103
|
+
measurer.style.height = '0';
|
|
104
|
+
measurer.style.margin = '0';
|
|
105
|
+
measurer.style.padding = '0';
|
|
106
|
+
measurer.style.border = '0';
|
|
107
|
+
measurer.style.visibility = 'hidden';
|
|
108
|
+
measurer.style.pointerEvents = 'none';
|
|
109
|
+
container.appendChild(measurer);
|
|
110
|
+
topMeasurers.set(container, measurer);
|
|
111
|
+
}
|
|
112
|
+
return measurer;
|
|
113
|
+
}
|
|
114
|
+
return { getTopMeasurer, topMeasurers };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveTopPxWithMeasurer(el, topValue, usedContainers, getTopMeasurer) {
|
|
118
|
+
const container = getStickyContainer(el);
|
|
119
|
+
if (usedContainers) usedContainers.add(container);
|
|
120
|
+
const measurer = getTopMeasurer(container);
|
|
121
|
+
measurer.style.top = topValue;
|
|
122
|
+
const computed = window.getComputedStyle(measurer).top;
|
|
123
|
+
const value = parseFloat(computed);
|
|
124
|
+
return isNaN(value) ? null : value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getStickyTopPx(el, usedContainers, getTopMeasurer) {
|
|
128
|
+
const computedStyle = window.getComputedStyle(el);
|
|
129
|
+
const topValue = computedStyle.top.trim();
|
|
130
|
+
if (!topValue) return null;
|
|
131
|
+
if (topValue === 'auto') return null;
|
|
132
|
+
if (topValue.endsWith('%')) {
|
|
133
|
+
const pct = parseFloat(topValue);
|
|
134
|
+
if (isNaN(pct)) return null;
|
|
135
|
+
const container = getStickyContainer(el);
|
|
136
|
+
return container.getBoundingClientRect().height * (pct / 100);
|
|
137
|
+
}
|
|
138
|
+
if (topValue.endsWith('rem')) {
|
|
139
|
+
const rem = parseFloat(topValue);
|
|
140
|
+
if (isNaN(rem)) return null;
|
|
141
|
+
const rootFont = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
|
|
142
|
+
if (isNaN(rootFont)) return null;
|
|
143
|
+
return rem * rootFont;
|
|
144
|
+
}
|
|
145
|
+
if (topValue.endsWith('em')) {
|
|
146
|
+
const em = parseFloat(topValue);
|
|
147
|
+
if (isNaN(em)) return null;
|
|
148
|
+
const fontSize = parseFloat(computedStyle.fontSize);
|
|
149
|
+
if (isNaN(fontSize)) return null;
|
|
150
|
+
return em * fontSize;
|
|
151
|
+
}
|
|
152
|
+
if (topValue.includes('(')) {
|
|
153
|
+
if (typeof el.computedStyleMap === 'function') {
|
|
154
|
+
try {
|
|
155
|
+
const map = el.computedStyleMap();
|
|
156
|
+
const v = map.get('top');
|
|
157
|
+
if (v) {
|
|
158
|
+
if (typeof v.to === 'function') {
|
|
159
|
+
const px = v.to('px');
|
|
160
|
+
if (px && typeof px.value === 'number') return px.value;
|
|
161
|
+
}
|
|
162
|
+
if (v.unit === 'px' && typeof v.value === 'number') return v.value;
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// fall through to measurer
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return resolveTopPxWithMeasurer(el, topValue, usedContainers, getTopMeasurer);
|
|
169
|
+
}
|
|
170
|
+
const value = parseFloat(topValue);
|
|
171
|
+
return isNaN(value) ? null : value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cleanupStickyState(el, idOverride) {
|
|
175
|
+
const id = idOverride || el.id;
|
|
176
|
+
if (id) {
|
|
177
|
+
document.documentElement.style.removeProperty(`--${id}-stuck`);
|
|
178
|
+
}
|
|
179
|
+
el.style.removeProperty('--stuck');
|
|
180
|
+
el.removeAttribute('data-keeptrack-stuck');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sameTypes(a, b) {
|
|
184
|
+
if (a === b) return true;
|
|
185
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
186
|
+
for (let i = 0; i < a.length; i++) {
|
|
187
|
+
if (a[i] !== b[i]) return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return function (options) {
|
|
193
|
+
const publicAPIs = {};
|
|
194
|
+
let settings;
|
|
195
|
+
let resizeHandler;
|
|
196
|
+
let resizeObserver;
|
|
197
|
+
let observer;
|
|
198
|
+
let pollId;
|
|
199
|
+
let scrollHandler;
|
|
200
|
+
let anchorHandler;
|
|
201
|
+
let hashNavHandler;
|
|
202
|
+
let scrollTicking = false;
|
|
203
|
+
let trackedElements = [];
|
|
204
|
+
const trackedSet = new Set();
|
|
205
|
+
const { getTopMeasurer, topMeasurers } = createTopMeasurerTools();
|
|
206
|
+
|
|
207
|
+
// Per-instance state
|
|
208
|
+
const valueCache = new WeakMap();
|
|
209
|
+
const appliedConfig = new WeakMap();
|
|
210
|
+
let configCache = new WeakMap();
|
|
211
|
+
let stickyTopCache = new WeakMap();
|
|
212
|
+
let lastScrollbarWidth;
|
|
213
|
+
let lastScrollbarHeight;
|
|
214
|
+
let lastScrollPaddingTop;
|
|
215
|
+
let anchorScrollLock = false;
|
|
216
|
+
let anchorScrollFallback;
|
|
217
|
+
const usedMeasurerContainers = new Set();
|
|
218
|
+
|
|
219
|
+
function getElementConfig(el) {
|
|
220
|
+
if (configCache.has(el)) return configCache.get(el);
|
|
221
|
+
|
|
222
|
+
const raw = el.getAttribute('data-keeptrack');
|
|
223
|
+
if (!raw) return null;
|
|
224
|
+
const types = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
225
|
+
const id = el.id || false;
|
|
226
|
+
const targetValue = el.getAttribute('data-keeptrack-target-parent');
|
|
227
|
+
const target = getTarget(el, targetValue);
|
|
228
|
+
|
|
229
|
+
const config = { types, id, target };
|
|
230
|
+
configCache.set(el, config);
|
|
231
|
+
return config;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function invalidateConfigCache() {
|
|
235
|
+
configCache = new WeakMap();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function invalidateStickyTopCache() {
|
|
239
|
+
stickyTopCache = new WeakMap();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getStickyTopPxCached(el) {
|
|
243
|
+
if (settings && settings.stickyTopDynamic) return getStickyTopPx(el, usedMeasurerContainers, getTopMeasurer);
|
|
244
|
+
if (stickyTopCache.has(el)) return stickyTopCache.get(el);
|
|
245
|
+
const value = getStickyTopPx(el, usedMeasurerContainers, getTopMeasurer);
|
|
246
|
+
stickyTopCache.set(el, value);
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function calculateScrollbars() {
|
|
251
|
+
if (settings.scrollbarWidth) {
|
|
252
|
+
const value = `${window.innerWidth - document.documentElement.clientWidth}px`;
|
|
253
|
+
if (value !== lastScrollbarWidth) {
|
|
254
|
+
lastScrollbarWidth = value;
|
|
255
|
+
document.documentElement.style.setProperty('--scrollbar-width', value);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (settings.scrollbarHeight) {
|
|
259
|
+
const value = `${window.innerHeight - document.documentElement.clientHeight}px`;
|
|
260
|
+
if (value !== lastScrollbarHeight) {
|
|
261
|
+
lastScrollbarHeight = value;
|
|
262
|
+
document.documentElement.style.setProperty('--scrollbar-height', value);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function cleanupElement(el, configOverride) {
|
|
268
|
+
const config = configOverride || appliedConfig.get(el) || configCache.get(el);
|
|
269
|
+
if (config) {
|
|
270
|
+
const { types, id, target } = config;
|
|
271
|
+
for (const prop of types) {
|
|
272
|
+
const name = id ? `--${id}-${prop}` : `--${prop}`;
|
|
273
|
+
if (target) {
|
|
274
|
+
target.style.removeProperty(name);
|
|
275
|
+
} else if (id) {
|
|
276
|
+
document.documentElement.style.removeProperty(name);
|
|
277
|
+
} else {
|
|
278
|
+
el.style.removeProperty(name);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
cleanupStickyState(el, config && config.id ? config.id : undefined);
|
|
283
|
+
valueCache.delete(el);
|
|
284
|
+
configCache.delete(el);
|
|
285
|
+
appliedConfig.delete(el);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function calculateElement(el) {
|
|
289
|
+
const config = getElementConfig(el);
|
|
290
|
+
if (!config || !config.types || config.types.length === 0) {
|
|
291
|
+
if (appliedConfig.has(el)) cleanupElement(el);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const { types, id, target } = config;
|
|
295
|
+
const prev = appliedConfig.get(el);
|
|
296
|
+
if (prev && (!sameTypes(prev.types, types) || prev.id !== id || prev.target !== target)) {
|
|
297
|
+
cleanupElement(el, prev);
|
|
298
|
+
}
|
|
299
|
+
const computed = window.getComputedStyle(el);
|
|
300
|
+
|
|
301
|
+
if (!valueCache.has(el)) valueCache.set(el, {});
|
|
302
|
+
const elCache = valueCache.get(el);
|
|
303
|
+
|
|
304
|
+
for (const prop of types) {
|
|
305
|
+
const style = computed.getPropertyValue(prop);
|
|
306
|
+
if (elCache[prop] === style) continue;
|
|
307
|
+
elCache[prop] = style;
|
|
308
|
+
const name = id ? `--${id}-${prop}` : `--${prop}`;
|
|
309
|
+
if (target) {
|
|
310
|
+
target.style.setProperty(name, style);
|
|
311
|
+
} else if (id) {
|
|
312
|
+
document.documentElement.style.setProperty(name, style);
|
|
313
|
+
} else {
|
|
314
|
+
el.style.setProperty(name, style);
|
|
315
|
+
}
|
|
316
|
+
if (settings.onChange) {
|
|
317
|
+
settings.onChange(el, prop, style);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
appliedConfig.set(el, { types, id, target });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isStickyElement(el) {
|
|
324
|
+
return window.getComputedStyle(el).position === 'sticky';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function unlockScrollPadding() {
|
|
328
|
+
anchorScrollLock = false;
|
|
329
|
+
clearTimeout(anchorScrollFallback);
|
|
330
|
+
calculateScrollPadding();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function calculateScrollPadding() {
|
|
334
|
+
// Don't override predicted scroll-padding during anchor navigation
|
|
335
|
+
if (anchorScrollLock) return;
|
|
336
|
+
|
|
337
|
+
let top = 0;
|
|
338
|
+
let hasAny = false;
|
|
339
|
+
for (const el of trackedElements) {
|
|
340
|
+
if (!el.hasAttribute('data-keeptrack-scroll-padding')) continue;
|
|
341
|
+
if (settings.detectSticky) {
|
|
342
|
+
const isSticky = isStickyElement(el);
|
|
343
|
+
if (isSticky && !el.hasAttribute('data-keeptrack-stuck')) continue;
|
|
344
|
+
}
|
|
345
|
+
hasAny = true;
|
|
346
|
+
top += el.getBoundingClientRect().height;
|
|
347
|
+
}
|
|
348
|
+
if (!hasAny) {
|
|
349
|
+
if (lastScrollPaddingTop) {
|
|
350
|
+
document.documentElement.style.removeProperty('scroll-padding-top');
|
|
351
|
+
lastScrollPaddingTop = undefined;
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const value = `${top}px`;
|
|
356
|
+
if (value !== lastScrollPaddingTop) {
|
|
357
|
+
lastScrollPaddingTop = value;
|
|
358
|
+
document.documentElement.style.setProperty('scroll-padding-top', value);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function checkStickyElements() {
|
|
363
|
+
for (const el of trackedElements) {
|
|
364
|
+
if (!el.hasAttribute('data-keeptrack') && !el.hasAttribute('data-keeptrack-scroll-padding')) continue;
|
|
365
|
+
const config = getElementConfig(el) || {};
|
|
366
|
+
if (config.isSticky === undefined) {
|
|
367
|
+
config.isSticky = isStickyElement(el);
|
|
368
|
+
}
|
|
369
|
+
if (!config.isSticky) continue;
|
|
370
|
+
|
|
371
|
+
const rect = el.getBoundingClientRect();
|
|
372
|
+
const stickyTop = getStickyTopPxCached(el);
|
|
373
|
+
if (stickyTop === null) continue;
|
|
374
|
+
|
|
375
|
+
const stuck = Math.abs(rect.top - stickyTop) < 1.5;
|
|
376
|
+
const wasStuck = el.hasAttribute('data-keeptrack-stuck');
|
|
377
|
+
|
|
378
|
+
if (stuck === wasStuck) continue;
|
|
379
|
+
|
|
380
|
+
if (stuck) {
|
|
381
|
+
el.setAttribute('data-keeptrack-stuck', '');
|
|
382
|
+
} else {
|
|
383
|
+
el.removeAttribute('data-keeptrack-stuck');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const id = el.id;
|
|
387
|
+
const stuckValue = stuck ? '1' : '0';
|
|
388
|
+
if (id) {
|
|
389
|
+
document.documentElement.style.setProperty(`--${id}-stuck`, stuckValue);
|
|
390
|
+
} else {
|
|
391
|
+
el.style.setProperty('--stuck', stuckValue);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (settings.onChange) {
|
|
395
|
+
settings.onChange(el, 'stuck', stuckValue);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function updateScrollPaddingForTarget(target) {
|
|
401
|
+
const targetTop = target.getBoundingClientRect().top + window.scrollY;
|
|
402
|
+
const candidates = [];
|
|
403
|
+
for (const el of trackedElements) {
|
|
404
|
+
if (!el.hasAttribute('data-keeptrack-scroll-padding')) continue;
|
|
405
|
+
const isSticky = isStickyElement(el);
|
|
406
|
+
const rect = el.getBoundingClientRect();
|
|
407
|
+
const height = rect.height;
|
|
408
|
+
if (!isSticky) {
|
|
409
|
+
candidates.push({ sticky: false, height });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const stickyTop = getStickyTopPxCached(el);
|
|
413
|
+
if (stickyTop === null) continue;
|
|
414
|
+
const elDocTop = measureUnstuckDocTop(el);
|
|
415
|
+
const container = getStickyContainer(el);
|
|
416
|
+
const containerTop = getContainerDocTop(container);
|
|
417
|
+
const containerBottom = containerTop + container.offsetHeight;
|
|
418
|
+
candidates.push({
|
|
419
|
+
sticky: true,
|
|
420
|
+
height,
|
|
421
|
+
stickyTop,
|
|
422
|
+
minScroll: elDocTop - stickyTop,
|
|
423
|
+
maxScroll: containerBottom - height - stickyTop
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let padding = 0;
|
|
428
|
+
let hasAny = false;
|
|
429
|
+
|
|
430
|
+
// Iterative convergence: scroll position depends on padding, padding depends on which
|
|
431
|
+
// sticky elements are stuck, which depends on scroll position. Max 5 iterations to converge.
|
|
432
|
+
for (let i = 0; i < 5; i++) {
|
|
433
|
+
const scrollTop = targetTop - padding;
|
|
434
|
+
let nextPadding = 0;
|
|
435
|
+
let nextHasAny = false;
|
|
436
|
+
for (const item of candidates) {
|
|
437
|
+
if (!item.sticky) {
|
|
438
|
+
nextHasAny = true;
|
|
439
|
+
nextPadding += item.height;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (scrollTop >= item.minScroll && scrollTop <= item.maxScroll) {
|
|
443
|
+
nextHasAny = true;
|
|
444
|
+
nextPadding += item.height;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (nextPadding === padding) {
|
|
448
|
+
padding = nextPadding;
|
|
449
|
+
hasAny = nextHasAny;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
padding = nextPadding;
|
|
453
|
+
hasAny = nextHasAny;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (hasAny) {
|
|
457
|
+
const value = `${padding}px`;
|
|
458
|
+
lastScrollPaddingTop = value;
|
|
459
|
+
document.documentElement.style.setProperty('scroll-padding-top', value);
|
|
460
|
+
} else if (lastScrollPaddingTop) {
|
|
461
|
+
document.documentElement.style.removeProperty('scroll-padding-top');
|
|
462
|
+
lastScrollPaddingTop = undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Lock scroll-padding until the browser finishes scrolling to the anchor.
|
|
466
|
+
// Uses scrollend event when available, with a generous fallback timeout.
|
|
467
|
+
anchorScrollLock = true;
|
|
468
|
+
clearTimeout(anchorScrollFallback);
|
|
469
|
+
if ('onscrollend' in window) {
|
|
470
|
+
window.addEventListener('scrollend', unlockScrollPadding, { once: true });
|
|
471
|
+
}
|
|
472
|
+
const startScrollY = window.scrollY;
|
|
473
|
+
requestAnimationFrame(() => {
|
|
474
|
+
requestAnimationFrame(() => {
|
|
475
|
+
if (window.scrollY === startScrollY) {
|
|
476
|
+
unlockScrollPadding();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
anchorScrollFallback = setTimeout(unlockScrollPadding, 5000);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function refreshElements() {
|
|
484
|
+
const nextElements = Array.from(document.querySelectorAll('[data-keeptrack], [data-keeptrack-scroll-padding]'));
|
|
485
|
+
const nextSet = new Set(nextElements);
|
|
486
|
+
for (const el of trackedElements) {
|
|
487
|
+
if (!nextSet.has(el)) {
|
|
488
|
+
if (resizeObserver) resizeObserver.unobserve(el);
|
|
489
|
+
cleanupElement(el);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (resizeObserver) {
|
|
493
|
+
for (const el of nextElements) {
|
|
494
|
+
if (!trackedSet.has(el)) {
|
|
495
|
+
resizeObserver.observe(el);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
trackedElements = nextElements;
|
|
500
|
+
trackedSet.clear();
|
|
501
|
+
for (const el of nextElements) trackedSet.add(el);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
publicAPIs.init = function (opts) {
|
|
505
|
+
publicAPIs.destroy();
|
|
506
|
+
|
|
507
|
+
settings = Object.assign({}, defaults, opts || {});
|
|
508
|
+
|
|
509
|
+
if (!document.body) {
|
|
510
|
+
if (document.readyState === 'loading') {
|
|
511
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
512
|
+
publicAPIs.init(opts);
|
|
513
|
+
}, { once: true });
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Viewport resize → scrollbar dimensions
|
|
519
|
+
resizeHandler = debounce(() => {
|
|
520
|
+
calculateScrollbars();
|
|
521
|
+
invalidateConfigCache();
|
|
522
|
+
invalidateStickyTopCache();
|
|
523
|
+
}, settings.debounceTime);
|
|
524
|
+
window.addEventListener('resize', resizeHandler);
|
|
525
|
+
|
|
526
|
+
// Element resize → recalculate that element
|
|
527
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
528
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
529
|
+
for (const entry of entries) {
|
|
530
|
+
calculateElement(entry.target);
|
|
531
|
+
}
|
|
532
|
+
calculateScrollPadding();
|
|
533
|
+
});
|
|
534
|
+
} else {
|
|
535
|
+
resizeObserver = null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// DOM changes → observe new elements if relevant
|
|
539
|
+
const debouncedMutation = debounce(() => {
|
|
540
|
+
invalidateConfigCache();
|
|
541
|
+
invalidateStickyTopCache();
|
|
542
|
+
refreshElements();
|
|
543
|
+
trackedElements.forEach((el) => calculateElement(el));
|
|
544
|
+
calculateScrollPadding();
|
|
545
|
+
}, settings.debounceTime);
|
|
546
|
+
|
|
547
|
+
observer = new MutationObserver((mutations) => {
|
|
548
|
+
let relevant = false;
|
|
549
|
+
for (const mutation of mutations) {
|
|
550
|
+
if (mutation.type === 'attributes') {
|
|
551
|
+
const el = mutation.target;
|
|
552
|
+
if (mutation.attributeName === 'data-keeptrack') {
|
|
553
|
+
if (el.hasAttribute('data-keeptrack')) {
|
|
554
|
+
relevant = true;
|
|
555
|
+
} else {
|
|
556
|
+
cleanupElement(el);
|
|
557
|
+
relevant = true;
|
|
558
|
+
}
|
|
559
|
+
} else if (mutation.attributeName === 'data-keeptrack-scroll-padding') {
|
|
560
|
+
if (!el.hasAttribute('data-keeptrack-scroll-padding')) {
|
|
561
|
+
cleanupStickyState(el);
|
|
562
|
+
}
|
|
563
|
+
relevant = true;
|
|
564
|
+
} else if (mutation.attributeName === 'id') {
|
|
565
|
+
const oldId = mutation.oldValue;
|
|
566
|
+
if (oldId && oldId !== el.id && el.hasAttribute('data-keeptrack-scroll-padding') && !el.hasAttribute('data-keeptrack')) {
|
|
567
|
+
cleanupStickyState(el, oldId);
|
|
568
|
+
}
|
|
569
|
+
if (el.hasAttribute('data-keeptrack')) {
|
|
570
|
+
cleanupElement(el);
|
|
571
|
+
invalidateConfigCache();
|
|
572
|
+
calculateElement(el);
|
|
573
|
+
calculateScrollPadding();
|
|
574
|
+
}
|
|
575
|
+
relevant = true;
|
|
576
|
+
} else if (el.hasAttribute('data-keeptrack')) {
|
|
577
|
+
cleanupElement(el);
|
|
578
|
+
invalidateConfigCache();
|
|
579
|
+
calculateElement(el);
|
|
580
|
+
calculateScrollPadding();
|
|
581
|
+
}
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (!relevant) {
|
|
585
|
+
for (const node of mutation.addedNodes) {
|
|
586
|
+
if (node.nodeType === 1 &&
|
|
587
|
+
(node.matches('[data-keeptrack]') ||
|
|
588
|
+
node.matches('[data-keeptrack-scroll-padding]') ||
|
|
589
|
+
node.querySelector('[data-keeptrack]') ||
|
|
590
|
+
node.querySelector('[data-keeptrack-scroll-padding]'))) {
|
|
591
|
+
relevant = true;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (!relevant) {
|
|
597
|
+
for (const node of mutation.removedNodes) {
|
|
598
|
+
if (node.nodeType === 1 &&
|
|
599
|
+
(node.matches('[data-keeptrack]') ||
|
|
600
|
+
node.matches('[data-keeptrack-scroll-padding]') ||
|
|
601
|
+
node.querySelector('[data-keeptrack]') ||
|
|
602
|
+
node.querySelector('[data-keeptrack-scroll-padding]'))) {
|
|
603
|
+
relevant = true;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (relevant) debouncedMutation();
|
|
610
|
+
});
|
|
611
|
+
observer.observe(document.body, {
|
|
612
|
+
childList: true,
|
|
613
|
+
subtree: true,
|
|
614
|
+
attributes: true,
|
|
615
|
+
attributeOldValue: true,
|
|
616
|
+
attributeFilter: ['data-keeptrack', 'data-keeptrack-target-parent', 'data-keeptrack-scroll-padding', 'id']
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Sticky detection on scroll
|
|
620
|
+
if (settings.detectSticky) {
|
|
621
|
+
scrollHandler = function () {
|
|
622
|
+
if (!scrollTicking) {
|
|
623
|
+
scrollTicking = true;
|
|
624
|
+
requestAnimationFrame(() => {
|
|
625
|
+
checkStickyElements();
|
|
626
|
+
calculateScrollPadding();
|
|
627
|
+
scrollTicking = false;
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
window.addEventListener('scroll', scrollHandler, { passive: true });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Predict scroll-padding-top on anchor link clicks
|
|
635
|
+
anchorHandler = function (e) {
|
|
636
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
637
|
+
const targetNode = e.target && e.target.nodeType === 1 ? e.target : e.target && e.target.parentElement;
|
|
638
|
+
if (!targetNode || !targetNode.closest) return;
|
|
639
|
+
const anchor = targetNode.closest('a[href^="#"]');
|
|
640
|
+
if (!anchor) return;
|
|
641
|
+
const targetId = anchor.getAttribute('href').slice(1);
|
|
642
|
+
if (!targetId) return;
|
|
643
|
+
const target = document.getElementById(targetId);
|
|
644
|
+
if (!target) return;
|
|
645
|
+
updateScrollPaddingForTarget(target);
|
|
646
|
+
};
|
|
647
|
+
document.addEventListener('click', anchorHandler);
|
|
648
|
+
|
|
649
|
+
// Predict scroll-padding-top on hash changes (programmatic, manual, or back/forward)
|
|
650
|
+
hashNavHandler = function () {
|
|
651
|
+
const targetId = window.location.hash ? window.location.hash.slice(1) : '';
|
|
652
|
+
if (!targetId) return;
|
|
653
|
+
const target = document.getElementById(targetId);
|
|
654
|
+
if (!target) return;
|
|
655
|
+
updateScrollPaddingForTarget(target);
|
|
656
|
+
};
|
|
657
|
+
window.addEventListener('hashchange', hashNavHandler);
|
|
658
|
+
window.addEventListener('popstate', hashNavHandler);
|
|
659
|
+
|
|
660
|
+
// Poll for non-resize computed style changes (sticky detection is handled by the scroll listener)
|
|
661
|
+
if (settings.poll) {
|
|
662
|
+
(function poll() {
|
|
663
|
+
trackedElements.forEach((el) => calculateElement(el));
|
|
664
|
+
calculateScrollPadding();
|
|
665
|
+
pollId = requestAnimationFrame(poll);
|
|
666
|
+
})();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Initial calculation
|
|
670
|
+
calculateScrollbars();
|
|
671
|
+
refreshElements();
|
|
672
|
+
trackedElements.forEach((el) => calculateElement(el));
|
|
673
|
+
if (settings.detectSticky) {
|
|
674
|
+
checkStickyElements();
|
|
675
|
+
}
|
|
676
|
+
calculateScrollPadding();
|
|
677
|
+
if (window.location.hash && window.location.hash.length > 1) {
|
|
678
|
+
const target = document.getElementById(window.location.hash.slice(1));
|
|
679
|
+
if (target) updateScrollPaddingForTarget(target);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
publicAPIs.observe = function (el) {
|
|
684
|
+
if (trackedSet.has(el)) return;
|
|
685
|
+
trackedElements.push(el);
|
|
686
|
+
trackedSet.add(el);
|
|
687
|
+
if (resizeObserver) resizeObserver.observe(el);
|
|
688
|
+
calculateElement(el);
|
|
689
|
+
calculateScrollPadding();
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
publicAPIs.unobserve = function (el) {
|
|
693
|
+
if (!trackedSet.has(el)) return;
|
|
694
|
+
cleanupElement(el);
|
|
695
|
+
trackedSet.delete(el);
|
|
696
|
+
const index = trackedElements.indexOf(el);
|
|
697
|
+
if (index !== -1) trackedElements.splice(index, 1);
|
|
698
|
+
if (resizeObserver) resizeObserver.unobserve(el);
|
|
699
|
+
calculateScrollPadding();
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
publicAPIs.recalculate = function () {
|
|
703
|
+
calculateScrollbars();
|
|
704
|
+
invalidateStickyTopCache();
|
|
705
|
+
trackedElements.forEach((el) => calculateElement(el));
|
|
706
|
+
if (settings.detectSticky) {
|
|
707
|
+
checkStickyElements();
|
|
708
|
+
}
|
|
709
|
+
calculateScrollPadding();
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
publicAPIs.destroy = function () {
|
|
713
|
+
if (resizeHandler) {
|
|
714
|
+
window.removeEventListener('resize', resizeHandler);
|
|
715
|
+
resizeHandler = null;
|
|
716
|
+
}
|
|
717
|
+
if (scrollHandler) {
|
|
718
|
+
window.removeEventListener('scroll', scrollHandler);
|
|
719
|
+
scrollHandler = null;
|
|
720
|
+
}
|
|
721
|
+
if (anchorHandler) {
|
|
722
|
+
document.removeEventListener('click', anchorHandler);
|
|
723
|
+
anchorHandler = null;
|
|
724
|
+
}
|
|
725
|
+
if (hashNavHandler) {
|
|
726
|
+
window.removeEventListener('hashchange', hashNavHandler);
|
|
727
|
+
window.removeEventListener('popstate', hashNavHandler);
|
|
728
|
+
hashNavHandler = null;
|
|
729
|
+
}
|
|
730
|
+
if (resizeObserver) {
|
|
731
|
+
resizeObserver.disconnect();
|
|
732
|
+
resizeObserver = null;
|
|
733
|
+
}
|
|
734
|
+
if (observer) {
|
|
735
|
+
observer.disconnect();
|
|
736
|
+
observer = null;
|
|
737
|
+
}
|
|
738
|
+
if (pollId) {
|
|
739
|
+
cancelAnimationFrame(pollId);
|
|
740
|
+
pollId = null;
|
|
741
|
+
}
|
|
742
|
+
window.removeEventListener('scrollend', unlockScrollPadding);
|
|
743
|
+
|
|
744
|
+
// Clean up CSS variables and attributes
|
|
745
|
+
trackedElements.forEach(cleanupElement);
|
|
746
|
+
|
|
747
|
+
if (lastScrollbarWidth) {
|
|
748
|
+
document.documentElement.style.removeProperty('--scrollbar-width');
|
|
749
|
+
lastScrollbarWidth = undefined;
|
|
750
|
+
}
|
|
751
|
+
if (lastScrollbarHeight) {
|
|
752
|
+
document.documentElement.style.removeProperty('--scrollbar-height');
|
|
753
|
+
lastScrollbarHeight = undefined;
|
|
754
|
+
}
|
|
755
|
+
if (lastScrollPaddingTop) {
|
|
756
|
+
document.documentElement.style.removeProperty('scroll-padding-top');
|
|
757
|
+
lastScrollPaddingTop = undefined;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
trackedElements = [];
|
|
761
|
+
trackedSet.clear();
|
|
762
|
+
scrollTicking = false;
|
|
763
|
+
anchorScrollLock = false;
|
|
764
|
+
clearTimeout(anchorScrollFallback);
|
|
765
|
+
for (const container of usedMeasurerContainers) {
|
|
766
|
+
const measurer = topMeasurers.get(container);
|
|
767
|
+
if (measurer && measurer.parentNode) {
|
|
768
|
+
measurer.parentNode.removeChild(measurer);
|
|
769
|
+
}
|
|
770
|
+
topMeasurers.delete(container);
|
|
771
|
+
}
|
|
772
|
+
usedMeasurerContainers.clear();
|
|
773
|
+
stickyTopCache = new WeakMap();
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
publicAPIs.init(options);
|
|
777
|
+
|
|
778
|
+
return publicAPIs;
|
|
779
|
+
};
|
|
780
|
+
});
|