Skip to content

Commit dba5918

Browse files
authored
Implement viewEnter types alternate/repeat/state (#51)
* Implement viewEnter types alternate/repeat/state * Fix cleanup of exit observer only if necessary
1 parent 0299e29 commit dba5918

7 files changed

Lines changed: 1063 additions & 24 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# `viewEnter` Beyond `type: 'once'`
2+
3+
This is specification document for implementing the rest of the values for `type` property of `params` for `trigger: 'viewEnter'`.
4+
5+
* Currently only `type: once` is implemented for the `viewEnter` trigger
6+
* Other types that should also be implemented are: `alternate`, `repeat`, and `state`
7+
* The initial flow for Interactions with these types are the same as for `once`
8+
* These types should also track when the element exits the range \- `isIntersecting: false`
9+
* We’ll start by using a default exit observer for each type \- without providing an API to specify its options
10+
* We need to make sure we [persist](https://developer.mozilla.org/en-US/docs/Web/API/Animation/persist) the animation on these types.
11+
12+
# `alternate`
13+
14+
* When exiting the range the animation should be reversed.
15+
* On subsequent re-entry the animation should be reversed (not play, since it was reversed on last exit)
16+
* By default we can use the same observer as the one for entry.
17+
18+
# `repeat`
19+
20+
* By default we can use a separate observer that watches when the element is completely out of view
21+
* When exiting the range the animation should be paused and set its progress to 0 (like “stop”).
22+
* On subsequent re-entry the animation should be played from 0
23+
24+
# `state`
25+
26+
* By default we can use a separate observer that watches when the element is completely out of view
27+
* When exiting the range the animation should be paused.
28+
* On subsequent re-entry the animation should be resumed (calling `.play()` should resume it)

packages/interact/src/handlers/viewEnter.ts

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,52 @@ const SAFE_OBSERVER_CONFIG: IntersectionObserverInit = {
1414
threshold: [0],
1515
};
1616

17+
// Exit observer config for repeat/state types - watches when element is completely out of view
18+
const EXIT_OBSERVER_CONFIG: IntersectionObserverInit = {
19+
root: null,
20+
rootMargin: '0px',
21+
threshold: [0],
22+
};
23+
1724
const observers: Record<string, IntersectionObserver> = {};
1825
const handlerMap = new WeakMap() as HandlerObjectMap;
1926
const elementFirstRun = new WeakSet<HTMLElement>();
2027
const elementObserverMap = new WeakMap<HTMLElement, IntersectionObserver>();
2128
let viewEnterOptions: Partial<ViewEnterParams> = {};
29+
let sharedExitObserver: IntersectionObserver | null = null;
2230

2331
function setOptions(options: Partial<ViewEnterParams>) {
2432
viewEnterOptions = options;
2533
}
2634

35+
function invokeHandlers(target: HTMLElement, isIntersecting: boolean) {
36+
const handlers = handlerMap.get(target);
37+
handlers?.forEach(({ source, handler }) => {
38+
if (source === target) {
39+
handler!(isIntersecting);
40+
}
41+
});
42+
}
43+
44+
function getExitObserver() {
45+
if (sharedExitObserver) {
46+
return sharedExitObserver;
47+
}
48+
49+
sharedExitObserver = new IntersectionObserver((entries) => {
50+
entries.forEach((entry) => {
51+
const target = entry.target as HTMLElement;
52+
53+
if (!entry.isIntersecting) {
54+
// Element has completely exited the view
55+
invokeHandlers(target, false);
56+
}
57+
});
58+
}, EXIT_OBSERVER_CONFIG);
59+
60+
return sharedExitObserver;
61+
}
62+
2763
function getObserver(options: ViewEnterParams, isSafeMode: boolean = false) {
2864
const key = JSON.stringify({ ...options, isSafeMode });
2965

@@ -78,20 +114,19 @@ const observer = new IntersectionObserver((entries) => {
78114
}
79115
}
80116

81-
if (entry.isIntersecting) {
82-
const handlers = handlerMap.get(target);
117+
const type = options.type || 'once';
83118

84-
handlers?.forEach(({ source, handler }) => {
85-
if (source === entry.target) {
86-
handler!();
87-
}
88-
});
119+
if (entry.isIntersecting || (type === 'alternate' && !isFirstRun)) {
120+
// For alternate type, handle exit using same observer as entry
121+
invokeHandlers(target, entry.isIntersecting);
89122

90-
if (options.type === 'once') {
123+
if (type === 'once') {
91124
observer.unobserve(entry.target);
92125
elementFirstRun.delete(target);
93126
}
94127
}
128+
// Note: repeat and state exit handling is done by a separate exit observer
129+
// that watches when element is completely out of view
95130
});
96131
}, config);
97132

@@ -107,31 +142,81 @@ function addViewEnterHandler(
107142
options: ViewEnterParams = {},
108143
{ reducedMotion, selectorCondition }: InteractOptions = {},
109144
) {
110-
const observer = getObserver({ ...viewEnterOptions, ...options });
145+
const mergedOptions = { ...viewEnterOptions, ...options };
146+
const observer = getObserver(mergedOptions);
147+
const type = mergedOptions.type || 'once';
111148
const animation = getAnimation(
112149
target,
113150
effectToAnimationOptions(effect),
114151
undefined,
115152
reducedMotion,
116153
) as AnimationGroup;
117154

118-
if (animation?.isCSS && options.type === 'once') {
155+
// Persist animation for non-once types to prevent auto-cleanup
156+
if (type !== 'once') {
157+
// Use persist() if available (Web Animations API)
158+
(animation as AnimationGroup & { persist?: () => void }).persist?.();
159+
}
160+
161+
// Track initial play state for alternate type
162+
let isInitialPlay = true;
163+
164+
if (animation?.isCSS) {
119165
animation.onFinish(() => {
120166
target.dataset.motionEnter = 'done';
121167
});
122168
}
123169

124-
const handler = () => {
170+
const handler = (isIntersecting?: boolean) => {
125171
if (selectorCondition && !target.matches(selectorCondition)) return;
126-
animation.play(() => {
127-
if (!animation.isCSS) {
128-
target.dataset.motionEnter = 'done';
172+
173+
if (type === 'once') {
174+
if (isIntersecting) {
175+
animation.play(() => {
176+
if (!animation.isCSS) {
177+
target.dataset.motionEnter = 'done';
178+
}
179+
});
129180
}
130-
});
181+
} else if (type === 'alternate') {
182+
if (isInitialPlay && isIntersecting) {
183+
isInitialPlay = false;
184+
animation.play();
185+
} else if (!isInitialPlay) {
186+
// On subsequent entry/exit reverse the animation
187+
animation.reverse();
188+
}
189+
} else if (type === 'repeat') {
190+
if (isIntersecting) {
191+
// On entry, reset progress to 0 before playing since the exit is a separate observer/range
192+
animation.progress(0);
193+
animation.play();
194+
} else {
195+
// On exit (completely out of view), pause and reset
196+
animation.pause();
197+
animation.progress(0);
198+
}
199+
} else if (type === 'state') {
200+
if (isIntersecting) {
201+
// Resume or start playing
202+
animation.play();
203+
} else {
204+
// On exit (completely out of view), just pause
205+
animation.pause();
206+
}
207+
}
131208
};
209+
132210
const cleanup = () => {
133211
const currentObserver = elementObserverMap.get(source) || observer;
134212
currentObserver.unobserve(source);
213+
214+
if (type === 'repeat' || type === 'state') {
215+
// Clean up exit observer if it exists
216+
const exitObserver = getExitObserver();
217+
exitObserver.unobserve(source);
218+
}
219+
135220
animation.cancel();
136221
elementFirstRun.delete(source);
137222
elementObserverMap.delete(source);
@@ -143,14 +228,27 @@ function addViewEnterHandler(
143228

144229
elementObserverMap.set(source, observer);
145230
observer.observe(source);
231+
232+
// For repeat and state types, set up a separate exit observer
233+
// that watches when element is completely out of view
234+
if (type === 'repeat' || type === 'state') {
235+
const exitObserver = getExitObserver();
236+
exitObserver.observe(source);
237+
}
146238
}
147239

148240
function removeViewEnterHandler(element: HTMLElement) {
149241
removeElementFromHandlerMap(handlerMap, element);
150242
}
151243

244+
function reset() {
245+
sharedExitObserver = null;
246+
Object.keys(observers).forEach((key) => delete observers[key]);
247+
}
248+
152249
export default {
153250
add: addViewEnterHandler,
154251
remove: removeViewEnterHandler,
155252
setOptions,
253+
reset,
156254
};

packages/interact/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export type TriggerType =
3232
| 'activate'
3333
| 'interest';
3434

35-
export type ViewEnterType = 'once' | 'repeat' | 'alternate';
35+
export type ViewEnterType = 'once' | 'repeat' | 'alternate' | 'state';
3636

3737
export type TransitionMethod = 'add' | 'remove' | 'toggle' | 'clear';
3838

@@ -251,7 +251,7 @@ export type HandlerObject = {
251251
source: HTMLElement;
252252
target: HTMLElement;
253253
cleanup: () => void;
254-
handler?: () => void;
254+
handler?: (isIntersecting?: boolean) => void;
255255
};
256256

257257
export type HandlerObjectMap = WeakMap<HTMLElement, Set<HandlerObject>>;

0 commit comments

Comments
 (0)