diff --git a/jest.config.js b/jest.config.js index 0440f8a..5f7e8da 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,5 +5,5 @@ module.exports = { // setupFilesAfterEnv: ['/test-utils/setupTests.js'], // added "(?=4.0.0" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA=", + "dev": true + }, "node_modules/css-select": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.1.3.tgz", @@ -19876,6 +19885,21 @@ } } }, + "node_modules/jsdom-testing-mocks": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jsdom-testing-mocks/-/jsdom-testing-mocks-1.2.2.tgz", + "integrity": "sha512-msu1L8K/bBf6QxJzmsWviyvoc0XS+FJKazSrdQu2xx4Kg+cePercoh+H0U4tdZMVtjOiyLSEIUMH3uwF8+omVg==", + "dev": true, + "dependencies": { + "css-mediaquery": "^0.1.2" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/jsdom/node_modules/acorn": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", @@ -20215,8 +20239,7 @@ "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -35648,14 +35671,12 @@ "@types/lodash": { "version": "4.14.178", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", - "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==", - "dev": true + "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==" }, "@types/lodash-es": { - "version": "4.17.5", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.5.tgz", - "integrity": "sha512-SHBoI8/0aoMQWAgUHMQ599VM6ZiSKg8sh/0cFqqlQQMyY9uEplc0ULU5yQNzcvdR4ZKa0ey8+vFmahuRbOCT1A==", - "dev": true, + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.6.tgz", + "integrity": "sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==", "requires": { "@types/lodash": "*" } @@ -38804,6 +38825,12 @@ } } }, + "css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA=", + "dev": true + }, "css-select": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.1.3.tgz", @@ -43934,6 +43961,15 @@ } } }, + "jsdom-testing-mocks": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jsdom-testing-mocks/-/jsdom-testing-mocks-1.2.2.tgz", + "integrity": "sha512-msu1L8K/bBf6QxJzmsWviyvoc0XS+FJKazSrdQu2xx4Kg+cePercoh+H0U4tdZMVtjOiyLSEIUMH3uwF8+omVg==", + "dev": true, + "requires": { + "css-mediaquery": "^0.1.2" + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -44174,8 +44210,7 @@ "lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "lodash.debounce": { "version": "4.0.8", diff --git a/package.json b/package.json index 54e581c..c815e49 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@types/jest": "^26.0.15", "babel-jest": "^26.6.3", "jest": "^26.3.0", + "jsdom-testing-mocks": "^1.2.2", "nanoid": "^3.1.30", "npm-run-all": "^4.1.5", "plop": "^3.0.6", @@ -83,5 +84,9 @@ }, "engines": { "npm": ">= 7.0.0" + }, + "dependencies": { + "@types/lodash-es": "^4.17.6", + "lodash-es": "^4.17.21" } } diff --git a/src/index.ts b/src/index.ts index 22e680e..a8e877f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ /* PLOP_ADD_EXPORT */ +export * from './useResizeObserver/useResizeObserver'; export * from './useToggle/useToggle'; export * from './useEventListener/useEventListener'; export * from './useKeyboardEvent/useKeyboardEvent'; diff --git a/src/useResizeObserver/useResizeObserver.stories.mdx b/src/useResizeObserver/useResizeObserver.stories.mdx new file mode 100644 index 0000000..b9caa00 --- /dev/null +++ b/src/useResizeObserver/useResizeObserver.stories.mdx @@ -0,0 +1,60 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# useResizeObserver + +Please add a description about the `useResizeObserver` hook. + +## Reference + +```ts +function useResizeObserver( + source: DomElementOrRef, + callback: KeyDownCallback, + debounceTime?: number, +): void +``` + +### Parameters + +* `source` - The DOM Element or Ref that you want to observe for resizes. +* `callback` - The function to invoke when the source is resized. +* `debounceTime` - The amount of debounce you want to apply to the callback function. + +### Returns + +* `void` + +## Usage + +```ts +useResizeObserver(refs.someRef, () => { + console.log('someRef was resized!') +}, 100); +```` + +```ts +const Demo = defineComponent({ + name: 'demo', + refs: { + someRef: 'some-ref', + }, + setup({ refs }) { + + // The most basic implementation of watching an element for resizes. + useResizeObserver(refs.someRef, () => { + console.log('someRef was resized') + }); + + // The callback method can easily be debounced to reduce the amount of triggers. + useResizeObserver(refs.someRef, () => { + console.log('someRef was resized.') + }, 500); + + return []; + } +}) +``` diff --git a/src/useResizeObserver/useResizeObserver.stories.ts b/src/useResizeObserver/useResizeObserver.stories.ts new file mode 100644 index 0000000..17d2455 --- /dev/null +++ b/src/useResizeObserver/useResizeObserver.stories.ts @@ -0,0 +1,68 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { bind, computed, defineComponent, ref } from '@muban/muban'; +import type { Story } from '@muban/storybook/types-6-0'; +import { html } from '@muban/template'; +import { useResizeObserver } from './useResizeObserver'; +import { useStorybookLog } from '../hooks/useStorybookLog'; + +export default { + title: 'useResizeObserver', +}; + +export const Demo: Story = () => ({ + component: defineComponent({ + name: 'story', + refs: { + testArea: 'test-area', + label: 'label', + resizeButton: 'resize-button', + }, + setup({ refs }) { + const [logBinding, log] = useStorybookLog(refs.label); + const width = ref(100); + + function onResizeObserverUpdate(): void { + log('resize observer triggered'); + } + + useResizeObserver(refs.testArea, onResizeObserverUpdate, 100); + + return [ + logBinding, + bind(refs.testArea, { + style: { + width: computed(() => `${width.value}%`), + }, + }), + bind(refs.resizeButton, { + click() { + const newWidth = width.value - 25; + width.value = newWidth > 0 ? newWidth : 100; + }, + }), + ]; + }, + }), + template: () => html`
+
+

Instructions!

+

Resize the window or click on the resize button to see the events being triggered.

+

+ Note: The callback is debounced by 100ms to avoid it from being called + way too much for the sake of the demo. +

+
+
+
+
Test Area
+
+

+ The resize observer is attached to this element, so it will trigger the callback method if + it's resized. +

+ +
+
+
`, +}); +Demo.storyName = 'demo'; diff --git a/src/useResizeObserver/useResizeObserver.test.ts b/src/useResizeObserver/useResizeObserver.test.ts new file mode 100644 index 0000000..0ee4789 --- /dev/null +++ b/src/useResizeObserver/useResizeObserver.test.ts @@ -0,0 +1,52 @@ +import { createMockElementRef, runComponentSetup } from '@muban/test-utils'; +import { mockResizeObserver } from 'jsdom-testing-mocks'; +import { useResizeObserver } from './useResizeObserver'; +import { timeout } from './useResizeObserver.test.utils'; + +jest.mock('@muban/muban', () => jest.requireActual('@muban/test-utils').getMubanLifecycleMock()); + +const resizeObserver = mockResizeObserver(); + +describe('useResizeObserver', () => { + it('should not crash', () => { + const { ref } = createMockElementRef(); + + runComponentSetup(() => { + useResizeObserver(ref, () => undefined); + }); + }); + + it('should attach a resize observer to a ref', async () => { + const mockHandler = jest.fn(); + const { ref, target } = createMockElementRef(); + + await runComponentSetup( + () => { + useResizeObserver(ref, mockHandler); + }, + async () => { + resizeObserver.resize(target); + await timeout(0); + }, + ); + + expect(mockHandler).toBeCalledTimes(1); + }); + + it('should attach a resize observer to a HTML element', async () => { + const mockHandler = jest.fn(); + const { target } = createMockElementRef(); + + await runComponentSetup( + () => { + useResizeObserver(target, mockHandler); + }, + async () => { + resizeObserver.resize(target); + await timeout(0); + }, + ); + + expect(mockHandler).toBeCalledTimes(1); + }); +}); diff --git a/src/useResizeObserver/useResizeObserver.test.utils.ts b/src/useResizeObserver/useResizeObserver.test.utils.ts new file mode 100644 index 0000000..2aa653f --- /dev/null +++ b/src/useResizeObserver/useResizeObserver.test.utils.ts @@ -0,0 +1,17 @@ +/** + * A helper method that allows you to easily create a timeout with the async/await notation + * + * ```ts + * async function someFunction(){ + * await timeout(100); + * console.log('This is delayed by 100ms'); + * } + * ``` + * + * @param duration The duration that you want to create the timeout for. + */ +export async function timeout(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, duration); + }); +} diff --git a/src/useResizeObserver/useResizeObserver.ts b/src/useResizeObserver/useResizeObserver.ts new file mode 100644 index 0000000..3474d7a --- /dev/null +++ b/src/useResizeObserver/useResizeObserver.ts @@ -0,0 +1,88 @@ +import { onMounted, onUnmounted, watchEffect } from '@muban/muban'; +import { debounce } from 'lodash-es'; +import type { DomElementOrRef } from '../utils/util.types'; +import { getElement } from '../utils/getElement'; + +const resizeObserverInstanceCallbacks = new Map(); +let resizeObserverInstance: ResizeObserver; + +/** + * We create only one ResizeObserver instance, and we create it on demand. This way we keep the + * amount of observers to a minimum. + */ +function getResizeObserverInstance(): ResizeObserver { + if (!resizeObserverInstance) { + resizeObserverInstance = new ResizeObserver((entries, observer) => { + entries.forEach((entry) => { + resizeObserverInstanceCallbacks.get(entry.target)?.([entry], observer); + }); + }); + } + return resizeObserverInstance; +} + +/** + * This helper method makes it easy to add a `DomElementOrRef` to the `resizeObserverInstance`. + * + * @param source The source that you want to observe for resizes. + * @param callback The function to invoke when the element is resized. + */ +function addToResizeObserver(source: DomElementOrRef, callback: ResizeObserverCallback): void { + const element = getElement(source); + const resizeObserver = getResizeObserverInstance(); + + if (element) { + resizeObserverInstanceCallbacks.set(element, callback); + resizeObserver.observe(element); + } +} + +/** + * This helper method makes it easy to remove a `DomElementOrRef` from the `resizeObserverInstance`. + * + * @param source The source that you want to unobserve for resizes. + */ +function removeFromResizeObserver(source: DomElementOrRef): void { + const element = getElement(source); + const resizeObserver = getResizeObserverInstance(); + + if (element) { + resizeObserver.unobserve(element); + resizeObserverInstanceCallbacks.delete(element); + } +} + +/** + * A wrapper around the native ResizeObserver that automatically cleans up when unmounted. It reuses + * the same instance when applied on a specific element to reduce the amount of instances created. + * + * @param source The DOM Element or Ref that you want to observe for resizes. + * @param callback The function to invoke when the element is resized. + * @param debounceTime The mount of debounce you want to apply to the callback function. + */ +export const useResizeObserver = ( + source: DomElementOrRef, + callback: ResizeObserverCallback, + debounceTime?: number, +): void => { + const debouncedCallback = debounce(callback, debounceTime); + + if (source instanceof HTMLElement || source instanceof SVGElement) { + onMounted(() => { + addToResizeObserver(source, debouncedCallback); + }); + + onUnmounted(() => { + removeFromResizeObserver(source); + }); + } else { + const removeWatchEffect = watchEffect(() => { + removeFromResizeObserver(source); + addToResizeObserver(source, debouncedCallback); + }); + + onUnmounted(() => { + removeWatchEffect(); + }); + } +}; diff --git a/src/useResizeObserver/useResizeObserver.utils.ts b/src/useResizeObserver/useResizeObserver.utils.ts new file mode 100644 index 0000000..6744e59 --- /dev/null +++ b/src/useResizeObserver/useResizeObserver.utils.ts @@ -0,0 +1 @@ +export const { ResizeObserver } = window; diff --git a/src/useResizeObserver/useResizeObserverStories.test.ts b/src/useResizeObserver/useResizeObserverStories.test.ts new file mode 100644 index 0000000..ed31bc8 --- /dev/null +++ b/src/useResizeObserver/useResizeObserverStories.test.ts @@ -0,0 +1,23 @@ +import '@testing-library/jest-dom'; +import { render, waitFor } from '@muban/testing-library'; +import { mockResizeObserver } from 'jsdom-testing-mocks'; +import { Demo } from './useResizeObserver.stories'; + +const resizeObserver = mockResizeObserver(); + +describe('useResizeObserver stories', () => { + it('should render', () => { + const { getByText } = render(Demo); + + expect(getByText('Test Area')).toBeInTheDocument(); + }); + + it('should trigger a resize', async () => { + const { getByRef, getByText } = render(Demo); + const target = getByRef('test-area'); + + resizeObserver.resize(target); + + await waitFor(() => expect(getByText('resize observer triggered')).toBeInTheDocument()); + }); +});