Skip to content

Commit

Permalink
feat: create utilities functions (#6137)
Browse files Browse the repository at this point in the history
- `useGlobalOnClickOutside()` – функция для отслеживания клика на обалсть вне целевых элементов.
- `useStableCallback()` – подобие `React.useCallback()`, но без второго аргумента с `deps`.
- `createPortal()` – внутри сразу фолбечит на `document.body`. Использую в `useFloatingWithIteractions()`.
    > **Note**
    >
    > Из-за особенности работы `iframe` в Styleguide, `document.body`, полученный даже через `document` из `getDOM()`, соответствует корневому элементу, а не тому, что в `iframe`. Создал функцию `getDocumentBody()`, с помощью которой нужно получать `document.body` и передавать в `createPortal()`.
  • Loading branch information
inomdzhon authored Nov 27, 2023
1 parent 2945305 commit 58652ee
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 2 deletions.
98 changes: 98 additions & 0 deletions packages/vkui/src/hooks/useGlobalOnClickOutside.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { useGlobalOnClickOutside } from './useGlobalOnClickOutside';

interface WrapperUseGlobalOnClickOutsideProps {
disableTarget?: 'target-1' | 'target-2';
disableAllTarget?: boolean;
globalClickHandler(): void;
}

const WrapperUseGlobalOnClickOutside = ({
disableTarget,
disableAllTarget = false,
globalClickHandler,
}: WrapperUseGlobalOnClickOutsideProps) => {
const target1Ref = React.createRef<HTMLDivElement>();
const target2Ref = React.createRef<HTMLDivElement>();

useGlobalOnClickOutside(
globalClickHandler,
disableAllTarget || disableTarget === 'target-1' ? null : target1Ref,
disableAllTarget || disableTarget === 'target-2' ? null : target2Ref,
);

return (
<div data-testid="root">
<div data-testid="outside" />
<div data-testid="target-1" ref={target1Ref}></div>
<div data-testid="target-2" ref={target2Ref}>
<div data-testid="target-2-child" />
</div>
</div>
);
};

describe(useGlobalOnClickOutside, () => {
it('should works with multiple refs provided', () => {
const globalClickHandler = jest.fn();
const result = render(
<WrapperUseGlobalOnClickOutside globalClickHandler={globalClickHandler} />,
);

fireEvent.click(result.getByTestId('target-1'));
fireEvent.click(result.getByTestId('target-2'));
fireEvent.click(result.getByTestId('target-2-child'));
expect(globalClickHandler).not.toHaveBeenCalled();

fireEvent.click(document.documentElement);
fireEvent.click(result.getByTestId('root'));
fireEvent.click(result.getByTestId('outside'));
expect(globalClickHandler).toHaveBeenCalledTimes(3);
});

it('should work with one ref provided', () => {
const globalClickHandler = jest.fn();
const result = render(
<WrapperUseGlobalOnClickOutside
globalClickHandler={globalClickHandler}
disableTarget="target-1"
/>,
);

fireEvent.click(result.getByTestId('target-1'));
fireEvent.click(result.getByTestId('target-2'));
expect(globalClickHandler).toHaveBeenCalledTimes(1);

result.rerender(<WrapperUseGlobalOnClickOutside globalClickHandler={globalClickHandler} />);

fireEvent.click(result.getByTestId('target-1'));
fireEvent.click(result.getByTestId('target-2'));
fireEvent.click(document.documentElement);
fireEvent.click(result.getByTestId('root'));
fireEvent.click(result.getByTestId('outside'));
expect(globalClickHandler).toHaveBeenCalledTimes(4);
});

it('should clear events if no refs provided', () => {
const globalClickHandler = jest.fn();

const result = render(
<WrapperUseGlobalOnClickOutside globalClickHandler={globalClickHandler} />,
);

fireEvent.click(result.getByTestId('target-2'));
expect(globalClickHandler).not.toHaveBeenCalled();

fireEvent.click(result.getByTestId('outside'));
expect(globalClickHandler).toHaveBeenCalledTimes(1);

result.rerender(
<WrapperUseGlobalOnClickOutside globalClickHandler={globalClickHandler} disableAllTarget />,
);

fireEvent.click(result.getByTestId('target-2'));
fireEvent.click(result.getByTestId('outside'));
expect(globalClickHandler).toHaveBeenCalledTimes(1);
});
});
40 changes: 40 additions & 0 deletions packages/vkui/src/hooks/useGlobalOnClickOutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from 'react';
import { isElement, useDOM } from '../lib/dom';
import { useIsomorphicLayoutEffect } from '../lib/useIsomorphicLayoutEffect';

/**
* Завязывается на document.
*
* @private
*/
export const useGlobalOnClickOutside = <
T extends React.RefObject<ElementType> | undefined | null,
ElementType extends Element = Element,
>(
callback: (event: MouseEvent) => void,
...refs: T[]
) => {
const { document } = useDOM();
useIsomorphicLayoutEffect(() => {
const someRefNotNull = refs.some((ref) => ref && ref.current !== null);
if (!document || !someRefNotNull) {
return;
}
const handleClick = (event: MouseEvent) => {
const targetEl = event.target;
const someRefHasTargetEl =
isElement(targetEl) &&
refs.some((ref) => ref && ref.current && ref.current.contains(targetEl));
if (!someRefHasTargetEl) {
callback(event);
}
};
document.addEventListener('click', handleClick, {
passive: true,
capture: true,
});
return () => {
document.removeEventListener('click', handleClick, true);
};
}, [document, callback, ...refs]);
};
23 changes: 23 additions & 0 deletions packages/vkui/src/hooks/useStableCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useStableCallback } from './useStableCallback';

describe(useStableCallback, () => {
it('should save first provided fn', () => {
const fn1 = jest.fn();
const stableCallback = renderHook((fn) => useStableCallback(fn), {
initialProps: fn1,
});
const memo = renderHook((fn) => React.useMemo(() => fn(), [fn]), {
initialProps: stableCallback.result.current,
});

expect(fn1).toHaveBeenCalledTimes(1);

const fn2 = jest.fn();
stableCallback.rerender(fn2);
memo.rerender(stableCallback.result.current);

expect(fn2).toHaveBeenCalledTimes(0);
});
});
21 changes: 21 additions & 0 deletions packages/vkui/src/hooks/useStableCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import { useIsomorphicLayoutEffect } from '../lib/useIsomorphicLayoutEffect';

/**
* Inspired by https://github.com/facebook/react/issues/14099#issuecomment-440013892
*/
export function useStableCallback<
Fn extends (...args: any[]) => any = (...args: unknown[]) => unknown,
>(fn: Fn): Fn;
export function useStableCallback<Args extends unknown[], Return>(
fn: (...args: Args) => Return,
): (...args: Args) => Return;
export function useStableCallback<Args extends unknown[], Return>(
fn: (...args: Args) => Return,
): (...args: Args) => Return {
const ref = React.useRef(fn);
useIsomorphicLayoutEffect(() => {
ref.current = fn;
});
return React.useRef((...args: Args) => (0, ref.current)(...args)).current;
}
13 changes: 13 additions & 0 deletions packages/vkui/src/lib/createPortal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { createPortal } from './createPortal';

describe(createPortal, () => {
it.each([
{ id: undefined, container: undefined },
{ id: 'document.body', container: document.body },
])('should in create portal to body if container is $id)', ({ container }) => {
const rendered = render(<div>{createPortal(<div data-testid="portal"></div>, container)}</div>);
expect(document.body).toContainElement(rendered.getByTestId('portal'));
});
});
11 changes: 11 additions & 0 deletions packages/vkui/src/lib/createPortal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as ReactDOM from 'react-dom';
import { getDocumentBody } from './dom';

export const createPortal = (
children: React.ReactNode,
container?: Element | DocumentFragment,
key?: null | string,
) => {
const resolvedContainer = container ? container : getDocumentBody();
return resolvedContainer && ReactDOM.createPortal(children, resolvedContainer, key);
};
15 changes: 15 additions & 0 deletions packages/vkui/src/lib/dom.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getBoundingClientRect,
getDocumentBody,
getScrollHeight,
getScrollRect,
getTransformedParentCoords,
Expand Down Expand Up @@ -142,3 +143,17 @@ describe('getBoundingClientRect', () => {
);
});
});

describe(getDocumentBody, () => {
it('should return document.body anyway', () => {
expect(getDocumentBody()).toBe(document.body);
expect(getDocumentBody(undefined)).toBe(document.body);
expect(getDocumentBody(null)).toBe(document.body);
expect(getDocumentBody(window)).toBe(document.body);
expect(getDocumentBody(document)).toBe(document.body);

const el = document.createElement('div');
document.body.appendChild(el);
expect(getDocumentBody(el)).toBe(document.body);
});
});
7 changes: 5 additions & 2 deletions packages/vkui/src/lib/dom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { rectToClientRect } from '@vkontakte/vkui-floating-ui/core';
import {
getNearestOverflowAncestor as getNearestOverflowAncestorLib,
getWindow,
isElement,
isHTMLElement,
} from '@vkontakte/vkui-floating-ui/utils/dom';

export { getWindow, getNodeScroll } from '@vkontakte/vkui-floating-ui/utils/dom';
export { getWindow, getNodeScroll, isElement } from '@vkontakte/vkui-floating-ui/utils/dom';

export { canUseDOM, canUseEventListeners, onDOMLoaded } from '@vkontakte/vkjs';
export interface DOMContextInterface {
Expand Down Expand Up @@ -128,7 +129,7 @@ export const getScrollHeight = (node: Element | Window) => {
};

export const getScrollRect = (node: Element | Window) => {
const window = node instanceof Element ? getWindow(node) : node;
const window = isElement(node) ? getWindow(node) : node;
const scrollElRect = getBoundingClientRect(node);

const edgeTop = window.scrollY + scrollElRect.top;
Expand All @@ -140,3 +141,5 @@ export const getScrollRect = (node: Element | Window) => {
edges: { y },
};
};

export const getDocumentBody = (node?: any) => getWindow(node).document.body;

0 comments on commit 58652ee

Please sign in to comment.