diff --git a/README.md b/README.md index dc5f4cf..ec749e3 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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` | `[]` | +| elements | Array of elements to observe | `ReadonlyArray` | `[]` | | 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` | `new Map()` | diff --git a/src/IntersectionObserver.svelte b/src/IntersectionObserver.svelte index c2273f4..609ba9d 100644 --- a/src/IntersectionObserver.svelte +++ b/src/IntersectionObserver.svelte @@ -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. @@ -33,7 +33,7 @@ children, } = $props(); - /** @type {null | HTMLElement} */ + /** @type {null | Element} */ let prevElement = null; let prevSkip = untrack(() => skip); @@ -85,7 +85,7 @@ }); $effect(() => { - const target = element; + const target = element ?? null; const isSkipped = skip; const activeObserver = observer; diff --git a/src/IntersectionObserver.svelte.d.ts b/src/IntersectionObserver.svelte.d.ts index 2e7dcc7..d41d421 100644 --- a/src/IntersectionObserver.svelte.d.ts +++ b/src/IntersectionObserver.svelte.d.ts @@ -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 @@ -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. diff --git a/src/MultipleIntersectionObserver.svelte b/src/MultipleIntersectionObserver.svelte index 87be4f5..68992ac 100644 --- a/src/MultipleIntersectionObserver.svelte +++ b/src/MultipleIntersectionObserver.svelte @@ -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} [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} [elementIntersections] Map of element to its intersection state. - * @property {Map} [elementEntries] Map of element to its latest entry. + * @property {Map} [elementIntersections] Map of element to its intersection state. + * @property {Map} [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, elementEntries: Map }]>} [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, elementEntries: Map }]>} [children] */ /** @type {Props} */ @@ -33,7 +33,7 @@ children, } = $props(); - /** @type {Set} */ + /** @type {Set} */ let prevElements = new Set(); let prevSkip = untrack(() => skip); @@ -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); diff --git a/src/MultipleIntersectionObserver.svelte.d.ts b/src/MultipleIntersectionObserver.svelte.d.ts index 9767eb3..b3baa09 100644 --- a/src/MultipleIntersectionObserver.svelte.d.ts +++ b/src/MultipleIntersectionObserver.svelte.d.ts @@ -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; /** * Set to `true` to unobserve the element @@ -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. @@ -39,13 +39,13 @@ export interface MultipleIntersectionObserverProps { * Map of element to its intersection state. * @default new Map() */ - elementIntersections?: Map; + elementIntersections?: Map; /** * Map of element to its latest entry. * @default new Map() */ - elementEntries?: Map; + elementEntries?: Map; /** * `IntersectionObserver` instance. @@ -67,7 +67,7 @@ export interface MultipleIntersectionObserverProps { */ onobserve?: (detail: { entry: IntersectionObserverEntry; - target: HTMLElement; + target: Element; }) => void; /** @@ -75,15 +75,18 @@ export interface MultipleIntersectionObserverProps { */ onintersect?: (detail: { entry: IntersectionObserverEntry & { isIntersecting: true }; - target: HTMLElement; + target: Element; }) => void; children?: Snippet< [ { observer: null | IntersectionObserver; - elementIntersections: Map; - elementEntries: Map; + elementIntersections: Map; + elementEntries: Map< + Element | null | undefined, + IntersectionObserverEntry + >; }, ] >; diff --git a/src/create-intersection-observer.svelte.d.ts b/src/create-intersection-observer.svelte.d.ts index 50af833..f7a327a 100644 --- a/src/create-intersection-observer.svelte.d.ts +++ b/src/create-intersection-observer.svelte.d.ts @@ -9,7 +9,7 @@ export interface IntersectionObserverState { readonly entry: null | IntersectionObserverEntry; /** Attachment to apply to the observed element via `{@attach}`. */ - attach: Attachment; + attach: Attachment; } /** diff --git a/src/create-intersection-observer.svelte.js b/src/create-intersection-observer.svelte.js index 05d8dce..6a4b609 100644 --- a/src/create-intersection-observer.svelte.js +++ b/src/create-intersection-observer.svelte.js @@ -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) => { diff --git a/src/intersect.svelte.d.ts b/src/intersect.svelte.d.ts index 7f162bb..19ab187 100644 --- a/src/intersect.svelte.d.ts +++ b/src/intersect.svelte.d.ts @@ -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. @@ -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) => void; @@ -59,7 +59,7 @@ export const intersect: Action< */ export function intersectAttachment( getOptions?: () => IntersectActionOptions, -): Attachment; +): Attachment; declare module "svelte/elements" { export interface HTMLAttributes { @@ -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. @@ -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; + attach(options?: IntersectGroupNodeOptions): Attachment; } /** diff --git a/src/intersect.svelte.js b/src/intersect.svelte.js index 1f4718f..0191d19 100644 --- a/src/intersect.svelte.js +++ b/src/intersect.svelte.js @@ -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 = {}) { diff --git a/tests/e2e/fixtures/ElementUndefinedFixture.svelte b/tests/e2e/fixtures/ElementUndefinedFixture.svelte new file mode 100644 index 0000000..e8cf996 --- /dev/null +++ b/tests/e2e/fixtures/ElementUndefinedFixture.svelte @@ -0,0 +1,51 @@ + + +
+ {intersecting ? "Element is in view" : "Element is not in view"} + + +
+ + +
Hello world
+
+ + diff --git a/tests/unit/IntersectionObserver.test.ts b/tests/unit/IntersectionObserver.test.ts index 88c8312..d89cd9e 100644 --- a/tests/unit/IntersectionObserver.test.ts +++ b/tests/unit/IntersectionObserver.test.ts @@ -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"; @@ -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; diff --git a/tests/unit/types.test-d.ts b/tests/unit/types.test-d.ts index a07dc62..5a41fb7 100644 --- a/tests/unit/types.test-d.ts +++ b/tests/unit/types.test-d.ts @@ -21,7 +21,7 @@ import { expectTypeOf, test } from "vitest"; test("IntersectActionOptions accepts the documented shape", () => { expectTypeOf().toEqualTypeOf<{ - root?: null | HTMLElement; + root?: Element | Document | null; rootMargin?: string; threshold?: number | number[]; once?: boolean; @@ -32,7 +32,7 @@ test("IntersectActionOptions accepts the documented shape", () => { test("intersect is a Svelte action dispatching observe/intersect handlers", () => { expectTypeOf(intersect).toEqualTypeOf< Action< - HTMLElement, + Element, IntersectActionOptions | undefined, { onobserve?: (event: CustomEvent) => void; @@ -46,15 +46,22 @@ test("intersect is a Svelte action dispatching observe/intersect handlers", () = >(); }); -test("intersectAttachment returns an Attachment", () => { +test("intersect is usable on SVGElement", () => { + expectTypeOf(intersect).toBeCallableWith( + {} as SVGElement, + {} as IntersectActionOptions, + ); +}); + +test("intersectAttachment returns an Attachment", () => { expectTypeOf(intersectAttachment).toEqualTypeOf< - (getOptions?: () => IntersectActionOptions) => Attachment + (getOptions?: () => IntersectActionOptions) => Attachment >(); }); test("IntersectGroupSharedOptions covers only the observer-wide options", () => { expectTypeOf().toEqualTypeOf<{ - root?: null | HTMLElement; + root?: Element | Document | null; rootMargin?: string; threshold?: number | number[]; }>(); @@ -75,7 +82,7 @@ test("IntersectionGroup.attach accepts node options and returns an Attachment", expectTypeOf() .toHaveProperty("attach") .toEqualTypeOf< - (options?: IntersectGroupNodeOptions) => Attachment + (options?: IntersectGroupNodeOptions) => Attachment >(); }); @@ -89,7 +96,7 @@ test("IntersectionObserverState exposes readonly intersecting/entry and an attac expectTypeOf().toEqualTypeOf<{ readonly intersecting: boolean; readonly entry: null | IntersectionObserverEntry; - attach: Attachment; + attach: Attachment; }>(); }); @@ -124,3 +131,36 @@ test("MultipleIntersectionObserver component exposes its bindable props", () => ComponentProps >().toEqualTypeOf(); }); + +test("IntersectionObserverProps.element accepts SVGElement | undefined and null", () => { + expectTypeOf().toExtend< + IntersectionObserverProps["element"] + >(); + expectTypeOf().toExtend(); +}); + +test("IntersectionObserverProps.root accepts Document", () => { + expectTypeOf().toExtend(); +}); + +test("MultipleIntersectionObserverProps.elements accepts (HTMLElement | undefined)[]", () => { + expectTypeOf<(HTMLElement | undefined)[]>().toExtend< + MultipleIntersectionObserverProps["elements"] + >(); +}); + +test("MultipleIntersectionObserverProps.root accepts Document", () => { + expectTypeOf().toExtend< + MultipleIntersectionObserverProps["root"] + >(); +}); + +test("elementIntersections.get type-checks with an HTMLElement | undefined ref", () => { + const ref = {} as HTMLElement | undefined; + const elementIntersections = {} as NonNullable< + MultipleIntersectionObserverProps["elementIntersections"] + >; + expectTypeOf(elementIntersections.get(ref)).toEqualTypeOf< + boolean | undefined + >(); +}); diff --git a/tests/widen-types.test.svelte b/tests/widen-types.test.svelte new file mode 100644 index 0000000..bcfd8a6 --- /dev/null +++ b/tests/widen-types.test.svelte @@ -0,0 +1,40 @@ + + + + {#snippet children({ intersecting })} +
+ {intersecting ? "Element is in view" : "Element is not in view"} +
+ {/snippet} +
+ + + {#snippet children({ elementIntersections })} +
{elementIntersections.get(ref1) ? "✓" : "✗"}
+
{elementIntersections.get(ref2) ? "✓" : "✗"}
+ {/snippet} +
+ + + Scroll indicator + +