diff --git a/components/image/.npmignore b/components/image/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/components/image/.npmignore @@ -0,0 +1 @@ +src diff --git a/components/image/README.md b/components/image/README.md new file mode 100644 index 00000000..37240e88 --- /dev/null +++ b/components/image/README.md @@ -0,0 +1,109 @@ +# `@byndyusoft-ui/image` +--- + +### Installation + +```sh + +npm i @byndyusoft-ui/image +# or +yarn add @byndyusoft-ui/image +``` + + +### Usage Image + +#### Basic usage + +```jsx +import React from 'react'; +import Image from '@byndyusoft-ui/image'; + +const App = () => { + return ( + Example Image + ); +}; + +export default App; +``` + +#### Fallback components + +```jsx +Example ImageLoading...} + errorFallback={
Error loading image
} +/> +``` + +#### Fallback src images + +```jsx +Example Image +``` + +#### Custom class names + + +The `rootFallbackClassName` parameter adds a class to the root element that displays the `fallback` content, +while the `rootErrorFallbackClassName` parameter adds a class to the root element that displays the `errorFallback` content. + +```jsx + Example ImageLoading...} + errorFallback={
Error loading image
} +/> +``` + +#### Lazy loading + +By default, `lazy` is set to `false`, which means the image will be loaded immediately. If `lazy` is set to `true`, +the image will only be loaded when it enters the viewport. This is achieved using the Intersection Observer pattern. +For correct lazy loading, it is also necessary to pass the `fallback` attribute, which will be placeholder as a placeholder until the image is loaded. + +By default, `lazy` is set to `false`, which means the image will be loaded immediately. If `lazy` is set to `true`, +the image will only be loaded when it enters the viewport. This is achieved using the Intersection Observer pattern. +For correct lazy loading, it is also necessary to pass the `fallback` attribute, which will serve as a placeholder until the image is loaded. + +```jsx +Example ImageLoading...} + lazy +/> +``` + +#### Settings Intersection Observer + +You can customize the options for the Intersection Observer using the `intersectionObserverSettings` attribute. + +```jsx +Example Image +``` + +> Modify the `intersectionObserverSettings` attribute with caution, as incorrect settings can disrupt the lazy loading +> mechanism and potentially lead to unexpected behavior. diff --git a/components/image/package.json b/components/image/package.json new file mode 100644 index 00000000..6eac0f4d --- /dev/null +++ b/components/image/package.json @@ -0,0 +1,37 @@ +{ + "name": "@byndyusoft-ui/image", + "version": "0.0.2", + "description": "Byndyusoft UI Image React Component", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "component", + "image" + ], + "author": "Gleb Fomin ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/components/image#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots components/image/src" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": ">=17", + "@byndyusoft-ui/use-intersection-observer": "^0.0.1" + } +} diff --git a/components/image/src/Image.tsx b/components/image/src/Image.tsx new file mode 100644 index 00000000..0f03000a --- /dev/null +++ b/components/image/src/Image.tsx @@ -0,0 +1,63 @@ +import React, { forwardRef, ReactElement, useImperativeHandle, useRef } from 'react'; +import { useImage } from './useImage'; +import type { IImageProps } from './Image.types'; + +const Image = forwardRef((props, forwardedRef) => { + const { + src, + alt = '', + lazy = false, + fallback, + fallbackSrc, + errorFallback, + errorFallbackSrc, + className, + rootFallbackClassName, + rootErrorFallbackClassName, + intersectionObserverSettings, + ...otherProps + } = props; + + const internalRef = useRef(null); + + const { isLoading, isError, setObserverTargetRef } = useImage({ + src, + lazy, + intersectionObserverSettings + }); + + const setRefs = (node: HTMLImageElement | null): void => { + internalRef.current = node; + setObserverTargetRef(node); + }; + + const renderImage = (imageSrc: string): JSX.Element => ( + {alt} + ); + + const renderFallback = (content: ReactElement, rootClassName?: string): JSX.Element => ( +
+ {content} +
+ ); + + useImperativeHandle(forwardedRef, () => internalRef.current as HTMLImageElement); + + if (fallback && isLoading) { + return renderFallback(fallback, rootFallbackClassName); + } + + if (errorFallback && isError) { + return renderFallback(errorFallback, rootErrorFallbackClassName); + } + + if (fallbackSrc && isLoading) return renderImage(fallbackSrc); + + if (errorFallbackSrc && isError) return renderImage(errorFallbackSrc); + + return renderImage(src); +}); + +Image.displayName = 'Image'; + +export default Image; diff --git a/components/image/src/Image.types.ts b/components/image/src/Image.types.ts new file mode 100644 index 00000000..1185d274 --- /dev/null +++ b/components/image/src/Image.types.ts @@ -0,0 +1,34 @@ +import { Dispatch, ImgHTMLAttributes, ReactElement, SetStateAction } from 'react'; +import { Callback } from '@byndyusoft-ui/types'; +import { IUseIntersectionObserverOptions } from '@byndyusoft-ui/use-intersection-observer'; + +export interface IImageProps extends ImgHTMLAttributes { + src: string; + className?: string; + rootFallbackClassName?: string; + rootErrorFallbackClassName?: string; + fallback?: ReactElement; + fallbackSrc?: string; + errorFallback?: ReactElement; + errorFallbackSrc?: string; + lazy?: boolean; + intersectionObserverSettings?: IUseIntersectionObserverOptions; +} + +type TSetState = Dispatch>; + +export interface IUseImageProps { + src: string; + lazy?: boolean; + intersectionObserverSettings?: IUseIntersectionObserverOptions; +} + +export interface IUseImageReturn { + setObserverTargetRef: TSetState; + isLoading: boolean; + isError: boolean; +} + +export type TLoadImageFunction = Callback< + [src: string, setIsLoading: TSetState, setIsError: TSetState] +>; diff --git a/components/image/src/__stories__/Image.stories.mdx b/components/image/src/__stories__/Image.stories.mdx new file mode 100644 index 00000000..32370ce3 --- /dev/null +++ b/components/image/src/__stories__/Image.stories.mdx @@ -0,0 +1,44 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; +import { Markdown } from '@storybook/blocks'; +import Readme from '../../README.md'; + + + +{Readme} + +# Image stories +### Lazy fallback skeleton + + + + + +### Lazy fallback src + + + + + +### Preload fallback skeleton + + + + + +### Preload fallback src + + + + + +### Error fallback + + + + + +### Error fallback src + + + + diff --git a/components/image/src/__stories__/Image.stories.module.css b/components/image/src/__stories__/Image.stories.module.css new file mode 100644 index 00000000..c2e0589c --- /dev/null +++ b/components/image/src/__stories__/Image.stories.module.css @@ -0,0 +1,59 @@ +.wrapper { + margin: auto; + display: flex; + gap: 12px; + flex-wrap: wrap; + width: 612px; + max-height: 600px; +} + +.skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; +} + +.errorFallback { + box-sizing: border-box; + border: 4px solid #7a0000; + background: #ffa9a9; + color: #b30000; + font-size: 32px; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; +} + +.refresh_btn { + position: fixed; + top: 15px; + left: 15px; + z-index: 999; + cursor: pointer; + background: yellowgreen; + border-radius: 50%; + width: 60px; + height: 60px; + border: none; + box-shadow: 0 0 5px 2px rgba(0, 0, 0, 0.38); +} + +.refresh_btn:hover { + scale: 1.03; +} + +.refresh_btn:active { + scale: 1.09; + opacity: 0.5; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/components/image/src/__stories__/Image.stories.tsx b/components/image/src/__stories__/Image.stories.tsx new file mode 100644 index 00000000..5cc2ab5a --- /dev/null +++ b/components/image/src/__stories__/Image.stories.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import type { StoryObj } from '@storybook/react'; +import Image from '../Image'; +import { IImageProps } from '../Image.types'; +import imagePlaceholder from './image-placeholder.png'; +import cls from './Image.stories.module.css'; + +interface ISkeletonProps { + width: number | string; + height: number | string; +} + +interface ITemplateProps { + imageProps: Partial; + mockLoadImageIds: () => Array; +} + +const generateRandomArray = (maxNumber: number, length: number): number[] => { + const randomArray = []; + for (let i = 0; i < length; i++) { + const randomNumber = Math.floor(Math.random() * maxNumber) + 1; + randomArray.push(randomNumber); + } + return randomArray; +}; + +const FallbackSkeleton = ({ width, height }: ISkeletonProps): JSX.Element => ( +
+); + +const ErrorFallbackComponent = ({ width, height }: ISkeletonProps): JSX.Element => ( +
+ Error Fallback +
+); + +const Template = ({ mockLoadImageIds, imageProps }: ITemplateProps): JSX.Element => { + const [idList, setIdList] = useState(mockLoadImageIds() || []); + + const onRefreshIdList = () => { + if (!mockLoadImageIds) return; + setIdList(mockLoadImageIds); + }; + + return ( +
+ + {idList?.map((id, index) => ( + + ))} +
+ ); +}; + +export const LazyFallbackSkeleton: StoryObj = { + args: { + mockLoadImageIds: () => generateRandomArray(826, 50), + imageProps: { + width: 300, + height: 300, + lazy: true, + fallback: , + errorFallback: + } + } +}; + +export const LazyFallbackSrc: StoryObj = { + args: { + mockLoadImageIds: () => generateRandomArray(826, 50), + imageProps: { + width: 300, + height: 300, + lazy: true, + fallbackSrc: imagePlaceholder, + errorFallback: + } + } +}; + +export const PreloadFallbackSkeleton: StoryObj = { + args: { + mockLoadImageIds: () => generateRandomArray(826, 30), + imageProps: { + width: 300, + height: 300, + lazy: false, + fallback: , + errorFallback: + } + } +}; + +export const PreloadFallbackSrc: StoryObj = { + args: { + mockLoadImageIds: () => generateRandomArray(826, 30), + imageProps: { + width: 300, + height: 300, + lazy: false, + fallbackSrc: imagePlaceholder, + errorFallback: + } + } +}; + +export const ErrorFallback: StoryObj = { + args: { + mockLoadImageIds: () => [-1, -2, -3, -4, -5, -6], + imageProps: { + width: 300, + height: 300, + lazy: true, + fallback: , + errorFallback: + } + } +}; + +export const ErrorFallbackSrc: StoryObj = { + args: { + mockLoadImageIds: () => [-1, -2, -3, -4, -5, -6], + imageProps: { + width: 300, + height: 300, + lazy: true, + fallback: , + errorFallbackSrc: imagePlaceholder + } + } +}; + +export default { + title: 'components/Image', + component: Template +}; diff --git a/components/image/src/__stories__/image-placeholder.png b/components/image/src/__stories__/image-placeholder.png new file mode 100644 index 00000000..66df081e Binary files /dev/null and b/components/image/src/__stories__/image-placeholder.png differ diff --git a/components/image/src/__tests__/Image.tests.tsx b/components/image/src/__tests__/Image.tests.tsx new file mode 100644 index 00000000..3cdc69f5 --- /dev/null +++ b/components/image/src/__tests__/Image.tests.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, RenderResult, screen } from '@testing-library/react'; +import { resetIntersectionMocking, setupIntersectionMocking } from './image.mocks'; +import Image from '../Image'; +import { IImageProps, IUseImageReturn } from '../Image.types'; +import { useImage } from '../useImage'; + +jest.mock('../useImage'); + +interface ISetupProps { + imageProps?: Partial; + useImageReturn?: Partial; +} + +const hookImageReturnMock = ({ + isLoading = false, + isError = false, + setObserverTargetRef = jest.fn() +}: Partial = {}): void => { + (useImage as jest.Mock).mockReturnValue({ + isLoading, + isError, + setObserverTargetRef + }); +}; + +const setup = (props: ISetupProps = {}): RenderResult => { + const { imageProps, useImageReturn } = props; + const requiredProps: IImageProps = { + src: 'test-image.jpg', + alt: 'Test Image' + }; + + hookImageReturnMock(useImageReturn); + + return render(); +}; + +describe('Image', () => { + beforeEach(() => { + setupIntersectionMocking(jest.fn); + }); + afterEach(() => { + resetIntersectionMocking(); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + test('renders the image correctly', () => { + setup(); + const imgElement = screen.getByAltText('Test Image'); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).toHaveAttribute('src', 'test-image.jpg'); + expect(imgElement).toHaveAttribute('alt', 'Test Image'); + }); + + test('renders fallback content when loading', () => { + setup({ + imageProps: { fallback:
Loading...
}, + useImageReturn: { isLoading: true } + }); + + const fallbackElement = screen.getByText('Loading...'); + expect(fallbackElement).toBeInTheDocument(); + }); + + test('renders fallbackSrc image when loading', () => { + setup({ + imageProps: { fallbackSrc: 'fallback-image.jpg' }, + useImageReturn: { isLoading: true } + }); + const fallbackImgElement = screen.getByAltText('Test Image'); + expect(fallbackImgElement).toBeInTheDocument(); + expect(fallbackImgElement).toHaveAttribute('src', 'fallback-image.jpg'); + }); + + test('renders correctly when lazy is disabled', () => { + setup({ imageProps: { lazy: false } }); + const imgElement = screen.getByAltText('Test Image'); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).toHaveAttribute('src', 'test-image.jpg'); + }); + + test('renders error fallback content when image fails to load', () => { + setup({ + imageProps: { errorFallback:
Error loading image
, lazy: false }, + useImageReturn: { isError: true } + }); + const errorElement = screen.getByText('Error loading image'); + expect(errorElement).toBeInTheDocument(); + }); +}); diff --git a/components/image/src/__tests__/image.mocks.ts b/components/image/src/__tests__/image.mocks.ts new file mode 100644 index 00000000..e5561c5d --- /dev/null +++ b/components/image/src/__tests__/image.mocks.ts @@ -0,0 +1,47 @@ +interface IObserverData { + callback: IntersectionObserverCallback; + elements: Set; + created: number; +} + +const observersMap = new Map(); + +export function setupIntersectionMocking(mockFn: typeof jest.fn): void { + global.IntersectionObserver = mockFn((cb, options = {}) => { + const observerData: IObserverData = { + callback: cb, + elements: new Set(), + created: Date.now() + }; + const instance: IntersectionObserver = { + thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0], + root: options.root ?? null, + rootMargin: options.rootMargin ?? '', + observe: mockFn((element: Element) => { + observerData.elements.add(element); + }), + unobserve: mockFn((element: Element) => { + observerData.elements.delete(element); + }), + disconnect: mockFn(() => { + observersMap.delete(instance); + }), + takeRecords: mockFn() + }; + + observersMap.set(instance, observerData); + + return instance; + }); +} + +export function resetIntersectionMocking(): void { + if ( + global.IntersectionObserver && + 'mockClear' in global.IntersectionObserver && + typeof global.IntersectionObserver.mockClear === 'function' + ) { + global.IntersectionObserver.mockClear(); + } + observersMap.clear(); +} diff --git a/components/image/src/index.ts b/components/image/src/index.ts new file mode 100644 index 00000000..c3f490df --- /dev/null +++ b/components/image/src/index.ts @@ -0,0 +1,5 @@ +import Image from './Image'; + +export { type IImageProps } from './Image.types'; +export { useImage } from './useImage'; +export default Image; diff --git a/components/image/src/useImage.ts b/components/image/src/useImage.ts new file mode 100644 index 00000000..e677784d --- /dev/null +++ b/components/image/src/useImage.ts @@ -0,0 +1,43 @@ +import { useLayoutEffect, useState } from 'react'; +import useIntersectionObserver from '@byndyusoft-ui/use-intersection-observer'; +import { IUseImageProps, IUseImageReturn, TLoadImageFunction } from './Image.types'; + +const loadImage: TLoadImageFunction = (src, setIsLoading, setIsError) => { + const img = new Image(); + img.src = src; + img.onload = () => { + setIsLoading(false); + }; + img.onerror = () => { + setIsLoading(false); + setIsError(true); + }; +}; + +export const useImage = ({ src, lazy, intersectionObserverSettings }: IUseImageProps): IUseImageReturn => { + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + const [setObserverTargetRef] = useIntersectionObserver({ + skip: !lazy, + triggerOnce: true, + onChange: isIntersecting => { + if (isIntersecting) { + loadImage(src, setIsLoading, setIsError); + } + }, + ...intersectionObserverSettings + }); + + useLayoutEffect(() => { + if (!lazy) { + loadImage(src, setIsLoading, setIsError); + } + }, [src, lazy]); + + return { + setObserverTargetRef, + isLoading, + isError + }; +}; diff --git a/components/image/tsconfig.build.json b/components/image/tsconfig.build.json new file mode 100644 index 00000000..b4b36060 --- /dev/null +++ b/components/image/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/*.tests.ts"] +} diff --git a/components/image/tsconfig.json b/components/image/tsconfig.json new file mode 100644 index 00000000..567aaea9 --- /dev/null +++ b/components/image/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": [ + "../../types.d.ts", + "src" + ] +} diff --git a/hooks/use-intersection-observer/.npmignore b/hooks/use-intersection-observer/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/hooks/use-intersection-observer/.npmignore @@ -0,0 +1 @@ +src diff --git a/hooks/use-intersection-observer/README.md b/hooks/use-intersection-observer/README.md new file mode 100644 index 00000000..61a91547 --- /dev/null +++ b/hooks/use-intersection-observer/README.md @@ -0,0 +1,84 @@ +# `@byndyusoft-ui/use-intersection-observer` +--- +> A React hook that ... + +### Installation + +``` +npm i @byndyusoft-ui/use-intersection-observer +``` + +### Usage `useIntersectionObserver` hook +```js +// Use object destructuring, so you don't need to remember the exact order +const { ref, isIntersecting, entry } = useIntersectionObserver(options); + +// Or array destructuring, making it easy to customize the field names +const [ref, isIntersecting, entry] = useIntersectionObserver(options); +``` + +#### Default +```jsx +import React from "react"; +import { useIntersectionObserver } from "@byndyusoft-ui/use-intersection-observer"; + +const Component = () => { + const { ref, isIntersecting, entry } = useIntersectionObserver(); + + return ( +
+
+ {`isIntersecting: ${isIntersecting}`} +
+
+ ); +}; +``` + +### Usage `useIntersectionObserver` with options + +```jsx +import React from "react"; +import { useIntersectionObserver } from "@byndyusoft-ui/use-intersection-observer"; + +const Component = () => { + const containerRef = useRef(null); + + const { ref, isIntersecting, entry } = useIntersectionObserver({ + root: scrollContainerRef.current, + rootMargin: "10px", + threshold: 0.5, + triggerOnce: false, + skip: false, + isIntersectingInitial: false, + isIntersectingFallback: false, + trackVisibility: false, // experimental + delay: 1500, // experimental + onChange: (isIntersecting, entry) => console.log(isIntersecting, entry), + }); + + return ( +
+
+ {`isIntersecting: ${isIntersecting}`} +
+
+ ); +}; +``` +### Options + +Provide these as the options argument in the `useIntersectionObserver ` hook. + +| Name | Type | Default | Description | +|------------------------------------|-----------------------------------| ----------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **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. | +| **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%"`. | +| **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. | +| **onChange** | `(isIntersecting, entry) => void` | `undefined` | Call this function whenever the `isIntersecting` state changes. It will receive the `isIntersecting` boolean, alongside the current `IntersectionObserverEntry`. | +| **trackVisibility** (experimental) | `boolean` | `false` | A boolean indicating whether this Intersection Observer will track visibility changes on the target. | +| **delay** (experimental) | `number` | `undefined` | A number indicating the minimum delay in milliseconds between notifications from this observer for a given target. This must be set to at least `100` if `trackVisibility` is `true`. | +| **skip** | `boolean` | `false` | Skip creating the IntersectionObserver. You can use this to enable and disable the observer as needed. If `skip` is set while `isIntersecting`, the current state will still be kept. | +| **triggerOnce** | `boolean` | `false` | Only trigger the observer once. | +| **isIntersectingInitial** | `boolean` | `false` | Set the initial value of the `isIntersecting` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. | +| **isIntersectingFallback** | `boolean` | `undefined` | If the `IntersectionObserver` API isn't available in the client, the default behavior is to throw an Error. You can set a specific fallback behavior, and the `isIntersecting` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` | diff --git a/hooks/use-intersection-observer/package.json b/hooks/use-intersection-observer/package.json new file mode 100644 index 00000000..5f9af423 --- /dev/null +++ b/hooks/use-intersection-observer/package.json @@ -0,0 +1,33 @@ +{ + "name": "@byndyusoft-ui/use-intersection-observer", + "version": "0.0.1", + "description": "Byndyusoft UI React Hook", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "hook", + "intersection-observer" + ], + "author": "Gleb Fomin ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/hooks/use-intersection-observer#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots hooks/use-intersection-observer/src" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.css b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.css new file mode 100644 index 00000000..bae2056b --- /dev/null +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.css @@ -0,0 +1,112 @@ +.wrapper { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: Arial, sans-serif; + font-size: 18px; + text-align: center; + height: calc(100vh - 32px); + overflow: hidden; +} + +.status-bar { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + width: 100%; + background: white; + border-bottom: 1px solid #ccc; + padding: 10px; +} + +.status-bar-title { + margin: 0; + font-size: 24px; + font-weight: bold; +} + +.status-bar-warning { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.warning { + color: orangered; + font-size: 16px; +} + +.status-bar-in-view { + margin: 8px; + display: flex; + align-items: center; + gap: 12px; +} + +.status-bar-entry { + margin: 0 10px; +} + +.status-bar-entry pre { + background: #f5f5f5; + border: 1px solid #ccc; + padding: 10px; + border-radius: 4px; + max-width: 400px; + overflow-x: auto; +} + +.scroll-container { + position: relative; + width: 100%; + height: calc(100vh - 82px); + overflow-y: auto; + overflow-x: hidden; +} + +.scroll-down, +.scroll-up { + height: 100vh; + background: lightgray; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + border: 1px solid #ccc; +} + +.observed-element { + height: 400px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + border: 1px solid #ccc; +} + +.status-label { + padding: 1px 8px; + border-radius: 4px; +} + +.in-view { + background: lightgreen; +} + +.out-of-view { + background: lightcoral; +} + +.root-margin-visual { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + border: 2px dashed red; + background: yellow; +} diff --git a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx new file mode 100644 index 00000000..f1409db9 --- /dev/null +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx @@ -0,0 +1,83 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; +import { Markdown } from '@storybook/blocks'; +import Readme from '../../README.md'; + + + +{Readme} + +# useIntersectionObserver stories +### 1. Default settings +```jsx +const { ref, isIntersecting, entry } = useIntersectionObserver(); +``` + + + + +### 2. Threshold +```jsx +const { ref, isIntersecting, entry } = useIntersectionObserver({ + threshold: 0.5, +}); +``` + + + + +### 3. Root margin +```jsx +const { ref, isIntersecting, entry } = useIntersectionObserver({ + rootMargin: "50px", +}); +``` + + + + +### 4. Skip +```jsx +const { ref, isIntersecting, entry } = useIntersectionObserver({ + skip: true, +}); +``` + + + + +### 5. Trigger once +```jsx +const { ref, isIntersecting, entry } = useIntersectionObserver({ + triggerOnce: true, +}); +``` + + + + +### 6. onChange +```jsx +const { ref } = useIntersectionObserver({ + onChange: (isIntersecting, entry) => { + console.log('useIntersectionObserver onChange', isIntersecting, entry) + if (isIntersecting) { + alert(`isIntersecting: ${isIntersecting}`); + } + } +}); +``` + + + + +### 6. Delay +```jsx +const { ref, isIntersecting, entry } = useIntersectionObserver({ + delay: 1000, // experimental +}); +``` + + + + + diff --git a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx new file mode 100644 index 00000000..eb104ae0 --- /dev/null +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx @@ -0,0 +1,160 @@ +import React, { useRef } from 'react'; +import type { StoryObj } from '@storybook/react'; +import useIntersectionObserver from '../useIntersectionObserver'; +import './useIntersectionObserver.stories.css'; + +type ITemplateProps = { + title: string; + options: any; + isExperimental?: boolean; +}; + +function convertEntryToString(entry?: IntersectionObserverEntry) { + if (!entry) return ''; + return ` + time: ${entry.time} + rootBounds: ${JSON.stringify(entry.rootBounds)} + boundingClientRect: ${JSON.stringify(entry.boundingClientRect)} + intersectionRect: ${JSON.stringify(entry.intersectionRect)} + isIntersecting: ${entry.isIntersecting} + intersectionRatio: ${entry.intersectionRatio} + target: ${entry.target.outerHTML} + `; +} + +const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Element => { + const scrollContainerRef = useRef(null); + const { ref, isIntersecting, entry } = useIntersectionObserver({ + root: scrollContainerRef.current, + onChange: (isIntersecting, entry) => console.log(isIntersecting, entry), + ...options + }); + + return ( +
+
+

{title}

+ {isExperimental && ( +
+

Warning: The {title} feature may not work in all browsers.

+ + Read more. + +
+ )} +
+ isIntersecting: + + {String(isIntersecting)} + + Entry: + +
+
+
+
Scroll down
+ {!!options?.rootMargin && ( +
+ {options.rootMargin} +
+ )} +
+ {isIntersecting ? 'In View' : 'Out of View'} +
+ {!!options?.rootMargin && ( +
+ {options.rootMargin} +
+ )} +
Scroll up
+
+
+ ); +}; + +export const Default: StoryObj = { + args: { + options: {}, + title: 'Default Settings' + } +}; + +export const Threshold: StoryObj = { + args: { + options: { + threshold: 0.5 + }, + title: 'Threshold: 0.5' + } +}; + +export const RootMargin: StoryObj = { + args: { + options: { + rootMargin: '50px' + }, + title: 'Root Margin: 50px' + } +}; + +export const Skip: StoryObj = { + args: { + options: { + skip: true + }, + title: 'skip: true' + } +}; + +export const TriggerOnce: StoryObj = { + args: { + options: { + triggerOnce: true + }, + title: 'Trigger Once' + } +}; + +export const OnChange: StoryObj = { + args: { + options: { + onChange: (isIntersecting: boolean, entry: IntersectionObserverEntry) => { + console.log('useIntersectionObserver onChange', isIntersecting, entry); + if (isIntersecting) { + alert(`isIntersecting: ${isIntersecting}`); + } + } + }, + title: 'OnChange' + } +}; + +export const Delay: StoryObj = { + args: { + options: { + delay: 1000 + }, + title: 'Delay', + isExperimental: true + } +}; + +export default { + title: 'hooks/useIntersectionObserver', + component: Template, + argTypes: { + options: { + control: 'object' + }, + title: { + control: 'text' + }, + isExperimental: { + control: 'boolean' + } + } +}; diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts new file mode 100644 index 00000000..8277cdd3 --- /dev/null +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts @@ -0,0 +1,134 @@ +import { act } from '@testing-library/react-hooks'; + +interface IObserverData { + callback: IntersectionObserverCallback; + elements: Set; + created: number; +} + +let isMocking = false; + +const observersMap = new Map(); + +function warnOnMissingSetup() { + if (isMocking) return; + console.error('Intersection Observer was not configured to handle mocking'); +} + +export function setupIntersectionMocking(mockFn: typeof jest.fn): void { + global.IntersectionObserver = mockFn((cb, options = {}) => { + const observerData: IObserverData = { + callback: cb, + elements: new Set(), + created: Date.now() + }; + const instance: IntersectionObserver = { + thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0], + root: options.root ?? null, + rootMargin: options.rootMargin ?? '', + observe: mockFn((element: Element) => { + observerData.elements.add(element); + }), + unobserve: mockFn((element: Element) => { + observerData.elements.delete(element); + }), + disconnect: mockFn(() => { + observersMap.delete(instance); + }), + takeRecords: mockFn() + }; + + observersMap.set(instance, observerData); + + return instance; + }); + + isMocking = true; +} + +export function resetIntersectionMocking(): void { + if ( + global.IntersectionObserver && + 'mockClear' in global.IntersectionObserver && + typeof global.IntersectionObserver.mockClear === 'function' + ) { + global.IntersectionObserver.mockClear(); + } + observersMap.clear(); +} + +function triggerIntersection( + elements: Element[], + trigger: boolean | number, + observer: IntersectionObserver, + observerData: IObserverData +): void { + const entries: IntersectionObserverEntry[] = []; + + const isIntersecting = + typeof trigger === 'number' ? observer.thresholds.some(threshold => trigger >= threshold) : trigger; + + let ratio: number; + + if (typeof trigger === 'number') { + const intersectedThresholds = observer.thresholds.filter(threshold => trigger >= threshold); + ratio = intersectedThresholds.length > 0 ? intersectedThresholds[intersectedThresholds.length - 1] : 0; + } else { + ratio = trigger ? 1 : 0; + } + + for (const element of elements) { + entries.push({ + boundingClientRect: element.getBoundingClientRect(), + intersectionRatio: ratio, + intersectionRect: isIntersecting + ? element.getBoundingClientRect() + : { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON() {} + }, + isIntersecting, + rootBounds: observer.root instanceof Element ? observer.root?.getBoundingClientRect() : null, + target: element, + time: Date.now() - observerData.created + }); + } + act(() => observerData.callback(entries, observer)); +} + +export function mockAllIsIntersecting(isIntersecting: boolean | number): void { + warnOnMissingSetup(); + for (const [observer, observerData] of observersMap) { + triggerIntersection(Array.from(observerData.elements), isIntersecting, observer, observerData); + } +} + +export function intersectionMockInstance(element: Element): IntersectionObserver { + warnOnMissingSetup(); + for (const [observer, observerData] of observersMap) { + if (observerData.elements.has(element)) { + return observer; + } + } + + throw new Error('Failed to find IntersectionObserver for element. Is it being observed?'); +} + +export function mockIsIntersecting(element: Element, isIntersecting: boolean | number): void { + warnOnMissingSetup(); + const observer = intersectionMockInstance(element); + if (!observer) { + throw new Error('No IntersectionObserver instance found for element. Is it still mounted in the DOM?'); + } + const observerData = observersMap.get(observer); + if (observerData) { + triggerIntersection([element], isIntersecting, observer, observerData); + } +} diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx new file mode 100644 index 00000000..aa90bbd3 --- /dev/null +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx @@ -0,0 +1,208 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { IUseIntersectionObserverOptions } from '../useIntersectionObserver.types'; +import { + intersectionMockInstance, + mockAllIsIntersecting, + mockIsIntersecting, + resetIntersectionMocking, + setupIntersectionMocking +} from './useIntersectionObserver.mocks'; +import useIntersectionObserver from '../useIntersectionObserver'; + +interface IComponentProps { + options?: IUseIntersectionObserverOptions; + unmount?: boolean; +} + +const HookComponent = ({ options, unmount }: IComponentProps) => { + const { ref, isIntersecting } = useIntersectionObserver(options); + return ( +
+ {isIntersecting.toString()} +
+ ); +}; + +const LazyHookComponent = ({ options, unmount }: IComponentProps) => { + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + setIsLoading(false); + }, []); + + const { ref, isIntersecting } = useIntersectionObserver(options); + + if (isLoading) return
Loading
; + + return ( +
+ {isIntersecting.toString()} +
+ ); +}; + +const HookComponentWithEntry = ({ options, unmount }: IComponentProps) => { + const { ref, entry } = useIntersectionObserver(options); + return ( +
+ {entry && Object.entries(entry).map(([key, value]) => `${key}: ${value}`)} +
+ ); +}; + +const setupHookComponent = (props: IComponentProps = {}) => render(); +const setupLazyHookComponent = (props: IComponentProps = {}) => render(); +const setupHookComponentWithEntry = (props: IComponentProps = {}) => render(); + +describe('hooks/useIntersectionObserver', () => { + beforeEach(() => { + setupIntersectionMocking(jest.fn); + }); + afterEach(() => { + resetIntersectionMocking(); + }); + + test('should create a hook', () => { + const { getByTestId } = setupHookComponent(); + const wrapper = getByTestId('wrapper'); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); + }); + + test('should create a hook with array threshold', () => { + const { getByTestId } = setupHookComponent({ + options: { threshold: [0.1, 1] } + }); + const wrapper = getByTestId('wrapper'); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); + }); + + test('should create a lazy hook', () => { + const { getByTestId } = setupLazyHookComponent(); + const wrapper = getByTestId('wrapper'); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); + }); + + test('should create a hook isIntersecting', () => { + const { getByText } = setupHookComponent(); + mockAllIsIntersecting(true); + + expect(getByText('true')).toBeInTheDocument(); + }); + + test('should mock thresholds', () => { + const { getByText } = setupHookComponent({ + options: { threshold: [0.5, 1] } + }); + mockAllIsIntersecting(0); + expect(getByText('false')).toBeInTheDocument(); + + mockAllIsIntersecting(0.5); + expect(getByText('true')).toBeInTheDocument(); + + mockAllIsIntersecting(1); + expect(getByText('true')).toBeInTheDocument(); + }); + + test('should create a hook with isIntersectingInitial', () => { + const { getByText } = setupHookComponent({ + options: { isIntersectingInitial: true } + }); + expect(getByText('true')).toBeInTheDocument(); + + mockAllIsIntersecting(false); + expect(getByText('false')).toBeInTheDocument(); + }); + + test('should trigger a hook leaving view', () => { + const { getByText } = setupHookComponent(); + mockAllIsIntersecting(true); + mockAllIsIntersecting(false); + + expect(getByText('false')).toBeInTheDocument(); + }); + + test('should respect trigger once', () => { + const { getByText } = setupHookComponent({ + options: { triggerOnce: true } + }); + mockAllIsIntersecting(true); + mockAllIsIntersecting(false); + + expect(getByText('true')).toBeInTheDocument(); + }); + + test('should trigger onChange', () => { + const onChange = jest.fn(); + setupHookComponent({ options: { onChange } }); + + mockAllIsIntersecting(true); + expect(onChange).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ intersectionRatio: 1, isIntersecting: true }) + ); + + mockAllIsIntersecting(false); + expect(onChange).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ intersectionRatio: 0, isIntersecting: false }) + ); + }); + + test('should respect skip', () => { + const { getByText, rerender } = setupHookComponent({ options: { skip: true } }); + mockAllIsIntersecting(false); + expect(getByText('false')).toBeInTheDocument(); + + rerender(); + mockAllIsIntersecting(true); + expect(getByText('true')).toBeInTheDocument(); + }); + + test('should not reset current state if changing skip', () => { + const { getByText, rerender } = setupHookComponent({ options: { skip: false } }); + mockAllIsIntersecting(true); + rerender(); + + expect(getByText('true')).toBeInTheDocument(); + }); + + test('should unmount the hook', () => { + const { unmount, getByTestId } = setupHookComponent(); + const wrapper = getByTestId('wrapper'); + const instance = intersectionMockInstance(wrapper); + unmount(); + + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); + }); + + test('isIntersecting should be false when component is unmounted', () => { + const { rerender, getByText } = setupHookComponent({ unmount: false }); + mockAllIsIntersecting(true); + expect(getByText('true')).toBeInTheDocument(); + + rerender(); + expect(getByText('false')).toBeInTheDocument(); + }); + + test('should handle trackVisibility', () => { + setupHookComponent({ options: { trackVisibility: true, delay: 100 } }); + mockAllIsIntersecting(true); + }); + + test('should set intersection ratio as the largest threshold smaller than trigger', () => { + const { getByTestId, getByText } = setupHookComponentWithEntry({ + options: { threshold: [0, 0.25, 0.5, 0.75, 1] } + }); + const wrapper = getByTestId('wrapper'); + mockIsIntersecting(wrapper, 0.5); + + expect(getByText(/intersectionRatio: 0.5/)).toBeInTheDocument(); + }); +}); diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts new file mode 100644 index 00000000..aca70fd5 --- /dev/null +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -0,0 +1,66 @@ +import { optionsToId, observe } from '../utilities/useIntersectionObserver.utilities'; +import { + intersectionMockInstance, + mockIsIntersecting, + resetIntersectionMocking, + setupIntersectionMocking +} from './useIntersectionObserver.mocks'; + +describe('hooks/useIntersectionObserver/utilities', () => { + beforeEach(() => { + setupIntersectionMocking(jest.fn); + }); + afterEach(() => { + resetIntersectionMocking(); + }); + + test('should be able to use observe', () => { + const element = document.createElement('div'); + const callback = jest.fn(); + const unmount = observe({ + element, + callback, + options: { threshold: 0.1 }, + isIntersectingFallback: false + }); + + mockIsIntersecting(element, true); + expect(callback).toHaveBeenCalled(); + + unmount(); + expect(() => intersectionMockInstance(element)).toThrow( + 'Failed to find IntersectionObserver for element. Is it being observed?' + ); + }); + + test('should convert options to id', () => { + expect( + optionsToId({ + root: document.createElement('div'), + rootMargin: '10px 10px', + threshold: [0, 1] + }) + ).toBe('root_1,rootMargin_10px 10px,threshold_0,1'); + + expect( + optionsToId({ + root: null, + rootMargin: '10px 10px', + threshold: 1 + }) + ).toBe('root_0,rootMargin_10px 10px,threshold_1'); + + expect( + optionsToId({ + threshold: 0, + // @ts-expect-error + trackVisibility: true, + delay: 500 + }) + ).toBe('delay_500,threshold_0,trackVisibility_true'); + + expect(optionsToId({ threshold: 0 })).toBe('threshold_0'); + + expect(optionsToId({ threshold: [0, 0.5, 1] })).toBe('threshold_0,0.5,1'); + }); +}); diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts new file mode 100644 index 00000000..fcd200c0 --- /dev/null +++ b/hooks/use-intersection-observer/src/index.ts @@ -0,0 +1,5 @@ +import useIntersectionObserver from './useIntersectionObserver'; + +export type { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from './useIntersectionObserver.types'; +export { useIntersectionObserver }; +export default useIntersectionObserver; diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts new file mode 100644 index 00000000..182b3c39 --- /dev/null +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react'; +import { observe } from './utilities/useIntersectionObserver.utilities'; +import type { + IUseIntersectionObserverReturn, + IUseIntersectionObserverOptions, + IUseIntersectionObserverTuple, + IUseIntersectionObserverObject +} from './useIntersectionObserver.types'; + +export default function useIntersectionObserver({ + threshold, + delay, + trackVisibility, + rootMargin, + root, + triggerOnce, + skip, + isIntersectingInitial, + isIntersectingFallback, + onChange +}: IUseIntersectionObserverOptions = {}): IUseIntersectionObserverReturn { + const [ref, setRef] = useState(null); + const [isIntersecting, setIsIntersecting] = useState(Boolean(isIntersectingInitial)); + const [entry, setEntry] = useState(undefined); + + const callback = useRef(); + const previousEntryTarget = useRef(); + + callback.current = onChange; + + useEffect(() => { + if (skip || !ref) return; + + let unobserve: (() => void) | undefined; + + unobserve = observe({ + element: ref, + options: { + root, + rootMargin, + threshold, + // @ts-expect-error experimental v2 api + trackVisibility, + delay + }, + callback: (isIntersectingValue, entryValue) => { + setIsIntersecting(isIntersectingValue); + setEntry(entryValue); + + if (callback.current) callback.current(isIntersectingValue, entryValue); + + if (entryValue.isIntersecting && triggerOnce && unobserve) { + unobserve(); + unobserve = undefined; + } + }, + isIntersectingFallback + }); + + return () => { + unobserve?.(); + }; + }, [ + Array.isArray(threshold) ? threshold.toString() : threshold, + ref, + root, + rootMargin, + triggerOnce, + skip, + trackVisibility, + isIntersectingFallback, + delay + ]); + + const entryTarget = entry?.target; + + if (!ref && entryTarget && !triggerOnce && !skip && previousEntryTarget.current !== entryTarget) { + previousEntryTarget.current = entryTarget; + setIsIntersecting(Boolean(isIntersectingInitial)); + setEntry(undefined); + } + + const tupleReturn: IUseIntersectionObserverTuple = [setRef, isIntersecting, entry]; + const objectReturn: IUseIntersectionObserverObject = { + ref: setRef, + isIntersecting, + entry + }; + + return Object.assign(tupleReturn, objectReturn); +} diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts new file mode 100644 index 00000000..8184c344 --- /dev/null +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -0,0 +1,51 @@ +import { Dispatch, SetStateAction } from 'react'; + +export type TObserverInstanceCallback = (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; + +export interface IObserveOptions { + element: Element; + callback: TObserverInstanceCallback; + options: IntersectionObserverInit; + isIntersectingFallback?: boolean; +} + +export interface IObserverItem { + id: string; + observer: IntersectionObserver; + elements: Map>; +} + +export interface IUseIntersectionObserverOptions extends IntersectionObserverInit { + /** 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.*/ + root?: Element | Document | null; + /** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */ + rootMargin?: string; + /** 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. */ + threshold?: number | number[]; + /** Only trigger the isIntersecting callback once */ + triggerOnce?: boolean; + /** Skip assigning the observer to the `ref` */ + skip?: boolean; + /** Set the initial value of the `isIntersecting` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */ + isIntersectingInitial?: boolean; + /** Fallback to this isIntersecting state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */ + isIntersectingFallback?: boolean; + /** IntersectionObserver v2 - Track the actual visibility of the element */ + trackVisibility?: boolean; + /** IntersectionObserver v2 - Set a minimum delay between notifications */ + delay?: number; + /** Call this function whenever the in view state changes */ + onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; +} + +type TSetRef = Dispatch>; + +export type IUseIntersectionObserverTuple = [TSetRef, boolean, IntersectionObserverEntry | undefined]; + +export type IUseIntersectionObserverObject = { + ref: TSetRef; + isIntersecting: boolean; + entry?: IntersectionObserverEntry; +}; + +export type IUseIntersectionObserverReturn = IUseIntersectionObserverTuple & IUseIntersectionObserverObject; diff --git a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts new file mode 100644 index 00000000..7e52261d --- /dev/null +++ b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts @@ -0,0 +1,118 @@ +import type { IObserveOptions, IObserverItem, TObserverInstanceCallback } from '../useIntersectionObserver.types'; + +const observerMap = new Map(); + +const rootIdsWeakMap: WeakMap = new WeakMap(); + +let rootId = 0; + +/** + * Generate a unique ID for the root element + */ +export function getRootId(root: IntersectionObserverInit['root']): string | undefined { + if (!root) return '0'; + + if (rootIdsWeakMap.has(root)) return rootIdsWeakMap.get(root); + + rootId += 1; + rootIdsWeakMap.set(root, rootId.toString()); + + return rootIdsWeakMap.get(root); +} + +/** + * Convert the options to a string Id, based on the values. + * Ensures we can reuse the same observer when observing elements with the same options. + */ +export function optionsToId(options: IntersectionObserverInit): string { + return Object.keys(options) + .sort() + .filter(key => options[key as keyof IntersectionObserverInit] !== undefined) + .map(key => { + const value = key === 'root' ? getRootId(options.root) : options[key as keyof IntersectionObserverInit]; + return `${key}_${value}`; + }) + .toString(); +} + +export function createObserver(options: IntersectionObserverInit): IObserverItem { + const id = optionsToId(options); + let instance = observerMap.get(id); + + if (instance) return instance; + + const elements = new Map>(); + let thresholds: number[] | readonly number[] = []; + + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + const isIntersecting = + entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); + + // @ts-expect-error support IntersectionObserver v2 + if (options.trackVisibility && typeof entry.isVisible === 'undefined') { + // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. + // @ts-expect-error + entry.isVisible = isIntersecting; + } + + elements.get(entry.target)?.forEach(callback => { + callback(isIntersecting, entry); + }); + }); + }, options); + + thresholds = + observer.thresholds || (Array.isArray(options.threshold) ? options.threshold : [options.threshold || 0]); + + instance = { + id, + observer, + elements + }; + + observerMap.set(id, instance); + + return instance; +} + +export function observe({ element, callback, options = {}, isIntersectingFallback }: IObserveOptions): () => void { + if (typeof window.IntersectionObserver === 'undefined' && isIntersectingFallback !== undefined) { + const bounds = element.getBoundingClientRect(); + callback(isIntersectingFallback, { + isIntersecting: isIntersectingFallback, + target: element, + intersectionRatio: typeof options.threshold === 'number' ? options.threshold : 0, + time: 0, + boundingClientRect: bounds, + intersectionRect: bounds, + rootBounds: bounds + }); + return () => {}; + } + + const { id, observer, elements } = createObserver(options); + + const callbacks = elements.get(element) || []; + + if (!elements.has(element)) { + elements.set(element, callbacks); + } + + callbacks.push(callback); + observer.observe(element); + + return function unobserve(): void { + callbacks.splice(callbacks.indexOf(callback), 1); + + if (callbacks.length === 0) { + elements.delete(element); + observer.unobserve(element); + } + + if (elements.size === 0) { + observer.disconnect(); + observerMap.delete(id); + } + }; +} diff --git a/hooks/use-intersection-observer/tsconfig.build.json b/hooks/use-intersection-observer/tsconfig.build.json new file mode 100644 index 00000000..b4b36060 --- /dev/null +++ b/hooks/use-intersection-observer/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/*.tests.ts"] +} diff --git a/hooks/use-intersection-observer/tsconfig.json b/hooks/use-intersection-observer/tsconfig.json new file mode 100644 index 00000000..5b7870da --- /dev/null +++ b/hooks/use-intersection-observer/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs", + "target": "es6" + }, + "include": ["src"] +}