@smartimpact-it/scroll-utils 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +158 -0
- package/dist/cjs/Bootstrap4AccordionScrollIntoView.js +74 -0
- package/dist/cjs/BootstrapAccordionScrollIntoView.js +68 -0
- package/dist/cjs/FixHashScrollPosition.js +38 -0
- package/dist/cjs/FoundationAccordionScrollIntoView.js +52 -0
- package/dist/cjs/ScrollDirection.js +120 -0
- package/dist/cjs/ScrollOffset.js +346 -0
- package/dist/cjs/ScrollPages.js +97 -0
- package/dist/cjs/index.js +93 -0
- package/dist/cjs/scrollWithMarginTop.js +34 -0
- package/dist/cjs/utils/onReady.js +46 -0
- package/dist/cjs/utils/utils.js +77 -0
- package/dist/esm/Bootstrap4AccordionScrollIntoView.js +57 -0
- package/dist/esm/BootstrapAccordionScrollIntoView.js +51 -0
- package/dist/esm/FixHashScrollPosition.js +24 -0
- package/dist/esm/FoundationAccordionScrollIntoView.js +35 -0
- package/dist/esm/ScrollDirection.js +98 -0
- package/dist/esm/ScrollOffset.js +283 -0
- package/dist/esm/ScrollPages.js +80 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/scrollWithMarginTop.js +25 -0
- package/dist/esm/utils/onReady.js +33 -0
- package/dist/esm/utils/utils.js +62 -0
- package/dist/types/Bootstrap4AccordionScrollIntoView.d.ts +11 -0
- package/dist/types/BootstrapAccordionScrollIntoView.d.ts +11 -0
- package/dist/types/FixHashScrollPosition.d.ts +7 -0
- package/dist/types/FoundationAccordionScrollIntoView.d.ts +11 -0
- package/dist/types/ScrollDirection.d.ts +29 -0
- package/dist/types/ScrollOffset.d.ts +49 -0
- package/dist/types/ScrollPages.d.ts +21 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/scrollWithMarginTop.d.ts +5 -0
- package/dist/types/utils/onReady.d.ts +20 -0
- package/dist/types/utils/utils.d.ts +4 -0
- package/dist/umd/index.js +2 -0
- package/dist/umd/index.js.LICENSE.txt +8 -0
- package/package.json +101 -0
- package/src/Bootstrap4AccordionScrollIntoView.ts +51 -0
- package/src/BootstrapAccordionScrollIntoView.ts +45 -0
- package/src/FixHashScrollPosition.ts +31 -0
- package/src/FoundationAccordionScrollIntoView.ts +31 -0
- package/src/ScrollDirection.ts +122 -0
- package/src/ScrollOffset.ts +316 -0
- package/src/ScrollPages.ts +81 -0
- package/src/index.js +9 -0
- package/src/scrollWithMarginTop.ts +33 -0
- package/src/utils/onReady.ts +38 -0
- package/src/utils/utils.ts +78 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { scrollWithMarginTop } from './scrollWithMarginTop';
|
|
2
|
+
import { onComplete } from './utils/onReady';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scroll to the opened accordion tab
|
|
6
|
+
*/
|
|
7
|
+
export class BootstrapAccordionScrollIntoView {
|
|
8
|
+
extraOffset: number;
|
|
9
|
+
|
|
10
|
+
constructor({ extraOffset = 0 } = {}) {
|
|
11
|
+
this.extraOffset = extraOffset;
|
|
12
|
+
onComplete(() => setTimeout(() => this.#addEventListeners(), 1000));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#addEventListeners() {
|
|
16
|
+
document.addEventListener('shown.bs.collapse', (e: Event) => {
|
|
17
|
+
this.#handle(e);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#handle = (e: Event) => {
|
|
22
|
+
let header: HTMLElement = null;
|
|
23
|
+
const target = e.target as unknown as HTMLElement;
|
|
24
|
+
|
|
25
|
+
// Skip scrolling if the clicked element or any of its parents has the class 'skip-scroll-into-view'.
|
|
26
|
+
if (target.closest?.('.skip-scroll-into-view, [data-skip-scroll-into-view]')) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tabId = target.id;
|
|
31
|
+
const headerId = target.getAttribute('aria-labelledby');
|
|
32
|
+
if (headerId) {
|
|
33
|
+
header = document.querySelector(`#${headerId}`);
|
|
34
|
+
}
|
|
35
|
+
if (!header && tabId) {
|
|
36
|
+
header = document.querySelector(`[data-bs-target="#${tabId}"]`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (header) {
|
|
40
|
+
scrollWithMarginTop(header, this.extraOffset, true);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default BootstrapAccordionScrollIntoView;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { scrollWithMarginTop } from './scrollWithMarginTop';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fix the scroll offset when loading a page with a #hash (anchor)
|
|
5
|
+
*/
|
|
6
|
+
export class FixHashScrollPosition {
|
|
7
|
+
constructor() {
|
|
8
|
+
// fix for scroll-margin-top
|
|
9
|
+
window.addEventListener(
|
|
10
|
+
'hashchange',
|
|
11
|
+
() => {
|
|
12
|
+
const hash = window.location.hash;
|
|
13
|
+
if (!hash || hash === '#') {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const element = document.querySelector(hash) as HTMLElement;
|
|
19
|
+
if (element) {
|
|
20
|
+
setTimeout(() => scrollWithMarginTop(element));
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.warn(e);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
false
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default FixHashScrollPosition;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { scrollWithMarginTop } from './scrollWithMarginTop';
|
|
2
|
+
import { onComplete } from './utils/onReady';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scroll to the opened accordion tab
|
|
6
|
+
*/
|
|
7
|
+
export class FoundationAccordionScrollIntoView {
|
|
8
|
+
extraOffset: number;
|
|
9
|
+
|
|
10
|
+
constructor({ extraOffset = 0 } = {}) {
|
|
11
|
+
this.extraOffset = extraOffset;
|
|
12
|
+
onComplete(() => setTimeout(() => this.#addEventListeners(), 1000));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#addEventListeners() {
|
|
16
|
+
if (!('$' in window)) return;
|
|
17
|
+
|
|
18
|
+
(window as any).$(document).on('down.zf.accordion', (e, $content) => {
|
|
19
|
+
const target = $content.get(0).parentNode;
|
|
20
|
+
|
|
21
|
+
// Skip scrolling if the clicked element or any of its parents has the class 'skip-scroll-into-view'.
|
|
22
|
+
if (target.closest?.('.skip-scroll-into-view, [data-skip-scroll-into-view]')) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
scrollWithMarginTop(target, this.extraOffset, true);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default FoundationAccordionScrollIntoView;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import isFunction from 'lodash/isFunction';
|
|
2
|
+
import throttle from 'lodash/throttle';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ScrollDirection - a class to handle scroll direction classes on body
|
|
6
|
+
*
|
|
7
|
+
* Author: Bogdan Barbu
|
|
8
|
+
* Team: Codingheads (codingheads.com)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface ThrottleSettings {
|
|
12
|
+
leading?: boolean | undefined;
|
|
13
|
+
trailing?: boolean | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ScrollDirectionOptions {
|
|
17
|
+
onlyFor?: () => boolean | null;
|
|
18
|
+
threshold?: number;
|
|
19
|
+
thresholdCallback?: Function | null;
|
|
20
|
+
throttle?: null | number;
|
|
21
|
+
throttleOptions?: ThrottleSettings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ScrollDirection {
|
|
25
|
+
onlyForCallback: () => boolean | null = null;
|
|
26
|
+
#lastScrollTop: number = 0;
|
|
27
|
+
threshold: number = 0;
|
|
28
|
+
thresholdCallback?: Function | null = null;
|
|
29
|
+
#throttle?: null | number;
|
|
30
|
+
#throttleOptions: ThrottleSettings;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* start an instance
|
|
34
|
+
* @param {{onlyFor: a callback to determine where to modify classes; threshold: amount in px to take into account }} param0
|
|
35
|
+
*/
|
|
36
|
+
constructor({
|
|
37
|
+
onlyFor = null,
|
|
38
|
+
threshold = 0,
|
|
39
|
+
thresholdCallback = null,
|
|
40
|
+
throttle = 16,
|
|
41
|
+
throttleOptions = {
|
|
42
|
+
trailing: false,
|
|
43
|
+
},
|
|
44
|
+
}: ScrollDirectionOptions = {}) {
|
|
45
|
+
this.onlyForCallback = onlyFor;
|
|
46
|
+
this.threshold = threshold;
|
|
47
|
+
this.thresholdCallback = thresholdCallback;
|
|
48
|
+
this.#throttle = throttle;
|
|
49
|
+
this.#throttleOptions = throttleOptions;
|
|
50
|
+
this.#lastScrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
51
|
+
|
|
52
|
+
this.#setupListeners();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* setup the scroll event listener
|
|
57
|
+
*/
|
|
58
|
+
#setupListeners() {
|
|
59
|
+
const update = () => {
|
|
60
|
+
const isValid = !this.onlyForCallback || this.onlyForCallback();
|
|
61
|
+
if (!isValid) return;
|
|
62
|
+
this.#determineDirection();
|
|
63
|
+
};
|
|
64
|
+
if (this.#throttle) {
|
|
65
|
+
const throttledUpdate = throttle(
|
|
66
|
+
update,
|
|
67
|
+
this.#throttle,
|
|
68
|
+
this.#throttleOptions || {}
|
|
69
|
+
);
|
|
70
|
+
window.addEventListener('scroll', throttledUpdate, { passive: true });
|
|
71
|
+
} else {
|
|
72
|
+
window.addEventListener('scroll', update, { passive: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* dispatch an event on the window
|
|
78
|
+
*/
|
|
79
|
+
#dispatchEvent(name: string) {
|
|
80
|
+
requestAnimationFrame(() => {
|
|
81
|
+
window.dispatchEvent(new CustomEvent(name));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* determine the scroll direction
|
|
87
|
+
*/
|
|
88
|
+
#determineDirection() {
|
|
89
|
+
const body = document.body,
|
|
90
|
+
classList = body.classList;
|
|
91
|
+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
92
|
+
const localThreshold =
|
|
93
|
+
this.thresholdCallback && isFunction(this.thresholdCallback)
|
|
94
|
+
? this.thresholdCallback({
|
|
95
|
+
scrollTop,
|
|
96
|
+
threshold: this.threshold,
|
|
97
|
+
lastScrollTop: this.#lastScrollTop,
|
|
98
|
+
})
|
|
99
|
+
: this.threshold;
|
|
100
|
+
if (Math.abs(scrollTop - this.#lastScrollTop) < localThreshold) return;
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
scrollTop > this.#lastScrollTop + localThreshold &&
|
|
104
|
+
!classList.contains('scrolling-down')
|
|
105
|
+
) {
|
|
106
|
+
classList.add('scrolling-down');
|
|
107
|
+
classList.remove('scrolling-up');
|
|
108
|
+
this.#dispatchEvent('scrollDirectionChange');
|
|
109
|
+
} else if (
|
|
110
|
+
scrollTop < this.#lastScrollTop - localThreshold &&
|
|
111
|
+
!classList.contains('scrolling-up')
|
|
112
|
+
) {
|
|
113
|
+
classList.add('scrolling-up');
|
|
114
|
+
classList.remove('scrolling-down');
|
|
115
|
+
this.#dispatchEvent('scrollDirectionChange');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.#lastScrollTop = Math.max(scrollTop, 0);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default ScrollDirection;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import debounce from 'lodash/debounce';
|
|
2
|
+
import isFunction from 'lodash/isFunction';
|
|
3
|
+
|
|
4
|
+
export type ScrollOffsetPartSettings = {
|
|
5
|
+
name?: string;
|
|
6
|
+
selectors?: string[];
|
|
7
|
+
elements?: HTMLElement[];
|
|
8
|
+
fixedHeight?: number | false;
|
|
9
|
+
condition?: Function | false;
|
|
10
|
+
resizeCondition?: Function | false;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ScrollOffsetVariable = {
|
|
14
|
+
name: string;
|
|
15
|
+
offsetParts: Array<string | ScrollOffsetPart>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ScrollOffsetMap = {
|
|
19
|
+
[key: string]: Array<string | ScrollOffsetPart>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ScrollOffsetSettings = {
|
|
23
|
+
offsetParts?: ScrollOffsetPart[] | ScrollOffsetPartSettings[];
|
|
24
|
+
variables?: ScrollOffsetVariable[] | ScrollOffsetMap;
|
|
25
|
+
extraEvents?: string[];
|
|
26
|
+
registerForHoudini?: boolean;
|
|
27
|
+
useResizeObserver?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class ScrollOffsetPart {
|
|
31
|
+
#name: string;
|
|
32
|
+
#selectors: string[];
|
|
33
|
+
#elements: HTMLElement[];
|
|
34
|
+
#fixedHeight: number | false;
|
|
35
|
+
#condition: Function | false;
|
|
36
|
+
#resizeCondition: Function | false;
|
|
37
|
+
|
|
38
|
+
#resizeConditionValue: boolean;
|
|
39
|
+
#totalHeight: number = 0;
|
|
40
|
+
#isValid: boolean = false;
|
|
41
|
+
#heightCache = new WeakMap<HTMLElement, number>();
|
|
42
|
+
|
|
43
|
+
constructor({
|
|
44
|
+
name = '',
|
|
45
|
+
selectors = [],
|
|
46
|
+
elements = [],
|
|
47
|
+
fixedHeight = false,
|
|
48
|
+
condition = false,
|
|
49
|
+
resizeCondition = false,
|
|
50
|
+
}: ScrollOffsetPartSettings) {
|
|
51
|
+
this.#name = name;
|
|
52
|
+
this.#selectors = (selectors || []).filter(Boolean);
|
|
53
|
+
this.#elements = (elements || []).filter(Boolean);
|
|
54
|
+
this.#fixedHeight = fixedHeight;
|
|
55
|
+
this.#condition = condition;
|
|
56
|
+
this.#resizeCondition = resizeCondition;
|
|
57
|
+
|
|
58
|
+
this.#selectors.forEach(selector => {
|
|
59
|
+
const newElements = [...document.querySelectorAll(selector)] as HTMLElement[];
|
|
60
|
+
if (newElements.length) {
|
|
61
|
+
this.#elements = [...this.#elements, ...newElements];
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.calculate();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get name(): string {
|
|
69
|
+
return this.#name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get totalHeight(): number {
|
|
73
|
+
return this.#totalHeight;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get isValid(): boolean {
|
|
77
|
+
return this.#isValid;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get elements(): HTMLElement[] {
|
|
81
|
+
return this.#elements;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setElementHeight = (element: HTMLElement, height: number) => {
|
|
85
|
+
if (!element || !Number.isFinite(height) || height < 0) return;
|
|
86
|
+
this.#heightCache.set(element, Math.round(height));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
#getElementHeight = (element: HTMLElement) => {
|
|
90
|
+
if (!element) return 0;
|
|
91
|
+
const cachedHeight = this.#heightCache.get(element);
|
|
92
|
+
if (cachedHeight !== undefined) return cachedHeight;
|
|
93
|
+
|
|
94
|
+
const measuredHeight = element.clientHeight || 0;
|
|
95
|
+
this.#heightCache.set(element, measuredHeight);
|
|
96
|
+
return measuredHeight;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
calculate = (): this => {
|
|
100
|
+
this.#isValid = !!this.#elements?.length;
|
|
101
|
+
if (this.#isValid && this.#condition && isFunction(this.#condition)) {
|
|
102
|
+
this.#isValid = this.#isValid && this.#condition();
|
|
103
|
+
}
|
|
104
|
+
if (this.#isValid && this.#resizeCondition && isFunction(this.#resizeCondition)) {
|
|
105
|
+
if (!('resizeConditionValue' in this)) {
|
|
106
|
+
this.#resizeConditionValue = this.#resizeCondition();
|
|
107
|
+
}
|
|
108
|
+
this.#isValid = this.#isValid && this.#resizeConditionValue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!this.#isValid) {
|
|
112
|
+
this.#totalHeight = 0;
|
|
113
|
+
} else if (this.#fixedHeight) {
|
|
114
|
+
this.#totalHeight = this.#fixedHeight;
|
|
115
|
+
} else {
|
|
116
|
+
let totalHeight = 0;
|
|
117
|
+
this.#elements.forEach(element => {
|
|
118
|
+
totalHeight += this.#getElementHeight(element);
|
|
119
|
+
});
|
|
120
|
+
this.#totalHeight = totalHeight;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return this;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
calculateResizeConditions = (): boolean => {
|
|
127
|
+
if (this.#resizeCondition && isFunction(this.#resizeCondition)) {
|
|
128
|
+
const oldValue = this.#resizeConditionValue || false;
|
|
129
|
+
this.#resizeConditionValue = this.#resizeCondition();
|
|
130
|
+
return oldValue != this.#resizeConditionValue;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**!
|
|
137
|
+
* ScrollOffset - a class to handle add css variables to the body element,
|
|
138
|
+
* with the value of the "header" parts that are visible
|
|
139
|
+
* so that we can adjust scroll positions etc. based on it
|
|
140
|
+
*
|
|
141
|
+
* Author: Bogdan Barbu
|
|
142
|
+
* Team: Codingheads (codingheads.com)
|
|
143
|
+
*/
|
|
144
|
+
export class ScrollOffset {
|
|
145
|
+
#offsetParts: ScrollOffsetPart[];
|
|
146
|
+
#variables: ScrollOffsetVariable[];
|
|
147
|
+
#extraEvents: string[];
|
|
148
|
+
#registerForHoudini: boolean;
|
|
149
|
+
#useResizeObserver: boolean;
|
|
150
|
+
#measureScheduled: boolean = false;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* setup the conditions
|
|
154
|
+
*/
|
|
155
|
+
constructor({
|
|
156
|
+
offsetParts = [],
|
|
157
|
+
variables = [],
|
|
158
|
+
registerForHoudini = true,
|
|
159
|
+
useResizeObserver = true,
|
|
160
|
+
extraEvents = [],
|
|
161
|
+
}: ScrollOffsetSettings = {}) {
|
|
162
|
+
// parse the variables
|
|
163
|
+
if (Array.isArray(variables)) {
|
|
164
|
+
this.#variables = variables;
|
|
165
|
+
} else {
|
|
166
|
+
this.#variables = Object.entries(variables).map(([key, value]) => {
|
|
167
|
+
return {
|
|
168
|
+
name: key,
|
|
169
|
+
offsetParts: value,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// take the offset parts from the variables
|
|
175
|
+
const variableOffsetParts = this.#variables
|
|
176
|
+
.map(({ offsetParts }) => {
|
|
177
|
+
return offsetParts.filter(
|
|
178
|
+
part => part instanceof ScrollOffsetPart
|
|
179
|
+
) as ScrollOffsetPart[];
|
|
180
|
+
})
|
|
181
|
+
.flat(1);
|
|
182
|
+
|
|
183
|
+
// merge with the settings and keep only distinct elements
|
|
184
|
+
offsetParts = [...new Set([...offsetParts, ...variableOffsetParts])];
|
|
185
|
+
|
|
186
|
+
// parse the offset parts
|
|
187
|
+
this.#offsetParts = offsetParts.map(part => {
|
|
188
|
+
let partObject = null;
|
|
189
|
+
if (!(part instanceof ScrollOffsetPart)) {
|
|
190
|
+
partObject = new ScrollOffsetPart(part);
|
|
191
|
+
} else {
|
|
192
|
+
partObject = part;
|
|
193
|
+
}
|
|
194
|
+
partObject.calculate();
|
|
195
|
+
return partObject;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
this.#extraEvents = extraEvents || [];
|
|
199
|
+
this.#registerForHoudini =
|
|
200
|
+
registerForHoudini && 'CSS' in window && 'registerProperty' in window.CSS;
|
|
201
|
+
this.#useResizeObserver = 'ResizeObserver' in window && useResizeObserver;
|
|
202
|
+
|
|
203
|
+
if (this.#registerForHoudini) {
|
|
204
|
+
this.#registerCssVariables();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.#setupListeners();
|
|
208
|
+
this.#calculateOffset();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* register the css variables as length in the Houdini CSS API
|
|
213
|
+
*/
|
|
214
|
+
#registerCssVariables = () => {
|
|
215
|
+
this.#variables.forEach(({ name }) => {
|
|
216
|
+
(window.CSS as any).registerProperty({
|
|
217
|
+
name: `--${name}`,
|
|
218
|
+
syntax: '<length>',
|
|
219
|
+
inherits: true,
|
|
220
|
+
initialValue: '0px',
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* calculate the offsets and set the css variables
|
|
227
|
+
*/
|
|
228
|
+
#calculateOffset = () => {
|
|
229
|
+
// calculate the conditions
|
|
230
|
+
this.#offsetParts.forEach(part => part.calculate());
|
|
231
|
+
|
|
232
|
+
// determine the css variables
|
|
233
|
+
const cssVariables = this.#variables.map(variable => {
|
|
234
|
+
const value = variable.offsetParts.reduce((acc: number, part) => {
|
|
235
|
+
if (typeof part == 'string') {
|
|
236
|
+
part = this.#offsetParts.find(
|
|
237
|
+
partInfo => partInfo.name.localeCompare(part as string) == 0
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (part && part.isValid) {
|
|
241
|
+
acc += part.totalHeight;
|
|
242
|
+
}
|
|
243
|
+
return acc;
|
|
244
|
+
}, 0);
|
|
245
|
+
return { ...variable, value };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// output the variables
|
|
249
|
+
cssVariables.forEach(({ name, value }) => {
|
|
250
|
+
document.documentElement.style.setProperty(`--${name}`, `${value}px`);
|
|
251
|
+
});
|
|
252
|
+
document.body.dispatchEvent(new CustomEvent('ScrollOffsetChange', { bubbles: true }));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* recalculate values for conditions that only depend on resize
|
|
257
|
+
*/
|
|
258
|
+
#calculateResizeConditions = () => {
|
|
259
|
+
this.#offsetParts.forEach(part => part.calculateResizeConditions());
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
#debouncedCalculateOffset = debounce(this.#calculateOffset, 32);
|
|
263
|
+
#debouncedCalculateResizeConditions = debounce(this.#calculateResizeConditions, 32);
|
|
264
|
+
|
|
265
|
+
#scheduleCalculateOffset = () => {
|
|
266
|
+
if (this.#measureScheduled) return;
|
|
267
|
+
this.#measureScheduled = true;
|
|
268
|
+
|
|
269
|
+
requestAnimationFrame(() => {
|
|
270
|
+
this.#measureScheduled = false;
|
|
271
|
+
this.#debouncedCalculateOffset();
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* setup event listeners
|
|
277
|
+
*/
|
|
278
|
+
#setupListeners = () => {
|
|
279
|
+
['resize', 'orientationchange'].forEach(type => {
|
|
280
|
+
window.addEventListener(type, this.#debouncedCalculateResizeConditions);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
[
|
|
284
|
+
'resize',
|
|
285
|
+
'orientationchange',
|
|
286
|
+
'scrollDirectionChange',
|
|
287
|
+
'stickyIsPinned',
|
|
288
|
+
'stickyIsUnpinned',
|
|
289
|
+
...this.#extraEvents,
|
|
290
|
+
].forEach(type => window.addEventListener(type, this.#scheduleCalculateOffset));
|
|
291
|
+
|
|
292
|
+
// setup resizeObservers for the elements
|
|
293
|
+
if (this.#useResizeObserver) {
|
|
294
|
+
const resizeObserver = new ResizeObserver(entries => {
|
|
295
|
+
entries.forEach(entry => {
|
|
296
|
+
const target = entry.target as HTMLElement;
|
|
297
|
+
const height = Math.round(entry.contentRect?.height || target?.clientHeight || 0);
|
|
298
|
+
if (!target || !height) return;
|
|
299
|
+
|
|
300
|
+
this.#offsetParts.forEach(part => {
|
|
301
|
+
if (part.elements.includes(target)) {
|
|
302
|
+
part.setElementHeight(target, height);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
this.#scheduleCalculateOffset();
|
|
308
|
+
});
|
|
309
|
+
this.#offsetParts.forEach(part =>
|
|
310
|
+
part.elements.forEach(element => resizeObserver.observe(element))
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default ScrollOffset;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollPages - a class to add classes to the body,
|
|
3
|
+
* detecting whether we are above the fold or below
|
|
4
|
+
* (the above-the-fold are can also be set as an existing element)
|
|
5
|
+
*
|
|
6
|
+
* Author: Bogdan Barbu
|
|
7
|
+
* Team: Codingheads (codingheads.com)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface ScrollPagesOptions {
|
|
11
|
+
intersectionElement?: HTMLElement;
|
|
12
|
+
belowTheFoldClass?: string;
|
|
13
|
+
aboveTheFoldClass?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ScrollPages {
|
|
17
|
+
#intersectionElement: HTMLElement;
|
|
18
|
+
#observer: IntersectionObserver = null;
|
|
19
|
+
#isBelowTheFold: boolean = false;
|
|
20
|
+
#belowTheFoldClass: string;
|
|
21
|
+
#aboveTheFoldClass: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* start an instance
|
|
25
|
+
*/
|
|
26
|
+
constructor({
|
|
27
|
+
intersectionElement = null,
|
|
28
|
+
belowTheFoldClass = 'below-the-fold',
|
|
29
|
+
aboveTheFoldClass = 'above-the-fold',
|
|
30
|
+
}: ScrollPagesOptions = {}) {
|
|
31
|
+
this.#intersectionElement = intersectionElement;
|
|
32
|
+
this.#belowTheFoldClass = belowTheFoldClass;
|
|
33
|
+
this.#aboveTheFoldClass = aboveTheFoldClass;
|
|
34
|
+
this.#setupObserver();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* dispatch an event on the window
|
|
39
|
+
*/
|
|
40
|
+
#dispatchEvent(name: string) {
|
|
41
|
+
requestAnimationFrame(() => {
|
|
42
|
+
window.dispatchEvent(new CustomEvent(name));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#setupObserver() {
|
|
47
|
+
const body = document.body;
|
|
48
|
+
|
|
49
|
+
// create the intersection element, if it doesn't exist
|
|
50
|
+
if (!this.#intersectionElement) {
|
|
51
|
+
let containerPosition = window.getComputedStyle(body).getPropertyValue('position');
|
|
52
|
+
if (!containerPosition || containerPosition == 'static') {
|
|
53
|
+
body.style.position = 'relative';
|
|
54
|
+
}
|
|
55
|
+
this.#intersectionElement = document.createElement('div');
|
|
56
|
+
this.#intersectionElement.classList.add('second-page-observer');
|
|
57
|
+
this.#intersectionElement.style.cssText =
|
|
58
|
+
'pointerEvents: none; visibility: hidden; position: absolute; top: 0; left: 0; right: 0; height: 100vh;';
|
|
59
|
+
body.appendChild(this.#intersectionElement);
|
|
60
|
+
}
|
|
61
|
+
body.classList.add(this.#aboveTheFoldClass);
|
|
62
|
+
|
|
63
|
+
this.#observer = new IntersectionObserver(entries => {
|
|
64
|
+
let isFirstPage = true;
|
|
65
|
+
entries.forEach(entry => {
|
|
66
|
+
if (!entry.isIntersecting) {
|
|
67
|
+
isFirstPage = false;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
if (this.#isBelowTheFold != !isFirstPage) {
|
|
71
|
+
this.#isBelowTheFold = !isFirstPage;
|
|
72
|
+
body.classList[this.#isBelowTheFold ? 'add' : 'remove'](this.#belowTheFoldClass);
|
|
73
|
+
body.classList[!this.#isBelowTheFold ? 'add' : 'remove'](this.#aboveTheFoldClass);
|
|
74
|
+
this.#dispatchEvent('ScrollPageChange');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
this.#observer.observe(this.#intersectionElement);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default ScrollPages;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { ScrollDirection } from './ScrollDirection';
|
|
2
|
+
export { ScrollOffset, ScrollOffsetPart } from './ScrollOffset';
|
|
3
|
+
export { ScrollPages } from './ScrollPages';
|
|
4
|
+
export { scrollWithMarginTop } from './scrollWithMarginTop';
|
|
5
|
+
export { FixHashScrollPosition } from './FixHashScrollPosition';
|
|
6
|
+
export { BootstrapAccordionScrollIntoView } from './BootstrapAccordionScrollIntoView';
|
|
7
|
+
export { Bootstrap4AccordionScrollIntoView } from './Bootstrap4AccordionScrollIntoView';
|
|
8
|
+
export { FoundationAccordionScrollIntoView } from './FoundationAccordionScrollIntoView';
|
|
9
|
+
export { onReadyState, onComplete, onInteractive, onReady } from './utils/onReady';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll to an element taking into account its "scroll-margin-top"/"scroll-snap-margin-top".
|
|
3
|
+
* This will help with Safari < 14.1 / Safari iOS <= 1.4, which doesn't support "scroll-margin-top".
|
|
4
|
+
*/
|
|
5
|
+
export const scrollWithMarginTop = (
|
|
6
|
+
element: HTMLElement,
|
|
7
|
+
offset: number = 0,
|
|
8
|
+
onlyWhenNeeded = false
|
|
9
|
+
) => {
|
|
10
|
+
if (!element) return;
|
|
11
|
+
|
|
12
|
+
const rect = element.getBoundingClientRect();
|
|
13
|
+
const pageYOffset = window.pageYOffset;
|
|
14
|
+
const top = rect.top + pageYOffset;
|
|
15
|
+
const style = window.getComputedStyle(element);
|
|
16
|
+
const scrollMarginTop =
|
|
17
|
+
parseInt(style.getPropertyValue('scroll-margin-top')) ||
|
|
18
|
+
parseInt(style.getPropertyValue('scroll-snap-margin-top')) ||
|
|
19
|
+
0;
|
|
20
|
+
const newPosition = top - scrollMarginTop + offset;
|
|
21
|
+
|
|
22
|
+
// don't scroll if the element is already visible in the first half of the screen
|
|
23
|
+
if (onlyWhenNeeded) {
|
|
24
|
+
const isVisible =
|
|
25
|
+
newPosition >= pageYOffset &&
|
|
26
|
+
newPosition + rect.height < pageYOffset + window.innerHeight / 2;
|
|
27
|
+
if (isVisible) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
window.scrollTo({ top: newPosition, left: 0, behavior: 'smooth' });
|
|
33
|
+
};
|