Skip to content

Commit b7b8a32

Browse files
author
João Dias
committed
feat(useIntersection): adds new hook
1 parent dc12a95 commit b7b8a32

File tree

4 files changed

+218
-0
lines changed

4 files changed

+218
-0
lines changed
+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Please refer to the terms of the license agreement in the root of the project
3+
*
4+
* (c) 2024 Feedzai
5+
*/
6+
import React, { createRef, useCallback } from "react";
7+
import { toggleDataAttribute } from "src/functions";
8+
import { useIntersection } from "src/hooks";
9+
10+
describe("useIntersection", () => {
11+
beforeEach(() => {
12+
cy.viewport(1000, 1000);
13+
});
14+
15+
it("should be defined", () => {
16+
expect(useIntersection).to.be.a("function");
17+
});
18+
19+
it("should setup an IntersectionObserver targeting the ref element and using the options provided", () => {
20+
const TestComponent = () => {
21+
const targetRef = createRef<HTMLButtonElement>();
22+
const observerOptions = { root: null, threshold: 0.8 };
23+
const intersection = useIntersection(targetRef, observerOptions);
24+
25+
const isVisible = !!(intersection && intersection.isIntersecting);
26+
27+
return (
28+
<button
29+
ref={targetRef}
30+
data-testid="target"
31+
data-intersecting={toggleDataAttribute(isVisible)}
32+
>
33+
Target Element
34+
</button>
35+
);
36+
};
37+
38+
cy.mount(<TestComponent />);
39+
cy.findByTestId("target")
40+
.should("exist")
41+
.and("be.visible")
42+
.and("have.attr", "data-intersecting");
43+
});
44+
45+
it("should return null if a ref without a current value is provided", () => {
46+
const TestComponent = () => {
47+
const targetRef = createRef<HTMLElement>();
48+
const intersection = useIntersection(targetRef, { root: null, threshold: 1 });
49+
50+
return (
51+
<div>
52+
<div>{JSON.stringify(intersection)}</div>
53+
</div>
54+
);
55+
};
56+
57+
cy.mount(<TestComponent />);
58+
cy.contains("null").should("exist");
59+
});
60+
61+
it("should reset an intersectionObserverEntry when the ref changes", () => {
62+
const TestComponent = () => {
63+
const [key, setKey] = React.useState("805e7392-002c-4172-9afe-4f7f3eb1f94d");
64+
const targetRef = createRef<HTMLDivElement>();
65+
const intersection = useIntersection(targetRef, { root: null, threshold: 0.8 });
66+
const isVisible = !!(intersection && intersection.isIntersecting);
67+
68+
const handleOnClick = useCallback(() => {
69+
setKey("0709c8d7-63e8-46a7-b0b4-8fbd6debeac9");
70+
}, [setKey]);
71+
72+
return (
73+
<div>
74+
<div key={key} ref={targetRef} data-testid="target">
75+
Target Element
76+
</div>
77+
<button onClick={handleOnClick}>Change Ref</button>
78+
<div data-testid="intersection" data-intersecting={toggleDataAttribute(isVisible)}>
79+
{String(isVisible)}
80+
</div>
81+
</div>
82+
);
83+
};
84+
85+
cy.mount(<TestComponent />);
86+
87+
cy.findByTestId("intersection").should("have.attr", "data-intersecting");
88+
cy.findByRole("button").click();
89+
cy.findByTestId("intersection").should("not.have.attr", "data-intersecting");
90+
});
91+
92+
it("should return the first IntersectionObserverEntry when the IntersectionObserver registers an intersection", () => {
93+
const TestComponent = () => {
94+
const targetRef = createRef<HTMLButtonElement>();
95+
const intersection = useIntersection(targetRef, { root: null, threshold: 0.8 });
96+
const isVisible = !!(intersection && intersection.isIntersecting);
97+
98+
return (
99+
<div style={{ height: "200vh" }}>
100+
<button style={{ marginTop: "100vh" }} ref={targetRef} data-testid="target">
101+
Target Element
102+
</button>
103+
<div data-testid="intersection" data-intersecting={toggleDataAttribute(isVisible)} />
104+
</div>
105+
);
106+
};
107+
108+
cy.mount(<TestComponent />);
109+
110+
cy.findByTestId("intersection").should("not.have.attr", "data-intersecting");
111+
cy.findByTestId("target").scrollIntoView();
112+
cy.findByTestId("intersection").should("have.attr", "data-intersecting");
113+
});
114+
});

docs/docs/hooks/use-intersection.mdx

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
title: useIntersection
3+
---
4+
Tracks the changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
5+
6+
Uses the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and returns a [IntersectionObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).
7+
8+
## API
9+
10+
```typescript
11+
function useIntersection<GenericElement extends HTMLElement>(ref: RefObject<GenericElement>, { root, rootMargin, threshold }: IntersectionObserverInit): IntersectionObserverEntry | null;
12+
```
13+
14+
### Usage
15+
16+
```tsx
17+
import { useRef } from 'react';
18+
import { useIntersection } from '@feedzai/js-utilities/hooks';
19+
20+
const Demo = () => {
21+
const intersectionRef = React.useRef(null);
22+
const intersection = useIntersection(intersectionRef, {
23+
root: null,
24+
rootMargin: '0px',
25+
threshold: 1
26+
});
27+
28+
return (
29+
<div ref={intersectionRef}>
30+
{intersection && intersection.intersectionRatio < 1
31+
? 'Obscured'
32+
: 'Fully in view'}
33+
</div>
34+
);
35+
};
36+
```

src/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from "./use-container-query";
1010
export * from "./use-controlled-state";
1111
export * from "./use-copy-to-clipboard";
1212
export * from "./use-effect-once";
13+
export * from "./use-intersection";
1314
export * from "./use-lifecycle";
1415
export * from "./use-live-ref";
1516
export * from "./use-merge-refs";

src/hooks/use-intersection.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Please refer to the terms of the license agreement in the root of the project
3+
*
4+
* (c) 2024 Feedzai
5+
*/
6+
import { RefObject, useEffect, useState } from "react";
7+
import { isFunction } from "src/functions";
8+
9+
/**
10+
* Tracks the changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
11+
*
12+
* Uses the Intersection Observer API and returns a IntersectionObserverEntry.
13+
*
14+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API|IntersectionObserver}
15+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry|IntersectionObserverEntry}
16+
*
17+
* @example
18+
* ```tsx
19+
* import { useRef } from 'react';
20+
* import { useIntersection } from '@feedzai/js-utilities/hooks';
21+
*
22+
* const Demo = () => {
23+
* const intersectionRef = React.useRef(null);
24+
* const intersection = useIntersection(intersectionRef, {
25+
* root: null,
26+
* rootMargin: '0px',
27+
* threshold: 1
28+
* });
29+
*
30+
* return (
31+
* <div ref={intersectionRef}>
32+
* {intersection && intersection.intersectionRatio < 1
33+
* ? 'Obscured'
34+
* : 'Fully in view'}
35+
* </div>
36+
* );
37+
* };
38+
* ```
39+
*/
40+
export function useIntersection<GenericElement extends HTMLElement>(
41+
elementRef: RefObject<GenericElement>,
42+
{ root, rootMargin, threshold }: IntersectionObserverInit
43+
): IntersectionObserverEntry | null {
44+
const [intersectionObserverEntry, setIntersectionObserverEntry] =
45+
useState<IntersectionObserverEntry | null>(null);
46+
47+
useEffect(() => {
48+
const CURRENT_REF = elementRef.current;
49+
50+
if (CURRENT_REF && isFunction(IntersectionObserver)) {
51+
const handler = (entries: IntersectionObserverEntry[]) => {
52+
setIntersectionObserverEntry(entries[0]);
53+
};
54+
const observer = new IntersectionObserver(handler, { root, rootMargin, threshold });
55+
observer.observe(CURRENT_REF);
56+
57+
return () => {
58+
setIntersectionObserverEntry(null);
59+
observer.disconnect();
60+
};
61+
}
62+
return () => {};
63+
// eslint-disable-next-line react-hooks/exhaustive-deps
64+
}, [elementRef.current, root, rootMargin, threshold]);
65+
66+
return intersectionObserverEntry;
67+
}

0 commit comments

Comments
 (0)