UNPKG

@solid-primitives/intersection-observer

Version:
289 lines (288 loc) 11.2 kB
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 }); }; }; }