Skip to content

Commit 32f8da6

Browse files
committed
Address scrollMargin review feedback
1 parent fdfcd59 commit 32f8da6

13 files changed

Lines changed: 82 additions & 14 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ Provide these as the options argument in the `useInView` hook or as props on the
206206
| ---------------------- | ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
207207
| **root** | `Element` | `document` | The Intersection Observer interface's read-only root property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the root is `null`, then the bounds of the actual document viewport are used. |
208208
| **rootMargin** | `string` | `'0px'` | Margin around the root. Can have values similar to the CSS margin property, e.g. `"10px 20px 30px 40px"` (top, right, bottom, left). Also supports percentages, to check if an element intersects with the center of the viewport for example `"-50% 0% -50% 0%"`. |
209+
| **scrollMargin** | `string` | `'0px'` | Margin around nested scroll containers that clip the target, including the root if it is a scroll container. |
209210
| **threshold** | `number` or `number[]` | `0` | Number between `0` and `1` indicating the percentage that should be visible before triggering. Can also be an array of numbers, to create multiple trigger points. |
210211
| **onChange** | `(inView, entry) => void` | `undefined` | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. |
211212
| **trackVisibility** 🧪 | `boolean` | `false` | A boolean indicating whether this Intersection Observer will track visibility changes on the target. |

src/InView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class InView extends React.Component<
8888
// If a IntersectionObserver option changed, reinit the observer
8989
if (
9090
prevProps.rootMargin !== this.props.rootMargin ||
91+
prevProps.scrollMargin !== this.props.scrollMargin ||
9192
prevProps.root !== this.props.root ||
9293
prevProps.threshold !== this.props.threshold ||
9394
prevProps.skip !== this.props.skip ||
@@ -109,6 +110,7 @@ export class InView extends React.Component<
109110
threshold,
110111
root,
111112
rootMargin,
113+
scrollMargin,
112114
trackVisibility,
113115
delay,
114116
fallbackInView,
@@ -124,7 +126,7 @@ export class InView extends React.Component<
124126
threshold,
125127
root,
126128
rootMargin,
127-
// @ts-expect-error
129+
scrollMargin,
128130
trackVisibility,
129131
delay,
130132
},

src/__tests__/InView.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ test("Should recreate observer when rootMargin change", () => {
124124
expect(instance.unobserve).toHaveBeenCalled();
125125
});
126126

127+
test("Should recreate observer when scrollMargin change", () => {
128+
const { container, rerender } = render(<InView>Inner</InView>);
129+
mockAllIsIntersecting(true);
130+
const instance = intersectionMockInstance(container.children[0]);
131+
vi.spyOn(instance, "unobserve");
132+
133+
rerender(<InView scrollMargin="10px">Inner</InView>);
134+
expect(instance.unobserve).toHaveBeenCalled();
135+
});
136+
127137
test("Should unobserve when triggerOnce comes into view", () => {
128138
const { container } = render(<InView triggerOnce>Inner</InView>);
129139
mockAllIsIntersecting(false);

src/__tests__/observe.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ test("should convert options to id", () => {
3737
expect(
3838
optionsToId({
3939
threshold: 0,
40-
// @ts-expect-error
4140
trackVisibility: true,
4241
delay: 500,
4342
}),
@@ -49,7 +48,8 @@ test("should convert options to id", () => {
4948
).toMatchInlineSnapshot(`"threshold_0"`);
5049
expect(
5150
optionsToId({
51+
scrollMargin: "10px 20px",
5252
threshold: [0, 0.5, 1],
5353
}),
54-
).toMatchInlineSnapshot(`"threshold_0,0.5,1"`);
54+
).toMatchInlineSnapshot(`"scrollMargin_10px 20px,threshold_0,0.5,1"`);
5555
});

src/__tests__/useInView.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ test("should create a hook with array threshold", () => {
5757
expect(instance.observe).toHaveBeenCalledWith(wrapper);
5858
});
5959

60+
test("should create a hook with scrollMargin", () => {
61+
const { getByTestId } = render(
62+
<HookComponent options={{ scrollMargin: "10px" }} />,
63+
);
64+
const wrapper = getByTestId("wrapper");
65+
const instance = intersectionMockInstance(wrapper);
66+
67+
expect(instance).toHaveProperty("scrollMargin", "10px");
68+
});
69+
6070
test("should create a lazy hook", () => {
6171
const { getByTestId } = render(<LazyHookComponent />);
6272
const wrapper = getByTestId("wrapper");

src/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ export { useOnInView } from "./useOnInView";
99

1010
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
1111

12+
export type IntersectionObserverInitWithOptions = IntersectionObserverInit & {
13+
/** Margin around nested scroll containers that clip the target, including the root if it is a scroll container. */
14+
scrollMargin?: string;
15+
/** IntersectionObserver v2 - Track the actual visibility of the element */
16+
trackVisibility?: boolean;
17+
/** IntersectionObserver v2 - Set a minimum delay between notifications */
18+
delay?: number;
19+
};
20+
1221
export type ObserverInstanceCallback = (
1322
inView: boolean,
1423
entry: IntersectionObserverEntry,
@@ -26,11 +35,14 @@ interface RenderProps {
2635
ref: React.RefObject<any> | ((node?: Element | null) => void);
2736
}
2837

29-
export interface IntersectionOptions extends IntersectionObserverInit {
38+
export interface IntersectionOptions
39+
extends IntersectionObserverInitWithOptions {
3040
/** The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.*/
3141
root?: Element | Document | null;
3242
/** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */
3343
rootMargin?: string;
44+
/** Margin around nested scroll containers that clips the target, including the root if it is a scroll container. */
45+
scrollMargin?: string;
3446
/** Number between `0` and `1` indicating the percentage that should be visible before triggering. Can also be an `array` of numbers, to create multiple trigger points. */
3547
threshold?: number | number[];
3648
/** Only trigger the inView callback once */

src/observe.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { ObserverInstanceCallback } from "./index";
1+
import type {
2+
IntersectionObserverInitWithOptions,
3+
ObserverInstanceCallback,
4+
} from "./index";
25

36
const observerMap = new Map<
47
string,
@@ -28,7 +31,7 @@ export function defaultFallbackInView(inView: boolean | undefined) {
2831
* Generate a unique ID for the root element
2932
* @param root
3033
*/
31-
function getRootId(root: IntersectionObserverInit["root"]) {
34+
function getRootId(root: IntersectionObserverInitWithOptions["root"]) {
3235
if (!root) return "0";
3336
if (RootIds.has(root)) return RootIds.get(root);
3437
rootId += 1;
@@ -41,23 +44,24 @@ function getRootId(root: IntersectionObserverInit["root"]) {
4144
* Ensures we can reuse the same observer when observing elements with the same options.
4245
* @param options
4346
*/
44-
export function optionsToId(options: IntersectionObserverInit) {
47+
export function optionsToId(options: IntersectionObserverInitWithOptions) {
4548
return Object.keys(options)
4649
.sort()
4750
.filter(
48-
(key) => options[key as keyof IntersectionObserverInit] !== undefined,
51+
(key) =>
52+
options[key as keyof IntersectionObserverInitWithOptions] !== undefined,
4953
)
5054
.map((key) => {
5155
return `${key}_${
5256
key === "root"
5357
? getRootId(options.root)
54-
: options[key as keyof IntersectionObserverInit]
58+
: options[key as keyof IntersectionObserverInitWithOptions]
5559
}`;
5660
})
5761
.toString();
5862
}
5963

60-
function createObserver(options: IntersectionObserverInit) {
64+
function createObserver(options: IntersectionObserverInitWithOptions) {
6165
// Create a unique ID for this observer instance, based on the root, root margin and threshold.
6266
const id = optionsToId(options);
6367
let instance = observerMap.get(id);
@@ -118,7 +122,7 @@ function createObserver(options: IntersectionObserverInit) {
118122
export function observe(
119123
element: Element,
120124
callback: ObserverInstanceCallback,
121-
options: IntersectionObserverInit = {},
125+
options: IntersectionObserverInitWithOptions = {},
122126
fallbackInView = unsupportedValue,
123127
) {
124128
if (

src/test-utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as React from "react";
22
import * as DeprecatedReactTestUtils from "react-dom/test-utils";
33

4+
type IntersectionObserverWithScrollMargin = IntersectionObserver & {
5+
scrollMargin: string;
6+
};
7+
48
type Item = {
59
callback: IntersectionObserverCallback;
610
elements: Set<Element>;
@@ -121,12 +125,13 @@ export function setupIntersectionMocking(mockFn: typeof vi.fn) {
121125
elements: new Set<Element>(),
122126
created: Date.now(),
123127
};
124-
const instance: IntersectionObserver = {
128+
const instance: IntersectionObserverWithScrollMargin = {
125129
thresholds: Array.isArray(options.threshold)
126130
? options.threshold
127131
: [options.threshold ?? 0],
128132
root: options.root ?? null,
129133
rootMargin: options.rootMargin ?? "",
134+
scrollMargin: options.scrollMargin ?? "",
130135
observe: mockFn((element: Element) => {
131136
item.elements.add(element);
132137
}),

src/useInView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function useInView({
3838
delay,
3939
trackVisibility,
4040
rootMargin,
41+
scrollMargin,
4142
root,
4243
triggerOnce,
4344
skip,
@@ -93,8 +94,8 @@ export function useInView({
9394
{
9495
root,
9596
rootMargin,
97+
scrollMargin,
9698
threshold,
97-
// @ts-expect-error
9899
trackVisibility,
99100
delay,
100101
},
@@ -115,6 +116,7 @@ export function useInView({
115116
ref,
116117
root,
117118
rootMargin,
119+
scrollMargin,
118120
triggerOnce,
119121
skip,
120122
trackVisibility,

src/useOnInView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const useOnInView = <TElement extends Element>(
4949
threshold,
5050
root,
5151
rootMargin,
52+
scrollMargin,
5253
trackVisibility,
5354
delay,
5455
triggerOnce,
@@ -116,6 +117,7 @@ export const useOnInView = <TElement extends Element>(
116117
threshold,
117118
root,
118119
rootMargin,
120+
scrollMargin,
119121
trackVisibility,
120122
delay,
121123
} as IntersectionObserverInit,
@@ -140,6 +142,7 @@ export const useOnInView = <TElement extends Element>(
140142
Array.isArray(threshold) ? threshold.toString() : threshold,
141143
root,
142144
rootMargin,
145+
scrollMargin,
143146
trackVisibility,
144147
delay,
145148
triggerOnce,

0 commit comments

Comments
 (0)