web/elements: add viewport helpers and extend intersection observer (#21508)

web/elements: add viewport helpers and opt-in ancestor-box to intersection observer

Some lazy-loaded elements render with `display: contents` so they
don't produce a layout box of their own, which makes
`IntersectionObserver` report them as never visible. Add
`useAncestorBox` to the `intersectionObserver` decorator: when set
(or when the element sets `displayBox="contents"`), fall back to the
nearest ancestor that actually has a layout box and test that against
the viewport.

Extract the lookups into a new `elements/utils/viewport.ts` with
`findNearestBoxTarget` and `isInViewport` helpers that can be reused
outside the decorator.
This commit is contained in:
Teffen Ellis
2026-04-10 17:51:56 +02:00
committed by GitHub
parent bb20350a2a
commit b590bffa57
2 changed files with 71 additions and 5 deletions

View File

@@ -2,6 +2,8 @@
* @file Intersection Observer Decorator for LitElement
*/
import { findNearestBoxTarget, isInViewport } from "#elements/utils/viewport";
import { LitElement } from "lit";
import { property } from "lit/decorators.js";
@@ -10,6 +12,17 @@ import { property } from "lit/decorators.js";
*/
export type IntersectionDecorator = <T extends LitElement>(target: T, propertyKey: keyof T) => void;
export interface LitElementWithDisplayBox extends LitElement {
displayBox?: "contents" | "block";
}
export interface IntersectionObserverDecoratorInit extends IntersectionObserverInit {
/**
* Whether to ascend the DOM tree to find a parent with a layout box (i.e. non "display: contents") and use that as the target for intersection checking.
*/
useAncestorBox?: boolean;
}
/**
* A decorator that applies an IntersectionObserver to the element.
* This is useful for lazy-loading elements that are not visible on the screen.
@@ -36,21 +49,48 @@ export type IntersectionDecorator = <T extends LitElement>(target: T, propertyKe
* }
* }
* ```
*
* @attr display-box If set to "contents", the element will be considered intersecting.
*/
export function intersectionObserver(init: IntersectionObserverInit = {}): IntersectionDecorator {
return <T extends LitElement, K extends keyof T>(target: T, key: K) => {
export function intersectionObserver({
useAncestorBox: initialUseAncestorBox = false,
...init
}: IntersectionObserverDecoratorInit = {}): IntersectionDecorator {
return <T extends LitElementWithDisplayBox, K extends keyof T>(target: T, key: K) => {
//#region Prepare observer
let useAncestorBox = initialUseAncestorBox;
property({ attribute: false, useDefault: false })(target, key);
const boxTargets = new WeakMap<T, Element>();
function findAndCacheBoxTarget(instance: T): Element {
let boxTarget = boxTargets.get(instance);
if (!boxTarget) {
boxTarget = findNearestBoxTarget(instance);
boxTargets.set(instance, boxTarget);
}
return boxTarget;
}
const observerCallback: IntersectionObserverCallback = (entries) => {
for (const entry of entries) {
const currentTarget = entry.target as T;
let intersecting = entry.isIntersecting;
if (!intersecting && useAncestorBox) {
const boxTarget = findAndCacheBoxTarget(currentTarget);
intersecting = isInViewport(boxTarget);
}
const cachedIntersecting = currentTarget[key];
if (cachedIntersecting !== entry.isIntersecting) {
if (cachedIntersecting !== intersecting) {
Object.assign(currentTarget, {
[key]: entry.isIntersecting,
[key]: intersecting,
});
currentTarget.requestUpdate(key, cachedIntersecting);
@@ -74,6 +114,8 @@ export function intersectionObserver(init: IntersectionObserverInit = {}): Inter
target.connectedCallback = function connectedCallbackWrapper(this: T) {
connectedCallback?.call(this);
useAncestorBox = this.displayBox === "contents" || initialUseAncestorBox;
if (this.hasUpdated) {
observer.observe(this);
} else {
@@ -83,7 +125,9 @@ export function intersectionObserver(init: IntersectionObserverInit = {}): Inter
}
};
target.disconnectedCallback = function disconnectedCallbackWrapper(this: LitElement) {
target.disconnectedCallback = function disconnectedCallbackWrapper(
this: LitElementWithDisplayBox,
) {
disconnectedCallback?.call(this);
if (observer) {

View File

@@ -0,0 +1,22 @@
export function findNearestBoxTarget(element?: Element | null): Element {
if (!element) {
return document.documentElement;
}
if (element.getClientRects().length) {
return element;
}
return findNearestBoxTarget(element.parentElement);
}
export function isInViewport(element: Element): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
);
}