From f4f5dd571c3e0b397c5d6f95e27804d85779e243 Mon Sep 17 00:00:00 2001 From: Aleksandr Smyshlyaev Date: Fri, 24 Sep 2021 22:49:24 +0400 Subject: [PATCH] feat(onupdate): onUpdate cb that called every time visibleItems changed --- README.md | 1 + example-nextjs/helpers/useDrag.ts | 33 +++++++-------- example-nextjs/pages/index.tsx | 26 ++++++------ src/hooks/useIntersectionObserver.ts | 3 +- src/hooks/useOnUpdate.test.ts | 48 +++++++++++++++++++++ src/hooks/useOnUpdate.ts | 29 +++++++++++++ src/hooks/usePrevious.test.ts | 28 +++++++++++++ src/hooks/usePrevious.ts | 13 ++++++ src/index.test.tsx | 62 +++++++++++++++++++++++++++- src/index.tsx | 12 +++++- 10 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 src/hooks/useOnUpdate.test.ts create mode 100644 src/hooks/useOnUpdate.ts create mode 100644 src/hooks/usePrevious.test.ts create mode 100644 src/hooks/usePrevious.ts diff --git a/README.md b/README.md index 6bd12ba0..4c81daa0 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ RightArrow | React component for right arrow onWheel | (VisibilityContext, event) => void onScroll | (VisibilityContext, event) => void onInit | (VisibilityContext) => void +onUpdate | (VisibilityContext) => void onMouseDown |(VisibilityContext) => (React.MouseEventHandler) => void onMouseUp | (VisibilityContext) => (React.MouseEventHandler) => void onMouseMove | (VisibilityContext) => (React.MouseEventHandler) => void diff --git a/example-nextjs/helpers/useDrag.ts b/example-nextjs/helpers/useDrag.ts index 1f297787..68630ffd 100644 --- a/example-nextjs/helpers/useDrag.ts +++ b/example-nextjs/helpers/useDrag.ts @@ -21,24 +21,21 @@ export default function useDrag() { [] ); - const dragMove = React.useCallback( - (ev: React.MouseEvent, cb: (newPos: number) => void) => { - const newDiff = position - ev.clientX; - - const movedEnough = Math.abs(newDiff) > 5; - - if (clicked && movedEnough) { - setDragging(true); - } - - if (dragging && movedEnough) { - setPosition(ev.clientX); - setDiff(newDiff); - cb(newDiff); - } - }, - [clicked, dragging, position] - ); + const dragMove = (ev: React.MouseEvent, cb: (newPos: number) => void) => { + const newDiff = position - ev.clientX; + + const movedEnough = Math.abs(newDiff) > 5; + + if (clicked && movedEnough) { + setDragging(true); + } + + if (dragging && movedEnough) { + setPosition(ev.clientX); + setDiff(newDiff); + cb(newDiff); + } + }; return { dragStart, diff --git a/example-nextjs/pages/index.tsx b/example-nextjs/pages/index.tsx index 3af858fa..413d2acb 100644 --- a/example-nextjs/pages/index.tsx +++ b/example-nextjs/pages/index.tsx @@ -48,23 +48,22 @@ const onWheel = ( }; function App() { - const [items] = React.useState(getItems); + const [items, setItems] = React.useState(getItems); const [selected, setSelected] = React.useState([]); const [position, setPosition] = React.useState(0); - // React.useEffect(() => { - // if (items.length < 25) { - // setTimeout(() => { - // const newItems = items.concat( - // Array(5) - // .fill(0) - // .map((_, ind) => ({ id: getId(items.length + ind) })) - // ); - // console.log('push new items'); - // setItems(newItems); - // }, 3000); + // NOTE: for add more items when last item reached + // const onUpdate = ({ isLastItemVisible }: scrollVisibilityApiType) => { + // if (isLastItemVisible) { + // const newItems = items.concat( + // Array(5) + // .fill(0) + // .map((_, ind) => ({ id: getId(items.length + ind) })) + // ); + // console.log('push new items'); + // setItems(newItems); // } - // }, [items]); + // }; const isItemSelected = (id: string): boolean => !!selected.find((el) => el === id); @@ -137,6 +136,7 @@ function App() { LeftArrow={LeftArrow} RightArrow={RightArrow} onInit={restorePosition} + // onUpdate={onUpdate} onScroll={savePosition} onWheel={onWheel} onMouseDown={() => (ev) => dragStart(ev)} diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts index 96bcd053..7ad6d935 100644 --- a/src/hooks/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver.ts @@ -23,8 +23,7 @@ function useIntersection({ items, itemsChanged, refs, options }: Props) { const ioCb = React.useCallback( (entries: IntersectionObserverEntry[]) => { - const newItems = observerEntriesToItems(entries, options); - items.set(newItems); + items.set(observerEntriesToItems(entries, options)); global.clearTimeout(throttleTimer.current); throttleTimer.current = +setTimeout( diff --git a/src/hooks/useOnUpdate.test.ts b/src/hooks/useOnUpdate.test.ts new file mode 100644 index 00000000..e2e10a59 --- /dev/null +++ b/src/hooks/useOnUpdate.test.ts @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useOnUpdate from './useOnUpdate'; + +describe('useOnUpdate', () => { + test('should fire cb when visibleItems changed', () => { + const cb = jest.fn(); + const visibleItems = ['it1', 'it2']; + const visibleItems2 = ['it2', 'it3']; + const condition = true; + + const { rerender } = renderHook(useOnUpdate, { + initialProps: { cb, condition, visibleItems }, + }); + + expect(cb).toHaveBeenCalledTimes(1); + + rerender(); + expect(cb).toHaveBeenCalledTimes(1); + + rerender({ cb, condition, visibleItems: visibleItems2 }); + expect(cb).toHaveBeenCalledTimes(2); + + rerender(); + expect(cb).toHaveBeenCalledTimes(2); + }); + + test('should fire cb only when condition is truthy', () => { + const cb = jest.fn(); + const visibleItems = ['it1', 'it2']; + const visibleItems2 = ['it2', 'it3']; + const condition = false; + + const { rerender } = renderHook(useOnUpdate, { + initialProps: { cb, condition, visibleItems }, + }); + + expect(cb).toHaveBeenCalledTimes(0); + + rerender(); + expect(cb).toHaveBeenCalledTimes(0); + + rerender({ cb, condition, visibleItems: visibleItems2 }); + expect(cb).toHaveBeenCalledTimes(0); + + rerender(); + expect(cb).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/hooks/useOnUpdate.ts b/src/hooks/useOnUpdate.ts new file mode 100644 index 00000000..45248317 --- /dev/null +++ b/src/hooks/useOnUpdate.ts @@ -0,0 +1,29 @@ +import React from 'react'; + +import usePrevious from './usePrevious'; + +import { visibleItems as visibleItemsType } from '../types'; + +interface Props { + cb: () => void; + condition: Boolean; + visibleItems: visibleItemsType; +} + +function useOnUpdate({ + cb = () => void 0, + condition, + visibleItems, +}: Props): void { + const currentItemsHash = condition ? JSON.stringify(visibleItems) : ''; + const prevItemsHash = usePrevious(currentItemsHash); + + React.useEffect(() => { + if (condition && prevItemsHash !== currentItemsHash) { + cb(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [condition, prevItemsHash, currentItemsHash]); +} + +export default useOnUpdate; diff --git a/src/hooks/usePrevious.test.ts b/src/hooks/usePrevious.test.ts new file mode 100644 index 00000000..8e3db576 --- /dev/null +++ b/src/hooks/usePrevious.test.ts @@ -0,0 +1,28 @@ +import { renderHook } from '@testing-library/react-hooks'; +import usePrevious from './usePrevious'; + +describe('usePrevious', () => { + test('should return previous value', () => { + const values = ['test1', 'test2', 'test3']; + + const { result, rerender } = renderHook(usePrevious, { + initialProps: values[0], + }); + expect(result.current).toEqual(undefined); + + rerender(); + expect(result.current).toEqual(values[0]); + + rerender(values[1]); + expect(result.current).toEqual(values[0]); + + rerender(values[2]); + expect(result.current).toEqual(values[1]); + + rerender(values[2]); + expect(result.current).toEqual(values[2]); + + rerender(values[2]); + expect(result.current).toEqual(values[2]); + }); +}); diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 00000000..972cdbf4 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +function usePrevious(value: T) { + const ref = React.useRef(); + + React.useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} + +export default usePrevious; diff --git a/src/index.test.tsx b/src/index.test.tsx index 2f1aa28f..f7417532 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -124,10 +124,10 @@ describe('ScrollMenu', () => { visibleItems: [], }) .mockReturnValueOnce({ - visibleItems: defaultItems, + visibleItems: defaultItemsWithSeparators, }) .mockReturnValueOnce({ - visibleItems: defaultItems, + visibleItems: defaultItemsWithSeparators, }); const onInit = jest.fn(); @@ -148,6 +148,64 @@ describe('ScrollMenu', () => { }); }); + describe('onUpdate', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should fire with publicApi', () => { + (useIntersectionObserver as jest.Mock).mockReturnValue({ + visibleItems: defaultItemsWithSeparators, + }); + const onInit = jest.fn(); + const onUpdate = jest.fn(); + + const { container } = setup({ onInit, onUpdate }); + + expect(container.firstChild).toBeTruthy(); + + expect(onInit).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(1); + const call = onUpdate.mock.calls[0][0]; + comparePublicApi(call); + }); + + test('should not fire if init not complete(when visibleItems empty)', () => { + (useIntersectionObserver as jest.Mock) + .mockReturnValueOnce({ + visibleItems: [], + }) + .mockReturnValueOnce({ + visibleItems: [], + }) + .mockReturnValueOnce({ + visibleItems: defaultItemsWithSeparators, + }) + .mockReturnValueOnce({ + visibleItems: defaultItemsWithSeparators, + }); + const onInit = jest.fn(); + const onUpdate = jest.fn(); + + const { container, rerender } = setup({ onInit, onUpdate }); + + expect(onInit).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + const textContent1 = container.firstChild!.textContent; + expect(textContent1!.includes('"initComplete":false')).toBeTruthy(); + + setup({ onInit, onUpdate, rerender }); + + expect(onInit).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledTimes(1); + const call = onUpdate.mock.calls[0][0]; + comparePublicApi(call); + expect(call.initComplete).toEqual(true); + const textContent2 = container.firstChild!.textContent; + expect(textContent2!.includes('"initComplete":true')).toBeTruthy(); + }); + }); + test('Structure - LeftArrow, ScrollContainer, MenuItems, RightArrow', () => { (useIntersectionObserver as jest.Mock).mockReturnValue({ visibleItems: defaultItemsWithSeparators, diff --git a/src/index.tsx b/src/index.tsx index cf6110dc..a7792597 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,7 @@ import { observerOptions as defaultObserverOptions } from './settings'; import * as constants from './constants'; import useOnInitCb from './hooks/useOnInitCb'; +import useOnUpdate from './hooks/useOnUpdate'; import { VisibilityContext } from './context'; @@ -23,12 +24,12 @@ import slidingWindow from './slidingWindow'; import getItemsPos from './getItemsPos'; type ArrowType = React.FC | React.ReactNode; - export interface Props { LeftArrow?: ArrowType; RightArrow?: ArrowType; children: ItemType | ItemType[]; onInit?: (api: publicApiType) => void; + onUpdate?: (api: publicApiType) => void; onScroll?: (api: publicApiType, ev: React.UIEvent) => void; onWheel?: (api: publicApiType, ev: React.WheelEvent) => void; options?: Partial; @@ -46,6 +47,7 @@ function ScrollMenu({ RightArrow: _RightArrow, children, onInit = (): void => void 0, + onUpdate = (): void => void 0, onMouseDown, onMouseUp, onMouseMove, @@ -90,11 +92,17 @@ function ScrollMenu({ const mounted = !!visibleItems.length; - useOnInitCb({ + const onInitCbFired = useOnInitCb({ cb: () => onInit(publicApi.current), condition: mounted, }); + useOnUpdate({ + cb: () => onUpdate(publicApi.current), + condition: onInitCbFired, + visibleItems, + }); + const api = React.useMemo( () => createApi(items, visibleItems), [items, visibleItems]