Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ In this example, "Hello world" fades in when its containing element intersects t

| Name | Description | Type | Default value |
| :----------- | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | :------------ |
| element | Observed element | `null` or `HTMLElement` | `null` |
| element | Observed element | `null` \| `undefined` \| `Element` | `null` |
| once | Unobserve the element after the first intersection event | `boolean` | `false` |
| intersecting | `true` if the observed element is intersecting the viewport | `boolean` | `false` |
| root | Containing element | `null` or `HTMLElement` | `null` |
| root | Containing element | `Element` \| `Document` \| `null` | `null` |
| rootMargin | Margin offset of the containing element | `string` | `"0px"` |
| threshold | Percentage of element visibility to trigger an event | `number` between 0 and 1, or an array of `number`s between 0 and 1 | `0` |
| entry | Observed element metadata | `null` or [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) | `null` |
Expand Down Expand Up @@ -237,9 +237,9 @@ As with the scroll-to-end example, `root` must be an element that scrolls on its

| Name | Description | Type | Default value |
| :------------------- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
| elements | Array of HTML elements to observe | `Array<HTMLElement \| null>` | `[]` |
| elements | Array of elements to observe | `ReadonlyArray<Element \| null \| undefined>` | `[]` |
| once | Unobserve elements after the first intersection event | `boolean` | `false` |
| root | Containing element | `null` or `HTMLElement` | `null` |
| root | Containing element | `Element` \| `Document` \| `null` | `null` |
| rootMargin | Margin offset of the containing element | `string` | `"0px"` |
| threshold | Percentage of element visibility to trigger an event | `number` between 0 and 1, or an array of `number`s between 0 and 1 | `0` |
| elementIntersections | Map of each element to its intersection state | `Map<HTMLElement \| null, boolean>` | `new Map()` |
Expand Down
8 changes: 4 additions & 4 deletions src/IntersectionObserver.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

/**
* @typedef {Object} Props
* @property {null | HTMLElement} [element] The HTML Element to observe.
* @property {Element | null | undefined} [element] The Element to observe.
* @property {boolean} [once] Set to `true` to unobserve the element after it intersects the viewport.
* @property {boolean} [intersecting] `true` if the observed element is intersecting the viewport.
* @property {null | HTMLElement} [root] Specify the containing element. Defaults to the browser viewport.
* @property {Element | Document | null | undefined} [root] Specify the containing element. Defaults to the browser viewport.
* @property {string} [rootMargin] Margin offset of the containing element.
* @property {number | number[]} [threshold] Percentage of element visibility to trigger an event. Value must be between 0 and 1.
* @property {null | IntersectionObserverEntry} [entry] Observed element metadata.
Expand All @@ -33,7 +33,7 @@
children,
} = $props();

/** @type {null | HTMLElement} */
/** @type {null | Element} */
let prevElement = null;

let prevSkip = untrack(() => skip);
Expand Down Expand Up @@ -85,7 +85,7 @@
});

$effect(() => {
const target = element;
const target = element ?? null;
const isSkipped = skip;
const activeObserver = observer;

Expand Down
6 changes: 3 additions & 3 deletions src/IntersectionObserver.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import type { Component, Snippet } from "svelte";

export interface IntersectionObserverProps {
/**
* The HTML Element to observe.
* The Element to observe.
* @default null
*/
element?: null | HTMLElement;
element?: Element | null | undefined;

/**
* Set to `true` to unobserve the element
Expand All @@ -26,7 +26,7 @@ export interface IntersectionObserverProps {
* Defaults to the browser viewport.
* @default null
*/
root?: null | HTMLElement;
root?: Element | Document | null | undefined;

/**
* Margin offset of the containing element.
Expand Down
18 changes: 9 additions & 9 deletions src/MultipleIntersectionObserver.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@

/**
* @typedef {Object} Props
* @property {(HTMLElement | null)[]} [elements] Array of HTML Elements to observe. Use this for better performance when observing multiple elements.
* @property {ReadonlyArray<Element | null | undefined>} [elements] Array of Elements to observe. Use this for better performance when observing multiple elements.
* @property {boolean} [once] Set to `true` to unobserve the element after it intersects the viewport.
* @property {null | HTMLElement} [root] Specify the containing element. Defaults to the browser viewport.
* @property {Element | Document | null | undefined} [root] Specify the containing element. Defaults to the browser viewport.
* @property {string} [rootMargin] Margin offset of the containing element.
* @property {number | number[]} [threshold] Percentage of element visibility to trigger an event. Value must be between 0 and 1.
* @property {Map<HTMLElement | null, boolean>} [elementIntersections] Map of element to its intersection state.
* @property {Map<HTMLElement | null, IntersectionObserverEntry>} [elementEntries] Map of element to its latest entry.
* @property {Map<Element | null | undefined, boolean>} [elementIntersections] Map of element to its intersection state.
* @property {Map<Element | null | undefined, IntersectionObserverEntry>} [elementEntries] Map of element to its latest entry.
* @property {null | IntersectionObserver} [observer] `IntersectionObserver` instance.
* @property {boolean} [skip] Set to `true` to pause observing all elements without disconnecting the observer or losing `elementIntersections`/`elementEntries` state. Set back to `false` to resume.
* @property {(detail: { entry: IntersectionObserverEntry, target: HTMLElement }) => void} [onobserve] Called when an element is first observed and also whenever an intersection event occurs.
* @property {(detail: { entry: IntersectionObserverEntry & { isIntersecting: true }, target: HTMLElement }) => void} [onintersect] Called only when an element is intersecting the viewport.
* @property {import("svelte").Snippet<[{ observer: null | IntersectionObserver, elementIntersections: Map<HTMLElement | null, boolean>, elementEntries: Map<HTMLElement | null, IntersectionObserverEntry> }]>} [children]
* @property {(detail: { entry: IntersectionObserverEntry, target: Element }) => void} [onobserve] Called when an element is first observed and also whenever an intersection event occurs.
* @property {(detail: { entry: IntersectionObserverEntry & { isIntersecting: true }, target: Element }) => void} [onintersect] Called only when an element is intersecting the viewport.
* @property {import("svelte").Snippet<[{ observer: null | IntersectionObserver, elementIntersections: Map<Element | null | undefined, boolean>, elementEntries: Map<Element | null | undefined, IntersectionObserverEntry> }]>} [children]
*/

/** @type {Props} */
Expand All @@ -33,7 +33,7 @@
children,
} = $props();

/** @type {Set<HTMLElement | null>} */
/** @type {Set<Element | null | undefined>} */
let prevElements = new Set();

let prevSkip = untrack(() => skip);
Expand All @@ -49,7 +49,7 @@
observer = new IntersectionObserver(
(entries) => {
for (const _entry of entries) {
const target = /** @type {HTMLElement} */ (_entry.target);
const target = /** @type {Element} */ (_entry.target);

elementIntersections.set(target, _entry.isIntersecting);
elementEntries.set(target, _entry);
Expand Down
21 changes: 12 additions & 9 deletions src/MultipleIntersectionObserver.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type { Component, Snippet } from "svelte";

export interface MultipleIntersectionObserverProps {
/**
* Array of HTML Elements to observe.
* Array of Elements to observe.
* Use this for better performance when observing multiple elements.
* @default []
*/
elements?: (HTMLElement | null)[];
elements?: ReadonlyArray<Element | null | undefined>;

/**
* Set to `true` to unobserve the element
Expand All @@ -20,7 +20,7 @@ export interface MultipleIntersectionObserverProps {
* Defaults to the browser viewport.
* @default null
*/
root?: null | HTMLElement;
root?: Element | Document | null | undefined;

/**
* Margin offset of the containing element.
Expand All @@ -39,13 +39,13 @@ export interface MultipleIntersectionObserverProps {
* Map of element to its intersection state.
* @default new Map()
*/
elementIntersections?: Map<HTMLElement | null, boolean>;
elementIntersections?: Map<Element | null | undefined, boolean>;

/**
* Map of element to its latest entry.
* @default new Map()
*/
elementEntries?: Map<HTMLElement | null, IntersectionObserverEntry>;
elementEntries?: Map<Element | null | undefined, IntersectionObserverEntry>;

/**
* `IntersectionObserver` instance.
Expand All @@ -67,23 +67,26 @@ export interface MultipleIntersectionObserverProps {
*/
onobserve?: (detail: {
entry: IntersectionObserverEntry;
target: HTMLElement;
target: Element;
}) => void;

/**
* Called only when an element is intersecting the viewport.
*/
onintersect?: (detail: {
entry: IntersectionObserverEntry & { isIntersecting: true };
target: HTMLElement;
target: Element;
}) => void;

children?: Snippet<
[
{
observer: null | IntersectionObserver;
elementIntersections: Map<HTMLElement | null, boolean>;
elementEntries: Map<HTMLElement | null, IntersectionObserverEntry>;
elementIntersections: Map<Element | null | undefined, boolean>;
elementEntries: Map<
Element | null | undefined,
IntersectionObserverEntry
>;
},
]
>;
Expand Down
2 changes: 1 addition & 1 deletion src/create-intersection-observer.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface IntersectionObserverState {
readonly entry: null | IntersectionObserverEntry;

/** Attachment to apply to the observed element via `{@attach}`. */
attach: Attachment<HTMLElement>;
attach: Attachment<Element>;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/create-intersection-observer.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function createIntersectionObserver(getOptions = () => ({})) {

const attachment = intersectAttachment(getOptions);

/** @param {HTMLElement} node */
/** @param {Element} node */
function attach(node) {
/** @param {Event} event */
const onObserve = (event) => {
Expand Down
10 changes: 5 additions & 5 deletions src/intersect.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface IntersectActionOptions {
* Defaults to the browser viewport.
* @default null
*/
root?: null | HTMLElement;
root?: Element | Document | null;

/**
* Margin offset of the containing element.
Expand Down Expand Up @@ -43,7 +43,7 @@ export interface IntersectActionOptions {
* viewport) `CustomEvent`s on the element — listen with `onobserve`/`onintersect`.
*/
export const intersect: Action<
HTMLElement,
Element,
IntersectActionOptions | undefined,
{
onobserve?: (event: CustomEvent<IntersectionObserverEntry>) => void;
Expand All @@ -59,7 +59,7 @@ export const intersect: Action<
*/
export function intersectAttachment(
getOptions?: () => IntersectActionOptions,
): Attachment<HTMLElement>;
): Attachment<Element>;

declare module "svelte/elements" {
export interface HTMLAttributes<T> {
Expand All @@ -81,7 +81,7 @@ export interface IntersectGroupSharedOptions {
* Defaults to the browser viewport.
* @default null
*/
root?: null | HTMLElement;
root?: Element | Document | null;

/**
* Margin offset of the containing element.
Expand Down Expand Up @@ -129,7 +129,7 @@ export interface IntersectionGroup {
* Returns an attachment for a single node in the group. Call once per
* element in a loop, e.g. `{@attach group.attach({ onobserve })}`.
*/
attach(options?: IntersectGroupNodeOptions): Attachment<HTMLElement>;
attach(options?: IntersectGroupNodeOptions): Attachment<Element>;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/intersect.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { fromAction } from "svelte/attachments";
* Svelte action that observes `node` with the Intersection Observer API.
* Dispatches `observe` (on every change) and `intersect` (on entering the
* viewport) `CustomEvent`s on `node` — listen with `onobserve`/`onintersect`.
* @param {HTMLElement} node
* @param {Element} node
* @param {import("./intersect.svelte.d.ts").IntersectActionOptions} [options]
*/
export function intersect(node, options = {}) {
Expand Down
51 changes: 51 additions & 0 deletions tests/e2e/fixtures/ElementUndefinedFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";

let ref: undefined | HTMLDivElement;
let includeElement = true;
let intersecting = false;

$: element = includeElement ? ref : undefined;
</script>

<header class:intersecting>
{intersecting ? "Element is in view" : "Element is not in view"}
<button
data-testid="clear"
onclick={() => (includeElement = false)}
>
Clear
</button>
<button
data-testid="restore"
onclick={() => (includeElement = true)}
>
Restore
</button>
</header>

<IntersectionObserver
{element}
bind:intersecting
>
<div bind:this={ref}>Hello world</div>
</IntersectionObserver>

<style>
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
padding: 1rem;
background-color: #e0f7f6;
}

div {
margin-top: calc(100vh + 1px);
height: 38vh;
padding: 1rem;
background-color: #376462;
color: #fff;
}
</style>
34 changes: 34 additions & 0 deletions tests/unit/IntersectionObserver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import BasicFixture from "../e2e/fixtures/BasicFixture.svelte";
import EachBindingFixture from "../e2e/fixtures/EachBindingFixture.svelte";
import ElementChangeFixture from "../e2e/fixtures/ElementChangeFixture.svelte";
import ElementNullFixture from "../e2e/fixtures/ElementNullFixture.svelte";
import ElementUndefinedFixture from "../e2e/fixtures/ElementUndefinedFixture.svelte";
import OnceElementChangeFixture from "../e2e/fixtures/OnceElementChangeFixture.svelte";
import OnceFixture from "../e2e/fixtures/OnceFixture.svelte";
import RootChangeFixture from "../e2e/fixtures/RootChangeFixture.svelte";
Expand Down Expand Up @@ -313,6 +314,39 @@ describe("IntersectionObserver", () => {
expect(observer.observedElements.has(el)).toBe(true);
});

test("element becoming undefined unobserves it and resets intersecting/entry; restoring re-observes", () => {
const rendered = render(ElementUndefinedFixture);
cleanup = rendered.cleanup;
const el = rendered.target.querySelector("div");
const header = rendered.target.querySelector("header");
const clearButton = rendered.target.querySelector('[data-testid="clear"]');
const restoreButton = rendered.target.querySelector(
'[data-testid="restore"]',
);
if (
!el ||
!header ||
!(clearButton instanceof HTMLButtonElement) ||
!(restoreButton instanceof HTMLButtonElement)
) {
throw new Error("fixture markup missing");
}

const observer = MockIntersectionObserver.last();
flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
expect(header.textContent).toContain("Element is in view");

clearButton.click();
flushSync();

expect(observer.observedElements.has(el)).toBe(false);
expect(header.textContent).toContain("Element is not in view");

restoreButton.click();
flushSync();
expect(observer.observedElements.has(el)).toBe(true);
});

test("value-equal but referentially-new threshold arrays do not recreate the observer", () => {
const rendered = render(ThresholdStableFixture);
cleanup = rendered.cleanup;
Expand Down
Loading