rune-scroller 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animations.css +237 -0
- package/dist/animations.d.ts +18 -0
- package/dist/animations.js +70 -0
- package/dist/aos.d.ts +38 -0
- package/dist/aos.js +273 -0
- package/dist/dom-utils.d.ts +30 -0
- package/dist/dom-utils.js +118 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +25 -0
- package/dist/observer-utils.d.ts +21 -0
- package/dist/observer-utils.js +31 -0
- package/dist/runeScroller.d.ts +9 -0
- package/dist/runeScroller.js +184 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +40 -0
- package/dist/useIntersection.svelte.d.ts +11 -0
- package/dist/useIntersection.svelte.js +98 -0
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rune Scroller — scroll animation styles
|
|
3
|
+
* CSS custom properties for flexible animation control
|
|
4
|
+
* Supports both native and AOS-compatible animation names
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* ===== Distance variable ===== */
|
|
8
|
+
:root {
|
|
9
|
+
--rs-distance: 100px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* ===== Base animation container ===== */
|
|
13
|
+
[data-animation] {
|
|
14
|
+
opacity: 0;
|
|
15
|
+
transition-property: opacity, transform;
|
|
16
|
+
transition-duration: var(--duration, 400ms);
|
|
17
|
+
transition-delay: var(--delay, 0ms);
|
|
18
|
+
transition-timing-function: var(--easing, ease);
|
|
19
|
+
transform: translate3d(var(--tx, 0), var(--ty, 0), 0) scale(var(--scale, 1))
|
|
20
|
+
rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg))
|
|
21
|
+
rotate(var(--rotate, 0deg));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ===== Visible state ===== */
|
|
25
|
+
[data-animation].is-visible {
|
|
26
|
+
opacity: 1 !important;
|
|
27
|
+
will-change: transform, opacity;
|
|
28
|
+
transform: translate3d(0, 0, 0) scale(1) rotateX(0deg) rotateY(0deg)
|
|
29
|
+
rotate(0deg);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ===== Fade animations ===== */
|
|
33
|
+
[data-animation="fade"] {
|
|
34
|
+
--tx: 0;
|
|
35
|
+
--ty: 0;
|
|
36
|
+
}
|
|
37
|
+
[data-animation="fade-up"] {
|
|
38
|
+
--ty: var(--rs-distance);
|
|
39
|
+
}
|
|
40
|
+
[data-animation="fade-down"] {
|
|
41
|
+
--ty: calc(-1 * var(--rs-distance));
|
|
42
|
+
}
|
|
43
|
+
[data-animation="fade-left"] {
|
|
44
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
45
|
+
}
|
|
46
|
+
[data-animation="fade-right"] {
|
|
47
|
+
--tx: var(--rs-distance);
|
|
48
|
+
}
|
|
49
|
+
[data-animation="fade-up-right"] {
|
|
50
|
+
--tx: var(--rs-distance);
|
|
51
|
+
--ty: var(--rs-distance);
|
|
52
|
+
}
|
|
53
|
+
[data-animation="fade-up-left"] {
|
|
54
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
55
|
+
--ty: var(--rs-distance);
|
|
56
|
+
}
|
|
57
|
+
[data-animation="fade-down-right"] {
|
|
58
|
+
--tx: var(--rs-distance);
|
|
59
|
+
--ty: calc(-1 * var(--rs-distance));
|
|
60
|
+
}
|
|
61
|
+
[data-animation="fade-down-left"] {
|
|
62
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
63
|
+
--ty: calc(-1 * var(--rs-distance));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ===== Zoom animations ===== */
|
|
67
|
+
[data-animation="zoom-in"] {
|
|
68
|
+
--scale: 0.6;
|
|
69
|
+
}
|
|
70
|
+
[data-animation="zoom-in-up"] {
|
|
71
|
+
--scale: 0.6;
|
|
72
|
+
--ty: var(--rs-distance);
|
|
73
|
+
}
|
|
74
|
+
[data-animation="zoom-in-down"] {
|
|
75
|
+
--scale: 0.6;
|
|
76
|
+
--ty: calc(-1 * var(--rs-distance));
|
|
77
|
+
}
|
|
78
|
+
[data-animation="zoom-in-left"] {
|
|
79
|
+
--scale: 0.6;
|
|
80
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
81
|
+
}
|
|
82
|
+
[data-animation="zoom-in-right"] {
|
|
83
|
+
--scale: 0.6;
|
|
84
|
+
--tx: var(--rs-distance);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
[data-animation="zoom-out"] {
|
|
88
|
+
--scale: 1.2;
|
|
89
|
+
}
|
|
90
|
+
[data-animation="zoom-out-up"] {
|
|
91
|
+
--scale: 1.2;
|
|
92
|
+
--ty: var(--rs-distance);
|
|
93
|
+
}
|
|
94
|
+
[data-animation="zoom-out-down"] {
|
|
95
|
+
--scale: 1.2;
|
|
96
|
+
--ty: calc(-1 * var(--rs-distance));
|
|
97
|
+
}
|
|
98
|
+
[data-animation="zoom-out-left"] {
|
|
99
|
+
--scale: 1.2;
|
|
100
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
101
|
+
}
|
|
102
|
+
[data-animation="zoom-out-right"] {
|
|
103
|
+
--scale: 1.2;
|
|
104
|
+
--tx: var(--rs-distance);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ===== Slide animations ===== */
|
|
108
|
+
[data-animation="slide-up"] {
|
|
109
|
+
opacity: 1;
|
|
110
|
+
transform: translate3d(0, 100%, 0);
|
|
111
|
+
}
|
|
112
|
+
[data-animation="slide-down"] {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
transform: translate3d(0, -100%, 0);
|
|
115
|
+
}
|
|
116
|
+
[data-animation="slide-left"] {
|
|
117
|
+
opacity: 1;
|
|
118
|
+
transform: translate3d(100%, 0, 0);
|
|
119
|
+
}
|
|
120
|
+
[data-animation="slide-right"] {
|
|
121
|
+
opacity: 1;
|
|
122
|
+
transform: translate3d(-100%, 0, 0);
|
|
123
|
+
}
|
|
124
|
+
[data-animation="slide-up"].is-visible,
|
|
125
|
+
[data-animation="slide-down"].is-visible,
|
|
126
|
+
[data-animation="slide-left"].is-visible,
|
|
127
|
+
[data-animation="slide-right"].is-visible {
|
|
128
|
+
transform: translate3d(0, 0, 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ===== Flip animations ===== */
|
|
132
|
+
[data-animation="flip-left"] {
|
|
133
|
+
opacity: 1;
|
|
134
|
+
backface-visibility: hidden;
|
|
135
|
+
transform: perspective(2500px) rotateY(-100deg);
|
|
136
|
+
}
|
|
137
|
+
[data-animation="flip-right"] {
|
|
138
|
+
opacity: 1;
|
|
139
|
+
backface-visibility: hidden;
|
|
140
|
+
transform: perspective(2500px) rotateY(100deg);
|
|
141
|
+
}
|
|
142
|
+
[data-animation="flip-up"] {
|
|
143
|
+
opacity: 1;
|
|
144
|
+
backface-visibility: hidden;
|
|
145
|
+
transform: perspective(2500px) rotateX(-100deg);
|
|
146
|
+
}
|
|
147
|
+
[data-animation="flip-down"] {
|
|
148
|
+
opacity: 1;
|
|
149
|
+
backface-visibility: hidden;
|
|
150
|
+
transform: perspective(2500px) rotateX(100deg);
|
|
151
|
+
}
|
|
152
|
+
[data-animation="flip-left"].is-visible {
|
|
153
|
+
transform: perspective(2500px) rotateY(0);
|
|
154
|
+
}
|
|
155
|
+
[data-animation="flip-right"].is-visible {
|
|
156
|
+
transform: perspective(2500px) rotateY(0);
|
|
157
|
+
}
|
|
158
|
+
[data-animation="flip-up"].is-visible {
|
|
159
|
+
transform: perspective(2500px) rotateX(0);
|
|
160
|
+
}
|
|
161
|
+
[data-animation="flip-down"].is-visible {
|
|
162
|
+
transform: perspective(2500px) rotateX(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ===== Special animations ===== */
|
|
166
|
+
[data-animation="slide-rotate"] {
|
|
167
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
168
|
+
--rotate: -45deg;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* Bounce — uses keyframes */
|
|
172
|
+
[data-animation="bounce-in"] {
|
|
173
|
+
--scale: 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@keyframes rs-bounce {
|
|
177
|
+
0% {
|
|
178
|
+
transform: scale(0);
|
|
179
|
+
}
|
|
180
|
+
50% {
|
|
181
|
+
transform: scale(1.1);
|
|
182
|
+
}
|
|
183
|
+
100% {
|
|
184
|
+
transform: scale(1);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
[data-animation="bounce-in"].is-visible {
|
|
189
|
+
animation: rs-bounce var(--duration, 1500ms)
|
|
190
|
+
cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
191
|
+
animation-delay: var(--delay, 0ms);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ===== Legacy aliases (backward compat with v2.x) ===== */
|
|
195
|
+
[data-animation="fade-in"] {
|
|
196
|
+
--tx: 0;
|
|
197
|
+
--ty: 0;
|
|
198
|
+
}
|
|
199
|
+
[data-animation="fade-in-up"] {
|
|
200
|
+
--ty: var(--rs-distance);
|
|
201
|
+
}
|
|
202
|
+
[data-animation="fade-in-down"] {
|
|
203
|
+
--ty: calc(-1 * var(--rs-distance));
|
|
204
|
+
}
|
|
205
|
+
[data-animation="fade-in-left"] {
|
|
206
|
+
--tx: calc(-1 * var(--rs-distance));
|
|
207
|
+
}
|
|
208
|
+
[data-animation="fade-in-right"] {
|
|
209
|
+
--tx: var(--rs-distance);
|
|
210
|
+
}
|
|
211
|
+
[data-animation="flip"] {
|
|
212
|
+
backface-visibility: hidden;
|
|
213
|
+
--ry: 90deg;
|
|
214
|
+
}
|
|
215
|
+
[data-animation="flip"].is-visible {
|
|
216
|
+
--ry: 0deg;
|
|
217
|
+
}
|
|
218
|
+
[data-animation="flip-x"] {
|
|
219
|
+
backface-visibility: hidden;
|
|
220
|
+
--rx: 90deg;
|
|
221
|
+
}
|
|
222
|
+
[data-animation="flip-x"].is-visible {
|
|
223
|
+
--rx: 0deg;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ===== Accessibility: Respect user's motion preferences ===== */
|
|
227
|
+
@media (prefers-reduced-motion: reduce) {
|
|
228
|
+
[data-animation] {
|
|
229
|
+
transition: none;
|
|
230
|
+
animation: none !important;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
[data-animation].is-visible {
|
|
234
|
+
opacity: 1;
|
|
235
|
+
transform: none !important;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
|
|
3
|
+
*
|
|
4
|
+
* @param {number} [offset] - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
|
|
5
|
+
* @param {string} [rootMargin] - Custom rootMargin string (takes precedence over offset)
|
|
6
|
+
* @returns {string} rootMargin string for IntersectionObserver
|
|
7
|
+
*/
|
|
8
|
+
export function calculateRootMargin(offset?: number, rootMargin?: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Animation utilities
|
|
11
|
+
* Type definitions have been moved to types.js for single source of truth
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* All available animation types in the library
|
|
15
|
+
* Includes both native rune-scroller animations and AOS-compatible names
|
|
16
|
+
* @type {readonly string[]}
|
|
17
|
+
*/
|
|
18
|
+
export const ANIMATION_TYPES: readonly string[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation utilities
|
|
3
|
+
* Type definitions have been moved to types.js for single source of truth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* All available animation types in the library
|
|
8
|
+
* Includes both native rune-scroller animations and AOS-compatible names
|
|
9
|
+
* @type {readonly string[]}
|
|
10
|
+
*/
|
|
11
|
+
export const ANIMATION_TYPES = [
|
|
12
|
+
// Fade (10)
|
|
13
|
+
"fade",
|
|
14
|
+
"fade-up",
|
|
15
|
+
"fade-down",
|
|
16
|
+
"fade-left",
|
|
17
|
+
"fade-right",
|
|
18
|
+
"fade-up-right",
|
|
19
|
+
"fade-up-left",
|
|
20
|
+
"fade-down-right",
|
|
21
|
+
"fade-down-left",
|
|
22
|
+
// Zoom (10)
|
|
23
|
+
"zoom-in",
|
|
24
|
+
"zoom-in-up",
|
|
25
|
+
"zoom-in-down",
|
|
26
|
+
"zoom-in-left",
|
|
27
|
+
"zoom-in-right",
|
|
28
|
+
"zoom-out",
|
|
29
|
+
"zoom-out-up",
|
|
30
|
+
"zoom-out-down",
|
|
31
|
+
"zoom-out-left",
|
|
32
|
+
"zoom-out-right",
|
|
33
|
+
// Slide (4)
|
|
34
|
+
"slide-up",
|
|
35
|
+
"slide-down",
|
|
36
|
+
"slide-left",
|
|
37
|
+
"slide-right",
|
|
38
|
+
// Flip (4)
|
|
39
|
+
"flip-left",
|
|
40
|
+
"flip-right",
|
|
41
|
+
"flip-up",
|
|
42
|
+
"flip-down",
|
|
43
|
+
// Special (2)
|
|
44
|
+
"slide-rotate",
|
|
45
|
+
"bounce-in",
|
|
46
|
+
// Legacy aliases (v2.x backward compat)
|
|
47
|
+
"fade-in",
|
|
48
|
+
"fade-in-up",
|
|
49
|
+
"fade-in-down",
|
|
50
|
+
"fade-in-left",
|
|
51
|
+
"fade-in-right",
|
|
52
|
+
"flip",
|
|
53
|
+
"flip-x",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
|
|
58
|
+
*
|
|
59
|
+
* @param {number} [offset] - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
|
|
60
|
+
* @param {string} [rootMargin] - Custom rootMargin string (takes precedence over offset)
|
|
61
|
+
* @returns {string} rootMargin string for IntersectionObserver
|
|
62
|
+
*/
|
|
63
|
+
export function calculateRootMargin(offset, rootMargin) {
|
|
64
|
+
return (
|
|
65
|
+
rootMargin ??
|
|
66
|
+
(offset !== undefined
|
|
67
|
+
? `-${100 - offset}% 0px -${offset}% 0px`
|
|
68
|
+
: "-10% 0px -10% 0px")
|
|
69
|
+
);
|
|
70
|
+
}
|
package/dist/aos.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
declare namespace _default {
|
|
2
|
+
export { init };
|
|
3
|
+
export { refresh };
|
|
4
|
+
export { refreshHard };
|
|
5
|
+
export { disable };
|
|
6
|
+
}
|
|
7
|
+
export default _default;
|
|
8
|
+
export type AOSOptions = {
|
|
9
|
+
offset?: number;
|
|
10
|
+
delay?: number;
|
|
11
|
+
duration?: number;
|
|
12
|
+
easing?: string;
|
|
13
|
+
once?: boolean;
|
|
14
|
+
mirror?: boolean;
|
|
15
|
+
anchorPlacement?: string;
|
|
16
|
+
disable?: boolean | string;
|
|
17
|
+
useClassNames?: boolean;
|
|
18
|
+
startEvent?: string;
|
|
19
|
+
animatedClassName?: string;
|
|
20
|
+
initClassName?: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Initialize AOS compatibility mode
|
|
24
|
+
* @param {AOSOptions} [settings]
|
|
25
|
+
*/
|
|
26
|
+
export function init(settings?: AOSOptions): void;
|
|
27
|
+
/**
|
|
28
|
+
* Soft refresh — recalculate positions (no-op for IntersectionObserver)
|
|
29
|
+
*/
|
|
30
|
+
export function refresh(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Hard refresh — destroy and re-process all elements
|
|
33
|
+
*/
|
|
34
|
+
export function refreshHard(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Disable — remove all AOS attributes and classes
|
|
37
|
+
*/
|
|
38
|
+
export function disable(): void;
|
package/dist/aos.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AOS compatibility layer for rune-scroller
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for AOS (Animate On Scroll).
|
|
5
|
+
* Supports the same data attributes and init() API.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { init, refresh, refreshHard } from 'rune-scroller/aos'
|
|
9
|
+
* init()
|
|
10
|
+
*
|
|
11
|
+
* Or as AOS drop-in:
|
|
12
|
+
* import AOS from 'rune-scroller/aos'
|
|
13
|
+
* AOS.init()
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { runeScroller } from "./runeScroller.js";
|
|
17
|
+
import { ANIMATION_TYPES } from "./animations.js";
|
|
18
|
+
|
|
19
|
+
/** @typedef {{ offset?: number, delay?: number, duration?: number, easing?: string, once?: boolean, mirror?: boolean, anchorPlacement?: string, disable?: boolean | string, useClassNames?: boolean, startEvent?: string, animatedClassName?: string, initClassName?: string }} AOSOptions */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map old animation names (v2.x) to new names
|
|
23
|
+
* @type {Record<string, string>}
|
|
24
|
+
*/
|
|
25
|
+
const LEGACY_MAP = {
|
|
26
|
+
"fade-in": "fade",
|
|
27
|
+
"fade-in-up": "fade-up",
|
|
28
|
+
"fade-in-down": "fade-down",
|
|
29
|
+
"fade-in-left": "fade-left",
|
|
30
|
+
"fade-in-right": "fade-right",
|
|
31
|
+
flip: "flip-left",
|
|
32
|
+
"flip-x": "flip-up",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize animation name (resolve legacy + validate)
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function resolveAnimation(name) {
|
|
41
|
+
if (LEGACY_MAP[name]) return LEGACY_MAP[name];
|
|
42
|
+
if (ANIMATION_TYPES.includes(name)) return name;
|
|
43
|
+
// Unknown animation — try as-is, CSS will silently ignore
|
|
44
|
+
return name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @type {AOSOptions} */
|
|
48
|
+
let options = {
|
|
49
|
+
offset: 120,
|
|
50
|
+
delay: 0,
|
|
51
|
+
duration: 400,
|
|
52
|
+
easing: "ease",
|
|
53
|
+
once: false,
|
|
54
|
+
mirror: false,
|
|
55
|
+
anchorPlacement: "top-bottom",
|
|
56
|
+
disable: false,
|
|
57
|
+
useClassNames: false,
|
|
58
|
+
startEvent: "DOMContentLoaded",
|
|
59
|
+
animatedClassName: "aos-animate",
|
|
60
|
+
initClassName: "aos-init",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** @type {Array<{ destroy: () => void }>} */
|
|
64
|
+
let activeActions = [];
|
|
65
|
+
|
|
66
|
+
/** @type {MutationObserver | null} */
|
|
67
|
+
let mutationObserver = null;
|
|
68
|
+
|
|
69
|
+
/** @type {boolean} */
|
|
70
|
+
let initialized = false;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a data-aos-* attribute from an element
|
|
74
|
+
* @param {HTMLElement} el
|
|
75
|
+
* @param {string} key
|
|
76
|
+
* @param {*} fallback
|
|
77
|
+
* @returns {*}
|
|
78
|
+
*/
|
|
79
|
+
function getInlineOption(el, key, fallback) {
|
|
80
|
+
const attr = el.getAttribute("data-aos-" + key);
|
|
81
|
+
if (attr === "true") return true;
|
|
82
|
+
if (attr === "false") return false;
|
|
83
|
+
return attr || fallback;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Apply rune-scroller action to a single element
|
|
88
|
+
* @param {HTMLElement} el
|
|
89
|
+
*/
|
|
90
|
+
function applyToElement(el) {
|
|
91
|
+
const animation = resolveAnimation(el.getAttribute("data-aos") || "fade-up");
|
|
92
|
+
|
|
93
|
+
const duration = Number(getInlineOption(el, "duration", options.duration));
|
|
94
|
+
const delay = Number(getInlineOption(el, "delay", options.delay));
|
|
95
|
+
const offset = Number(getInlineOption(el, "offset", options.offset));
|
|
96
|
+
const once = getInlineOption(el, "once", options.once);
|
|
97
|
+
const mirror = getInlineOption(el, "mirror", options.mirror);
|
|
98
|
+
|
|
99
|
+
// Set easing as CSS variable
|
|
100
|
+
if (options.easing || el.getAttribute("data-aos-easing")) {
|
|
101
|
+
const easing = getInlineOption(el, "easing", options.easing);
|
|
102
|
+
el.style.setProperty("--easing", easing);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Add init class
|
|
106
|
+
if (options.initClassName) {
|
|
107
|
+
el.classList.add(options.initClassName);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Use useClassNames to add animation name as extra class
|
|
111
|
+
if (options.useClassNames && animation) {
|
|
112
|
+
el.classList.add(animation);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Apply runeScroller action
|
|
116
|
+
const action = runeScroller(el, {
|
|
117
|
+
animation,
|
|
118
|
+
duration,
|
|
119
|
+
offset: offset - 120, // AOS offset is "px from viewport bottom", we adjust
|
|
120
|
+
repeat: !once || mirror,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Set delay CSS variable AFTER runeScroller (which sets --delay: 0ms)
|
|
124
|
+
el.style.setProperty("--delay", `${delay}ms`);
|
|
125
|
+
|
|
126
|
+
activeActions.push(action);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Process all [data-aos] elements in the DOM
|
|
131
|
+
*/
|
|
132
|
+
function processElements() {
|
|
133
|
+
/** @type {NodeListOf<HTMLElement>} */
|
|
134
|
+
const elements = document.querySelectorAll("[data-aos]");
|
|
135
|
+
elements.forEach(applyToElement);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Watch for new [data-aos] elements added to the DOM
|
|
140
|
+
*/
|
|
141
|
+
function observeMutations() {
|
|
142
|
+
if (mutationObserver) mutationObserver.disconnect();
|
|
143
|
+
|
|
144
|
+
mutationObserver = new MutationObserver((mutations) => {
|
|
145
|
+
let hasNewAOS = false;
|
|
146
|
+
|
|
147
|
+
for (const mutation of mutations) {
|
|
148
|
+
for (const node of mutation.addedNodes) {
|
|
149
|
+
if (node instanceof HTMLElement) {
|
|
150
|
+
if (node.hasAttribute && node.hasAttribute("data-aos")) {
|
|
151
|
+
hasNewAOS = true;
|
|
152
|
+
}
|
|
153
|
+
if (node.querySelectorAll) {
|
|
154
|
+
const aosChildren = node.querySelectorAll("[data-aos]");
|
|
155
|
+
if (aosChildren.length > 0) hasNewAOS = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (hasNewAOS) {
|
|
162
|
+
refreshHard();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
mutationObserver.observe(document.documentElement, {
|
|
167
|
+
childList: true,
|
|
168
|
+
subtree: true,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Initialize AOS compatibility mode
|
|
174
|
+
* @param {AOSOptions} [settings]
|
|
175
|
+
*/
|
|
176
|
+
function init(settings = {}) {
|
|
177
|
+
if (typeof window === "undefined") return;
|
|
178
|
+
|
|
179
|
+
Object.assign(options, settings);
|
|
180
|
+
|
|
181
|
+
// Set global easing on body for CSS
|
|
182
|
+
const body = document.querySelector("body");
|
|
183
|
+
if (body) {
|
|
184
|
+
body.setAttribute("data-aos-easing", options.easing);
|
|
185
|
+
body.setAttribute("data-aos-duration", String(options.duration));
|
|
186
|
+
body.setAttribute("data-aos-delay", String(options.delay));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Process elements on start event or immediately
|
|
190
|
+
const startEvent = options.startEvent || "DOMContentLoaded";
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
startEvent === "DOMContentLoaded" &&
|
|
194
|
+
["complete", "interactive"].includes(document.readyState)
|
|
195
|
+
) {
|
|
196
|
+
processElements();
|
|
197
|
+
observeMutations();
|
|
198
|
+
initialized = true;
|
|
199
|
+
} else if (startEvent === "load") {
|
|
200
|
+
window.addEventListener("load", () => {
|
|
201
|
+
processElements();
|
|
202
|
+
observeMutations();
|
|
203
|
+
initialized = true;
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
document.addEventListener(startEvent, () => {
|
|
207
|
+
processElements();
|
|
208
|
+
observeMutations();
|
|
209
|
+
initialized = true;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Soft refresh — recalculate positions (no-op for IntersectionObserver)
|
|
216
|
+
*/
|
|
217
|
+
function refresh() {
|
|
218
|
+
// IntersectionObserver handles position automatically
|
|
219
|
+
// Only refresh if initialized
|
|
220
|
+
if (!initialized) return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Hard refresh — destroy and re-process all elements
|
|
225
|
+
*/
|
|
226
|
+
function refreshHard() {
|
|
227
|
+
// Destroy all active actions
|
|
228
|
+
activeActions.forEach((action) => {
|
|
229
|
+
try {
|
|
230
|
+
action.destroy();
|
|
231
|
+
} catch {
|
|
232
|
+
/* ignore */
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
activeActions = [];
|
|
236
|
+
|
|
237
|
+
// Remove init classes
|
|
238
|
+
if (options.initClassName) {
|
|
239
|
+
document
|
|
240
|
+
.querySelectorAll(`[data-aos].${options.initClassName}`)
|
|
241
|
+
.forEach((el) => el.classList.remove(options.initClassName));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
processElements();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Disable — remove all AOS attributes and classes
|
|
249
|
+
*/
|
|
250
|
+
function disable() {
|
|
251
|
+
activeActions.forEach((action) => {
|
|
252
|
+
try {
|
|
253
|
+
action.destroy();
|
|
254
|
+
} catch {
|
|
255
|
+
/* ignore */
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
activeActions = [];
|
|
259
|
+
|
|
260
|
+
document.querySelectorAll("[data-aos]").forEach((el) => {
|
|
261
|
+
el.removeAttribute("data-aos");
|
|
262
|
+
el.removeAttribute("data-aos-easing");
|
|
263
|
+
el.removeAttribute("data-aos-duration");
|
|
264
|
+
el.removeAttribute("data-aos-delay");
|
|
265
|
+
el.removeAttribute("data-aos-offset");
|
|
266
|
+
|
|
267
|
+
if (options.initClassName) el.classList.remove(options.initClassName);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Public API — compatible with AOS
|
|
272
|
+
export default { init, refresh, refreshHard, disable };
|
|
273
|
+
export { init, refresh, refreshHard, disable };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {HTMLElement} element
|
|
3
|
+
* @param {number} [duration]
|
|
4
|
+
* @param {number} [delay=0]
|
|
5
|
+
*/
|
|
6
|
+
export function setCSSVariables(element: HTMLElement, duration?: number, delay?: number): void;
|
|
7
|
+
/**
|
|
8
|
+
* @param {HTMLElement} element
|
|
9
|
+
* @param {import('./types.js').AnimationType} animation
|
|
10
|
+
*/
|
|
11
|
+
export function setupAnimationElement(element: HTMLElement, animation: import("./types.js").AnimationType): void;
|
|
12
|
+
/**
|
|
13
|
+
* @param {HTMLElement} element
|
|
14
|
+
* @param {boolean} [debug=false]
|
|
15
|
+
* @param {number} [offset=0]
|
|
16
|
+
* @param {string} [sentinelColor='#00e0ff']
|
|
17
|
+
* @param {string} [debugLabel]
|
|
18
|
+
* @param {string} [sentinelId]
|
|
19
|
+
* @returns {{ element: HTMLElement, id: string }}
|
|
20
|
+
*/
|
|
21
|
+
export function createSentinel(element: HTMLElement, debug?: boolean, offset?: number, sentinelColor?: string, debugLabel?: string, sentinelId?: string): {
|
|
22
|
+
element: HTMLElement;
|
|
23
|
+
id: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Check if CSS animations are loaded and warn if not (dev only)
|
|
27
|
+
* Uses cache to avoid expensive getComputedStyle() on every element creation
|
|
28
|
+
* @returns {boolean} True if CSS appears to be loaded
|
|
29
|
+
*/
|
|
30
|
+
export function checkAndWarnIfCSSNotLoaded(): boolean;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global counter for auto-generating sentinel IDs
|
|
3
|
+
* @type {number}
|
|
4
|
+
*/
|
|
5
|
+
let sentinelCounter = 0;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cache to check CSS only once per page load
|
|
9
|
+
* Avoids expensive getComputedStyle() calls
|
|
10
|
+
* @type {boolean | null}
|
|
11
|
+
*/
|
|
12
|
+
let cssCheckResult = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {HTMLElement} element
|
|
16
|
+
* @param {number} [duration]
|
|
17
|
+
* @param {number} [delay=0]
|
|
18
|
+
*/
|
|
19
|
+
export function setCSSVariables(element, duration, delay = 0) {
|
|
20
|
+
if (duration !== undefined) {
|
|
21
|
+
element.style.setProperty("--duration", `${duration}ms`);
|
|
22
|
+
}
|
|
23
|
+
element.style.setProperty("--delay", `${delay}ms`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {HTMLElement} element
|
|
28
|
+
* @param {import('./types.js').AnimationType} animation
|
|
29
|
+
*/
|
|
30
|
+
export function setupAnimationElement(element, animation) {
|
|
31
|
+
element.classList.add("scroll-animate");
|
|
32
|
+
element.setAttribute("data-animation", animation);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {HTMLElement} element
|
|
37
|
+
* @param {boolean} [debug=false]
|
|
38
|
+
* @param {number} [offset=0]
|
|
39
|
+
* @param {string} [sentinelColor='#00e0ff']
|
|
40
|
+
* @param {string} [debugLabel]
|
|
41
|
+
* @param {string} [sentinelId]
|
|
42
|
+
* @returns {{ element: HTMLElement, id: string }}
|
|
43
|
+
*/
|
|
44
|
+
export function createSentinel(
|
|
45
|
+
element,
|
|
46
|
+
debug = false,
|
|
47
|
+
offset = 0,
|
|
48
|
+
sentinelColor = "#00e0ff",
|
|
49
|
+
debugLabel = "",
|
|
50
|
+
sentinelId,
|
|
51
|
+
) {
|
|
52
|
+
const sentinel = document.createElement("div");
|
|
53
|
+
// Use offsetHeight instead of getBoundingClientRect for accurate dimensions
|
|
54
|
+
// getBoundingClientRect returns transformed dimensions (affected by scale, etc)
|
|
55
|
+
// offsetHeight returns actual element height independent of CSS transforms
|
|
56
|
+
const elementHeight = element.offsetHeight;
|
|
57
|
+
const sentinelTop = elementHeight + offset;
|
|
58
|
+
|
|
59
|
+
// Generate auto-ID if not provided
|
|
60
|
+
if (!sentinelId) {
|
|
61
|
+
sentinelCounter++;
|
|
62
|
+
sentinelId = `sentinel-${sentinelCounter}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Always set to data-sentinel-id attribute
|
|
66
|
+
sentinel.setAttribute("data-sentinel-id", sentinelId);
|
|
67
|
+
|
|
68
|
+
if (debug) {
|
|
69
|
+
sentinel.style.cssText = `position:absolute;top:${sentinelTop}px;left:0;width:100%;height:3px;background:${sentinelColor};margin:0;padding:2px 4px;box-sizing:border-box;z-index:999;pointer-events:none;display:flex;align-items:center;font-size:10px;color:#000;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis`;
|
|
70
|
+
sentinel.setAttribute("data-sentinel-debug", "true");
|
|
71
|
+
// Show ID in debug mode (or debugLabel if provided)
|
|
72
|
+
if (debugLabel) {
|
|
73
|
+
sentinel.textContent = debugLabel;
|
|
74
|
+
} else {
|
|
75
|
+
sentinel.textContent = sentinelId;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
sentinel.style.cssText = `position:absolute;top:${sentinelTop}px;left:0;width:100%;height:1px;visibility:hidden;margin:0;padding:0;box-sizing:border-box;pointer-events:none`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { element: sentinel, id: sentinelId };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if CSS animations are loaded and warn if not (dev only)
|
|
86
|
+
* Uses cache to avoid expensive getComputedStyle() on every element creation
|
|
87
|
+
* @returns {boolean} True if CSS appears to be loaded
|
|
88
|
+
*/
|
|
89
|
+
export function checkAndWarnIfCSSNotLoaded() {
|
|
90
|
+
if (typeof document === "undefined") return false;
|
|
91
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return true;
|
|
92
|
+
|
|
93
|
+
// Return cached result if already checked (avoids expensive reflows)
|
|
94
|
+
if (cssCheckResult !== null) return cssCheckResult;
|
|
95
|
+
|
|
96
|
+
// Try to detect if animations.css is loaded by checking for animation classes
|
|
97
|
+
const test = document.createElement("div");
|
|
98
|
+
test.className = "scroll-animate is-visible";
|
|
99
|
+
test.style.position = "absolute";
|
|
100
|
+
test.style.opacity = "0";
|
|
101
|
+
document.body.appendChild(test);
|
|
102
|
+
const computed = getComputedStyle(test);
|
|
103
|
+
const hasAnimation =
|
|
104
|
+
computed.animation !== "none" && computed.animation !== "";
|
|
105
|
+
test.remove();
|
|
106
|
+
|
|
107
|
+
if (!hasAnimation) {
|
|
108
|
+
console.warn(
|
|
109
|
+
"[rune-scroller] CSS animations not found. Make sure to import the animations:\n" +
|
|
110
|
+
' import "rune-scroller/animations.css";\n' +
|
|
111
|
+
"Documentation: https://github.com/lelabdev/rune-scroller#installation",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Cache the result for future calls
|
|
116
|
+
cssCheckResult = hasAnimation;
|
|
117
|
+
return hasAnimation;
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export default runeScroller;
|
|
2
|
+
import { runeScroller } from "./runeScroller.js";
|
|
3
|
+
export { runeScroller, runeScroller as rs };
|
|
4
|
+
export { useIntersection, useIntersectionOnce } from "./useIntersection.svelte.js";
|
|
5
|
+
export { calculateRootMargin, ANIMATION_TYPES } from "./animations.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rune Scroller - Lightweight scroll animations for Svelte 5
|
|
3
|
+
*
|
|
4
|
+
* Main entry point exporting all public APIs
|
|
5
|
+
*
|
|
6
|
+
* @module rune-scroller
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Import CSS animations automatically
|
|
10
|
+
import "./animations.css";
|
|
11
|
+
|
|
12
|
+
// Main action (default export - recommended)
|
|
13
|
+
import { runeScroller } from "./runeScroller.js";
|
|
14
|
+
export default runeScroller;
|
|
15
|
+
export { runeScroller };
|
|
16
|
+
export { runeScroller as rs };
|
|
17
|
+
|
|
18
|
+
// Composables
|
|
19
|
+
export {
|
|
20
|
+
useIntersection,
|
|
21
|
+
useIntersectionOnce,
|
|
22
|
+
} from "./useIntersection.svelte.js";
|
|
23
|
+
|
|
24
|
+
// Utilities
|
|
25
|
+
export { calculateRootMargin, ANIMATION_TYPES } from "./animations.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared IntersectionObserver utility functions
|
|
3
|
+
* Reduces code duplication between action implementations
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* @param {HTMLElement} target
|
|
7
|
+
* @param {IntersectionObserverCallback} callback
|
|
8
|
+
* @param {IntersectionObserverInit} options
|
|
9
|
+
* @returns {{ observer: IntersectionObserver, isConnected: boolean }}
|
|
10
|
+
*/
|
|
11
|
+
export function createManagedObserver(target: HTMLElement, callback: IntersectionObserverCallback, options: IntersectionObserverInit): {
|
|
12
|
+
observer: IntersectionObserver;
|
|
13
|
+
isConnected: boolean;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* @param {IntersectionObserver} observer
|
|
17
|
+
* @param {{ isConnected: boolean }} state
|
|
18
|
+
*/
|
|
19
|
+
export function disconnectObserver(observer: IntersectionObserver, state: {
|
|
20
|
+
isConnected: boolean;
|
|
21
|
+
}): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared IntersectionObserver utility functions
|
|
3
|
+
* Reduces code duplication between action implementations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {HTMLElement} target
|
|
8
|
+
* @param {IntersectionObserverCallback} callback
|
|
9
|
+
* @param {IntersectionObserverInit} options
|
|
10
|
+
* @returns {{ observer: IntersectionObserver, isConnected: boolean }}
|
|
11
|
+
*/
|
|
12
|
+
export function createManagedObserver(target, callback, options) {
|
|
13
|
+
const observer = new IntersectionObserver(callback, options);
|
|
14
|
+
observer.observe(target);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
observer,
|
|
18
|
+
isConnected: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {IntersectionObserver} observer
|
|
24
|
+
* @param {{ isConnected: boolean }} state
|
|
25
|
+
*/
|
|
26
|
+
export function disconnectObserver(observer, state) {
|
|
27
|
+
if (state.isConnected && observer) {
|
|
28
|
+
observer.disconnect();
|
|
29
|
+
state.isConnected = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {HTMLElement} element
|
|
3
|
+
* @param {import('./types.js').RuneScrollerOptions} [options]
|
|
4
|
+
* @returns {{ update: (newOptions?: import('./types.js').RuneScrollerOptions) => void, destroy: () => void }}
|
|
5
|
+
*/
|
|
6
|
+
export function runeScroller(element: HTMLElement, options?: import("./types.js").RuneScrollerOptions): {
|
|
7
|
+
update: (newOptions?: import("./types.js").RuneScrollerOptions) => void;
|
|
8
|
+
destroy: () => void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {
|
|
2
|
+
setCSSVariables,
|
|
3
|
+
setupAnimationElement,
|
|
4
|
+
createSentinel,
|
|
5
|
+
checkAndWarnIfCSSNotLoaded,
|
|
6
|
+
} from "./dom-utils.js";
|
|
7
|
+
import { createManagedObserver, disconnectObserver } from "./observer-utils.js";
|
|
8
|
+
import { ANIMATION_TYPES } from "./animations.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {HTMLElement} element
|
|
12
|
+
* @param {import('./types.js').RuneScrollerOptions} [options]
|
|
13
|
+
* @returns {{ update: (newOptions?: import('./types.js').RuneScrollerOptions) => void, destroy: () => void }}
|
|
14
|
+
*/
|
|
15
|
+
export function runeScroller(element, options) {
|
|
16
|
+
// SSR Guard: Return no-op action when running on server
|
|
17
|
+
if (typeof window === "undefined") {
|
|
18
|
+
return {
|
|
19
|
+
update: () => {},
|
|
20
|
+
destroy: () => {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Warn if CSS is not loaded (first time only)
|
|
25
|
+
if (typeof document !== "undefined") {
|
|
26
|
+
checkAndWarnIfCSSNotLoaded();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Validate animation type
|
|
30
|
+
let animation = options?.animation ?? "fade-in";
|
|
31
|
+
if (animation && !ANIMATION_TYPES.includes(animation)) {
|
|
32
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
33
|
+
console.warn(
|
|
34
|
+
`[rune-scroller] Invalid animation "${animation}". Using "fade-in" instead. ` +
|
|
35
|
+
`Valid options: ${ANIMATION_TYPES.join(", ")}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
animation = "fade-in";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// CSS handles initial opacity via [data-animation] { opacity: 0 }
|
|
42
|
+
// No inline opacity needed — it would override slide animations that use opacity: 1
|
|
43
|
+
|
|
44
|
+
// Setup animation classes and CSS variables
|
|
45
|
+
if (animation) {
|
|
46
|
+
setupAnimationElement(element, animation);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Warn about overflow:hidden in debug mode
|
|
50
|
+
if (options?.debug && element.style.overflow === "hidden") {
|
|
51
|
+
console.warn(
|
|
52
|
+
"[rune-scroller] Element has overflow:hidden — the sentinel indicator may be clipped in debug mode.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Set CSS variables for duration
|
|
57
|
+
if (options?.duration !== undefined) {
|
|
58
|
+
setCSSVariables(element, options.duration);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Force reflow to ensure transitions are ready
|
|
62
|
+
void element.offsetHeight;
|
|
63
|
+
|
|
64
|
+
// Create a wrapper div around the element to position the sentinel
|
|
65
|
+
const wrapper = document.createElement("div");
|
|
66
|
+
wrapper.style.cssText =
|
|
67
|
+
"position:relative;display:block;width:100%;margin:0;padding:0;box-sizing:border-box";
|
|
68
|
+
element.insertAdjacentElement("beforebegin", wrapper);
|
|
69
|
+
wrapper.appendChild(element);
|
|
70
|
+
|
|
71
|
+
// Create the invisible sentinel (or visible if debug=true)
|
|
72
|
+
// Positioned absolutely relative to the wrapper
|
|
73
|
+
const sentinelResult = createSentinel(
|
|
74
|
+
element,
|
|
75
|
+
options?.debug,
|
|
76
|
+
options?.offset,
|
|
77
|
+
options?.sentinelColor,
|
|
78
|
+
options?.debugLabel,
|
|
79
|
+
options?.sentinelId,
|
|
80
|
+
);
|
|
81
|
+
const sentinel = sentinelResult.element;
|
|
82
|
+
const sentinelId = sentinelResult.id;
|
|
83
|
+
|
|
84
|
+
// Add sentinel ID to element (either provided or auto-generated)
|
|
85
|
+
element.setAttribute("data-sentinel-id", sentinelId);
|
|
86
|
+
|
|
87
|
+
wrapper.appendChild(sentinel);
|
|
88
|
+
|
|
89
|
+
// Observe the sentinel with cleanup tracking
|
|
90
|
+
const state = { isConnected: true };
|
|
91
|
+
let currentSentinel = sentinel;
|
|
92
|
+
let resizeObserver;
|
|
93
|
+
let intersectionObserver;
|
|
94
|
+
|
|
95
|
+
const { observer } = createManagedObserver(
|
|
96
|
+
sentinel,
|
|
97
|
+
(entries) => {
|
|
98
|
+
const isIntersecting = entries[0].isIntersecting;
|
|
99
|
+
if (isIntersecting) {
|
|
100
|
+
// Add the is-visible class to trigger animation
|
|
101
|
+
element.classList.add("is-visible");
|
|
102
|
+
// Call onVisible callback if provided
|
|
103
|
+
options?.onVisible?.(element);
|
|
104
|
+
// Disconnect if not in repeat mode
|
|
105
|
+
if (!options?.repeat) {
|
|
106
|
+
disconnectObserver(intersectionObserver, state);
|
|
107
|
+
}
|
|
108
|
+
} else if (options?.repeat) {
|
|
109
|
+
// In repeat mode, remove the class when the sentinel exits
|
|
110
|
+
element.classList.remove("is-visible");
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{ threshold: 0 },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
intersectionObserver = observer;
|
|
117
|
+
|
|
118
|
+
// Function to recreate sentinel when element is resized
|
|
119
|
+
const recreateSentinel = () => {
|
|
120
|
+
const newSentinelResult = createSentinel(
|
|
121
|
+
element,
|
|
122
|
+
options?.debug,
|
|
123
|
+
options?.offset,
|
|
124
|
+
options?.sentinelColor,
|
|
125
|
+
options?.debugLabel,
|
|
126
|
+
sentinelId,
|
|
127
|
+
);
|
|
128
|
+
const newSentinel = newSentinelResult.element;
|
|
129
|
+
currentSentinel.replaceWith(newSentinel);
|
|
130
|
+
currentSentinel = newSentinel;
|
|
131
|
+
// Update observer to watch the new sentinel
|
|
132
|
+
intersectionObserver.disconnect();
|
|
133
|
+
intersectionObserver.observe(newSentinel);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Setup ResizeObserver to handle element resizing
|
|
137
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
138
|
+
resizeObserver = new ResizeObserver(() => {
|
|
139
|
+
recreateSentinel();
|
|
140
|
+
});
|
|
141
|
+
resizeObserver.observe(element);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
update(newOptions) {
|
|
146
|
+
if (newOptions?.animation) {
|
|
147
|
+
element.setAttribute("data-animation", newOptions.animation);
|
|
148
|
+
}
|
|
149
|
+
if (newOptions?.duration) {
|
|
150
|
+
setCSSVariables(element, newOptions.duration);
|
|
151
|
+
}
|
|
152
|
+
// Update repeat option
|
|
153
|
+
if (
|
|
154
|
+
newOptions?.repeat !== undefined &&
|
|
155
|
+
newOptions.repeat !== options?.repeat
|
|
156
|
+
) {
|
|
157
|
+
options = { ...options, repeat: newOptions.repeat };
|
|
158
|
+
}
|
|
159
|
+
// Update offset and debug if changed
|
|
160
|
+
if (
|
|
161
|
+
(newOptions?.offset !== undefined &&
|
|
162
|
+
newOptions.offset !== options?.offset) ||
|
|
163
|
+
(newOptions?.debug !== undefined && newOptions.debug !== options?.debug)
|
|
164
|
+
) {
|
|
165
|
+
options = { ...options, ...newOptions };
|
|
166
|
+
recreateSentinel();
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
destroy() {
|
|
170
|
+
disconnectObserver(intersectionObserver, state);
|
|
171
|
+
// Cleanup ResizeObserver
|
|
172
|
+
if (resizeObserver) {
|
|
173
|
+
resizeObserver.disconnect();
|
|
174
|
+
}
|
|
175
|
+
currentSentinel.remove();
|
|
176
|
+
// Unwrap element (move it out of wrapper)
|
|
177
|
+
const parent = wrapper.parentElement;
|
|
178
|
+
if (parent) {
|
|
179
|
+
wrapper.insertAdjacentElement("beforebegin", element);
|
|
180
|
+
}
|
|
181
|
+
wrapper.remove();
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation type names (includes AOS-compatible names)
|
|
3
|
+
*/
|
|
4
|
+
export type AnimationType = "fade" | "fade-up" | "fade-down" | "fade-left" | "fade-right" | "fade-up-right" | "fade-up-left" | "fade-down-right" | "fade-down-left" | "zoom-in" | "zoom-in-up" | "zoom-in-down" | "zoom-in-left" | "zoom-in-right" | "zoom-out" | "zoom-out-up" | "zoom-out-down" | "zoom-out-left" | "zoom-out-right" | "slide-up" | "slide-down" | "slide-left" | "slide-right" | "flip-left" | "flip-right" | "flip-up" | "flip-down" | "slide-rotate" | "bounce-in" | "fade-in" | "fade-in-up" | "fade-in-down" | "fade-in-left" | "fade-in-right" | "flip" | "flip-x";
|
|
5
|
+
/**
|
|
6
|
+
* Options for the runeScroller action
|
|
7
|
+
*/
|
|
8
|
+
export type RuneScrollerOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* - Animation type to apply
|
|
11
|
+
*/
|
|
12
|
+
animation?: AnimationType | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* - Animation duration in milliseconds
|
|
15
|
+
*/
|
|
16
|
+
duration?: number | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* - Repeat animation on every scroll
|
|
19
|
+
*/
|
|
20
|
+
repeat?: boolean | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* - Show sentinel as visible line for debugging
|
|
23
|
+
*/
|
|
24
|
+
debug?: boolean | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* - Sentinel color for debug mode
|
|
27
|
+
*/
|
|
28
|
+
sentinelColor?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* - Unique identifier for sentinel
|
|
31
|
+
*/
|
|
32
|
+
sentinelId?: string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* - Debug label to show on sentinel
|
|
35
|
+
*/
|
|
36
|
+
debugLabel?: string | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* - Offset of sentinel in pixels (negative = above element)
|
|
39
|
+
*/
|
|
40
|
+
offset?: number | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* - CSS timing function
|
|
43
|
+
*/
|
|
44
|
+
easing?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* - Callback when animation triggers
|
|
47
|
+
*/
|
|
48
|
+
onVisible?: ((element: HTMLElement) => void) | undefined;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Configuration options for IntersectionObserver
|
|
52
|
+
*/
|
|
53
|
+
export type IntersectionOptions = {
|
|
54
|
+
/**
|
|
55
|
+
* - IntersectionObserver threshold
|
|
56
|
+
*/
|
|
57
|
+
threshold?: number | number[] | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* - Custom margin around root element
|
|
60
|
+
*/
|
|
61
|
+
rootMargin?: string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* - Root element for intersection observation
|
|
64
|
+
*/
|
|
65
|
+
root?: Element | null | undefined;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Return type for useIntersection and useIntersectionOnce composables
|
|
69
|
+
*/
|
|
70
|
+
export type UseIntersectionReturn = {
|
|
71
|
+
/**
|
|
72
|
+
* - Reference to the DOM element being observed
|
|
73
|
+
*/
|
|
74
|
+
element: HTMLElement | null;
|
|
75
|
+
/**
|
|
76
|
+
* - Whether the element is currently visible in viewport
|
|
77
|
+
*/
|
|
78
|
+
isVisible: boolean;
|
|
79
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized type definitions for Rune Scroller library
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Animation type names (includes AOS-compatible names)
|
|
7
|
+
* @typedef {'fade' | 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'fade-up-right' | 'fade-up-left' | 'fade-down-right' | 'fade-down-left' | 'zoom-in' | 'zoom-in-up' | 'zoom-in-down' | 'zoom-in-left' | 'zoom-in-right' | 'zoom-out' | 'zoom-out-up' | 'zoom-out-down' | 'zoom-out-left' | 'zoom-out-right' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'flip-left' | 'flip-right' | 'flip-up' | 'flip-down' | 'slide-rotate' | 'bounce-in' | 'fade-in' | 'fade-in-up' | 'fade-in-down' | 'fade-in-left' | 'fade-in-right' | 'flip' | 'flip-x'} AnimationType
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for the runeScroller action
|
|
12
|
+
* @typedef {Object} RuneScrollerOptions
|
|
13
|
+
* @property {AnimationType} [animation='fade-up'] - Animation type to apply
|
|
14
|
+
* @property {number} [duration=400] - Animation duration in milliseconds
|
|
15
|
+
* @property {boolean} [repeat=false] - Repeat animation on every scroll
|
|
16
|
+
* @property {boolean} [debug=false] - Show sentinel as visible line for debugging
|
|
17
|
+
* @property {string} [sentinelColor='#00e0ff'] - Sentinel color for debug mode
|
|
18
|
+
* @property {string} [sentinelId] - Unique identifier for sentinel
|
|
19
|
+
* @property {string} [debugLabel] - Debug label to show on sentinel
|
|
20
|
+
* @property {number} [offset=0] - Offset of sentinel in pixels (negative = above element)
|
|
21
|
+
* @property {string} [easing='ease'] - CSS timing function
|
|
22
|
+
* @property {(element: HTMLElement) => void} [onVisible] - Callback when animation triggers
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration options for IntersectionObserver
|
|
27
|
+
* @typedef {Object} IntersectionOptions
|
|
28
|
+
* @property {number | number[]} [threshold] - IntersectionObserver threshold
|
|
29
|
+
* @property {string} [rootMargin] - Custom margin around root element
|
|
30
|
+
* @property {Element | null} [root] - Root element for intersection observation
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return type for useIntersection and useIntersectionOnce composables
|
|
35
|
+
* @typedef {Object} UseIntersectionReturn
|
|
36
|
+
* @property {HTMLElement | null} element - Reference to the DOM element being observed
|
|
37
|
+
* @property {boolean} isVisible - Whether the element is currently visible in viewport
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {import('./types.js').IntersectionOptions} [options={}]
|
|
3
|
+
* @param {(isVisible: boolean) => void} [onVisible]
|
|
4
|
+
* @returns {import('./types.js').UseIntersectionReturn}
|
|
5
|
+
*/
|
|
6
|
+
export function useIntersection(options?: import("./types.js").IntersectionOptions, onVisible?: (isVisible: boolean) => void): import("./types.js").UseIntersectionReturn;
|
|
7
|
+
/**
|
|
8
|
+
* @param {import('./types.js').IntersectionOptions} [options={}]
|
|
9
|
+
* @returns {import('./types.js').UseIntersectionReturn}
|
|
10
|
+
*/
|
|
11
|
+
export function useIntersectionOnce(options?: import("./types.js").IntersectionOptions): import("./types.js").UseIntersectionReturn;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable for handling IntersectionObserver logic
|
|
3
|
+
* Reduces duplication between animation components
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('./types.js').IntersectionOptions} [options={}]
|
|
8
|
+
* @param {((entry: IntersectionObserverEntry, isVisible: boolean) => void) | undefined} onIntersect
|
|
9
|
+
* @param {boolean} [once=false]
|
|
10
|
+
* @returns {import('./types.js').UseIntersectionReturn}
|
|
11
|
+
*/
|
|
12
|
+
function createIntersectionObserver(
|
|
13
|
+
options = {},
|
|
14
|
+
onIntersect = undefined,
|
|
15
|
+
once = false,
|
|
16
|
+
) {
|
|
17
|
+
const {
|
|
18
|
+
threshold = 0.5,
|
|
19
|
+
rootMargin = "-10% 0px -10% 0px",
|
|
20
|
+
root = null,
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
let element = $state(null);
|
|
24
|
+
let isVisible = $state(false);
|
|
25
|
+
let hasTriggeredOnce = false;
|
|
26
|
+
/** @type {IntersectionObserver | null} */
|
|
27
|
+
let observer = null;
|
|
28
|
+
|
|
29
|
+
$effect(() => {
|
|
30
|
+
if (!element) return;
|
|
31
|
+
|
|
32
|
+
observer = new IntersectionObserver(
|
|
33
|
+
(entries) => {
|
|
34
|
+
entries.forEach((entry) => {
|
|
35
|
+
// For once-only behavior, check if already triggered
|
|
36
|
+
if (once && hasTriggeredOnce) return;
|
|
37
|
+
|
|
38
|
+
isVisible = entry.isIntersecting;
|
|
39
|
+
if (onIntersect) {
|
|
40
|
+
onIntersect(entry, entry.isIntersecting);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Unobserve after first trigger if once=true
|
|
44
|
+
if (once && entry.isIntersecting) {
|
|
45
|
+
hasTriggeredOnce = true;
|
|
46
|
+
observer?.unobserve(entry.target);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
threshold,
|
|
52
|
+
rootMargin,
|
|
53
|
+
root,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
observer.observe(element);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
observer?.disconnect();
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
get element() {
|
|
66
|
+
return element;
|
|
67
|
+
},
|
|
68
|
+
set element(value) {
|
|
69
|
+
element = value;
|
|
70
|
+
},
|
|
71
|
+
get isVisible() {
|
|
72
|
+
return isVisible;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {import('./types.js').IntersectionOptions} [options={}]
|
|
79
|
+
* @param {(isVisible: boolean) => void} [onVisible]
|
|
80
|
+
* @returns {import('./types.js').UseIntersectionReturn}
|
|
81
|
+
*/
|
|
82
|
+
export function useIntersection(options = {}, onVisible) {
|
|
83
|
+
return createIntersectionObserver(
|
|
84
|
+
options,
|
|
85
|
+
(_entry, isVisible) => {
|
|
86
|
+
onVisible?.(isVisible);
|
|
87
|
+
},
|
|
88
|
+
false,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('./types.js').IntersectionOptions} [options={}]
|
|
94
|
+
* @returns {import('./types.js').UseIntersectionReturn}
|
|
95
|
+
*/
|
|
96
|
+
export function useIntersectionOnce(options = {}) {
|
|
97
|
+
return createIntersectionObserver(options, () => {}, true);
|
|
98
|
+
}
|
package/package.json
CHANGED