From 99b4e511ac106aa6549b610c8188c4d4317c175c Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Sun, 6 Oct 2024 12:39:20 +0500 Subject: [PATCH 01/26] feat(Image): add image component with fallback --- components/Image/src/Image.tsx | 28 +++++++++++++++++++++++++++ components/Image/src/Image.types.ts | 7 +++++++ components/Image/src/index.ts | 5 +++++ components/Image/src/useStateImage.ts | 23 ++++++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 components/Image/src/Image.tsx create mode 100644 components/Image/src/Image.types.ts create mode 100644 components/Image/src/index.ts create mode 100644 components/Image/src/useStateImage.ts diff --git a/components/Image/src/Image.tsx b/components/Image/src/Image.tsx new file mode 100644 index 00000000..9138aab4 --- /dev/null +++ b/components/Image/src/Image.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; +import useStateImage from "./useStateImage"; +import { ImageProps } from "./Image.types"; + +const Image: FC = (props) => { + const { + className, + src, + alt = 'image', + errorFallback, + fallback, + ...otherProps + } = props; + + const { isLoading, isError } = useStateImage(src); + + if (isLoading && fallback) { + return fallback; + } + + if (isError && errorFallback) { + return errorFallback; + } + + return {alt}; +}; + +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..60261d53 --- /dev/null +++ b/components/Image/src/Image.types.ts @@ -0,0 +1,7 @@ +import { ImgHTMLAttributes, ReactElement } from 'react'; + +export interface ImageProps extends ImgHTMLAttributes { + className?: string; + fallback?: ReactElement; + errorFallback?: ReactElement; +} diff --git a/components/Image/src/index.ts b/components/Image/src/index.ts new file mode 100644 index 00000000..b4877e0b --- /dev/null +++ b/components/Image/src/index.ts @@ -0,0 +1,5 @@ +import Image from './Image'; + +export { default as useStateImage } from './useStateImage'; +export { ImageProps } from './Image.types'; +export default Image; diff --git a/components/Image/src/useStateImage.ts b/components/Image/src/useStateImage.ts new file mode 100644 index 00000000..0122350b --- /dev/null +++ b/components/Image/src/useStateImage.ts @@ -0,0 +1,23 @@ +import { useLayoutEffect, useState } from "react"; + +export default function useStateImage(src: string | undefined) { + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useLayoutEffect(() => { + const img = new Image(); + img.src = src ?? ''; + img.onload = () => { + setIsLoading(false); + }; + img.onerror = () => { + setIsLoading(false); + setIsError(true); + }; + }, [src]); + + return { + isLoading, + isError, + } +} From 7129eb7b317b5ddceb7c24ad9e0d71b147f66171 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Sun, 3 Nov 2024 10:00:59 +0500 Subject: [PATCH 02/26] feat(hooks/use-intersection-observer): add hook and stories --- hooks/use-intersection-observer/src/index.ts | 0 .../use-intersection-observer/src/observe.ts | 119 +++++++++++++ .../src/useIntersectionObserver.stories.css | 112 +++++++++++++ .../src/useIntersectionObserver.stories.tsx | 157 ++++++++++++++++++ .../src/useIntersectionObserver.ts | 95 +++++++++++ .../src/useIntersectionObserver.types.ts | 47 ++++++ 6 files changed, 530 insertions(+) create mode 100644 hooks/use-intersection-observer/src/index.ts create mode 100644 hooks/use-intersection-observer/src/observe.ts create mode 100644 hooks/use-intersection-observer/src/useIntersectionObserver.stories.css create mode 100644 hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx create mode 100644 hooks/use-intersection-observer/src/useIntersectionObserver.ts create mode 100644 hooks/use-intersection-observer/src/useIntersectionObserver.types.ts diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/hooks/use-intersection-observer/src/observe.ts b/hooks/use-intersection-observer/src/observe.ts new file mode 100644 index 00000000..dd3d17d8 --- /dev/null +++ b/hooks/use-intersection-observer/src/observe.ts @@ -0,0 +1,119 @@ +import type { IObserveOptions, IObserverItem, TObserverInstanceCallback } from './useIntersectionObserver.types'; + +const observerMap = new Map(); + +const RootIds: WeakMap = new WeakMap(); + +let rootId = 0; + +/** + * Generate a unique ID for the root element + */ +function getRootId(root: IntersectionObserverInit['root']) { + if (!root) return '0'; + if (RootIds.has(root)) return RootIds.get(root); + rootId += 1; + RootIds.set(root, rootId.toString()); + return RootIds.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. + */ +function optionsToId(options: IntersectionObserverInit) { + return Object.keys(options) + .sort() + .filter(key => options[key as keyof IntersectionObserverInit] !== undefined) + .map(key => { + return `${key}_${ + key === 'root' ? getRootId(options.root) : options[key as keyof IntersectionObserverInit] + }`; + }) + .toString(); +} + +function createObserver(options: IntersectionObserverInit) { + const id = optionsToId(options); + let instance = observerMap.get(id); + + if (!instance) { + const elements = new Map>(); + let thresholds: number[] | readonly number[]; + + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + const inView = + entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); + + // @ts-ignore support IntersectionObserver v2 + if (options.trackVisibility && typeof entry.isVisible === 'undefined') { + // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. + // @ts-ignore + entry.isVisible = inView; + } + + elements.get(entry.target)?.forEach(callback => { + callback(inView, 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 = {}, fallbackInView }: IObserveOptions) { + if (typeof window.IntersectionObserver === 'undefined' && fallbackInView !== undefined) { + const bounds = element.getBoundingClientRect(); + callback(fallbackInView, { + isIntersecting: fallbackInView, + 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() { + // Remove the callback from the callback list + callbacks.splice(callbacks.indexOf(callback), 1); + + if (callbacks.length === 0) { + // No more callback exists for element, so destroy it + elements.delete(element); + observer.unobserve(element); + } + + if (elements.size === 0) { + // No more elements are being observer by this instance, so destroy it + observer.disconnect(); + observerMap.delete(id); + } + }; +} diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.stories.css b/hooks/use-intersection-observer/src/useIntersectionObserver.stories.css new file mode 100644 index 00000000..b6756eaf --- /dev/null +++ b/hooks/use-intersection-observer/src/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 - 32px - 50px); + overflow-y: auto; + overflow-x: hidden; +} + +.scroll-down, +.scroll-up { + height: 1000px; + 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/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx new file mode 100644 index 00000000..3f90874b --- /dev/null +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx @@ -0,0 +1,157 @@ +import React, { useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import useIntersectionObserver from './useIntersectionObserver'; +import './useIntersectionObserver.stories.css'; + +type TStory = StoryObj; + +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 wrapperRef = useRef(null); + const { ref, inView, entry } = useIntersectionObserver({ + root: wrapperRef.current, + onChange: (inView, entry) => console.log(inView, entry), + ...options + }); + + return ( +
+
+

{title}

+ {isExperimental && ( +
+

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

+ + Read more. + +
+ )} +
+ In View: + {String(inView)} + Entry: + +
+
+
+
Scroll down
+ {!!options?.rootMargin && ( +
+ {options.rootMargin} +
+ )} +
+ {inView ? '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 Delay: StoryObj = { + args: { + options: { + delay: 1000 + }, + title: 'Delay', + isExperimental: true + } +}; + +export const OnChange: StoryObj = { + args: { + options: { + onChange: (inView: boolean) => alert(`inView: ${inView}`) + }, + title: 'OnChange' + } +}; + +const meta: Meta = { + title: 'hooks/useIntersectionObserver', + component: Template, + argTypes: { + options: { + control: 'object' + }, + title: { + control: 'text' + }, + isExperimental: { + control: 'boolean' + } + } +}; + +export default meta; diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts new file mode 100644 index 00000000..6a6431f3 --- /dev/null +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react'; +import { observe } from './observe'; +import type { IUseIntersectionObserverReturn, IUseIntersectionObserverOptions } from './useIntersectionObserver.types'; + +type State = { + inView: boolean; + entry?: IntersectionObserverEntry; +}; + +export default function useIntersectionObserver({ + threshold, + delay, + trackVisibility, + rootMargin, + root, + triggerOnce, + skip, + initialInView, + fallbackInView, + onChange +}: IUseIntersectionObserverOptions = {}): IUseIntersectionObserverReturn { + const [ref, setRef] = useState(null); + const callback = useRef(); + const [state, setState] = useState({ + inView: !!initialInView, + entry: undefined + }); + + callback.current = onChange; + + useEffect(() => { + if (skip || !ref) return; + + let unobserve: (() => void) | undefined; + unobserve = observe({ + element: ref, + options: { + root, + rootMargin, + threshold, + // @ts-ignore experimental v2 api + trackVisibility, + delay + }, + callback: (inView, entry) => { + setState({ + inView, + entry + }); + if (callback.current) callback.current(inView, entry); + + if (entry.isIntersecting && triggerOnce && unobserve) { + // If it should only trigger once, unobserve the element after it's inView + unobserve(); + unobserve = undefined; + } + }, + fallbackInView + }); + + return () => { + if (unobserve) { + unobserve(); + } + }; + }, [ + Array.isArray(threshold) ? threshold.toString() : threshold, + ref, + root, + rootMargin, + triggerOnce, + skip, + trackVisibility, + fallbackInView, + delay + ]); + + const entryTarget = state.entry?.target; + const previousEntryTarget = useRef(); + if (!ref && entryTarget && !triggerOnce && !skip && previousEntryTarget.current !== entryTarget) { + previousEntryTarget.current = entryTarget; + setState({ + inView: !!initialInView, + entry: undefined + }); + } + + const result = [setRef, state.inView, state.entry] as IUseIntersectionObserverReturn; + + result.ref = result[0]; + result.inView = result[1]; + result.entry = result[2]; + + return result; +} 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..23a6439f --- /dev/null +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -0,0 +1,47 @@ +export type TObserverInstanceCallback = (inView: boolean, entry: IntersectionObserverEntry) => void; + +export interface IObserveOptions { + element: Element; + callback: TObserverInstanceCallback; + options: IntersectionObserverInit; + fallbackInView?: 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 inView callback once */ + triggerOnce?: boolean; + /** Skip assigning the observer to the `ref` */ + skip?: boolean; + /** Set the initial value of the `inView` 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. */ + initialInView?: boolean; + /** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */ + fallbackInView?: 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?: (inView: boolean, entry: IntersectionObserverEntry) => void; +} + +export type IUseIntersectionObserverReturn = [ + (node?: Element | null) => void, + boolean, + IntersectionObserverEntry | undefined +] & { + ref: (node?: Element | null) => void; + inView: boolean; + entry?: IntersectionObserverEntry; +}; From 7b4163a75f8a1627de16445de6386b6dda1e9a21 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Tue, 5 Nov 2024 07:12:19 +0500 Subject: [PATCH 03/26] feat(hooks/useIntersectionObserver): added tests for observe.ts --- .../src/__tests__/observe.tests.ts | 38 +++++++++++++++++++ .../use-intersection-observer/src/observe.ts | 4 +- .../src/useIntersectionObserver.stories.tsx | 16 ++++---- 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 hooks/use-intersection-observer/src/__tests__/observe.tests.ts diff --git a/hooks/use-intersection-observer/src/__tests__/observe.tests.ts b/hooks/use-intersection-observer/src/__tests__/observe.tests.ts new file mode 100644 index 00000000..598a2bfb --- /dev/null +++ b/hooks/use-intersection-observer/src/__tests__/observe.tests.ts @@ -0,0 +1,38 @@ +import { optionsToId } from '../observe'; + +describe('hooks/useIntersectionObserver/utilities', () => { + test('should convert options to id', () => { + expect( + optionsToId({ + root: document.createElement('div'), + rootMargin: '10px 10px', + threshold: [0, 1] + }) + ).toMatchInlineSnapshot(`"root_1,rootMargin_10px 10px,threshold_0,1"`); + expect( + optionsToId({ + root: null, + rootMargin: '10px 10px', + threshold: 1 + }) + ).toMatchInlineSnapshot(`"root_0,rootMargin_10px 10px,threshold_1"`); + expect( + optionsToId({ + threshold: 0, + // @ts-ignore + trackVisibility: true, + delay: 500 + }) + ).toMatchInlineSnapshot(`"delay_500,threshold_0,trackVisibility_true"`); + expect( + optionsToId({ + threshold: 0 + }) + ).toMatchInlineSnapshot(`"threshold_0"`); + expect( + optionsToId({ + threshold: [0, 0.5, 1] + }) + ).toMatchInlineSnapshot(`"threshold_0,0.5,1"`); + }); +}); diff --git a/hooks/use-intersection-observer/src/observe.ts b/hooks/use-intersection-observer/src/observe.ts index dd3d17d8..35ce0f62 100644 --- a/hooks/use-intersection-observer/src/observe.ts +++ b/hooks/use-intersection-observer/src/observe.ts @@ -9,7 +9,7 @@ let rootId = 0; /** * Generate a unique ID for the root element */ -function getRootId(root: IntersectionObserverInit['root']) { +export function getRootId(root: IntersectionObserverInit['root']) { if (!root) return '0'; if (RootIds.has(root)) return RootIds.get(root); rootId += 1; @@ -21,7 +21,7 @@ function getRootId(root: IntersectionObserverInit['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. */ -function optionsToId(options: IntersectionObserverInit) { +export function optionsToId(options: IntersectionObserverInit) { return Object.keys(options) .sort() .filter(key => options[key as keyof IntersectionObserverInit] !== undefined) diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx index 3f90874b..61dd1c48 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx @@ -3,8 +3,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import useIntersectionObserver from './useIntersectionObserver'; import './useIntersectionObserver.stories.css'; -type TStory = StoryObj; - type ITemplateProps = { title: string; options: any; @@ -119,22 +117,22 @@ export const TriggerOnce: StoryObj = { } }; -export const Delay: StoryObj = { +export const OnChange: StoryObj = { args: { options: { - delay: 1000 + onChange: (inView: boolean) => alert(`inView: ${inView}`) }, - title: 'Delay', - isExperimental: true + title: 'OnChange' } }; -export const OnChange: StoryObj = { +export const Delay: StoryObj = { args: { options: { - onChange: (inView: boolean) => alert(`inView: ${inView}`) + delay: 1000 }, - title: 'OnChange' + title: 'Delay', + isExperimental: true } }; From 9d6de33a65bd6834c05af85c96f9b76be990de62 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Tue, 5 Nov 2024 10:00:54 +0500 Subject: [PATCH 04/26] feat(hooks/useIntersectionObserver): added configuration and readme --- hooks/use-intersection-observer/.npmignore | 1 + hooks/use-intersection-observer/README.md | 58 ++++++++++++ hooks/use-intersection-observer/package.json | 33 +++++++ .../useIntersectionObserver.stories.css | 4 +- .../useIntersectionObserver.stories.mdx | 7 ++ .../useIntersectionObserver.stories.tsx | 2 +- .../useIntersectionObserver.tests.ts | 89 +++++++++++++++++++ ...seIntersectionObserver.utilities.tests.ts} | 4 +- hooks/use-intersection-observer/src/index.ts | 5 ++ .../src/useIntersectionObserver.ts | 38 +++----- ...s => useIntersectionObserver.utilities.ts} | 5 +- .../tsconfig.build.json | 4 + hooks/use-intersection-observer/tsconfig.json | 11 +++ 13 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 hooks/use-intersection-observer/.npmignore create mode 100644 hooks/use-intersection-observer/README.md create mode 100644 hooks/use-intersection-observer/package.json rename hooks/use-intersection-observer/src/{ => __stories__}/useIntersectionObserver.stories.css (96%) create mode 100644 hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx rename hooks/use-intersection-observer/src/{ => __stories__}/useIntersectionObserver.stories.tsx (98%) create mode 100644 hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts rename hooks/use-intersection-observer/src/__tests__/{observe.tests.ts => useIntersectionObserver.utilities.tests.ts} (89%) rename hooks/use-intersection-observer/src/{observe.ts => useIntersectionObserver.utilities.ts} (93%) create mode 100644 hooks/use-intersection-observer/tsconfig.build.json create mode 100644 hooks/use-intersection-observer/tsconfig.json 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..6bcfa0ae --- /dev/null +++ b/hooks/use-intersection-observer/README.md @@ -0,0 +1,58 @@ +# `@byndyusoft-ui/use-intersection-observer` +--- +> A React hook that ... + +### Installation + +``` +npm i @byndyusoft-ui/use-intersection-observer +``` + +## Usage + +### `useIntersectionObserver` hook + +```jsx +import React from "react"; +import { useIntersectionObserver } from "@byndyusoft-ui/use-intersection-observer"; + +const Component = () => { + const { ref, inView, entry } = useIntersectionObserver({ + /* Optional options */ + threshold: 0, + }); + + return ( +
+

{`inView: ${inView}`}

+
+ ); +}; +``` + +```js +// Use object destructuring, so you don't need to remember the exact order +const { ref, inView, entry } = useInView(options); + +// Or array destructuring, making it easy to customize the field names +const [ref, inView, entry] = useInView(options); +``` + +### Options + +Provide these as the options argument in the `useInView` 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** | `(inView, entry) => void` | `undefined` | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. | +| **trackVisibility** 🧪 | `boolean` | `false` | A boolean indicating whether this Intersection Observer will track visibility changes on the target. | +| **delay** 🧪 | `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 `inView`, the current state will still be kept. | +| **triggerOnce** | `boolean` | `false` | Only trigger the observer once. | +| **initialInView** | `boolean` | `false` | Set the initial value of the `inView` 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. | +| **fallbackInView** | `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 `inView` 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..9dce5a62 --- /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": "", + "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/useIntersectionObserver.stories.css b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.css similarity index 96% rename from hooks/use-intersection-observer/src/useIntersectionObserver.stories.css rename to hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.css index b6756eaf..bae2056b 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.stories.css +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.css @@ -63,14 +63,14 @@ .scroll-container { position: relative; width: 100%; - height: calc(100vh - 32px - 50px); + height: calc(100vh - 82px); overflow-y: auto; overflow-x: hidden; } .scroll-down, .scroll-up { - height: 1000px; + height: 100vh; background: lightgray; display: flex; align-items: center; 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..b39fc7e2 --- /dev/null +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs'; +import { Markdown } from '@storybook/blocks'; +import Readme from '../../README.md'; + + + +{Readme} diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx similarity index 98% rename from hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx rename to hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx index 61dd1c48..02ad2e6f 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.stories.tsx +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx @@ -1,6 +1,6 @@ import React, { useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import useIntersectionObserver from './useIntersectionObserver'; +import useIntersectionObserver from '../useIntersectionObserver'; import './useIntersectionObserver.stories.css'; type ITemplateProps = { diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts new file mode 100644 index 00000000..57d7a9db --- /dev/null +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts @@ -0,0 +1,89 @@ +import { renderHook, act, RenderHookResult, Renderer } from '@testing-library/react-hooks'; +import useIntersectionObserver from '../useIntersectionObserver'; +import { observe } from '../useIntersectionObserver.utilities'; +import { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from '../useIntersectionObserver.types'; + +jest.mock('../useIntersectionObserver.utilities'); + +const setup = ( + options: IUseIntersectionObserverOptions +): RenderHookResult> => + renderHook(state => useIntersectionObserver(state), { initialProps: options }); + +describe('hooks/useIntersectionObserver', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should initialize with initialInView', () => { + const { result } = setup({ initialInView: true }); + + expect(result.current.inView).toBe(true); + }); + + test('should call useIntersectionObserverUtilities with correct parameters', () => { + const options = { + threshold: 0.5, + delay: 100, + trackVisibility: true, + rootMargin: '0px', + root: null, + triggerOnce: false, + skip: false, + initialInView: false, + fallbackInView: false, + onChange: jest.fn() + }; + + const { result } = setup(options); + const ref = document.createElement('div'); + + act(() => { + result.current.ref(ref); + }); + + expect(observe).toHaveBeenCalledWith({ + element: ref, + options: { + root: null, + rootMargin: '0px', + threshold: 0.5, + trackVisibility: true, + delay: 100 + }, + callback: expect.any(Function), + fallbackInView: false + }); + }); + + test('should update state when observe callback is called', () => { + const onChange = jest.fn(); + const { result } = setup({ onChange }); + const ref = document.createElement('div'); + + act(() => { + result.current.ref(ref); + }); + + const callback = (observe as jest.Mock).mock.calls[0][0].callback; + + act(() => { + callback(true, { isIntersecting: true, target: ref }); + }); + + expect(result.current.inView).toBe(true); + expect(result.current.entry).toEqual({ isIntersecting: true, target: ref }); + expect(onChange).toHaveBeenCalledWith(true, { isIntersecting: true, target: ref }); + }); + + test('should not call observe if skip is true', () => { + const { result } = setup({ skip: true }); + const ref = document.createElement('div'); + + act(() => { + result.current.ref(ref); + }); + + expect(observe).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/use-intersection-observer/src/__tests__/observe.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts similarity index 89% rename from hooks/use-intersection-observer/src/__tests__/observe.tests.ts rename to hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts index 598a2bfb..2ae0ba44 100644 --- a/hooks/use-intersection-observer/src/__tests__/observe.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -1,6 +1,6 @@ -import { optionsToId } from '../observe'; +import { optionsToId } from '../useIntersectionObserver.utilities'; -describe('hooks/useIntersectionObserver/utilities', () => { +describe('hooks/useIntersectionObserver.utilities', () => { test('should convert options to id', () => { expect( optionsToId({ diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts index e69de29b..3269594c 100644 --- a/hooks/use-intersection-observer/src/index.ts +++ b/hooks/use-intersection-observer/src/index.ts @@ -0,0 +1,5 @@ +import useIntersectionObserver from './useIntersectionObserver'; + +export { useIntersectionObserver }; +export type { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from './useIntersectionObserver.types'; +export default useIntersectionObserver; diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts index 6a6431f3..141854ae 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -1,12 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { observe } from './observe'; +import { observe } from './useIntersectionObserver.utilities'; import type { IUseIntersectionObserverReturn, IUseIntersectionObserverOptions } from './useIntersectionObserver.types'; -type State = { - inView: boolean; - entry?: IntersectionObserverEntry; -}; - export default function useIntersectionObserver({ threshold, delay, @@ -20,11 +15,11 @@ export default function useIntersectionObserver({ onChange }: IUseIntersectionObserverOptions = {}): IUseIntersectionObserverReturn { const [ref, setRef] = useState(null); + const [inView, setInView] = useState(!!initialInView); + const [entry, setEntry] = useState(undefined); + const callback = useRef(); - const [state, setState] = useState({ - inView: !!initialInView, - entry: undefined - }); + const previousEntryTarget = useRef(); callback.current = onChange; @@ -43,14 +38,11 @@ export default function useIntersectionObserver({ delay }, callback: (inView, entry) => { - setState({ - inView, - entry - }); + setInView(inView); + setEntry(entry); if (callback.current) callback.current(inView, entry); if (entry.isIntersecting && triggerOnce && unobserve) { - // If it should only trigger once, unobserve the element after it's inView unobserve(); unobserve = undefined; } @@ -59,9 +51,7 @@ export default function useIntersectionObserver({ }); return () => { - if (unobserve) { - unobserve(); - } + unobserve?.(); }; }, [ Array.isArray(threshold) ? threshold.toString() : threshold, @@ -75,17 +65,15 @@ export default function useIntersectionObserver({ delay ]); - const entryTarget = state.entry?.target; - const previousEntryTarget = useRef(); + const entryTarget = entry?.target; + if (!ref && entryTarget && !triggerOnce && !skip && previousEntryTarget.current !== entryTarget) { previousEntryTarget.current = entryTarget; - setState({ - inView: !!initialInView, - entry: undefined - }); + setInView(!!initialInView); + setEntry(undefined); } - const result = [setRef, state.inView, state.entry] as IUseIntersectionObserverReturn; + const result = [setRef, inView, entry] as IUseIntersectionObserverReturn; result.ref = result[0]; result.inView = result[1]; diff --git a/hooks/use-intersection-observer/src/observe.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts similarity index 93% rename from hooks/use-intersection-observer/src/observe.ts rename to hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts index 35ce0f62..31057f3d 100644 --- a/hooks/use-intersection-observer/src/observe.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts @@ -33,7 +33,7 @@ export function optionsToId(options: IntersectionObserverInit) { .toString(); } -function createObserver(options: IntersectionObserverInit) { +export function createObserver(options: IntersectionObserverInit) { const id = optionsToId(options); let instance = observerMap.get(id); @@ -101,17 +101,14 @@ export function observe({ element, callback, options = {}, fallbackInView }: IOb observer.observe(element); return function unobserve() { - // Remove the callback from the callback list callbacks.splice(callbacks.indexOf(callback), 1); if (callbacks.length === 0) { - // No more callback exists for element, so destroy it elements.delete(element); observer.unobserve(element); } if (elements.size === 0) { - // No more elements are being observer by this instance, so destroy it 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"] +} From 34dd9f8398d0eea661883074d0651067f4d2a51e Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Tue, 5 Nov 2024 10:05:04 +0500 Subject: [PATCH 05/26] feat(hooks/useIntersectionObserver): minor --- .../src/__stories__/useIntersectionObserver.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx index 02ad2e6f..f5ba38bf 100644 --- a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx @@ -23,9 +23,9 @@ function convertEntryToString(entry?: IntersectionObserverEntry) { } const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Element => { - const wrapperRef = useRef(null); + const scrollContainerRef = useRef(null); const { ref, inView, entry } = useIntersectionObserver({ - root: wrapperRef.current, + root: scrollContainerRef.current, onChange: (inView, entry) => console.log(inView, entry), ...options }); @@ -53,7 +53,7 @@ const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Eleme -
+
Scroll down
{!!options?.rootMargin && (
From 819e63cc48c2112d40230af5e375379a198ed7a7 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Mon, 11 Nov 2024 08:16:21 +0500 Subject: [PATCH 06/26] chore(hooks/useIntersectionObserver): update readme, rename variables --- hooks/use-intersection-observer/README.md | 85 +++++++++++++------ .../useIntersectionObserver.stories.tsx | 26 +++--- .../useIntersectionObserver.tests.ts | 6 +- ...useIntersectionObserver.utilities.tests.ts | 4 + .../src/useIntersectionObserver.ts | 20 +++-- .../src/useIntersectionObserver.types.ts | 6 +- .../src/useIntersectionObserver.utilities.ts | 77 +++++++++-------- 7 files changed, 132 insertions(+), 92 deletions(-) diff --git a/hooks/use-intersection-observer/README.md b/hooks/use-intersection-observer/README.md index 6bcfa0ae..40a684a2 100644 --- a/hooks/use-intersection-observer/README.md +++ b/hooks/use-intersection-observer/README.md @@ -8,51 +8,80 @@ npm i @byndyusoft-ui/use-intersection-observer ``` -## Usage +### Usage `useIntersectionObserver` hook +```js +// Use object destructuring, so you don't need to remember the exact order +const { ref, isIntersecting, entry } = useIntersectionObserver(options); -### `useIntersectionObserver` hook +// 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, inView, entry } = useIntersectionObserver({ - /* Optional options */ - threshold: 0, - }); + const { ref, isIntersecting, entry } = useIntersectionObserver(); return ( -
-

{`inView: ${inView}`}

+
+
+ {`isIntersecting: ${isIntersecting}`} +
); }; ``` -```js -// Use object destructuring, so you don't need to remember the exact order -const { ref, inView, entry } = useInView(options); +### Usage `useIntersectionObserver` with options -// Or array destructuring, making it easy to customize the field names -const [ref, inView, entry] = useInView(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, + fallbackInView: false, + trackVisibility: false, // experimental + delay: 1500, // experimental + onChange: (inView, entry) => console.log(inView, entry), + }); + + return ( +
+
+ {`isIntersecting: ${isIntersecting}`} +
+
+ ); +}; +``` ### Options -Provide these as the options argument in the `useInView` 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** | `(inView, entry) => void` | `undefined` | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. | -| **trackVisibility** 🧪 | `boolean` | `false` | A boolean indicating whether this Intersection Observer will track visibility changes on the target. | -| **delay** 🧪 | `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 `inView`, the current state will still be kept. | -| **triggerOnce** | `boolean` | `false` | Only trigger the observer once. | -| **initialInView** | `boolean` | `false` | Set the initial value of the `inView` 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. | -| **fallbackInView** | `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 `inView` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` | +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 in view 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. | +| **initialInView** | `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. | +| **fallbackInView** | `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/src/__stories__/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx index f5ba38bf..50f108a8 100644 --- a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx @@ -12,19 +12,19 @@ type ITemplateProps = { 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} - `; + 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, inView, entry } = useIntersectionObserver({ + const { ref, isIntersecting, entry } = useIntersectionObserver({ root: scrollContainerRef.current, onChange: (inView, entry) => console.log(inView, entry), ...options @@ -48,7 +48,9 @@ const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Eleme )}
In View: - {String(inView)} + + {String(isIntersecting)} + Entry:
@@ -60,8 +62,8 @@ const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Eleme {options.rootMargin}
)} -
- {inView ? 'In View' : 'Out of View'} +
+ {isIntersecting ? 'In View' : 'Out of View'}
{!!options?.rootMargin && (
diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts index 57d7a9db..581d56ec 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts @@ -16,9 +16,9 @@ describe('hooks/useIntersectionObserver', () => { }); test('should initialize with initialInView', () => { - const { result } = setup({ initialInView: true }); + const { result } = setup({ isIntersectingInitial: true }); - expect(result.current.inView).toBe(true); + expect(result.current.isIntersecting).toBe(true); }); test('should call useIntersectionObserverUtilities with correct parameters', () => { @@ -71,7 +71,7 @@ describe('hooks/useIntersectionObserver', () => { callback(true, { isIntersecting: true, target: ref }); }); - expect(result.current.inView).toBe(true); + expect(result.current.isIntersecting).toBe(true); expect(result.current.entry).toEqual({ isIntersecting: true, target: ref }); expect(onChange).toHaveBeenCalledWith(true, { isIntersecting: true, target: ref }); }); diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts index 2ae0ba44..b3a7c562 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -9,6 +9,7 @@ describe('hooks/useIntersectionObserver.utilities', () => { threshold: [0, 1] }) ).toMatchInlineSnapshot(`"root_1,rootMargin_10px 10px,threshold_0,1"`); + expect( optionsToId({ root: null, @@ -16,6 +17,7 @@ describe('hooks/useIntersectionObserver.utilities', () => { threshold: 1 }) ).toMatchInlineSnapshot(`"root_0,rootMargin_10px 10px,threshold_1"`); + expect( optionsToId({ threshold: 0, @@ -24,11 +26,13 @@ describe('hooks/useIntersectionObserver.utilities', () => { delay: 500 }) ).toMatchInlineSnapshot(`"delay_500,threshold_0,trackVisibility_true"`); + expect( optionsToId({ threshold: 0 }) ).toMatchInlineSnapshot(`"threshold_0"`); + expect( optionsToId({ threshold: [0, 0.5, 1] diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts index 141854ae..73c98be0 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -10,12 +10,12 @@ export default function useIntersectionObserver({ root, triggerOnce, skip, - initialInView, + isIntersectingInitial, fallbackInView, onChange }: IUseIntersectionObserverOptions = {}): IUseIntersectionObserverReturn { const [ref, setRef] = useState(null); - const [inView, setInView] = useState(!!initialInView); + const [isIntersecting, setIsIntersecting] = useState(!!isIntersectingInitial); const [entry, setEntry] = useState(undefined); const callback = useRef(); @@ -27,6 +27,7 @@ export default function useIntersectionObserver({ if (skip || !ref) return; let unobserve: (() => void) | undefined; + unobserve = observe({ element: ref, options: { @@ -37,17 +38,18 @@ export default function useIntersectionObserver({ trackVisibility, delay }, - callback: (inView, entry) => { - setInView(inView); + callback: (isIntersecting, entry) => { + setIsIntersecting(isIntersecting); setEntry(entry); - if (callback.current) callback.current(inView, entry); + + if (callback.current) callback.current(isIntersecting, entry); if (entry.isIntersecting && triggerOnce && unobserve) { unobserve(); unobserve = undefined; } }, - fallbackInView + fallbackIsInView: fallbackInView }); return () => { @@ -69,14 +71,14 @@ export default function useIntersectionObserver({ if (!ref && entryTarget && !triggerOnce && !skip && previousEntryTarget.current !== entryTarget) { previousEntryTarget.current = entryTarget; - setInView(!!initialInView); + setIsIntersecting(!!isIntersectingInitial); setEntry(undefined); } - const result = [setRef, inView, entry] as IUseIntersectionObserverReturn; + const result = [setRef, isIntersecting, entry] as IUseIntersectionObserverReturn; result.ref = result[0]; - result.inView = result[1]; + result.isIntersecting = result[1]; result.entry = result[2]; return result; diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts index 23a6439f..3a21944a 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -4,7 +4,7 @@ export interface IObserveOptions { element: Element; callback: TObserverInstanceCallback; options: IntersectionObserverInit; - fallbackInView?: boolean; + fallbackIsInView?: boolean; } export interface IObserverItem { @@ -25,7 +25,7 @@ export interface IUseIntersectionObserverOptions extends IntersectionObserverIni /** Skip assigning the observer to the `ref` */ skip?: boolean; /** Set the initial value of the `inView` 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. */ - initialInView?: boolean; + isIntersectingInitial?: boolean; /** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */ fallbackInView?: boolean; /** IntersectionObserver v2 - Track the actual visibility of the element */ @@ -42,6 +42,6 @@ export type IUseIntersectionObserverReturn = [ IntersectionObserverEntry | undefined ] & { ref: (node?: Element | null) => void; - inView: boolean; + isIntersecting: boolean; entry?: IntersectionObserverEntry; }; diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts index 31057f3d..912c35fb 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts @@ -2,7 +2,7 @@ import type { IObserveOptions, IObserverItem, TObserverInstanceCallback } from ' const observerMap = new Map(); -const RootIds: WeakMap = new WeakMap(); +const rootIdsWeakMap: WeakMap = new WeakMap(); let rootId = 0; @@ -11,10 +11,13 @@ let rootId = 0; */ export function getRootId(root: IntersectionObserverInit['root']) { if (!root) return '0'; - if (RootIds.has(root)) return RootIds.get(root); + + if (rootIdsWeakMap.has(root)) return rootIdsWeakMap.get(root); + rootId += 1; - RootIds.set(root, rootId.toString()); - return RootIds.get(root); + rootIdsWeakMap.set(root, rootId.toString()); + + return rootIdsWeakMap.get(root); } /** @@ -37,48 +40,48 @@ export function createObserver(options: IntersectionObserverInit) { const id = optionsToId(options); let instance = observerMap.get(id); - if (!instance) { - const elements = new Map>(); - let thresholds: number[] | readonly number[]; - - const observer = new IntersectionObserver(entries => { - entries.forEach(entry => { - const inView = - entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); - - // @ts-ignore support IntersectionObserver v2 - if (options.trackVisibility && typeof entry.isVisible === 'undefined') { - // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. - // @ts-ignore - entry.isVisible = inView; - } - - elements.get(entry.target)?.forEach(callback => { - callback(inView, entry); - }); + 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-ignore support IntersectionObserver v2 + if (options.trackVisibility && typeof entry.isVisible === 'undefined') { + // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. + // @ts-ignore + entry.isVisible = isIntersecting; + } + + elements.get(entry.target)?.forEach(callback => { + callback(isIntersecting, entry); }); - }, options); + }); + }, options); - thresholds = - observer.thresholds || (Array.isArray(options.threshold) ? options.threshold : [options.threshold || 0]); + thresholds = + observer.thresholds || (Array.isArray(options.threshold) ? options.threshold : [options.threshold || 0]); - instance = { - id, - observer, - elements - }; + instance = { + id, + observer, + elements + }; - observerMap.set(id, instance); - } + observerMap.set(id, instance); return instance; } -export function observe({ element, callback, options = {}, fallbackInView }: IObserveOptions) { - if (typeof window.IntersectionObserver === 'undefined' && fallbackInView !== undefined) { +export function observe({ element, callback, options = {}, fallbackIsInView }: IObserveOptions) { + if (typeof window.IntersectionObserver === 'undefined' && fallbackIsInView !== undefined) { const bounds = element.getBoundingClientRect(); - callback(fallbackInView, { - isIntersecting: fallbackInView, + callback(fallbackIsInView, { + isIntersecting: fallbackIsInView, target: element, intersectionRatio: typeof options.threshold === 'number' ? options.threshold : 0, time: 0, From e9aecbcfd58077c88379fc7c15919a83f428c4ea Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Mon, 11 Nov 2024 13:14:35 +0500 Subject: [PATCH 07/26] feat(hooks/useIntersectionObserver): add new tests and minor fixes --- hooks/use-intersection-observer/README.md | 5 +- .../useIntersectionObserver.stories.tsx | 2 +- .../useIntersectionObserver.tests.ts | 89 -------- .../useIntersectionObserver.tests.tsx | 205 ++++++++++++++++++ ...useIntersectionObserver.utilities.tests.ts | 36 ++- hooks/use-intersection-observer/src/index.ts | 2 +- .../src/useIntersectionObserver.ts | 8 +- .../src/useIntersectionObserver.types.ts | 14 +- ...useIntersectionObserver.tests.utilities.ts | 134 ++++++++++++ .../useIntersectionObserver.utilities.ts | 10 +- 10 files changed, 393 insertions(+), 112 deletions(-) delete mode 100644 hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts create mode 100644 hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx create mode 100644 hooks/use-intersection-observer/src/utilities/useIntersectionObserver.tests.utilities.ts rename hooks/use-intersection-observer/src/{ => utilities}/useIntersectionObserver.utilities.ts (90%) diff --git a/hooks/use-intersection-observer/README.md b/hooks/use-intersection-observer/README.md index 40a684a2..6f07af58 100644 --- a/hooks/use-intersection-observer/README.md +++ b/hooks/use-intersection-observer/README.md @@ -44,7 +44,6 @@ import { useIntersectionObserver } from "@byndyusoft-ui/use-intersection-observe const Component = () => { const containerRef = useRef(null); - const { ref, isIntersecting, entry } = useIntersectionObserver({ root: scrollContainerRef.current, rootMargin: "10px", @@ -52,10 +51,10 @@ const Component = () => { triggerOnce: false, skip: false, isIntersectingInitial: false, - fallbackInView: false, + isIntersectingFallback: false, trackVisibility: false, // experimental delay: 1500, // experimental - onChange: (inView, entry) => console.log(inView, entry), + onChange: (isIntersecting, entry) => console.log(isIntersecting, entry), }); return ( diff --git a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx index 50f108a8..ce2cf7a7 100644 --- a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx @@ -26,7 +26,7 @@ const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Eleme const scrollContainerRef = useRef(null); const { ref, isIntersecting, entry } = useIntersectionObserver({ root: scrollContainerRef.current, - onChange: (inView, entry) => console.log(inView, entry), + onChange: (isIntersecting, entry) => console.log(isIntersecting, entry), ...options }); diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts deleted file mode 100644 index 581d56ec..00000000 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { renderHook, act, RenderHookResult, Renderer } from '@testing-library/react-hooks'; -import useIntersectionObserver from '../useIntersectionObserver'; -import { observe } from '../useIntersectionObserver.utilities'; -import { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from '../useIntersectionObserver.types'; - -jest.mock('../useIntersectionObserver.utilities'); - -const setup = ( - options: IUseIntersectionObserverOptions -): RenderHookResult> => - renderHook(state => useIntersectionObserver(state), { initialProps: options }); - -describe('hooks/useIntersectionObserver', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - test('should initialize with initialInView', () => { - const { result } = setup({ isIntersectingInitial: true }); - - expect(result.current.isIntersecting).toBe(true); - }); - - test('should call useIntersectionObserverUtilities with correct parameters', () => { - const options = { - threshold: 0.5, - delay: 100, - trackVisibility: true, - rootMargin: '0px', - root: null, - triggerOnce: false, - skip: false, - initialInView: false, - fallbackInView: false, - onChange: jest.fn() - }; - - const { result } = setup(options); - const ref = document.createElement('div'); - - act(() => { - result.current.ref(ref); - }); - - expect(observe).toHaveBeenCalledWith({ - element: ref, - options: { - root: null, - rootMargin: '0px', - threshold: 0.5, - trackVisibility: true, - delay: 100 - }, - callback: expect.any(Function), - fallbackInView: false - }); - }); - - test('should update state when observe callback is called', () => { - const onChange = jest.fn(); - const { result } = setup({ onChange }); - const ref = document.createElement('div'); - - act(() => { - result.current.ref(ref); - }); - - const callback = (observe as jest.Mock).mock.calls[0][0].callback; - - act(() => { - callback(true, { isIntersecting: true, target: ref }); - }); - - expect(result.current.isIntersecting).toBe(true); - expect(result.current.entry).toEqual({ isIntersecting: true, target: ref }); - expect(onChange).toHaveBeenCalledWith(true, { isIntersecting: true, target: ref }); - }); - - test('should not call observe if skip is true', () => { - const { result } = setup({ skip: true }); - const ref = document.createElement('div'); - - act(() => { - result.current.ref(ref); - }); - - expect(observe).not.toHaveBeenCalled(); - }); -}); 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..22f2b21c --- /dev/null +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx @@ -0,0 +1,205 @@ +import { render, screen } from '@testing-library/react'; +import React, { lazy, useCallback } from 'react'; +import { IUseIntersectionObserverOptions } from '../useIntersectionObserver.types'; +import { + intersectionMockInstance, + mockAllIsIntersecting, + mockIsIntersecting, + resetIntersectionMocking, + setupIntersectionMocking +} from '../utilities/useIntersectionObserver.tests.utilities'; +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 inView', () => { + const { getByText } = setupHookComponent(); + + mockAllIsIntersecting(true); + + getByText('true'); + }); + + test('should mock thresholds', () => { + const { getByText } = setupHookComponent({ + options: { threshold: [0.5, 1] } + }); + mockAllIsIntersecting(0); + getByText('false'); + mockAllIsIntersecting(0.5); + getByText('true'); + mockAllIsIntersecting(1); + getByText('true'); + }); + + test('should create a hook with initialInView', () => { + const { getByText } = setupHookComponent({ + options: { isIntersectingInitial: true } + }); + + getByText('true'); + mockAllIsIntersecting(false); + getByText('false'); + }); + + test('should trigger a hook leaving view', () => { + const { getByText } = setupHookComponent(); + mockAllIsIntersecting(true); + mockAllIsIntersecting(false); + getByText('false'); + }); + + test('should respect trigger once', () => { + const { getByText } = setupHookComponent({ + options: { triggerOnce: true } + }); + mockAllIsIntersecting(true); + mockAllIsIntersecting(false); + + getByText('true'); + }); + + 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); + getByText('false'); + + rerender(); + mockAllIsIntersecting(true); + getByText('true'); + }); + + test('should not reset current state if changing skip', () => { + const { getByText, rerender } = setupHookComponent({ options: { skip: false } }); + + mockAllIsIntersecting(true); + rerender(); + getByText('true'); + }); + + test('should unmount the hook', () => { + const { unmount, getByTestId } = setupHookComponent(); + const wrapper = getByTestId('wrapper'); + const instance = intersectionMockInstance(wrapper); + unmount(); + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); + }); + + test('inView should be false when component is unmounted', () => { + const { rerender, getByText } = setupHookComponent({ unmount: false }); + mockAllIsIntersecting(true); + + getByText('true'); + rerender(); + getByText('false'); + }); + + 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); + getByText(/intersectionRatio: 0.5/); + }); +}); diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts index b3a7c562..b46f8abe 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -1,6 +1,38 @@ -import { optionsToId } from '../useIntersectionObserver.utilities'; +import { optionsToId, observe } from '../utilities/useIntersectionObserver.utilities'; +import { + intersectionMockInstance, + mockIsIntersecting, + resetIntersectionMocking, + setupIntersectionMocking +} from '../utilities/useIntersectionObserver.tests.utilities'; + +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?' + ); + }); -describe('hooks/useIntersectionObserver.utilities', () => { test('should convert options to id', () => { expect( optionsToId({ diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts index 3269594c..fcd200c0 100644 --- a/hooks/use-intersection-observer/src/index.ts +++ b/hooks/use-intersection-observer/src/index.ts @@ -1,5 +1,5 @@ import useIntersectionObserver from './useIntersectionObserver'; -export { 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 index 73c98be0..cf776083 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { observe } from './useIntersectionObserver.utilities'; +import { observe } from './utilities/useIntersectionObserver.utilities'; import type { IUseIntersectionObserverReturn, IUseIntersectionObserverOptions } from './useIntersectionObserver.types'; export default function useIntersectionObserver({ @@ -11,7 +11,7 @@ export default function useIntersectionObserver({ triggerOnce, skip, isIntersectingInitial, - fallbackInView, + isIntersectingFallback, onChange }: IUseIntersectionObserverOptions = {}): IUseIntersectionObserverReturn { const [ref, setRef] = useState(null); @@ -49,7 +49,7 @@ export default function useIntersectionObserver({ unobserve = undefined; } }, - fallbackIsInView: fallbackInView + isIntersectingFallback }); return () => { @@ -63,7 +63,7 @@ export default function useIntersectionObserver({ triggerOnce, skip, trackVisibility, - fallbackInView, + isIntersectingFallback, delay ]); diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts index 3a21944a..23f16efd 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -1,10 +1,10 @@ -export type TObserverInstanceCallback = (inView: boolean, entry: IntersectionObserverEntry) => void; +export type TObserverInstanceCallback = (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; export interface IObserveOptions { element: Element; callback: TObserverInstanceCallback; options: IntersectionObserverInit; - fallbackIsInView?: boolean; + isIntersectingFallback?: boolean; } export interface IObserverItem { @@ -20,20 +20,20 @@ export interface IUseIntersectionObserverOptions extends IntersectionObserverIni 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 inView callback once */ + /** Only trigger the isIntersecting callback once */ triggerOnce?: boolean; /** Skip assigning the observer to the `ref` */ skip?: boolean; - /** Set the initial value of the `inView` 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. */ + /** 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 inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */ - fallbackInView?: 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?: (inView: boolean, entry: IntersectionObserverEntry) => void; + onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; } export type IUseIntersectionObserverReturn = [ diff --git a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.tests.utilities.ts b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.tests.utilities.ts new file mode 100644 index 00000000..8de70597 --- /dev/null +++ b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.tests.utilities.ts @@ -0,0 +1,134 @@ +import { act } from '@testing-library/react-hooks'; + +interface IObserverData { + callback: IntersectionObserverCallback; + elements: Set; + created: number; +} + +let isMocking: boolean = 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) { + 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() { + 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 +) { + 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) { + 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) { + 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/useIntersectionObserver.utilities.ts b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts similarity index 90% rename from hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts rename to hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts index 912c35fb..2330311a 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.utilities.ts +++ b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts @@ -1,4 +1,4 @@ -import type { IObserveOptions, IObserverItem, TObserverInstanceCallback } from './useIntersectionObserver.types'; +import type { IObserveOptions, IObserverItem, TObserverInstanceCallback } from '../useIntersectionObserver.types'; const observerMap = new Map(); @@ -77,11 +77,11 @@ export function createObserver(options: IntersectionObserverInit) { return instance; } -export function observe({ element, callback, options = {}, fallbackIsInView }: IObserveOptions) { - if (typeof window.IntersectionObserver === 'undefined' && fallbackIsInView !== undefined) { +export function observe({ element, callback, options = {}, isIntersectingFallback }: IObserveOptions) { + if (typeof window.IntersectionObserver === 'undefined' && isIntersectingFallback !== undefined) { const bounds = element.getBoundingClientRect(); - callback(fallbackIsInView, { - isIntersecting: fallbackIsInView, + callback(isIntersectingFallback, { + isIntersecting: isIntersectingFallback, target: element, intersectionRatio: typeof options.threshold === 'number' ? options.threshold : 0, time: 0, From 9a0f4b6f7586722dc54f06176c7cb85837381cda Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Mon, 11 Nov 2024 13:18:04 +0500 Subject: [PATCH 08/26] chore(hooks/useIntersectionObserver): removed unused imports --- .../src/__tests__/useIntersectionObserver.tests.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx index 22f2b21c..a0630e5d 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react'; -import React, { lazy, useCallback } from 'react'; +import { render } from '@testing-library/react'; +import React from 'react'; import { IUseIntersectionObserverOptions } from '../useIntersectionObserver.types'; import { intersectionMockInstance, From dbd14a2f7f40eac1d8f193439343bd11b1301381 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 13 Nov 2024 10:26:26 +0500 Subject: [PATCH 09/26] chore(hooks/useIntersectionObserver): update tests, stories --- .../useIntersectionObserver.stories.mdx | 78 ++++++++++++++++++- .../useIntersectionObserver.stories.tsx | 15 ++-- .../useIntersectionObserver.tests.tsx | 43 +++++----- ...useIntersectionObserver.utilities.tests.ts | 18 ++--- .../src/useIntersectionObserver.ts | 24 +++--- .../src/useIntersectionObserver.types.ts | 24 +++++- 6 files changed, 149 insertions(+), 53 deletions(-) diff --git a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx index b39fc7e2..f1409db9 100644 --- a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.mdx @@ -1,7 +1,83 @@ -import { Meta } from '@storybook/addon-docs'; +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 index ce2cf7a7..eb104ae0 100644 --- a/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx +++ b/hooks/use-intersection-observer/src/__stories__/useIntersectionObserver.stories.tsx @@ -1,5 +1,5 @@ import React, { useRef } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { StoryObj } from '@storybook/react'; import useIntersectionObserver from '../useIntersectionObserver'; import './useIntersectionObserver.stories.css'; @@ -47,7 +47,7 @@ const Template = ({ title, options, isExperimental }: ITemplateProps): JSX.Eleme
)}
- In View: + isIntersecting: {String(isIntersecting)} @@ -122,7 +122,12 @@ export const TriggerOnce: StoryObj = { export const OnChange: StoryObj = { args: { options: { - onChange: (inView: boolean) => alert(`inView: ${inView}`) + onChange: (isIntersecting: boolean, entry: IntersectionObserverEntry) => { + console.log('useIntersectionObserver onChange', isIntersecting, entry); + if (isIntersecting) { + alert(`isIntersecting: ${isIntersecting}`); + } + } }, title: 'OnChange' } @@ -138,7 +143,7 @@ export const Delay: StoryObj = { } }; -const meta: Meta = { +export default { title: 'hooks/useIntersectionObserver', component: Template, argTypes: { @@ -153,5 +158,3 @@ const meta: Meta = { } } }; - -export default meta; diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx index a0630e5d..a126a470 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx @@ -89,12 +89,11 @@ describe('hooks/useIntersectionObserver', () => { expect(instance.observe).toHaveBeenCalledWith(wrapper); }); - test('should create a hook inView', () => { + test('should create a hook isIntersecting', () => { const { getByText } = setupHookComponent(); - mockAllIsIntersecting(true); - getByText('true'); + expect(getByText('true')).toBeInTheDocument(); }); test('should mock thresholds', () => { @@ -102,28 +101,31 @@ describe('hooks/useIntersectionObserver', () => { options: { threshold: [0.5, 1] } }); mockAllIsIntersecting(0); - getByText('false'); + expect(getByText('false')).toBeInTheDocument(); + mockAllIsIntersecting(0.5); - getByText('true'); + expect(getByText('true')).toBeInTheDocument(); + mockAllIsIntersecting(1); - getByText('true'); + expect(getByText('true')).toBeInTheDocument(); }); - test('should create a hook with initialInView', () => { + test('should create a hook with isIntersectingInitial', () => { const { getByText } = setupHookComponent({ options: { isIntersectingInitial: true } }); + expect(getByText('true')).toBeInTheDocument(); - getByText('true'); mockAllIsIntersecting(false); - getByText('false'); + expect(getByText('false')).toBeInTheDocument(); }); test('should trigger a hook leaving view', () => { const { getByText } = setupHookComponent(); mockAllIsIntersecting(true); mockAllIsIntersecting(false); - getByText('false'); + + expect(getByText('false')).toBeInTheDocument(); }); test('should respect trigger once', () => { @@ -133,7 +135,7 @@ describe('hooks/useIntersectionObserver', () => { mockAllIsIntersecting(true); mockAllIsIntersecting(false); - getByText('true'); + expect(getByText('true')).toBeInTheDocument(); }); test('should trigger onChange', () => { @@ -156,19 +158,19 @@ describe('hooks/useIntersectionObserver', () => { test('should respect skip', () => { const { getByText, rerender } = setupHookComponent({ options: { skip: true } }); mockAllIsIntersecting(false); - getByText('false'); + expect(getByText('false')).toBeInTheDocument(); rerender(); mockAllIsIntersecting(true); - getByText('true'); + expect(getByText('true')).toBeInTheDocument(); }); test('should not reset current state if changing skip', () => { const { getByText, rerender } = setupHookComponent({ options: { skip: false } }); - mockAllIsIntersecting(true); rerender(); - getByText('true'); + + expect(getByText('true')).toBeInTheDocument(); }); test('should unmount the hook', () => { @@ -176,16 +178,17 @@ describe('hooks/useIntersectionObserver', () => { const wrapper = getByTestId('wrapper'); const instance = intersectionMockInstance(wrapper); unmount(); + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); }); - test('inView should be false when component is unmounted', () => { + test('isIntersecting should be false when component is unmounted', () => { const { rerender, getByText } = setupHookComponent({ unmount: false }); mockAllIsIntersecting(true); + expect(getByText('true')).toBeInTheDocument(); - getByText('true'); rerender(); - getByText('false'); + expect(getByText('false')).toBeInTheDocument(); }); test('should handle trackVisibility', () => { @@ -197,9 +200,9 @@ describe('hooks/useIntersectionObserver', () => { const { getByTestId, getByText } = setupHookComponentWithEntry({ options: { threshold: [0, 0.25, 0.5, 0.75, 1] } }); - const wrapper = getByTestId('wrapper'); mockIsIntersecting(wrapper, 0.5); - getByText(/intersectionRatio: 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 index b46f8abe..1ac657bb 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -40,7 +40,7 @@ describe('hooks/useIntersectionObserver/utilities', () => { rootMargin: '10px 10px', threshold: [0, 1] }) - ).toMatchInlineSnapshot(`"root_1,rootMargin_10px 10px,threshold_0,1"`); + ).toBe('root_1,rootMargin_10px 10px,threshold_0,1'); expect( optionsToId({ @@ -48,7 +48,7 @@ describe('hooks/useIntersectionObserver/utilities', () => { rootMargin: '10px 10px', threshold: 1 }) - ).toMatchInlineSnapshot(`"root_0,rootMargin_10px 10px,threshold_1"`); + ).toBe('root_0,rootMargin_10px 10px,threshold_1'); expect( optionsToId({ @@ -57,18 +57,10 @@ describe('hooks/useIntersectionObserver/utilities', () => { trackVisibility: true, delay: 500 }) - ).toMatchInlineSnapshot(`"delay_500,threshold_0,trackVisibility_true"`); + ).toBe('delay_500,threshold_0,trackVisibility_true'); - expect( - optionsToId({ - threshold: 0 - }) - ).toMatchInlineSnapshot(`"threshold_0"`); + expect(optionsToId({ threshold: 0 })).toBe('threshold_0'); - expect( - optionsToId({ - threshold: [0, 0.5, 1] - }) - ).toMatchInlineSnapshot(`"threshold_0,0.5,1"`); + expect(optionsToId({ threshold: [0, 0.5, 1] })).toBe('threshold_0,0.5,1'); }); }); diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts index cf776083..d821ea12 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -1,6 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { observe } from './utilities/useIntersectionObserver.utilities'; -import type { IUseIntersectionObserverReturn, IUseIntersectionObserverOptions } from './useIntersectionObserver.types'; +import type { + IUseIntersectionObserverReturn, + IUseIntersectionObserverOptions, + IUseIntersectionObserverTuple, + IUseIntersectionObserverObject +} from './useIntersectionObserver.types'; export default function useIntersectionObserver({ threshold, @@ -15,7 +20,7 @@ export default function useIntersectionObserver({ onChange }: IUseIntersectionObserverOptions = {}): IUseIntersectionObserverReturn { const [ref, setRef] = useState(null); - const [isIntersecting, setIsIntersecting] = useState(!!isIntersectingInitial); + const [isIntersecting, setIsIntersecting] = useState(Boolean(isIntersectingInitial)); const [entry, setEntry] = useState(undefined); const callback = useRef(); @@ -71,15 +76,16 @@ export default function useIntersectionObserver({ if (!ref && entryTarget && !triggerOnce && !skip && previousEntryTarget.current !== entryTarget) { previousEntryTarget.current = entryTarget; - setIsIntersecting(!!isIntersectingInitial); + setIsIntersecting(Boolean(isIntersectingInitial)); setEntry(undefined); } - const result = [setRef, isIntersecting, entry] as IUseIntersectionObserverReturn; + const tupleReturn: IUseIntersectionObserverTuple = [setRef, isIntersecting, entry]; + const objectReturn: IUseIntersectionObserverObject = { + ref: setRef, + isIntersecting, + entry + }; - result.ref = result[0]; - result.isIntersecting = result[1]; - result.entry = result[2]; - - return result; + 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 index 23f16efd..04cbf612 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -1,3 +1,5 @@ +import { Dispatch, SetStateAction } from 'react'; + export type TObserverInstanceCallback = (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; export interface IObserveOptions { @@ -36,12 +38,26 @@ export interface IUseIntersectionObserverOptions extends IntersectionObserverIni onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; } -export type IUseIntersectionObserverReturn = [ - (node?: Element | null) => void, +// export type IUseIntersectionObserverReturn = [ +// (node?: Element | null) => void, +// boolean, +// IntersectionObserverEntry | undefined +// ] & { +// ref: (node?: Element | null) => void; +// isIntersecting: boolean; +// entry?: IntersectionObserverEntry; +// }; + +export type IUseIntersectionObserverTuple = [ + Dispatch>, boolean, IntersectionObserverEntry | undefined -] & { - ref: (node?: Element | null) => void; +]; + +export type IUseIntersectionObserverObject = { + ref: Dispatch>; isIntersecting: boolean; entry?: IntersectionObserverEntry; }; + +export type IUseIntersectionObserverReturn = IUseIntersectionObserverTuple & IUseIntersectionObserverObject; From 72c3977526112157750af201139f6bfc9c4d6d0b Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 13 Nov 2024 10:32:51 +0500 Subject: [PATCH 10/26] chore(hooks/useIntersectionObserver): readme --- hooks/use-intersection-observer/README.md | 26 +++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/hooks/use-intersection-observer/README.md b/hooks/use-intersection-observer/README.md index 6f07af58..61a91547 100644 --- a/hooks/use-intersection-observer/README.md +++ b/hooks/use-intersection-observer/README.md @@ -70,17 +70,15 @@ const Component = () => { 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 in view 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. | -| **initialInView** | `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. | -| **fallbackInView** | `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()` | - - +| 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()` | From db5ca4241e3d70532f50f96d4969fdde4f1e907f Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 13 Nov 2024 10:38:09 +0500 Subject: [PATCH 11/26] chore(hooks/useIntersectionObserver): deleted comments --- .../src/useIntersectionObserver.types.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts index 04cbf612..95ce00e9 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -38,16 +38,6 @@ export interface IUseIntersectionObserverOptions extends IntersectionObserverIni onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; } -// export type IUseIntersectionObserverReturn = [ -// (node?: Element | null) => void, -// boolean, -// IntersectionObserverEntry | undefined -// ] & { -// ref: (node?: Element | null) => void; -// isIntersecting: boolean; -// entry?: IntersectionObserverEntry; -// }; - export type IUseIntersectionObserverTuple = [ Dispatch>, boolean, From c7a0937096bd1e8e4b3171eb9ceae3365c9ea4ef Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Fri, 15 Nov 2024 07:07:20 +0500 Subject: [PATCH 12/26] chore(hooks/useIntersectionObserver): rename tests helpers --- hooks/use-intersection-observer/package.json | 2 +- .../useIntersectionObserver.mocks.ts} | 0 .../src/__tests__/useIntersectionObserver.tests.tsx | 2 +- .../useIntersectionObserver.utilities.tests.ts | 2 +- .../src/useIntersectionObserver.types.ts | 10 ++++------ 5 files changed, 7 insertions(+), 9 deletions(-) rename hooks/use-intersection-observer/src/{utilities/useIntersectionObserver.tests.utilities.ts => __tests__/useIntersectionObserver.mocks.ts} (100%) diff --git a/hooks/use-intersection-observer/package.json b/hooks/use-intersection-observer/package.json index 9dce5a62..5f9af423 100644 --- a/hooks/use-intersection-observer/package.json +++ b/hooks/use-intersection-observer/package.json @@ -9,7 +9,7 @@ "hook", "intersection-observer" ], - "author": "", + "author": "Gleb Fomin ", "homepage": "https://github.com/Byndyusoft/ui/tree/master/hooks/use-intersection-observer#readme", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.tests.utilities.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts similarity index 100% rename from hooks/use-intersection-observer/src/utilities/useIntersectionObserver.tests.utilities.ts rename to hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx index a126a470..aa90bbd3 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.tests.tsx @@ -7,7 +7,7 @@ import { mockIsIntersecting, resetIntersectionMocking, setupIntersectionMocking -} from '../utilities/useIntersectionObserver.tests.utilities'; +} from './useIntersectionObserver.mocks'; import useIntersectionObserver from '../useIntersectionObserver'; interface IComponentProps { diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts index 1ac657bb..d5e3e577 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -4,7 +4,7 @@ import { mockIsIntersecting, resetIntersectionMocking, setupIntersectionMocking -} from '../utilities/useIntersectionObserver.tests.utilities'; +} from './useIntersectionObserver.mocks'; describe('hooks/useIntersectionObserver/utilities', () => { beforeEach(() => { diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts index 95ce00e9..8184c344 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.types.ts @@ -38,14 +38,12 @@ export interface IUseIntersectionObserverOptions extends IntersectionObserverIni onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; } -export type IUseIntersectionObserverTuple = [ - Dispatch>, - boolean, - IntersectionObserverEntry | undefined -]; +type TSetRef = Dispatch>; + +export type IUseIntersectionObserverTuple = [TSetRef, boolean, IntersectionObserverEntry | undefined]; export type IUseIntersectionObserverObject = { - ref: Dispatch>; + ref: TSetRef; isIntersecting: boolean; entry?: IntersectionObserverEntry; }; From 7d9e1520a6804c46b824348d9423836d6bd22446 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Mon, 18 Nov 2024 16:57:28 +0500 Subject: [PATCH 13/26] feat(image): integration with useIntersectionObserver --- components/Image/src/Image.tsx | 28 ---- components/Image/src/Image.types.ts | 7 - components/Image/src/index.ts | 5 - components/Image/src/useStateImage.ts | 23 ---- components/image/.npmignore | 1 + components/image/README.md | 84 ++++++++++++ components/image/package.json | 37 ++++++ components/image/src/Image.tsx | 34 +++++ components/image/src/Image.types.ts | 10 ++ .../src/__stories__/Image.stories.module.css | 35 +++++ .../image/src/__stories__/Image.stories.tsx | 122 ++++++++++++++++++ .../src/__stories__/image-placeholder.png | Bin 0 -> 3783 bytes components/image/src/index.ts | 5 + components/image/src/useImage.ts | 48 +++++++ components/image/tsconfig.build.json | 4 + components/image/tsconfig.json | 11 ++ 16 files changed, 391 insertions(+), 63 deletions(-) delete mode 100644 components/Image/src/Image.tsx delete mode 100644 components/Image/src/Image.types.ts delete mode 100644 components/Image/src/index.ts delete mode 100644 components/Image/src/useStateImage.ts create mode 100644 components/image/.npmignore create mode 100644 components/image/README.md create mode 100644 components/image/package.json create mode 100644 components/image/src/Image.tsx create mode 100644 components/image/src/Image.types.ts create mode 100644 components/image/src/__stories__/Image.stories.module.css create mode 100644 components/image/src/__stories__/Image.stories.tsx create mode 100644 components/image/src/__stories__/image-placeholder.png create mode 100644 components/image/src/index.ts create mode 100644 components/image/src/useImage.ts create mode 100644 components/image/tsconfig.build.json create mode 100644 components/image/tsconfig.json diff --git a/components/Image/src/Image.tsx b/components/Image/src/Image.tsx deleted file mode 100644 index 9138aab4..00000000 --- a/components/Image/src/Image.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC } from 'react'; -import useStateImage from "./useStateImage"; -import { ImageProps } from "./Image.types"; - -const Image: FC = (props) => { - const { - className, - src, - alt = 'image', - errorFallback, - fallback, - ...otherProps - } = props; - - const { isLoading, isError } = useStateImage(src); - - if (isLoading && fallback) { - return fallback; - } - - if (isError && errorFallback) { - return errorFallback; - } - - return {alt}; -}; - -export default Image; diff --git a/components/Image/src/Image.types.ts b/components/Image/src/Image.types.ts deleted file mode 100644 index 60261d53..00000000 --- a/components/Image/src/Image.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ImgHTMLAttributes, ReactElement } from 'react'; - -export interface ImageProps extends ImgHTMLAttributes { - className?: string; - fallback?: ReactElement; - errorFallback?: ReactElement; -} diff --git a/components/Image/src/index.ts b/components/Image/src/index.ts deleted file mode 100644 index b4877e0b..00000000 --- a/components/Image/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Image from './Image'; - -export { default as useStateImage } from './useStateImage'; -export { ImageProps } from './Image.types'; -export default Image; diff --git a/components/Image/src/useStateImage.ts b/components/Image/src/useStateImage.ts deleted file mode 100644 index 0122350b..00000000 --- a/components/Image/src/useStateImage.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useLayoutEffect, useState } from "react"; - -export default function useStateImage(src: string | undefined) { - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - - useLayoutEffect(() => { - const img = new Image(); - img.src = src ?? ''; - img.onload = () => { - setIsLoading(false); - }; - img.onerror = () => { - setIsLoading(false); - setIsError(true); - }; - }, [src]); - - return { - isLoading, - isError, - } -} 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..19d6ff15 --- /dev/null +++ b/components/image/README.md @@ -0,0 +1,84 @@ +# `@byndyusoft-ui/image` +--- + + +### Installation + +``` +npm i @byndyusoft-ui/image +``` + +### 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/components/image/package.json b/components/image/package.json new file mode 100644 index 00000000..4fc3c6d3 --- /dev/null +++ b/components/image/package.json @@ -0,0 +1,37 @@ +{ + "name": "@byndyusoft-ui/image", + "version": "0.0.1", + "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..f0d92385 --- /dev/null +++ b/components/image/src/Image.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import useImage from './useImage'; +import { ImageProps } from './Image.types'; + +const Image = forwardRef((props, forwardedRef) => { + const { className, src, alt = '', lazy = true, errorFallback, fallback, fallbackUrl, ...otherProps } = props; + + const internalRef = useRef(null); + + const { isLoading, isError, setObserverTargetRef } = useImage({ src, lazy }); + + useImperativeHandle(forwardedRef, () => internalRef.current as HTMLImageElement); + + const setRefs = (node: HTMLImageElement | null) => { + internalRef.current = node; + setObserverTargetRef(node); + }; + + if (fallback && isLoading) { + return
{fallback}
; + } + + if (fallbackUrl && isLoading) { + return {alt}; + } + + if (errorFallback && isError) { + return
{errorFallback}
; + } + + return {alt}; +}); + +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..e1d669d2 --- /dev/null +++ b/components/image/src/Image.types.ts @@ -0,0 +1,10 @@ +import { ImgHTMLAttributes, ReactElement } from 'react'; + +export interface ImageProps extends ImgHTMLAttributes { + src: string; + className?: string; + fallback?: ReactElement; + fallbackUrl?: string; + errorFallback?: ReactElement; + lazy?: boolean; +} 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..ea75e9b4 --- /dev/null +++ b/components/image/src/__stories__/Image.stories.module.css @@ -0,0 +1,35 @@ +.wrapper { + margin: auto; + width: 800px; + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.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; +} + +@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..5c30840d --- /dev/null +++ b/components/image/src/__stories__/Image.stories.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { StoryObj } from '@storybook/react'; +import Image from '../Image'; +import { ImageProps } 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 { + mockImageIds: Array; + imageProps: Partial; +} + +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) => { + return
; +}; + +const ErrorFallbackComponent = ({ width, height }: ISkeletonProps) => { + return ( +
+ Error Fallback +
+ ); +}; + +const Template = ({ mockImageIds, imageProps }: ITemplateProps): JSX.Element => { + const ref = useRef(null); + + useEffect(() => { + console.log('Template ref', ref.current); + }, []); + return ( +
+ {mockImageIds?.map((id, index) => ( + + ))} +
+ ); +}; + +export const LazyFallbackSkeleton: StoryObj = { + args: { + mockImageIds: generateRandomArray(826, 100), + imageProps: { + width: 300, + fallback: , + errorFallback: + } + } +}; + +export const LazyFallbackUrl: StoryObj = { + args: { + mockImageIds: generateRandomArray(826, 100), + imageProps: { + width: 300, + height: 300, + fallbackUrl: ImagePlaceholder, + errorFallback: + } + } +}; + +export const PreloadFallbackSkeleton: StoryObj = { + args: { + mockImageIds: generateRandomArray(826, 30), + imageProps: { + width: 300, + height: 300, + lazy: false, + fallback: , + errorFallback: + } + } +}; + +export const PreloadFallbackUrl: StoryObj = { + args: { + mockImageIds: generateRandomArray(826, 30), + imageProps: { + width: 300, + height: 300, + lazy: false, + fallbackUrl: ImagePlaceholder, + errorFallback: + } + } +}; + +export const ErrorFallback: StoryObj = { + args: { + mockImageIds: [-1, -1, -1, -1, -1, -1], + imageProps: { + width: 300, + fallback: , + errorFallback: + } + } +}; + +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 0000000000000000000000000000000000000000..66df081e7addbffbc6ee21b0b534405b00565548 GIT binary patch literal 3783 zcmeHK={MVn8ji@-v{SW>npi5GjIp-bPDw*;Q)-x6rm1B#LhjsKDu|($u_TeIjxpt` zt+Z%IOKC_fVN%qTwk|4`(2N8rN*I1oTS6kg+}uCme!a(s_se_EbI$X==XsvFr(hRc&$?;Ho%12#(mGog@T>?=vA#I81wo#)O9G>Zrl4!Qg|R zBdM;3)p~xZ^Y({nBW)b15a?+b+`7ifO~;kWTvY*HX(Q?P>R}!bwVq~mI8yrpmHGF2 z-A~n2;3NCs{e|s|6ohhfkWoO7GgyBl3`@Va;Fp4H=rn5}3*BIK7ZQ_^| zEn}O?EANwU{>^2lX|2p&RC}r(#Px)XX+|pHRn8d>d!T$R;b5|O3X?9KPVPku(_5}> z@EBoQ`Ny0L`AT0?xdgcHBwb;2<2Tt_$IgF7R&4WTr#q6ZUAQxw0#dh0o{3}ILTmk7 z_QLyLULXl42q4qx@5 zGE1VZ`Icit_t8O@ee$FDc9lsV?3`t&O1uLQ`BZT!ZOhZoCz!iJz>eDWD?avWwGi=& z@BAGb&S%KB%(2hTh|F(pKoekaV*k-OK+xVx>Q>lyQk3MK&0&|@*jnGAP@C@b-3{+K z@S9~$g=6nvscnRiV1wzIU>+$C%P+5AVL?^oKHe^<7ZL*|NAkS}=0Q2%X)n1k>!-uOul7zNc(W zoZM?iwXUD8JSy=_2hMKPR*^$=q6Y=;=e!$~V8~Kw4CkT#?rODepzCYE;z`b8X&Cu? z_EFZ--0s#AarE_H%XZaCeCd3?3#W!ub=di*jekt&jPKmSUo!Wr#O)R}yU5BbYb3m< z_Rm4q+XM}|SI*B_`u>~0<#K%txkP^>lj7FXavs7DkgZ&u=kavkUC9Q<#iA-oWye!t z-UzrepFca>d)xX3dh_7?nPCL;OS8{aWQKwoab+EW%+cGaspN-Ta0FYf4-_BFc4u{z=T@G@@n`ont(-AYC&3RlqBrX zv*W9Nnzg}b2Yq9TJ%K|_+@+&*1O2;ztrZC%ur}b-8Z;@1gcA;S(UIr`t3QL7s&N_y zBM`;Ro+!Yj$%y0@yw-N>$5+dBVTIGLa}6SdAuDaSSiT1Z45^LGt^(w~k?etbeM58j zMGV^UW|bfnn#J0_)V``O_zp`km#2DOHr=qC-D=C$QWV8CU^|y%^iWwI$rDoQ*y2<( zt!w0f9+IwgQ8MiOsI>3aCkBfr0~zLYmbk@G&(AihJ*+a8_Xq!n208tW$cem{BG&6p z5OEr0p|6~!MPt9An#VE9NlBONqDp|Muaz6pG|ZOrPdR0YTU`N;$6A%(Z*V&%<=p`;2qn=c%Fj23bvsM1hj(}oq~ht8u!*vn z_mhJ^6rAG&TEM6{$B?3ynyQF0rpSm+0C7I;Me25xm=}N4d8fkGn4k5yg>02UlnI*qVlB$}jt&R}3^OpfO$@^{;QK%=nH%{Jgb#LL6iz47aC+J6A0C`Oh5 literal 0 HcmV?d00001 diff --git a/components/image/src/index.ts b/components/image/src/index.ts new file mode 100644 index 00000000..f6f5cd80 --- /dev/null +++ b/components/image/src/index.ts @@ -0,0 +1,5 @@ +import Image from './Image'; + +export { type ImageProps } from './Image.types'; +export { default as 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..fd9641f8 --- /dev/null +++ b/components/image/src/useImage.ts @@ -0,0 +1,48 @@ +import { useLayoutEffect, useState } from 'react'; +import useIntersectionObserver from '@byndyusoft-ui/use-intersection-observer'; + +interface IUseImageProps { + src: string; + lazy?: boolean; +} + +const loadImage = (src: string, setIsLoading: (loading: boolean) => void, setIsError: (error: boolean) => void) => { + const img = new Image(); + img.src = src; + img.onload = () => { + setIsLoading(false); + }; + img.onerror = () => { + setIsLoading(false); + setIsError(true); + }; +}; + +export default function useImage({ src, lazy }: IUseImageProps) { + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + const [setObserverTargetRef] = useIntersectionObserver({ + skip: !lazy, + triggerOnce: true, + rootMargin: '20px', + threshold: 0, + onChange: isIntersecting => { + if (lazy && isIntersecting) { + loadImage(src, setIsLoading, setIsError); + } + } + }); + + 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..5b7870da --- /dev/null +++ b/components/image/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs", + "target": "es6" + }, + "include": ["src"] +} From ce4c89efc0e7ea0e2fd5e2b918d7b2b5a395b34e Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Tue, 19 Nov 2024 12:41:10 +0500 Subject: [PATCH 14/26] feat(image): add tests and readme --- components/image/README.md | 104 +++++++----------- components/image/src/Image.tsx | 41 +++++-- components/image/src/Image.types.ts | 26 ++++- .../image/src/__stories__/Image.stories.tsx | 38 ++++--- .../image/src/__tests__/Image.tests.tsx | 96 ++++++++++++++++ components/image/src/index.ts | 2 +- components/image/src/useImage.ts | 16 +-- components/image/tsconfig.json | 8 +- 8 files changed, 224 insertions(+), 107 deletions(-) create mode 100644 components/image/src/__tests__/Image.tests.tsx diff --git a/components/image/README.md b/components/image/README.md index 19d6ff15..a97e0df7 100644 --- a/components/image/README.md +++ b/components/image/README.md @@ -1,84 +1,58 @@ # `@byndyusoft-ui/image` --- - ### Installation - -``` +```sh npm i @byndyusoft-ui/image +# or +yarn add @byndyusoft-ui/image ``` -### 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 +### Usage Image +#### Basic usage ```jsx -import React from "react"; -import { useIntersectionObserver } from "@byndyusoft-ui/use-intersection-observer"; - -const Component = () => { - const { ref, isIntersecting, entry } = useIntersectionObserver(); +import React from 'react'; +import Image from '@byndyusoft-ui/image'; +const App = () => { return ( -
-
- {`isIntersecting: ${isIntersecting}`} -
-
+ Example Image ); }; -``` -### Usage `useIntersectionObserver` with options +export default App; +``` +#### With fallback content ```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}`} -
-
- ); -}; +Example ImageLoading...
} + errorFallback={
Error loading image
} +/> ``` -### Options -Provide these as the options argument in the `useIntersectionObserver ` hook. +#### With fallback placeholder images +```jsx +Example Image +``` -| 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()` | +#### With custom class names +```jsx + Example Image +``` diff --git a/components/image/src/Image.tsx b/components/image/src/Image.tsx index f0d92385..caf1c238 100644 --- a/components/image/src/Image.tsx +++ b/components/image/src/Image.tsx @@ -1,9 +1,20 @@ import React, { forwardRef, useImperativeHandle, useRef } from 'react'; -import useImage from './useImage'; -import { ImageProps } from './Image.types'; - -const Image = forwardRef((props, forwardedRef) => { - const { className, src, alt = '', lazy = true, errorFallback, fallback, fallbackUrl, ...otherProps } = props; +import { useImage } from './useImage'; +import type { IImageProps } from './Image.types'; + +const Image = forwardRef((props, forwardedRef) => { + const { + src, + alt = '', + lazy = true, + fallback, + fallbackSrc, + errorFallback, + errorFallbackSrc, + className, + fallbackClassName, + ...otherProps + } = props; const internalRef = useRef(null); @@ -17,15 +28,27 @@ const Image = forwardRef((props, forwardedRef) => }; if (fallback && isLoading) { - return
{fallback}
; + return ( +
+ {fallback} +
+ ); } - if (fallbackUrl && isLoading) { - return {alt}; + if (fallbackSrc && isLoading) { + return {alt}; } if (errorFallback && isError) { - return
{errorFallback}
; + return ( +
+ {errorFallback} +
+ ); + } + + if (errorFallbackSrc && isError) { + return {alt}; } return {alt}; diff --git a/components/image/src/Image.types.ts b/components/image/src/Image.types.ts index e1d669d2..a82c74ae 100644 --- a/components/image/src/Image.types.ts +++ b/components/image/src/Image.types.ts @@ -1,10 +1,30 @@ -import { ImgHTMLAttributes, ReactElement } from 'react'; +import { Dispatch, ImgHTMLAttributes, ReactElement, SetStateAction } from 'react'; +import { Callback } from '@byndyusoft-ui/types'; -export interface ImageProps extends ImgHTMLAttributes { +export interface IImageProps extends ImgHTMLAttributes { src: string; className?: string; + fallbackClassName?: string; fallback?: ReactElement; - fallbackUrl?: string; + fallbackSrc?: string; errorFallback?: ReactElement; + errorFallbackSrc?: string; lazy?: boolean; } + +type TSetState = Dispatch>; + +export interface IUseImageProps { + src: string; + lazy?: boolean; +} + +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.tsx b/components/image/src/__stories__/Image.stories.tsx index 5c30840d..8e62f058 100644 --- a/components/image/src/__stories__/Image.stories.tsx +++ b/components/image/src/__stories__/Image.stories.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; import type { StoryObj } from '@storybook/react'; import Image from '../Image'; -import { ImageProps } from '../Image.types'; -import ImagePlaceholder from './image-placeholder.png'; +import { IImageProps } from '../Image.types'; +import imagePlaceholder from './image-placeholder.png'; import cls from './Image.stories.module.css'; interface ISkeletonProps { @@ -12,7 +12,7 @@ interface ISkeletonProps { interface ITemplateProps { mockImageIds: Array; - imageProps: Partial; + imageProps: Partial; } const generateRandomArray = (maxNumber: number, length: number): number[] => { @@ -37,18 +37,12 @@ const ErrorFallbackComponent = ({ width, height }: ISkeletonProps) => { }; const Template = ({ mockImageIds, imageProps }: ITemplateProps): JSX.Element => { - const ref = useRef(null); - - useEffect(() => { - console.log('Template ref', ref.current); - }, []); return (
{mockImageIds?.map((id, index) => ( ))} @@ -61,19 +55,20 @@ export const LazyFallbackSkeleton: StoryObj = { mockImageIds: generateRandomArray(826, 100), imageProps: { width: 300, + height: 300, fallback: , errorFallback: } } }; -export const LazyFallbackUrl: StoryObj = { +export const LazyFallbackSrc: StoryObj = { args: { mockImageIds: generateRandomArray(826, 100), imageProps: { width: 300, height: 300, - fallbackUrl: ImagePlaceholder, + fallbackSrc: imagePlaceholder, errorFallback: } } @@ -92,14 +87,14 @@ export const PreloadFallbackSkeleton: StoryObj = { } }; -export const PreloadFallbackUrl: StoryObj = { +export const PreloadFallbackSrc: StoryObj = { args: { mockImageIds: generateRandomArray(826, 30), imageProps: { width: 300, height: 300, lazy: false, - fallbackUrl: ImagePlaceholder, + fallbackSrc: imagePlaceholder, errorFallback: } } @@ -110,12 +105,25 @@ export const ErrorFallback: StoryObj = { mockImageIds: [-1, -1, -1, -1, -1, -1], imageProps: { width: 300, + height: 300, fallback: , errorFallback: } } }; +export const ErrorFallbackSrc: StoryObj = { + args: { + mockImageIds: [-1, -1, -1, -1, -1, -1], + imageProps: { + width: 300, + height: 300, + fallback: , + errorFallbackSrc: imagePlaceholder + } + } +}; + export default { title: 'components/Image', component: Template diff --git a/components/image/src/__tests__/Image.tests.tsx b/components/image/src/__tests__/Image.tests.tsx new file mode 100644 index 00000000..f6f5c436 --- /dev/null +++ b/components/image/src/__tests__/Image.tests.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { render, RenderResult, screen } from '@testing-library/react'; +import Image from '../Image'; +import { IImageProps, IUseImageReturn } from '../Image.types'; +import { + resetIntersectionMocking, + setupIntersectionMocking +} from '@byndyusoft-ui/use-intersection-observer/dist/__tests__/useIntersectionObserver.mocks'; +import { useImage } from '../useImage'; + +jest.mock('../useImage'); + +interface ISetupProps { + imageProps?: Partial; + useImageReturn?: Partial; +} + +const useImageReturnMock = ({ + isLoading = false, + isError = false, + setObserverTargetRef = jest.fn() +}: Partial = {}) => { + (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' + }; + + useImageReturnMock(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/index.ts b/components/image/src/index.ts index f6f5cd80..31577deb 100644 --- a/components/image/src/index.ts +++ b/components/image/src/index.ts @@ -1,5 +1,5 @@ import Image from './Image'; -export { type ImageProps } from './Image.types'; +export { type IImageProps } from './Image.types'; export { default as useImage } from './useImage'; export default Image; diff --git a/components/image/src/useImage.ts b/components/image/src/useImage.ts index fd9641f8..3ac95f8d 100644 --- a/components/image/src/useImage.ts +++ b/components/image/src/useImage.ts @@ -1,12 +1,8 @@ import { useLayoutEffect, useState } from 'react'; import useIntersectionObserver from '@byndyusoft-ui/use-intersection-observer'; +import { IUseImageProps, IUseImageReturn, TLoadImageFunction } from './Image.types'; -interface IUseImageProps { - src: string; - lazy?: boolean; -} - -const loadImage = (src: string, setIsLoading: (loading: boolean) => void, setIsError: (error: boolean) => void) => { +const loadImage: TLoadImageFunction = (src, setIsLoading, setIsError) => { const img = new Image(); img.src = src; img.onload = () => { @@ -18,17 +14,15 @@ const loadImage = (src: string, setIsLoading: (loading: boolean) => void, setIsE }; }; -export default function useImage({ src, lazy }: IUseImageProps) { +export const useImage = ({ src, lazy }: IUseImageProps): IUseImageReturn => { const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); const [setObserverTargetRef] = useIntersectionObserver({ skip: !lazy, triggerOnce: true, - rootMargin: '20px', - threshold: 0, onChange: isIntersecting => { - if (lazy && isIntersecting) { + if (isIntersecting) { loadImage(src, setIsLoading, setIsError); } } @@ -45,4 +39,4 @@ export default function useImage({ src, lazy }: IUseImageProps) { isLoading, isError }; -} +}; diff --git a/components/image/tsconfig.json b/components/image/tsconfig.json index 5b7870da..567aaea9 100644 --- a/components/image/tsconfig.json +++ b/components/image/tsconfig.json @@ -4,8 +4,10 @@ "declaration": true, "declarationDir": "dist", "outDir": "dist", - "module": "commonjs", - "target": "es6" + "module": "commonjs" }, - "include": ["src"] + "include": [ + "../../types.d.ts", + "src" + ] } From 5859ffacb591e31728e2289770474f4d86f2c1f6 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Tue, 19 Nov 2024 12:44:04 +0500 Subject: [PATCH 15/26] chore(useIntersectionObserver): import tests helpers --- hooks/use-intersection-observer/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts index fcd200c0..8b7ffbff 100644 --- a/hooks/use-intersection-observer/src/index.ts +++ b/hooks/use-intersection-observer/src/index.ts @@ -1,5 +1,6 @@ import useIntersectionObserver from './useIntersectionObserver'; export type { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from './useIntersectionObserver.types'; +export * from './__tests__/useIntersectionObserver.mocks'; export { useIntersectionObserver }; export default useIntersectionObserver; From 300d006b3d2b6790d855cdce840e92fbfc5ae64f Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 20 Nov 2024 07:00:19 +0500 Subject: [PATCH 16/26] chore(useIntersectionObserver): import tests helpers --- hooks/use-intersection-observer/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts index 8b7ffbff..2183fde3 100644 --- a/hooks/use-intersection-observer/src/index.ts +++ b/hooks/use-intersection-observer/src/index.ts @@ -1,6 +1,12 @@ import useIntersectionObserver from './useIntersectionObserver'; export type { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from './useIntersectionObserver.types'; -export * from './__tests__/useIntersectionObserver.mocks'; +export { + intersectionMockInstance, + mockIsIntersecting, + setupIntersectionMocking, + mockAllIsIntersecting, + resetIntersectionMocking +} from './__tests__/useIntersectionObserver.mocks'; export { useIntersectionObserver }; export default useIntersectionObserver; From 3b954216fa43e6914604f25cbc009e79ef1d04ad Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 20 Nov 2024 07:19:56 +0500 Subject: [PATCH 17/26] chore(useIntersectionObserver): delete import tests helpers --- hooks/use-intersection-observer/src/index.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/hooks/use-intersection-observer/src/index.ts b/hooks/use-intersection-observer/src/index.ts index 2183fde3..fcd200c0 100644 --- a/hooks/use-intersection-observer/src/index.ts +++ b/hooks/use-intersection-observer/src/index.ts @@ -1,12 +1,5 @@ import useIntersectionObserver from './useIntersectionObserver'; export type { IUseIntersectionObserverOptions, IUseIntersectionObserverReturn } from './useIntersectionObserver.types'; -export { - intersectionMockInstance, - mockIsIntersecting, - setupIntersectionMocking, - mockAllIsIntersecting, - resetIntersectionMocking -} from './__tests__/useIntersectionObserver.mocks'; export { useIntersectionObserver }; export default useIntersectionObserver; From aa04f8316504e7d046081345c5e798f9b1e239dc Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 20 Nov 2024 07:39:23 +0500 Subject: [PATCH 18/26] chore(image): update readme, add stories docs --- components/image/README.md | 7 +++++-- components/image/src/__stories__/Image.stories.mdx | 7 +++++++ components/image/src/__tests__/Image.tests.tsx | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 components/image/src/__stories__/Image.stories.mdx diff --git a/components/image/README.md b/components/image/README.md index a97e0df7..d3e0eab0 100644 --- a/components/image/README.md +++ b/components/image/README.md @@ -27,7 +27,7 @@ const App = () => { export default App; ``` -#### With fallback content +#### With fallback component ```jsx ``` -#### With fallback placeholder images +#### With fallback src images ```jsx Example ImageLoading...
} + errorFallback={
Error loading image
} /> ``` diff --git a/components/image/src/__stories__/Image.stories.mdx b/components/image/src/__stories__/Image.stories.mdx new file mode 100644 index 00000000..676d1699 --- /dev/null +++ b/components/image/src/__stories__/Image.stories.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs'; +import { Markdown } from '@storybook/blocks'; +import Readme from '../../README.md'; + + + +{Readme} diff --git a/components/image/src/__tests__/Image.tests.tsx b/components/image/src/__tests__/Image.tests.tsx index f6f5c436..18539836 100644 --- a/components/image/src/__tests__/Image.tests.tsx +++ b/components/image/src/__tests__/Image.tests.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { render, RenderResult, screen } from '@testing-library/react'; -import Image from '../Image'; -import { IImageProps, IUseImageReturn } from '../Image.types'; import { resetIntersectionMocking, setupIntersectionMocking } from '@byndyusoft-ui/use-intersection-observer/dist/__tests__/useIntersectionObserver.mocks'; +import Image from '../Image'; +import { IImageProps, IUseImageReturn } from '../Image.types'; import { useImage } from '../useImage'; jest.mock('../useImage'); From 0a70d56bc8d66721578d248dd4f05aa9b14bb05a Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 20 Nov 2024 08:04:20 +0500 Subject: [PATCH 19/26] chore(image): improve code structure and readability --- components/image/src/Image.tsx | 46 +++++++++++++++------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/components/image/src/Image.tsx b/components/image/src/Image.tsx index caf1c238..243b528b 100644 --- a/components/image/src/Image.tsx +++ b/components/image/src/Image.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import React, { forwardRef, ReactElement, useImperativeHandle, useRef } from 'react'; import { useImage } from './useImage'; import type { IImageProps } from './Image.types'; @@ -27,31 +27,25 @@ const Image = forwardRef((props, forwardedRef) => setObserverTargetRef(node); }; - if (fallback && isLoading) { - return ( -
- {fallback} -
- ); - } - - if (fallbackSrc && isLoading) { - return {alt}; - } - - if (errorFallback && isError) { - return ( -
- {errorFallback} -
- ); - } - - if (errorFallbackSrc && isError) { - return {alt}; - } - - return {alt}; + const renderImage = (src: string): JSX.Element => ( + {alt} + ); + + const renderFallback = (content: ReactElement): JSX.Element => ( +
+ {content} +
+ ); + + if (fallback && isLoading) return renderFallback(fallback); + + if (fallbackSrc && isLoading) return renderImage(fallbackSrc); + + if (errorFallback && isError) return renderFallback(errorFallback); + + if (errorFallbackSrc && isError) return renderImage(errorFallbackSrc); + + return renderImage(src); }); export default Image; From 16db302cb6622d584851647ee19338d33650265d Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 20 Nov 2024 08:23:28 +0500 Subject: [PATCH 20/26] chore(image): update readme --- components/image/README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/components/image/README.md b/components/image/README.md index d3e0eab0..5c5f3ac7 100644 --- a/components/image/README.md +++ b/components/image/README.md @@ -27,7 +27,7 @@ const App = () => { export default App; ``` -#### With fallback component +#### Fallback components ```jsx ``` -#### With fallback src images +#### Fallback src images ```jsx ``` -#### With custom class names +#### Custom class names The fallbackClassName parameter applies a class to the container that will display the fallback or errorFallback elements. ```jsx Error loading image
} /> ``` + +#### Lazy loading +By default, `lazy` is set to `true`, which means the image will only be loaded when it enters the viewport. +This is achieved using the Intersection Observer pattern. If `lazy` is set to `false`, the image will be loaded immediately. +```jsx +Example Image Date: Fri, 22 Nov 2024 07:18:54 +0500 Subject: [PATCH 21/26] chore(image): add mocks for testing --- .../image/src/__tests__/Image.tests.tsx | 5 +- components/image/src/__tests__/image.mocks.ts | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 components/image/src/__tests__/image.mocks.ts diff --git a/components/image/src/__tests__/Image.tests.tsx b/components/image/src/__tests__/Image.tests.tsx index 18539836..28f787bd 100644 --- a/components/image/src/__tests__/Image.tests.tsx +++ b/components/image/src/__tests__/Image.tests.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { render, RenderResult, screen } from '@testing-library/react'; -import { - resetIntersectionMocking, - setupIntersectionMocking -} from '@byndyusoft-ui/use-intersection-observer/dist/__tests__/useIntersectionObserver.mocks'; +import { resetIntersectionMocking, setupIntersectionMocking } from './image.mocks'; import Image from '../Image'; import { IImageProps, IUseImageReturn } from '../Image.types'; import { useImage } from '../useImage'; diff --git a/components/image/src/__tests__/image.mocks.ts b/components/image/src/__tests__/image.mocks.ts new file mode 100644 index 00000000..1b0d5735 --- /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) { + 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() { + if ( + global.IntersectionObserver && + 'mockClear' in global.IntersectionObserver && + typeof global.IntersectionObserver.mockClear === 'function' + ) { + global.IntersectionObserver.mockClear(); + } + observersMap.clear(); +} From 5889695584fb97f01e50908d0b4ebd57be156bdb Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Thu, 28 Nov 2024 09:30:16 +0500 Subject: [PATCH 22/26] chore(image): add return types and fix errors --- components/image/README.md | 4 +- components/image/package.json | 2 +- components/image/src/Image.tsx | 20 ++++---- components/image/src/Image.types.ts | 2 +- .../image/src/__stories__/Image.stories.tsx | 46 +++++++++---------- .../image/src/__tests__/Image.tests.tsx | 6 +-- components/image/src/__tests__/image.mocks.ts | 4 +- components/image/src/index.ts | 2 +- 8 files changed, 42 insertions(+), 44 deletions(-) diff --git a/components/image/README.md b/components/image/README.md index 5c5f3ac7..ce6c60d3 100644 --- a/components/image/README.md +++ b/components/image/README.md @@ -48,13 +48,13 @@ export default App; ``` #### Custom class names -The fallbackClassName parameter applies a class to the container that will display the fallback or errorFallback elements. +The rootFallbackClassName parameter applies a class to the container that will display the fallback or errorFallback elements. ```jsx Example ImageLoading...
} errorFallback={
Error loading image
} /> diff --git a/components/image/package.json b/components/image/package.json index 4fc3c6d3..6eac0f4d 100644 --- a/components/image/package.json +++ b/components/image/package.json @@ -1,6 +1,6 @@ { "name": "@byndyusoft-ui/image", - "version": "0.0.1", + "version": "0.0.2", "description": "Byndyusoft UI Image React Component", "keywords": [ "byndyusoft", diff --git a/components/image/src/Image.tsx b/components/image/src/Image.tsx index 243b528b..b094b7ef 100644 --- a/components/image/src/Image.tsx +++ b/components/image/src/Image.tsx @@ -12,7 +12,7 @@ const Image = forwardRef((props, forwardedRef) => errorFallback, errorFallbackSrc, className, - fallbackClassName, + rootFallbackClassName, ...otherProps } = props; @@ -20,32 +20,34 @@ const Image = forwardRef((props, forwardedRef) => const { isLoading, isError, setObserverTargetRef } = useImage({ src, lazy }); - useImperativeHandle(forwardedRef, () => internalRef.current as HTMLImageElement); - - const setRefs = (node: HTMLImageElement | null) => { + const setRefs = (node: HTMLImageElement | null): void => { internalRef.current = node; setObserverTargetRef(node); }; - const renderImage = (src: string): JSX.Element => ( - {alt} + const renderImage = (imageSrc: string): JSX.Element => ( + {alt} ); const renderFallback = (content: ReactElement): JSX.Element => ( -
+
{content}
); - if (fallback && isLoading) return renderFallback(fallback); + useImperativeHandle(forwardedRef, () => internalRef.current as HTMLImageElement); - if (fallbackSrc && isLoading) return renderImage(fallbackSrc); + if (fallback && isLoading) return renderFallback(fallback); if (errorFallback && isError) return renderFallback(errorFallback); + 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 index a82c74ae..cb93accd 100644 --- a/components/image/src/Image.types.ts +++ b/components/image/src/Image.types.ts @@ -4,7 +4,7 @@ import { Callback } from '@byndyusoft-ui/types'; export interface IImageProps extends ImgHTMLAttributes { src: string; className?: string; - fallbackClassName?: string; + rootFallbackClassName?: string; fallback?: ReactElement; fallbackSrc?: string; errorFallback?: ReactElement; diff --git a/components/image/src/__stories__/Image.stories.tsx b/components/image/src/__stories__/Image.stories.tsx index 8e62f058..7008f54c 100644 --- a/components/image/src/__stories__/Image.stories.tsx +++ b/components/image/src/__stories__/Image.stories.tsx @@ -24,31 +24,27 @@ const generateRandomArray = (maxNumber: number, length: number): number[] => { return randomArray; }; -const FallbackSkeleton = ({ width, height }: ISkeletonProps) => { - return
; -}; +const FallbackSkeleton = ({ width, height }: ISkeletonProps): JSX.Element => ( +
+); -const ErrorFallbackComponent = ({ width, height }: ISkeletonProps) => { - return ( -
- Error Fallback -
- ); -}; +const ErrorFallbackComponent = ({ width, height }: ISkeletonProps): JSX.Element => ( +
+ Error Fallback +
+); -const Template = ({ mockImageIds, imageProps }: ITemplateProps): JSX.Element => { - return ( -
- {mockImageIds?.map((id, index) => ( - - ))} -
- ); -}; +const Template = ({ mockImageIds, imageProps }: ITemplateProps): JSX.Element => ( +
+ {mockImageIds?.map((id, index) => ( + + ))} +
+); export const LazyFallbackSkeleton: StoryObj = { args: { @@ -102,7 +98,7 @@ export const PreloadFallbackSrc: StoryObj = { export const ErrorFallback: StoryObj = { args: { - mockImageIds: [-1, -1, -1, -1, -1, -1], + mockImageIds: [-1, -2, -3, -4, -5, -6], imageProps: { width: 300, height: 300, @@ -114,7 +110,7 @@ export const ErrorFallback: StoryObj = { export const ErrorFallbackSrc: StoryObj = { args: { - mockImageIds: [-1, -1, -1, -1, -1, -1], + mockImageIds: [-1, -2, -3, -4, -5, -6], imageProps: { width: 300, height: 300, diff --git a/components/image/src/__tests__/Image.tests.tsx b/components/image/src/__tests__/Image.tests.tsx index 28f787bd..3cdc69f5 100644 --- a/components/image/src/__tests__/Image.tests.tsx +++ b/components/image/src/__tests__/Image.tests.tsx @@ -12,11 +12,11 @@ interface ISetupProps { useImageReturn?: Partial; } -const useImageReturnMock = ({ +const hookImageReturnMock = ({ isLoading = false, isError = false, setObserverTargetRef = jest.fn() -}: Partial = {}) => { +}: Partial = {}): void => { (useImage as jest.Mock).mockReturnValue({ isLoading, isError, @@ -31,7 +31,7 @@ const setup = (props: ISetupProps = {}): RenderResult => { alt: 'Test Image' }; - useImageReturnMock(useImageReturn); + hookImageReturnMock(useImageReturn); return render(); }; diff --git a/components/image/src/__tests__/image.mocks.ts b/components/image/src/__tests__/image.mocks.ts index 1b0d5735..e5561c5d 100644 --- a/components/image/src/__tests__/image.mocks.ts +++ b/components/image/src/__tests__/image.mocks.ts @@ -6,7 +6,7 @@ interface IObserverData { const observersMap = new Map(); -export function setupIntersectionMocking(mockFn: typeof jest.fn) { +export function setupIntersectionMocking(mockFn: typeof jest.fn): void { global.IntersectionObserver = mockFn((cb, options = {}) => { const observerData: IObserverData = { callback: cb, @@ -35,7 +35,7 @@ export function setupIntersectionMocking(mockFn: typeof jest.fn) { }); } -export function resetIntersectionMocking() { +export function resetIntersectionMocking(): void { if ( global.IntersectionObserver && 'mockClear' in global.IntersectionObserver && diff --git a/components/image/src/index.ts b/components/image/src/index.ts index 31577deb..c3f490df 100644 --- a/components/image/src/index.ts +++ b/components/image/src/index.ts @@ -1,5 +1,5 @@ import Image from './Image'; export { type IImageProps } from './Image.types'; -export { default as useImage } from './useImage'; +export { useImage } from './useImage'; export default Image; From a6f33276043731be6a594bcfbcc497c4e5760672 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Thu, 28 Nov 2024 11:48:59 +0500 Subject: [PATCH 23/26] chore(use-intersection-observer): add return types --- .../src/__tests__/useIntersectionObserver.mocks.ts | 10 +++++----- .../useIntersectionObserver.utilities.tests.ts | 2 +- .../src/useIntersectionObserver.ts | 2 +- .../utilities/useIntersectionObserver.utilities.ts | 14 +++++++------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts index 8de70597..9c65421f 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts @@ -15,7 +15,7 @@ function warnOnMissingSetup() { console.error('Intersection Observer was not configured to handle mocking'); } -export function setupIntersectionMocking(mockFn: typeof jest.fn) { +export function setupIntersectionMocking(mockFn: typeof jest.fn): void { global.IntersectionObserver = mockFn((cb, options = {}) => { const observerData: IObserverData = { callback: cb, @@ -46,7 +46,7 @@ export function setupIntersectionMocking(mockFn: typeof jest.fn) { isMocking = true; } -export function resetIntersectionMocking() { +export function resetIntersectionMocking(): void { if ( global.IntersectionObserver && 'mockClear' in global.IntersectionObserver && @@ -62,7 +62,7 @@ function triggerIntersection( trigger: boolean | number, observer: IntersectionObserver, observerData: IObserverData -) { +): void { const entries: IntersectionObserverEntry[] = []; const isIntersecting = @@ -103,7 +103,7 @@ function triggerIntersection( act(() => observerData.callback(entries, observer)); } -export function mockAllIsIntersecting(isIntersecting: boolean | number) { +export function mockAllIsIntersecting(isIntersecting: boolean | number): void { warnOnMissingSetup(); for (const [observer, observerData] of observersMap) { triggerIntersection(Array.from(observerData.elements), isIntersecting, observer, observerData); @@ -121,7 +121,7 @@ export function intersectionMockInstance(element: Element): IntersectionObserver throw new Error('Failed to find IntersectionObserver for element. Is it being observed?'); } -export function mockIsIntersecting(element: Element, isIntersecting: boolean | number) { +export function mockIsIntersecting(element: Element, isIntersecting: boolean | number): void { warnOnMissingSetup(); const observer = intersectionMockInstance(element); if (!observer) { diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts index d5e3e577..aca70fd5 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.utilities.tests.ts @@ -53,7 +53,7 @@ describe('hooks/useIntersectionObserver/utilities', () => { expect( optionsToId({ threshold: 0, - // @ts-ignore + // @ts-expect-error trackVisibility: true, delay: 500 }) diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts index d821ea12..ae53759f 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -39,7 +39,7 @@ export default function useIntersectionObserver({ root, rootMargin, threshold, - // @ts-ignore experimental v2 api + // @ts-expect-error experimental v2 api trackVisibility, delay }, diff --git a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts index 2330311a..d2dd759a 100644 --- a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts +++ b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts @@ -9,7 +9,7 @@ let rootId = 0; /** * Generate a unique ID for the root element */ -export function getRootId(root: IntersectionObserverInit['root']) { +export function getRootId(root: IntersectionObserverInit['root']): string | undefined { if (!root) return '0'; if (rootIdsWeakMap.has(root)) return rootIdsWeakMap.get(root); @@ -24,7 +24,7 @@ export function getRootId(root: IntersectionObserverInit['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) { +export function optionsToId(options: IntersectionObserverInit): string { return Object.keys(options) .sort() .filter(key => options[key as keyof IntersectionObserverInit] !== undefined) @@ -36,7 +36,7 @@ export function optionsToId(options: IntersectionObserverInit) { .toString(); } -export function createObserver(options: IntersectionObserverInit) { +export function createObserver(options: IntersectionObserverInit): IObserverItem { const id = optionsToId(options); let instance = observerMap.get(id); @@ -50,10 +50,10 @@ export function createObserver(options: IntersectionObserverInit) { const isIntersecting = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); - // @ts-ignore support IntersectionObserver v2 + // @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-ignore + // @ts-expect-error entry.isVisible = isIntersecting; } @@ -77,7 +77,7 @@ export function createObserver(options: IntersectionObserverInit) { return instance; } -export function observe({ element, callback, options = {}, isIntersectingFallback }: IObserveOptions) { +export function observe({ element, callback, options = {}, isIntersectingFallback }: IObserveOptions): () => void { if (typeof window.IntersectionObserver === 'undefined' && isIntersectingFallback !== undefined) { const bounds = element.getBoundingClientRect(); callback(isIntersectingFallback, { @@ -103,7 +103,7 @@ export function observe({ element, callback, options = {}, isIntersectingFallbac callbacks.push(callback); observer.observe(element); - return function unobserve() { + return function unobserve(): void { callbacks.splice(callbacks.indexOf(callback), 1); if (callbacks.length === 0) { From 90adf7cab647cc1f0826a03c4da66bf4a8cb2289 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Fri, 29 Nov 2024 15:13:29 +0500 Subject: [PATCH 24/26] fix(use-intersection-observer): fix eslint errors --- .../src/__tests__/useIntersectionObserver.mocks.ts | 2 +- .../src/useIntersectionObserver.ts | 10 +++++----- .../src/utilities/useIntersectionObserver.utilities.ts | 7 +++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts index 9c65421f..8277cdd3 100644 --- a/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts +++ b/hooks/use-intersection-observer/src/__tests__/useIntersectionObserver.mocks.ts @@ -6,7 +6,7 @@ interface IObserverData { created: number; } -let isMocking: boolean = false; +let isMocking = false; const observersMap = new Map(); diff --git a/hooks/use-intersection-observer/src/useIntersectionObserver.ts b/hooks/use-intersection-observer/src/useIntersectionObserver.ts index ae53759f..182b3c39 100644 --- a/hooks/use-intersection-observer/src/useIntersectionObserver.ts +++ b/hooks/use-intersection-observer/src/useIntersectionObserver.ts @@ -43,13 +43,13 @@ export default function useIntersectionObserver({ trackVisibility, delay }, - callback: (isIntersecting, entry) => { - setIsIntersecting(isIntersecting); - setEntry(entry); + callback: (isIntersectingValue, entryValue) => { + setIsIntersecting(isIntersectingValue); + setEntry(entryValue); - if (callback.current) callback.current(isIntersecting, entry); + if (callback.current) callback.current(isIntersectingValue, entryValue); - if (entry.isIntersecting && triggerOnce && unobserve) { + if (entryValue.isIntersecting && triggerOnce && unobserve) { unobserve(); unobserve = undefined; } diff --git a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts index d2dd759a..7e52261d 100644 --- a/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts +++ b/hooks/use-intersection-observer/src/utilities/useIntersectionObserver.utilities.ts @@ -29,9 +29,8 @@ export function optionsToId(options: IntersectionObserverInit): string { .sort() .filter(key => options[key as keyof IntersectionObserverInit] !== undefined) .map(key => { - return `${key}_${ - key === 'root' ? getRootId(options.root) : options[key as keyof IntersectionObserverInit] - }`; + const value = key === 'root' ? getRootId(options.root) : options[key as keyof IntersectionObserverInit]; + return `${key}_${value}`; }) .toString(); } @@ -43,7 +42,7 @@ export function createObserver(options: IntersectionObserverInit): IObserverItem if (instance) return instance; const elements = new Map>(); - let thresholds: number[] | readonly number[]; + let thresholds: number[] | readonly number[] = []; const observer = new IntersectionObserver(entries => { entries.forEach(entry => { From f5c6955e499c4c35dc02eb395074ccd3acf00b55 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 4 Dec 2024 11:51:32 +0500 Subject: [PATCH 25/26] chore(image): update component, stories, readme --- components/image/README.md | 38 +++++++++++-- components/image/src/Image.tsx | 22 +++++--- components/image/src/Image.types.ts | 4 ++ .../image/src/__stories__/Image.stories.mdx | 39 +++++++++++++- .../src/__stories__/Image.stories.module.css | 28 +++++++++- .../image/src/__stories__/Image.stories.tsx | 54 ++++++++++++------- components/image/src/useImage.ts | 5 +- 7 files changed, 157 insertions(+), 33 deletions(-) diff --git a/components/image/README.md b/components/image/README.md index ce6c60d3..211bb0c3 100644 --- a/components/image/README.md +++ b/components/image/README.md @@ -2,7 +2,9 @@ --- ### Installation + ```sh + npm i @byndyusoft-ui/image # or yarn add @byndyusoft-ui/image @@ -10,7 +12,9 @@ yarn add @byndyusoft-ui/image ### Usage Image + #### Basic usage + ```jsx import React from 'react'; import Image from '@byndyusoft-ui/image'; @@ -28,6 +32,7 @@ export default App; ``` #### Fallback components + ```jsx Example ImageLoading...
} errorFallback={
Error loading image
} /> ``` #### Lazy loading -By default, `lazy` is set to `true`, which means the image will only be loaded when it enters the viewport. -This is achieved using the Intersection Observer pattern. If `lazy` is set to `false`, the image will be loaded immediately. + +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. + +```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/src/Image.tsx b/components/image/src/Image.tsx index b094b7ef..0f03000a 100644 --- a/components/image/src/Image.tsx +++ b/components/image/src/Image.tsx @@ -6,19 +6,25 @@ const Image = forwardRef((props, forwardedRef) => const { src, alt = '', - lazy = true, + lazy = false, fallback, fallbackSrc, errorFallback, errorFallbackSrc, className, rootFallbackClassName, + rootErrorFallbackClassName, + intersectionObserverSettings, ...otherProps } = props; const internalRef = useRef(null); - const { isLoading, isError, setObserverTargetRef } = useImage({ src, lazy }); + const { isLoading, isError, setObserverTargetRef } = useImage({ + src, + lazy, + intersectionObserverSettings + }); const setRefs = (node: HTMLImageElement | null): void => { internalRef.current = node; @@ -29,17 +35,21 @@ const Image = forwardRef((props, forwardedRef) => {alt} ); - const renderFallback = (content: ReactElement): JSX.Element => ( -
+ const renderFallback = (content: ReactElement, rootClassName?: string): JSX.Element => ( +
{content}
); useImperativeHandle(forwardedRef, () => internalRef.current as HTMLImageElement); - if (fallback && isLoading) return renderFallback(fallback); + if (fallback && isLoading) { + return renderFallback(fallback, rootFallbackClassName); + } - if (errorFallback && isError) return renderFallback(errorFallback); + if (errorFallback && isError) { + return renderFallback(errorFallback, rootErrorFallbackClassName); + } if (fallbackSrc && isLoading) return renderImage(fallbackSrc); diff --git a/components/image/src/Image.types.ts b/components/image/src/Image.types.ts index cb93accd..1185d274 100644 --- a/components/image/src/Image.types.ts +++ b/components/image/src/Image.types.ts @@ -1,15 +1,18 @@ 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>; @@ -17,6 +20,7 @@ type TSetState = Dispatch>; export interface IUseImageProps { src: string; lazy?: boolean; + intersectionObserverSettings?: IUseIntersectionObserverOptions; } export interface IUseImageReturn { diff --git a/components/image/src/__stories__/Image.stories.mdx b/components/image/src/__stories__/Image.stories.mdx index 676d1699..32370ce3 100644 --- a/components/image/src/__stories__/Image.stories.mdx +++ b/components/image/src/__stories__/Image.stories.mdx @@ -1,7 +1,44 @@ -import { Meta } from '@storybook/addon-docs'; +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 index ea75e9b4..c2e0589c 100644 --- a/components/image/src/__stories__/Image.stories.module.css +++ b/components/image/src/__stories__/Image.stories.module.css @@ -1,9 +1,10 @@ .wrapper { margin: auto; - width: 800px; display: flex; + gap: 12px; flex-wrap: wrap; - gap: 20px; + width: 612px; + max-height: 600px; } .skeleton { @@ -25,6 +26,29 @@ 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; diff --git a/components/image/src/__stories__/Image.stories.tsx b/components/image/src/__stories__/Image.stories.tsx index 7008f54c..5cc2ab5a 100644 --- a/components/image/src/__stories__/Image.stories.tsx +++ b/components/image/src/__stories__/Image.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { StoryObj } from '@storybook/react'; import Image from '../Image'; import { IImageProps } from '../Image.types'; @@ -11,8 +11,8 @@ interface ISkeletonProps { } interface ITemplateProps { - mockImageIds: Array; imageProps: Partial; + mockLoadImageIds: () => Array; } const generateRandomArray = (maxNumber: number, length: number): number[] => { @@ -34,24 +34,37 @@ const ErrorFallbackComponent = ({ width, height }: ISkeletonProps): JSX.Element
); -const Template = ({ mockImageIds, imageProps }: ITemplateProps): JSX.Element => ( -
- {mockImageIds?.map((id, index) => ( - - ))} -
-); +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: { - mockImageIds: generateRandomArray(826, 100), + mockLoadImageIds: () => generateRandomArray(826, 50), imageProps: { width: 300, height: 300, + lazy: true, fallback: , errorFallback: } @@ -60,10 +73,11 @@ export const LazyFallbackSkeleton: StoryObj = { export const LazyFallbackSrc: StoryObj = { args: { - mockImageIds: generateRandomArray(826, 100), + mockLoadImageIds: () => generateRandomArray(826, 50), imageProps: { width: 300, height: 300, + lazy: true, fallbackSrc: imagePlaceholder, errorFallback: } @@ -72,7 +86,7 @@ export const LazyFallbackSrc: StoryObj = { export const PreloadFallbackSkeleton: StoryObj = { args: { - mockImageIds: generateRandomArray(826, 30), + mockLoadImageIds: () => generateRandomArray(826, 30), imageProps: { width: 300, height: 300, @@ -85,7 +99,7 @@ export const PreloadFallbackSkeleton: StoryObj = { export const PreloadFallbackSrc: StoryObj = { args: { - mockImageIds: generateRandomArray(826, 30), + mockLoadImageIds: () => generateRandomArray(826, 30), imageProps: { width: 300, height: 300, @@ -98,10 +112,11 @@ export const PreloadFallbackSrc: StoryObj = { export const ErrorFallback: StoryObj = { args: { - mockImageIds: [-1, -2, -3, -4, -5, -6], + mockLoadImageIds: () => [-1, -2, -3, -4, -5, -6], imageProps: { width: 300, height: 300, + lazy: true, fallback: , errorFallback: } @@ -110,10 +125,11 @@ export const ErrorFallback: StoryObj = { export const ErrorFallbackSrc: StoryObj = { args: { - mockImageIds: [-1, -2, -3, -4, -5, -6], + mockLoadImageIds: () => [-1, -2, -3, -4, -5, -6], imageProps: { width: 300, height: 300, + lazy: true, fallback: , errorFallbackSrc: imagePlaceholder } diff --git a/components/image/src/useImage.ts b/components/image/src/useImage.ts index 3ac95f8d..e677784d 100644 --- a/components/image/src/useImage.ts +++ b/components/image/src/useImage.ts @@ -14,7 +14,7 @@ const loadImage: TLoadImageFunction = (src, setIsLoading, setIsError) => { }; }; -export const useImage = ({ src, lazy }: IUseImageProps): IUseImageReturn => { +export const useImage = ({ src, lazy, intersectionObserverSettings }: IUseImageProps): IUseImageReturn => { const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); @@ -25,7 +25,8 @@ export const useImage = ({ src, lazy }: IUseImageProps): IUseImageReturn => { if (isIntersecting) { loadImage(src, setIsLoading, setIsError); } - } + }, + ...intersectionObserverSettings }); useLayoutEffect(() => { From a9289563e6cef295b998b58d5053ff843c44ef75 Mon Sep 17 00:00:00 2001 From: Gleb Fomin Date: Wed, 4 Dec 2024 12:10:15 +0500 Subject: [PATCH 26/26] fix(image): readme --- components/image/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/image/README.md b/components/image/README.md index 211bb0c3..37240e88 100644 --- a/components/image/README.md +++ b/components/image/README.md @@ -55,7 +55,9 @@ export default App; #### Custom class names -The rootFallbackClassName parameter applies a class to the container that will display the fallback or errorFallback elements. + +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