Skip to content

Commit 911ed5b

Browse files
committed
Changes v1.1.0
* modified the observer to fully leverage `IntersectionObserver`, now the `IntersectionObserverEntry` is the entry of our observer * added 3 new options: `rootMargin`, `threshold` (for `IntersectionObserver`) and `callbackMode` (for `PositionObserver`) * updated tests * updated README
1 parent 14d8844 commit 911ed5b

File tree

14 files changed

+406
-274
lines changed

14 files changed

+406
-274
lines changed

README.md

Lines changed: 76 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
[![vitest version](https://img.shields.io/badge/vitest-3.2.3-brightgreen)](https://vitest.dev/)
77
[![vite version](https://img.shields.io/badge/vite-6.3.5-brightgreen)](https://vitejs.dev/)
88

9-
If you were looking for an observer that could replace all your `resize` and/or `scroll` EventListeners, this should be it! The **PositionObserver** works with the [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) under the hood and uses a very simple design.
109

11-
The **PositionObserver** provides a way to asynchronously observe changes in the position of a target element with an ancestor element or with a top-level document's viewport. It tries to do what you would expect after your element has intersected as if you would listen to `resize` or `scroll` without attaching event listeners.
10+
The **PositionObserver** is a lightweight utility that replaces traditional `resize` and `scroll` event listeners. Built on the [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver), it provides a way to asynchronously observe changes in the position of a target element with an ancestor element or with a top-level `document`'s viewport.
1211

1312

1413
## Installation
@@ -33,91 +32,109 @@ deno add npm:@thednp/position-observer@latest
3332
## Usage
3433

3534
```ts
36-
// import the PositionObserver class
37-
import PositionObserver, { type PositionObserverEntry } from '@thednp/position-observer';
35+
import PositionObserver from '@thednp/position-observer';
3836

39-
// find a suitable target
37+
// Find a target element
4038
const myTarget = document.getElementById('myElement');
4139

42-
// define a callback
43-
const callback = (entries: PositionObserverEntry[], currentObserver: PositionObserver) => {
44-
/* keep an eye on your entries */
45-
// console.log(entries);
46-
47-
// access the observer inside your callback
48-
// to find entry for myTarget
49-
const entry = currentObserver.getEntry(myTarget);
50-
if (entry.target === myTarget/* and/or other conditions */) {
51-
// do something about it
52-
}
40+
// Define a callback
41+
const callback = (entries: IntersectionObserverEntry[], observer: PositionObserver) => {
42+
// Access the observer inside your callback
43+
// const otherEntry = observer.getEntry(anyOtherTarget);
44+
entries.forEach((entry) => {
45+
if (entry?.target === myTarget /* and your own conditions apply */) {
46+
// Handle position changes
47+
console.log(entry.boundingClientRect);
48+
}
49+
})
5350
};
5451

55-
// set some options
52+
// Set options
5653
const options = {
57-
// if not set, it will use the document.documentElement
58-
root: document.getElementById('myModal'),
59-
}
54+
root: document.getElementById('myModal'), // Defaults to document.documentElement
55+
rootMargin: '0px', // Margin around the root, this applies to IntersectionObserver
56+
threshold: 0, // Trigger when any part of the target is visible, this applies to IntersectionObserver
57+
callbackMode: 'intersecting', // Options: 'all', 'intersecting', 'update'
58+
};
6059

61-
// create the observer
60+
// Create the observer
6261
const observer = new PositionObserver(callback, options);
6362

64-
// start observing the target element position
65-
observer.observe(target);
63+
// Start observing
64+
observer.observe(myTarget);
6665

67-
// when the position of the element changes from DOM manipulations and/or
68-
// the position change was triggered by either scroll / resize events
69-
// these will be the entries of this observer callback example
66+
// Example callback entries
7067
[{
71-
// the observed target element
7268
target: <div#myElement>,
73-
// the target's bounding client react
74-
boundingClientRect: DOMRect,
75-
// parent <div#myModal> root clientWidth
76-
clientWidth: number,
77-
// root <div#myModal> clientHeight
78-
clientHeight: number,
69+
boundingClientRect: DOMRectReadOnly,
70+
intersectionRatio: number,
71+
isIntersecting: boolean,
72+
// ... other IntersectionObserverEntry properties
7973
}]
8074

81-
// anytime you need the entry, find it!!
82-
observer.getEntry(target);
75+
// Get an entry
76+
observer.getEntry(myTarget);
8377

84-
// stop observing the changes for #myElement at any point
85-
observer.unobserve(target);
78+
// Stop observing a target
79+
observer.unobserve(myTarget);
8680

87-
// anytime re-start observing the target
88-
observer.observe(target);
81+
// Resume observing
82+
observer.observe(myTarget);
8983

90-
// when no targets require observation
91-
// you should disconect the observer
92-
observer.disconect();
84+
// Stop all observation
85+
observer.disconnect();
9386
```
9487

9588

9689
## Instance Options
9790

98-
### root: Element | undefined
99-
Sets the `instance._root` private property which identifies the `Element` whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If not defined then the `Document.documentElement` will be used.
91+
| Option | Type | Description |
92+
|--------| -----|-------------|
93+
| `root` | `Element` \| `undefined` | The element used as the viewport for checking target visibility. Defaults to `document.documentElement`.|
94+
| `callbackMode` | "all" \| "intersecting" \| "update" \| `undefined` | Controls `PositionObserver` callback behavior. Defaults to "intersecting". See below for details. |
95+
| `rootMargin` | `string` \| `undefined` | Margin around the root of the `IntersectionObserver`. Uses same format as CSS margins (e.g., "10px 20px"). |
96+
| `threshold` | `number` \| `number[]` \| `undefined` | Percentage of the target's visibility required to trigger the `IntersectionObserver` callback. |
97+
98+
### root
99+
The **PositionObserver** `instance.root` identifies the `Element` whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. Since we're observing for its width and height changes, this root can only be an instance of `Element`, so `Document` cannot be the root of your PositionObserver instance.
100+
101+
The **IntersectionObserver** `instance.root` is always the default, which is `Document`. The two observers really care for different things: one cares about intersection the other cares about position, which is why the two observers cannot use the same root.
102+
103+
### IntersectionObserver
104+
The two initialization options specifically for the IntersectionObserver are `rootMargin` and `threshold` and only apply when using "intersecting" or "update" modes.
105+
106+
### Callback Modes
107+
* `all`: Triggers the callback for all observed targets, regardless of visibility or position changes.
108+
* `intersecting`: Triggers the callback only for targets that are intersecting with the document's viewport and have changed position or root dimensions.
109+
* `update`: Triggers the callback for targets with position/root dimension changes or when a target's intersection status changes (e.g., from intersecting to non-intersecting).
100110

101-
When observing multiple targets from a **scrollable** parent element, that parent must be set as root. The same applies to embeddings and `IFrame`s. See the [ScrollSpy](https://github.com/thednp/bootstrap.native/blob/master/src/components/scrollspy.ts) example for implementation details.
111+
When observing targets from a **scrollable** parent element, that parent must be set as root. The same applies to embeddings and `IFrame`s. See the [ScrollSpy](https://github.com/thednp/bootstrap.native/blob/master/src/components/scrollspy.ts) example for implementation details.
102112

103113

104-
## How it works
105-
* when the observer is initialized without a callback, it will throw an `Error`;
106-
* if you call the `observe()` method without a valid `Element` target, it will throw an `Error`;
107-
* if the target isn't attached to the DOM, it will not be added to the observer entries;
108-
* once propertly set up, the **PositionObserver** will observe the changes of either **top** or **left** for a given Element target in relation to its designated root, as well as the **clientWidth** and **clientHeight** of that parent;
109-
* when the target `Element` is intersecting with the bounds of the designated viewport and at least one of the observed values changes, only then the target's entry will be queued for the callback runtime.
114+
## How it Works
115+
* **Initialization**: Requires a valid callback function, or it throws an Error.
116+
* **Target Validation**: The `observe()` method requires a valid `Element`, or it throws an Error. Targets not attached to the DOM are ignored.
117+
* **Observation**: Tracks changes in the target's top or left position relative to the root, as well as the root's `clientWidth` and `clientHeight`.
118+
* **Callback Trigger**: The callback is invoked based on the `callbackMode`:
119+
- `all`: Includes every observed target's entry.
120+
- `intersecting`: Includes only intersecting targets with position or root dimension changes.
121+
- `update`: Includes targets with position/root dimension changes or a change in intersection status.
122+
* **Intersection Checks**: Uses `IntersectionObserver` with the `document` as the root to determine `isIntersecting`. The `rootMargin` and `threshold` options apply to these checks but have no impact in `all` mode.
110123

111124

112125
## Notes
113-
* **use with caution**: for performance reasons, if your callback is focused on values of the target's bounding client rect, be sure to make use of `entry.boundingClientRect` values (`observer.getEntry(target)`) instead of invoking `getBoundingClientRect()` again on your target;
114-
* this implementation is partially inspired by the [async-bounds](https://github.com/glued/async-bounds), the async model is very efficient;
115-
* if nothing happens when observing a target, please know that the observer's runtime will only call the callback for elements that are descendents of the given root element; this also means that if a target is removed from the document, the target's entry will not be queued into the runtime;
116-
* also if the target `Element` is hidden with either `display: none` or `visibility: hidden` or attributes with the same effect, the bounding box always has ZERO values and never changes, so make sure to have your target visible before calling `observer.observe(target)`;
117-
* because the functionality is powered by `requestAnimationFrame` and **IntersectionObserver**, all computation is always processed asynchronously before the next paint, in some cases you might want to consider wrapping your **PositionObserver** callback in a `requestAnimationFrame()` invokation for a consistent syncronicity and to eliminate any [unwanted anomalies](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors);
118-
* while the performance benefits over the use of event listeners is undeniable, it's still **important** to `unobserve` targets or `disconnect` the observer to make room in the main thread;
119-
* if you want to make your **PositionObserver** instance work like a `ResizeObserver`, well you can simply filter your callback with the inequality of `entry.boundingClientRect.height` and `lastHeight` OR `entry.boundingClientRect.width` and `lastWidth`;
120-
* lastly, the **PositionObserver** will observe changes to all sides of a target, but in some cases you might want to narrow down to the changes triggered by scroll, mainly top and left, in which case you can filter your callback to a single side `entry.boundingClientRect.top !== lastTop`, further increasing performance.
126+
* **Performance**: Use `entry.boundingClientRect` from `observer.getEntry(target)` to avoid redundant `getBoundingClientRect()` calls.
127+
* **Async Design**: Leverages `requestAnimationFrame` and `IntersectionObserver` for efficient, asynchronous operation. Consider wrapping callbacks in `requestAnimationFrame` for synchronization and to eliminate any potential [observation errors](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors).
128+
* **Visibility**: Targets must be visible (no `display: none` or `visibility: hidden`) for actual accurate bounding box measurements.
129+
* **Cleanup**: Call unobserve() or disconnect() when observation is no longer needed to free resources.
130+
* **ResizeObserver Alternative**: Filter callbacks on `entry.boundingClientRect.width` or height changes to mimic `ResizeObserver`.
131+
* **Scroll Optimization**: For scroll-specific changes, filter callbacks on `entry.boundingClientRect.top` or `left`.
132+
* **IntersectionObserver Root**: The underlying `IntersectionObserver` uses the `document` as its root, while `the PositionObserver`'s root option defines the reference `Element` for position tracking.
133+
* **Callback Mode Selection**: Choose `callbackMode` based on your use case:
134+
- Use `intersecting` for most scenarios where only visible elements matter.
135+
- Use `update` to track intersection state changes.
136+
- Use `all` for comprehensive monitoring of *all* targets.
137+
* **RootMargin and Threshold**: These options have no impact in `all` mode, as non-intersecting targets are still processed. They are however relevant in `intersecting` or `update` modes for defining visibility conditions.
121138

122139

123140
## License

demo/script.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const handleUpdate = (e) => {
7878

7979
const observer = new PositionObserver((_, ob) => {
8080
start = window.performance.now();
81+
// console.log({..._})
8182
requestAnimationFrame(updateObserver);
8283
// updateObserver();
8384
if (testRuns.length < 500) {

dist/index.d.mts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
//#region src/index.d.ts
2-
type PositionObserverCallback = (entries: PositionObserverEntry[], observer: PositionObserver) => void;
3-
type PositionObserverEntry = {
4-
target: Element;
5-
boundingClientRect: DOMRect;
6-
clientHeight: number;
7-
clientWidth: number;
8-
};
2+
type PositionObserverCallback = (entries: IntersectionObserverEntry[], observer: PositionObserver) => void;
3+
type CallbackMode = "all" | "intersecting" | "update";
94
type PositionObserverOptions = {
10-
root: HTMLElement;
5+
root?: Element;
6+
rootMargin?: IntersectionObserverInit["rootMargin"];
7+
threshold?: IntersectionObserverInit["threshold"];
8+
callbackMode?: CallbackMode;
119
};
1210
/**
1311
* The PositionObserver class is a utility class that observes the position
1412
* of DOM elements and triggers a callback when their position changes.
1513
*/
1614
declare class PositionObserver {
17-
entries: Map<Element, PositionObserverEntry>;
15+
entries: Map<Element, IntersectionObserverEntry>;
1816
static version: string;
19-
private _tick;
20-
private _root;
21-
private _callback;
17+
/** `PositionObserver.tick` */
18+
protected _t: number;
19+
/** `PositionObserver.root` */
20+
protected _r: Element;
21+
/** `PositionObserver.callbackMode` */
22+
protected _cm: 0 | 1 | 2;
23+
/** `PositionObserver.root.clientWidth` */
24+
protected _w: number;
25+
/** `PositionObserver.root.clientHeight` */
26+
protected _h: number;
27+
/** `IntersectionObserver.options.rootMargin` */
28+
protected _rm: string | undefined;
29+
/** `IntersectionObserver.options.threshold` */
30+
protected _th: number | number[] | undefined;
31+
/** `PositionObserver.callback` */
32+
protected _c: PositionObserverCallback;
2233
/**
2334
* The constructor takes two arguments, a `callback`, which is called
2435
* whenever the position of an observed element changes and an `options` object.
@@ -28,7 +39,7 @@ declare class PositionObserver {
2839
* @param callback the callback that applies to all targets of this observer
2940
* @param options the options of this observer
3041
*/
31-
constructor(callback: PositionObserverCallback, options?: Partial<PositionObserverOptions>);
42+
constructor(callback: PositionObserverCallback, options?: PositionObserverOptions);
3243
/**
3344
* Start observing the position of the specified element.
3445
* If the element is not currently attached to the DOM,
@@ -46,26 +57,29 @@ declare class PositionObserver {
4657
/**
4758
* Private method responsible for all the heavy duty,
4859
* the observer's runtime.
60+
* `PositionObserver.runCallback`
4961
*/
50-
private _runCallback;
62+
protected _rc: () => void;
5163
/**
5264
* Check intersection status and resolve it
5365
* right away.
5466
*
67+
* `PositionObserver.newEntryForTarget`
68+
*
5569
* @param target an `Element` target
5670
*/
57-
private _new;
71+
protected _n: (target: Element) => Promise<IntersectionObserverEntry>;
5872
/**
5973
* Find the entry for a given target.
6074
*
6175
* @param target an `HTMLElement` target
6276
*/
63-
getEntry: (target: Element) => PositionObserverEntry | undefined;
77+
getEntry: (target: Element) => IntersectionObserverEntry | undefined;
6478
/**
6579
* Immediately stop observing all elements.
6680
*/
6781
disconnect: () => void;
6882
}
6983
//#endregion
70-
export { PositionObserverCallback, PositionObserverEntry, PositionObserverOptions, PositionObserver as default };
84+
export { PositionObserverCallback, PositionObserverOptions, PositionObserver as default };
7185
//# sourceMappingURL=index.d.mts.map

dist/index.d.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
//#region src/index.d.ts
2-
type PositionObserverCallback = (entries: PositionObserverEntry[], observer: PositionObserver) => void;
3-
type PositionObserverEntry = {
4-
target: Element;
5-
boundingClientRect: DOMRect;
6-
clientHeight: number;
7-
clientWidth: number;
8-
};
2+
type PositionObserverCallback = (entries: IntersectionObserverEntry[], observer: PositionObserver) => void;
3+
type CallbackMode = "all" | "intersecting" | "update";
94
type PositionObserverOptions = {
10-
root: HTMLElement;
5+
root?: Element;
6+
rootMargin?: IntersectionObserverInit["rootMargin"];
7+
threshold?: IntersectionObserverInit["threshold"];
8+
callbackMode?: CallbackMode;
119
};
1210
/**
1311
* The PositionObserver class is a utility class that observes the position
1412
* of DOM elements and triggers a callback when their position changes.
1513
*/
1614
declare class PositionObserver {
17-
entries: Map<Element, PositionObserverEntry>;
15+
entries: Map<Element, IntersectionObserverEntry>;
1816
static version: string;
19-
private _tick;
20-
private _root;
21-
private _callback;
17+
/** `PositionObserver.tick` */
18+
protected _t: number;
19+
/** `PositionObserver.root` */
20+
protected _r: Element;
21+
/** `PositionObserver.callbackMode` */
22+
protected _cm: 0 | 1 | 2;
23+
/** `PositionObserver.root.clientWidth` */
24+
protected _w: number;
25+
/** `PositionObserver.root.clientHeight` */
26+
protected _h: number;
27+
/** `IntersectionObserver.options.rootMargin` */
28+
protected _rm: string | undefined;
29+
/** `IntersectionObserver.options.threshold` */
30+
protected _th: number | number[] | undefined;
31+
/** `PositionObserver.callback` */
32+
protected _c: PositionObserverCallback;
2233
/**
2334
* The constructor takes two arguments, a `callback`, which is called
2435
* whenever the position of an observed element changes and an `options` object.
@@ -28,7 +39,7 @@ declare class PositionObserver {
2839
* @param callback the callback that applies to all targets of this observer
2940
* @param options the options of this observer
3041
*/
31-
constructor(callback: PositionObserverCallback, options?: Partial<PositionObserverOptions>);
42+
constructor(callback: PositionObserverCallback, options?: PositionObserverOptions);
3243
/**
3344
* Start observing the position of the specified element.
3445
* If the element is not currently attached to the DOM,
@@ -46,26 +57,29 @@ declare class PositionObserver {
4657
/**
4758
* Private method responsible for all the heavy duty,
4859
* the observer's runtime.
60+
* `PositionObserver.runCallback`
4961
*/
50-
private _runCallback;
62+
protected _rc: () => void;
5163
/**
5264
* Check intersection status and resolve it
5365
* right away.
5466
*
67+
* `PositionObserver.newEntryForTarget`
68+
*
5569
* @param target an `Element` target
5670
*/
57-
private _new;
71+
protected _n: (target: Element) => Promise<IntersectionObserverEntry>;
5872
/**
5973
* Find the entry for a given target.
6074
*
6175
* @param target an `HTMLElement` target
6276
*/
63-
getEntry: (target: Element) => PositionObserverEntry | undefined;
77+
getEntry: (target: Element) => IntersectionObserverEntry | undefined;
6478
/**
6579
* Immediately stop observing all elements.
6680
*/
6781
disconnect: () => void;
6882
}
6983
//#endregion
70-
export { PositionObserverCallback, PositionObserverEntry, PositionObserverOptions, PositionObserver as default };
84+
export { PositionObserverCallback, PositionObserverOptions, PositionObserver as default };
7185
//# sourceMappingURL=index.d.ts.map

0 commit comments

Comments
 (0)