@solid-primitives/intersection-observer
Version:
Primitives to support using the intersection observer API.
289 lines (288 loc) • 11.2 kB
JavaScript
import { onMount, onCleanup, createSignal, createEffect, untrack, DEV, } from "solid-js";
import { isServer } from "solid-js/web";
import { access, handleDiffArray, } from "@solid-primitives/utils";
function observe(el, instance) {
// Elements with 'display: "contents"' don't work with IO, even if they are visible by users
// (https://github.com/solidjs-community/solid-primitives/issues/116)
if (DEV && el instanceof HTMLElement && el.style.display === "contents") {
// eslint-disable-next-line no-console
console.warn(`[@solid-primitives/intersection-observer] IntersectionObserver is not able to observe elements with 'display: "contents"' style:`, el);
}
instance.observe(el);
}
/**
* @deprecated Please use native {@link IntersectionObserver}, or {@link createIntersectionObserver} instead.
*/
export function makeIntersectionObserver(elements, onChange, options) {
if (isServer)
return {
add: () => void 0,
remove: () => void 0,
start: () => void 0,
reset: () => void 0,
stop: () => void 0,
instance: {},
};
const instance = new IntersectionObserver(onChange, options);
const add = el => observe(el, instance);
const remove = el => instance.unobserve(el);
const start = () => elements.forEach(add);
const reset = () => instance.takeRecords().forEach(el => remove(el.target));
start();
return { add, remove, start, stop: onCleanup(() => instance.disconnect()), reset, instance };
}
/**
* Creates a reactive Intersection Observer primitive.
*
* @param elements - A list of elements to watch
* @param onChange - An event handler that returns an array of observer entires
* @param options - IntersectionObserver constructor options:
* - `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection.
* - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, effectively shrinking or growing the root for calculation purposes.
* - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target.
*
* @example
* ```tsx
* const createIntersectionObserver(els, entries =>
* console.log(entries)
* );
* ```
*/
export function createIntersectionObserver(elements, onChange, options) {
if (isServer)
return;
const io = new IntersectionObserver(onChange, options);
onCleanup(() => io.disconnect());
createEffect((p) => {
const list = elements();
handleDiffArray(list, p, el => observe(el, io), el => io.unobserve(el));
return list;
}, []);
}
export function createViewportObserver(...a) {
if (isServer) {
return [() => void 0, { start: () => void 0, stop: () => void 0 }];
}
let initial = [];
let options = {};
if (Array.isArray(a[0]) || a[0] instanceof Function) {
if (a[1] instanceof Function) {
initial = access(a[0]).map(el => [el, a[1]]);
options = a[2];
}
else {
initial = access(a[0]);
options = a[1];
}
}
else
options = a[0];
const callbacks = new WeakMap();
const onChange = (entries, instance) => entries.forEach(entry => {
const cb = callbacks.get(entry.target)?.(entry, instance);
// Additional check to prevent errors when the user
// use "observe" directive without providing a callback
cb instanceof Function && cb(entry, instance);
});
const { add, remove, stop, instance } = makeIntersectionObserver([], onChange, options);
const addEntry = (el, callback) => {
add(el);
callbacks.set(el, callback);
};
const removeEntry = el => {
callbacks.delete(el);
remove(el);
};
const start = () => initial.forEach(([el, cb]) => addEntry(el, cb));
onMount(start);
return [addEntry, { remove: removeEntry, start, stop, instance }];
}
/**
* Creates reactive signal that changes when a single element's visibility changes.
*
* @param options - A Primitive and IntersectionObserver constructor options:
* - `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection.
* - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, effectively shrinking or growing the root for calculation purposes.
* - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target.
* - `initialValue` — Initial value of the signal *(default: false)*
*
* @returns A configured *"use"* function for creating a visibility signal for a single element. The passed element can be a **reactive signal** or a DOM element. Returning a falsy value will remove the element from the observer.
* ```ts
* (element: Accessor<Element | FalsyValue> | Element) => Accessor<boolean>
* ```
*
* @example
* ```tsx
* let el: HTMLDivElement | undefined
* const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 })
* const visible = useVisibilityObserver(() => el)
* <div ref={el}>{ visible() ? "Visible" : "Hidden" }</div>
* ```
*/
export function createVisibilityObserver(options, setter) {
if (isServer) {
return () => () => false;
}
const callbacks = new WeakMap();
const io = new IntersectionObserver((entries, instance) => {
for (const entry of entries)
callbacks.get(entry.target)?.(entry, instance);
}, options);
onCleanup(() => io.disconnect());
function removeEntry(el) {
io.unobserve(el);
callbacks.delete(el);
}
function addEntry(el, callback) {
observe(el, io);
callbacks.set(el, callback);
}
const getCallback = setter
? (get, set) => {
const setterRef = access(setter);
return entry => set(setterRef(entry, { visible: untrack(get) }));
}
: (_, set) => entry => set(entry.isIntersecting);
return element => {
const [isVisible, setVisible] = createSignal(options?.initialValue ?? false);
const callback = getCallback(isVisible, setVisible);
let prevEl;
if (!(element instanceof Element)) {
createEffect(() => {
const el = element();
if (el === prevEl)
return;
if (prevEl)
removeEntry(prevEl);
if (el)
addEntry(el, callback);
prevEl = el;
});
}
else
addEntry(element, callback);
onCleanup(() => prevEl && removeEntry(prevEl));
return isVisible;
};
}
export var Occurrence;
(function (Occurrence) {
Occurrence["Entering"] = "Entering";
Occurrence["Leaving"] = "Leaving";
Occurrence["Inside"] = "Inside";
Occurrence["Outside"] = "Outside";
})(Occurrence || (Occurrence = {}));
/**
* Calculates the occurrence of an element in the viewport.
*/
export function getOccurrence(isIntersecting, prevIsIntersecting) {
if (isServer) {
return Occurrence.Outside;
}
return isIntersecting
? prevIsIntersecting
? Occurrence.Inside
: Occurrence.Entering
: prevIsIntersecting === true
? Occurrence.Leaving
: Occurrence.Outside;
}
/**
* A visibility setter factory function. It provides information about element occurrence in the viewport — `"Entering"`, `"Leaving"`, `"Inside"` or `"Outside"`.
* @param setter - A function that sets the occurrence of an element in the viewport.
* @returns A visibility setter function.
* @example
* ```ts
* const useVisibilityObserver = createVisibilityObserver(
* { threshold: 0.8 },
* withOccurrence((entry, { occurrence }) => {
* console.log(occurrence);
* return entry.isIntersecting;
* })
* );
* ```
*/
export function withOccurrence(setter) {
if (isServer) {
return () => () => false;
}
return () => {
let prevIntersecting;
const cb = access(setter);
return (entry, ctx) => {
const { isIntersecting } = entry;
const occurrence = getOccurrence(isIntersecting, prevIntersecting);
prevIntersecting = isIntersecting;
return cb(entry, { ...ctx, occurrence });
};
};
}
export var DirectionX;
(function (DirectionX) {
DirectionX["Left"] = "Left";
DirectionX["Right"] = "Right";
DirectionX["None"] = "None";
})(DirectionX || (DirectionX = {}));
export var DirectionY;
(function (DirectionY) {
DirectionY["Top"] = "Top";
DirectionY["Bottom"] = "Bottom";
DirectionY["None"] = "None";
})(DirectionY || (DirectionY = {}));
/**
* Calculates the direction of an element in the viewport. The direction is calculated based on the element's rect, it's previous rect and the `isIntersecting` flag.
* @returns A direction string: `"Left"`, `"Right"`, `"Top"`, `"Bottom"` or `"None"`.
*/
export function getDirection(rect, prevRect, intersecting) {
if (isServer) {
return {
directionX: DirectionX.None,
directionY: DirectionY.None,
};
}
let directionX = DirectionX.None;
let directionY = DirectionY.None;
if (!prevRect)
return { directionX, directionY };
if (rect.top < prevRect.top)
directionY = intersecting ? DirectionY.Bottom : DirectionY.Top;
else if (rect.top > prevRect.top)
directionY = intersecting ? DirectionY.Top : DirectionY.Bottom;
if (rect.left > prevRect.left)
directionX = intersecting ? DirectionX.Left : DirectionX.Right;
else if (rect.left < prevRect.left)
directionX = intersecting ? DirectionX.Right : DirectionX.Left;
return { directionX, directionY };
}
/**
* A visibility setter factory function. It provides information about element direction on the screen — `"Left"`, `"Right"`, `"Top"`, `"Bottom"` or `"None"`.
* @param setter - A function that sets the occurrence of an element in the viewport.
* @returns A visibility setter function.
* @example
* ```ts
* const useVisibilityObserver = createVisibilityObserver(
* { threshold: 0.8 },
* withDirection((entry, { directionY, directionX, visible }) => {
* if (!entry.isIntersecting && directionY === "Top" && visible) {
* return true;
* }
* return entry.isIntersecting;
* })
* );
* ```
*/
export function withDirection(callback) {
if (isServer) {
return () => () => false;
}
return () => {
let prevBounds;
const cb = access(callback);
return (entry, ctx) => {
const { boundingClientRect } = entry;
const direction = getDirection(boundingClientRect, prevBounds, entry.isIntersecting);
prevBounds = boundingClientRect;
return cb(entry, { ...ctx, ...direction });
};
};
}