diff --git a/README.md b/README.md index 1f8f8457e..849b7896a 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,5 @@ import { useMountEffect } from "@react-hookz/web/esnext"; - [**`useResizeObserver`**](https://react-hookz.github.io/?path=/docs/sensor-useresizeobserver) — Invokes a callback whenever ResizeObserver detects a change to target's size. + - [**`useMeasure`**](https://react-hookz.github.io/?path=/docs/sensor-usemeasure) + — Uses ResizeObserver to track element dimensions and re-render component when they change. diff --git a/src/useMeasure.ts b/src/useMeasure.ts new file mode 100644 index 000000000..b9e5931fb --- /dev/null +++ b/src/useMeasure.ts @@ -0,0 +1,19 @@ +import { RefObject, useRef } from 'react'; +import { useSafeState } from './useSafeState'; +import { IUseResizeObserverCallback, useResizeObserver } from './useResizeObserver'; +import { useRafCallback } from './useRafCallback'; + +/** + * Uses ResizeObserver to track element dimensions and re-render component when they change. + */ +export function useMeasure(): [DOMRectReadOnly | undefined, RefObject] { + const elementRef = useRef(null); + const [rect, setRect] = useSafeState(); + const [observerHandler] = useRafCallback((entry) => + setRect(entry.contentRect) + ); + + useResizeObserver(elementRef, observerHandler); + + return [rect, elementRef]; +} diff --git a/stories/Sensor/useMeasure.stories.tsx b/stories/Sensor/useMeasure.stories.tsx new file mode 100644 index 000000000..af056f809 --- /dev/null +++ b/stories/Sensor/useMeasure.stories.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { useMeasure } from '../../src/useMeasure'; + +export const Example: React.FC = () => { + const [measure, ref] = useMeasure(); + + return ( +
+
{JSON.stringify(measure)}
+
+ resize me UwU +
+
+ ); +}; diff --git a/stories/Sensor/useMeasure.story.mdx b/stories/Sensor/useMeasure.story.mdx new file mode 100644 index 000000000..53733529e --- /dev/null +++ b/stories/Sensor/useMeasure.story.mdx @@ -0,0 +1,31 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './useMeasure.stories'; + + + +# useMeasure + +Uses ResizeObserver to track element dimensions and re-render component when they change. + +- It's ResizeObserver callback uses RAF debouncing, therefore it is pretty performant. +- SSR friendly, returns `undefined` on initial mount. +- Automatically creates ref for you, that you can easily pass to needed element. + +#### Example + + + + + +## Reference + +```ts +function useMeasure(): [DOMRectReadOnly | undefined, RefObject]; +``` + +#### Return + +Array of two elements: + +- **0** _`DOMRectReadOnly | undefined`_ - Current element rect, received from ResizeObserver; +- **1** _`RefObject`_ - Ref objet that should be passed to tracked element; diff --git a/stories/Sensor/useResizeObserver.stories.tsx b/stories/Sensor/useResizeObserver.stories.tsx index ff07c4f02..7ed4313b4 100644 --- a/stories/Sensor/useResizeObserver.stories.tsx +++ b/stories/Sensor/useResizeObserver.stories.tsx @@ -18,8 +18,9 @@ export const Example: React.FC = () => { resize: 'both', overflow: 'auto', background: 'red', - }} - /> + }}> + resize me UwU + ); }; @@ -43,8 +44,9 @@ export const ExampleDebounced: React.FC = () => { resize: 'both', overflow: 'auto', background: 'red', - }} - /> + }}> + resize me UwU + ); }; diff --git a/tests/dom/useMeasure.test.ts b/tests/dom/useMeasure.test.ts new file mode 100644 index 000000000..e5c2318f8 --- /dev/null +++ b/tests/dom/useMeasure.test.ts @@ -0,0 +1,93 @@ +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { MutableRefObject } from 'react'; +import { useMeasure } from '../../src/useMeasure'; +import Mock = jest.Mock; + +describe('useMeasure', () => { + const raf = global.requestAnimationFrame; + const caf = global.cancelAnimationFrame; + const observeSpy = jest.fn(); + const unobserveSpy = jest.fn(); + const disconnectSpy = jest.fn(); + + let ResizeObserverSpy: Mock; + const initialRO = global.ResizeObserver; + + beforeAll(() => { + jest.useFakeTimers(); + + global.requestAnimationFrame = (cb) => setTimeout(cb); + global.cancelAnimationFrame = (cb) => clearTimeout(cb); + + ResizeObserverSpy = jest.fn(() => ({ + observe: observeSpy, + unobserve: unobserveSpy, + disconnect: disconnectSpy, + })); + + global.ResizeObserver = ResizeObserverSpy; + }); + + beforeEach(() => { + observeSpy.mockClear(); + unobserveSpy.mockClear(); + disconnectSpy.mockClear(); + }); + + afterAll(() => { + global.ResizeObserver = initialRO; + + global.requestAnimationFrame = raf; + global.cancelAnimationFrame = caf; + + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useMeasure).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useMeasure()); + + expect(result.error).toBeUndefined(); + }); + + it('should return undefined sate on initial render', () => { + const { result } = renderHook(() => useMeasure()); + + expect(result.current[0]).toBeUndefined(); + }); + + it('should return reference as a second array element', () => { + const { result } = renderHook(() => useMeasure()); + + expect(result.current[1]).toStrictEqual({ current: null }); + }); + + it('should set content rect as a state', () => { + const div = document.createElement('div'); + const { result } = renderHook(() => { + const res = useMeasure(); + + (res[1] as MutableRefObject).current = div; + + return res; + }); + + const entry = { + target: div, + contentRect: {}, + borderBoxSize: {}, + contentBoxSize: {}, + } as unknown as ResizeObserverEntry; + + act(() => { + ResizeObserverSpy.mock.calls[0][0]([entry]); + jest.advanceTimersToNextTimer(); + }); + + expect(result.current[1]).toStrictEqual({ current: div }); + expect(result.current[0]).toBe(entry.contentRect); + }); +}); diff --git a/tests/ssr/useMeasure.test.ts b/tests/ssr/useMeasure.test.ts new file mode 100644 index 000000000..711bd6eb6 --- /dev/null +++ b/tests/ssr/useMeasure.test.ts @@ -0,0 +1,54 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useMeasure } from '../../src/useMeasure'; +import Mock = jest.Mock; + +describe('useMeasure', () => { + const observeSpy = jest.fn(); + const unobserveSpy = jest.fn(); + const disconnectSpy = jest.fn(); + + let ResizeObserverSpy: Mock; + const initialRO = global.ResizeObserver; + + beforeAll(() => { + ResizeObserverSpy = jest.fn(() => ({ + observe: observeSpy, + unobserve: unobserveSpy, + disconnect: disconnectSpy, + })); + + global.ResizeObserver = ResizeObserverSpy; + }); + + beforeEach(() => { + observeSpy.mockClear(); + unobserveSpy.mockClear(); + disconnectSpy.mockClear(); + }); + + afterAll(() => { + global.ResizeObserver = initialRO; + }); + + it('should be defined', () => { + expect(useMeasure).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useMeasure()); + + expect(result.error).toBeUndefined(); + }); + + it('should return undefined sate on initial render', () => { + const { result } = renderHook(() => useMeasure()); + + expect(result.current[0]).toBeUndefined(); + }); + + it('should return reference as a second array element', () => { + const { result } = renderHook(() => useMeasure()); + + expect(result.current[1]).toStrictEqual({ current: null }); + }); +});