diff --git a/package.json b/package.json index 8dc4dfc..b19246f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@worksolutions/react-utils", "private": false, - "version": "1.2.65", + "version": "1.2.66", "description": "", "types": "dist/esm/index.d.ts", "main": "dist/cjs/index.js", diff --git a/src/hooks/useChildrenMeasure.ts b/src/hooks/useChildrenMeasure.ts index fcd1e01..57facbb 100644 --- a/src/hooks/useChildrenMeasure.ts +++ b/src/hooks/useChildrenMeasure.ts @@ -1,35 +1,41 @@ -import React from "react"; +import React, { useEffect } from "react"; import { htmlCollectionToArray } from "@worksolutions/utils"; +export function computeRelativeMeasures(childrenRects: DOMRect[], parentRect: DOMRect) { + return childrenRects.map((childrenRect) => { + const x = childrenRect.x - parentRect.x; + const y = childrenRect.y - parentRect.y; + + return { + toJSON: () => "", + width: childrenRect.width, + height: childrenRect.height, + x, + y, + left: x, + top: y, + bottom: y + childrenRect.height, + right: x + childrenRect.width, + }; + }); +} + export function useChildrenMeasure(useResizeObserver = false) { + const elementRef = React.useRef(null); + const resizeObserverRef = React.useRef(null!); + const [measures, setMeasures] = React.useState(null); const [relativeMeasures, setRelativeMeasures] = React.useState(null); - const elementRef = React.useRef(null); const update = React.useCallback(() => { if (!elementRef.current) return; const childrenRects = htmlCollectionToArray(elementRef.current.children).map((element) => element.getBoundingClientRect(), ); + const parentRect = elementRef.current.getBoundingClientRect(); setMeasures(childrenRects); - setRelativeMeasures( - childrenRects.map((childrenRect) => { - const x = childrenRect.x - parentRect.x; - const y = childrenRect.y - parentRect.y; - return { - toJSON: () => "", - width: childrenRect.width, - height: childrenRect.height, - x, - y, - left: x, - top: y, - bottom: y + childrenRect.height, - right: x + childrenRect.width, - }; - }), - ); + setRelativeMeasures(computeRelativeMeasures(childrenRects, parentRect)); }, []); const initRef = React.useCallback( @@ -38,7 +44,8 @@ export function useChildrenMeasure(useResizeObserver = false) { if (!element) return; if (useResizeObserver) { - new ResizeObserver(update).observe(element); + resizeObserverRef.current = new ResizeObserver(update); + resizeObserverRef.current.observe(element); return; } @@ -46,5 +53,8 @@ export function useChildrenMeasure(useResizeObserver = false) { }, [update, useResizeObserver], ); + + useEffect(() => () => resizeObserverRef.current?.disconnect(), []); + return { measures, relativeMeasures, initRef, update }; } diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index f35074d..fae2e70 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -17,7 +17,7 @@ export function useTimer({ onSuccess?: () => void; }) { const forceUpdate = useForceUpdate(); - const timerRef = useRef(null!); + const timerRef = useRef(null!); const valueRef = useRef(initialValue() || 0); const start = useCallback( diff --git a/src/stories/useChildrenWidthDetector/index.stories.tsx b/src/stories/useChildrenMeasure/index.stories.tsx similarity index 88% rename from src/stories/useChildrenWidthDetector/index.stories.tsx rename to src/stories/useChildrenMeasure/index.stories.tsx index 274da6d..60169b1 100644 --- a/src/stories/useChildrenWidthDetector/index.stories.tsx +++ b/src/stories/useChildrenMeasure/index.stories.tsx @@ -36,7 +36,7 @@ const Template: ComponentStory = (props) => { }; export default { - title: "Hooks/useChildrenWidthDetector", + title: "Hooks/useChildrenMeasure", component: Demo, argTypes: { useResizeObserver: { @@ -58,7 +58,7 @@ export default { }, } as ComponentMeta; -export const useChildrenWidthDetectorInfo = Template.bind({}); -useChildrenWidthDetectorInfo.args = {}; +export const useChildrenMeasureInfo = Template.bind({}); +useChildrenMeasureInfo.args = {}; -useChildrenWidthDetectorInfo.storyName = "useChildrenWidthDetector"; +useChildrenMeasureInfo.storyName = "useChildrenMeasure"; diff --git a/src/tests/useChildrenMeasure.test.tsx b/src/tests/useChildrenMeasure.test.tsx new file mode 100644 index 0000000..ab82e36 --- /dev/null +++ b/src/tests/useChildrenMeasure.test.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { renderHook } from "@testing-library/react-hooks"; +import { act } from "react-dom/test-utils"; +import { htmlCollectionToArray } from "@worksolutions/utils"; + +import { ResizeObserverMocker } from "./utils/ResizeObserverMocker"; +import { useChildrenMeasure } from "../hooks"; + +const mockRect: DOMRect = { + x: 10, + y: 10, + width: 200, + height: 200, + top: 100, + bottom: 0, + left: 100, + right: 0, + toJSON: () => "", +}; + +const defaultEmptyArray = new Array(4).fill(undefined); + +const $rootTestElement = document.createElement("div"); +$rootTestElement.getBoundingClientRect = jest.fn(() => { + const newMockRect = { ...mockRect }; + newMockRect.x = 10; + newMockRect.y = 10; + + return newMockRect; +}); + +defaultEmptyArray.forEach(() => { + const $childElement = document.createElement("div"); + $rootTestElement.appendChild($childElement); +}); + +function rewriteChildrenRect(rect: DOMRect = ResizeObserverMocker.defaultDOMRect.contentRect) { + htmlCollectionToArray($rootTestElement.children).forEach(($childElement: HTMLElement) => { + $childElement.getBoundingClientRect = jest.fn(() => rect); + }); +} + +describe("useChildrenMeasure", () => { + test("useChildrenMeasure is defined", () => { + expect(useChildrenMeasure).toBeDefined(); + }); + + test("useChildrenMeasure should get default values", () => { + const { result } = renderHook(() => useChildrenMeasure()); + rewriteChildrenRect(); + + act(() => result.current.initRef($rootTestElement)); + expect(result.current.measures).toMatchObject( + defaultEmptyArray.map(() => ResizeObserverMocker.defaultDOMRect.contentRect), + ); + }); + + test("useChildrenMeasure should get children rect", () => { + const { result } = renderHook(() => useChildrenMeasure()); + rewriteChildrenRect(mockRect); + + act(() => result.current.initRef($rootTestElement)); + + expect(result.current.measures).toMatchObject(defaultEmptyArray.map(() => mockRect)); + }); + + test("useChildrenMeasure should track changes in childrens", () => { + const resizeObserverMocker = new ResizeObserverMocker(); + resizeObserverMocker.setResizeObserverToWindow(); + rewriteChildrenRect(); + + const { result } = renderHook(() => useChildrenMeasure(true)); + act(() => result.current.initRef($rootTestElement)); + act(() => result.current.update()); + + expect(result.current.measures).toMatchObject( + defaultEmptyArray.map(() => ResizeObserverMocker.defaultDOMRect.contentRect), + ); + + rewriteChildrenRect(mockRect); + act(() => resizeObserverMocker.listener()); + + expect(result.current.measures).toMatchObject(defaultEmptyArray.map(() => mockRect)); + }); + + test("disconnect should call when component unmount", () => { + const resizeObserverMocker = new ResizeObserverMocker(); + resizeObserverMocker.setResizeObserverToWindow(); + rewriteChildrenRect(); + + const { result, unmount } = renderHook(() => useChildrenMeasure(true)); + act(() => result.current.initRef($rootTestElement)); + + unmount(); + + expect(resizeObserverMocker.disconnect).toBeCalled(); + }); +}); diff --git a/src/tests/utils/ResizeObserverMocker/ResizeObserverMocker.test.ts b/src/tests/utils/ResizeObserverMocker/ResizeObserverMocker.test.ts new file mode 100644 index 0000000..34d8010 --- /dev/null +++ b/src/tests/utils/ResizeObserverMocker/ResizeObserverMocker.test.ts @@ -0,0 +1,66 @@ +import { ResizeObserverMocker } from "./index"; + +const globalState = { + domRect: {}, +}; + +const mockRect: DOMRect = { + x: 1, + y: 2, + width: 200, + height: 200, + top: 100, + bottom: 0, + left: 100, + right: 0, + toJSON: () => "", +}; + +const newMockRect = { ...mockRect, width: 9999 }; + +describe("ResizeObserverMocker", () => { + test("ResizeObserverMocker should be defined", () => { + expect(ResizeObserverMocker).toBeDefined(); + }); + + test("method in resizeObserverMocker should set default DOMRect", () => { + const resizeObserverMocker = new ResizeObserverMocker(); + + resizeObserverMocker.setResizeObserverToWindow(); + const resizeObserverCallback: any = jest.fn((data: DOMRect) => (globalState.domRect = data)); + new ResizeObserver(resizeObserverCallback); + + resizeObserverMocker.listener(ResizeObserverMocker.defaultDOMRect); + + expect(globalState.domRect).toMatchObject(ResizeObserverMocker.defaultDOMRect); + }); + + test("multiple updates listener", () => { + const resizeObserverMocker = new ResizeObserverMocker(); + + resizeObserverMocker.setResizeObserverToWindow(); + const resizeObserverCallback: any = jest.fn((data: DOMRect) => (globalState.domRect = data)); + new ResizeObserver(resizeObserverCallback); + + resizeObserverMocker.listener(ResizeObserverMocker.defaultDOMRect); + expect(globalState.domRect).toMatchObject(ResizeObserverMocker.defaultDOMRect); + + resizeObserverMocker.listener(mockRect); + expect(globalState.domRect).toMatchObject(mockRect); + + resizeObserverMocker.listener(newMockRect); + expect(globalState.domRect).toMatchObject(newMockRect); + }); + + test("methods in resizeObserverMocker should be called", () => { + const resizeObserverMocker = new ResizeObserverMocker(); + + resizeObserverMocker.disconnect(); + resizeObserverMocker.unobserve(); + resizeObserverMocker.observe(); + + expect(resizeObserverMocker.disconnect).toBeCalled(); + expect(resizeObserverMocker.unobserve).toBeCalled(); + expect(resizeObserverMocker.observe).toBeCalled(); + }); +}); diff --git a/src/tests/utils/ResizeObserverMocker/index.ts b/src/tests/utils/ResizeObserverMocker/index.ts new file mode 100644 index 0000000..254998f --- /dev/null +++ b/src/tests/utils/ResizeObserverMocker/index.ts @@ -0,0 +1,51 @@ +import { ResizeObserverMethodsNames } from "./types"; + +const Global = window || global; +export class ResizeObserverMocker { + static defaultDOMRect: { contentRect: DOMRect } = { + contentRect: { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => "", + }, + }; + + constructor() { + this.setListener = this.setListener.bind(this); + } + + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + listener: any = undefined as any; + + implementResizeObserverMethod(methodName: ResizeObserverMethodsNames, mockMethod: jest.Mock) { + this[methodName] = mockMethod; + } + + setResizeObserverToWindow() { + const setListener = this.setListener.bind(this); + const { observe, unobserve, disconnect } = this; + + // @ts-ignore + Global.ResizeObserver = class ResizeObserver { + constructor(ls: any) { + setListener(ls); + } + }; + + Global.ResizeObserver.prototype.observe = observe; + Global.ResizeObserver.prototype.unobserve = unobserve; + Global.ResizeObserver.prototype.disconnect = disconnect; + } + + private setListener(ls: ResizeObserverCallback) { + this.listener = ls; + } +} diff --git a/src/tests/utils/ResizeObserverMocker/types.ts b/src/tests/utils/ResizeObserverMocker/types.ts new file mode 100644 index 0000000..c8d29f7 --- /dev/null +++ b/src/tests/utils/ResizeObserverMocker/types.ts @@ -0,0 +1,6 @@ +export enum ResizeObserverMethodsNames { + observe = "observe", + unobserve = "unobserve", + disconnect = "disconnect", + listener = "listener", +}