Skip to content

Commit

Permalink
feat: new hook useMeasure
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed May 25, 2021
1 parent ccf2c26 commit a43022e
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 4 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions src/useMeasure.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Element>(): [DOMRectReadOnly | undefined, RefObject<T>] {
const elementRef = useRef<T>(null);
const [rect, setRect] = useSafeState<DOMRectReadOnly>();
const [observerHandler] = useRafCallback<IUseResizeObserverCallback>((entry) =>
setRect(entry.contentRect)
);

useResizeObserver(elementRef, observerHandler);

return [rect, elementRef];
}
23 changes: 23 additions & 0 deletions stories/Sensor/useMeasure.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import { useMeasure } from '../../src/useMeasure';

export const Example: React.FC = () => {
const [measure, ref] = useMeasure<HTMLDivElement>();

return (
<div>
<pre>{JSON.stringify(measure)}</pre>
<div
ref={ref}
style={{
minWidth: 100,
minHeight: 100,
resize: 'both',
overflow: 'auto',
background: 'red',
}}>
resize me UwU
</div>
</div>
);
};
31 changes: 31 additions & 0 deletions stories/Sensor/useMeasure.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './useMeasure.stories';

<Meta title="Sensor/useMeasure" />

# 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

<Canvas isColumn>
<Story name="Example" story={Example} />
</Canvas>

## Reference

```ts
function useMeasure<T extends Element>(): [DOMRectReadOnly | undefined, RefObject<T>];
```

#### Return

Array of two elements:

- **0** _`DOMRectReadOnly | undefined`_ - Current element rect, received from ResizeObserver;
- **1** _`RefObject<T>`_ - Ref objet that should be passed to tracked element;
10 changes: 6 additions & 4 deletions stories/Sensor/useResizeObserver.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export const Example: React.FC = () => {
resize: 'both',
overflow: 'auto',
background: 'red',
}}
/>
}}>
resize me UwU
</div>
</div>
);
};
Expand All @@ -43,8 +44,9 @@ export const ExampleDebounced: React.FC = () => {
resize: 'both',
overflow: 'auto',
background: 'red',
}}
/>
}}>
resize me UwU
</div>
</div>
);
};
95 changes: 95 additions & 0 deletions tests/dom/useMeasure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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<ResizeObserver>;
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 only set state within animation frame', () => {
const div = document.createElement('div');
const { result } = renderHook(() => {
const res = useMeasure<HTMLDivElement>();

(res[1] as MutableRefObject<HTMLDivElement>).current = div;

return res;
});

const entry = {
target: div,
contentRect: {},
borderBoxSize: {},
contentBoxSize: {},
} as unknown as ResizeObserverEntry;

ResizeObserverSpy.mock.calls[0][0]([entry]);
expect(result.current[0]).toBeUndefined();

act(() => {
jest.advanceTimersToNextTimer();
});

expect(result.current[1]).toStrictEqual({ current: div });
expect(result.current[0]).toBe(entry.contentRect);
});
});
54 changes: 54 additions & 0 deletions tests/ssr/useMeasure.test.ts
Original file line number Diff line number Diff line change
@@ -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<ResizeObserver>;
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 });
});
});

0 comments on commit a43022e

Please sign in to comment.