From 43e5c5c9779d2f52a785b98d80e82c7677cd8006 Mon Sep 17 00:00:00 2001 From: Inomdzhon Mirdzhamolov Date: Mon, 20 Nov 2023 11:16:18 +0300 Subject: [PATCH] feat(Cell): add auto scroll for draggable mode (#5833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tech: extend e2e test infra - Импортиурем файл с утилитами - Добавляем запуск для конткретных движков * feat(Cell): add auto scroll for draggable mode * fix(review): reword checkIfElementIsInsideYEdgesOfViewport args * fix(review): refactor useDraggable * chore: add tests * chore: add comments with references * refactor: revert now() moving to lib/date * review: mv condition for icon to var * review: extend dom.test.ts --- packages/vkui/jest.setup.js | 44 +++ .../vkui/src/components/Cell/Cell.module.css | 10 - .../vkui/src/components/Cell/Cell.test.tsx | 30 -- packages/vkui/src/components/Cell/Cell.tsx | 55 ++-- .../Cell/CellDragger/CellDragger.module.css | 7 + .../Cell/CellDragger/CellDragger.tsx | 47 +-- .../vkui/src/components/Cell/constants.ts | 1 + .../vkui/src/components/Cell/useDraggable.tsx | 128 -------- .../vkui/src/components/List/List.module.css | 3 - packages/vkui/src/components/List/List.tsx | 17 +- .../vkui/src/components/List/ListContext.ts | 6 - packages/vkui/src/components/Touch/Touch.tsx | 13 +- .../useDraggableWithDomApi/autoScroll.test.ts | 148 ++++++++++ .../useDraggableWithDomApi/autoScroll.ts | 75 +++++ .../hooks/useDraggableWithDomApi/constants.ts | 7 + .../hooks/useDraggableWithDomApi/index.tsx | 5 + .../src/hooks/useDraggableWithDomApi/types.ts | 40 +++ .../useDraggableWithDomApi.ts | 278 ++++++++++++++++++ .../useDraggableWithDomApi/utils.test.ts | 157 ++++++++++ .../src/hooks/useDraggableWithDomApi/utils.ts | 99 +++++++ packages/vkui/src/lib/dom.test.ts | 144 +++++++++ packages/vkui/src/lib/dom.tsx | 99 ++++++- packages/vkui/src/lib/rafSchd.ts | 48 +++ packages/vkui/src/styles/constants.css | 2 +- .../vkui/src/testing/e2e/index.playwright.ts | 39 ++- packages/vkui/src/testing/e2e/types.ts | 2 + packages/vkui/src/testing/e2e/utils.tsx | 8 + packages/vkui/src/testing/utils.tsx | 52 +++- packages/vkui/tsconfig.json | 3 +- tsconfig.json | 3 +- 30 files changed, 1293 insertions(+), 277 deletions(-) create mode 100644 packages/vkui/src/components/Cell/constants.ts delete mode 100644 packages/vkui/src/components/Cell/useDraggable.tsx delete mode 100644 packages/vkui/src/components/List/List.module.css delete mode 100644 packages/vkui/src/components/List/ListContext.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.test.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/constants.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/index.tsx create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/types.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/utils.test.ts create mode 100644 packages/vkui/src/hooks/useDraggableWithDomApi/utils.ts create mode 100644 packages/vkui/src/lib/dom.test.ts create mode 100644 packages/vkui/src/lib/rafSchd.ts diff --git a/packages/vkui/jest.setup.js b/packages/vkui/jest.setup.js index 093264800d..b8dac34262 100644 --- a/packages/vkui/jest.setup.js +++ b/packages/vkui/jest.setup.js @@ -1 +1,45 @@ require('@testing-library/jest-dom'); + +// Не реализован в JSDOM. +// https://jestjs.io/docs/manual-mocks +global.DOMRect = class DOMRect { + top = 0; + right = 0; + bottom = 0; + left = 0; + width = 0; + height = 0; + constructor(x = 0, y = 0, width = 0, height = 0) { + this.x = x; + this.y = y; + this.top = y; + this.right = x + width; + this.bottom = y + height; + this.left = x; + this.width = width; + this.height = height; + } + static fromRect(other) { + return new DOMRect(other?.x, other?.y, other?.width, other?.height); + } + toJSON() { + const { x, y, top, right, bottom, left, width, height } = this; + return JSON.stringify({ x, y, top, right, bottom, left, width, height }); + } +}; + +// Не реализован в JSDOM. +// Объявление скопировано из документации https://jestjs.io/docs/manual-mocks +Object.defineProperty(global, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // устарело + removeListener: jest.fn(), // устарело + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); diff --git a/packages/vkui/src/components/Cell/Cell.module.css b/packages/vkui/src/components/Cell/Cell.module.css index 1ea4403327..f0b50f041d 100644 --- a/packages/vkui/src/components/Cell/Cell.module.css +++ b/packages/vkui/src/components/Cell/Cell.module.css @@ -2,19 +2,9 @@ position: relative; } -/** - * CMP: - * List - */ -:global(.vkuiInternalList--dragging) .Cell:not(.Cell--dragging) { - transition: transform 0.3s ease; - pointer-events: none; -} - .Cell--dragging { background-color: var(--vkui--color_background_secondary); box-shadow: var(--vkui--elevation3); - z-index: var(--vkui_internal--z_index_cell_dragging); } .Cell--selectable.Cell--disabled { diff --git a/packages/vkui/src/components/Cell/Cell.test.tsx b/packages/vkui/src/components/Cell/Cell.test.tsx index 05582cc770..ab63d480d0 100644 --- a/packages/vkui/src/components/Cell/Cell.test.tsx +++ b/packages/vkui/src/components/Cell/Cell.test.tsx @@ -5,44 +5,14 @@ import { Platform } from '../../lib/platform'; import { baselineComponent } from '../../testing/utils'; import { ConfigProvider } from '../ConfigProvider/ConfigProvider'; import { List } from '../List/List'; -import { ListContext } from '../List/ListContext'; import { Cell } from './Cell'; const label = 'Перенести ячейку'; -const dragger = () => screen.getByLabelText(label); describe('Cell', () => { baselineComponent((props) => Cell); describe('Controls dragging', () => { - it('on mouse up/down', () => { - const toggleDrag = jest.fn(); - render( - - - , - ); - - fireEvent.mouseDown(dragger()); - expect(toggleDrag).toHaveBeenLastCalledWith(true); - - fireEvent.mouseUp(dragger()); - expect(toggleDrag).toHaveBeenLastCalledWith(false); - }); - - it('stops drag on unmount', () => { - const toggleDrag = jest.fn(); - const { rerender } = render( - - - , - ); - - fireEvent.mouseDown(dragger()); - rerender(); - expect(toggleDrag).toHaveBeenLastCalledWith(false); - }); - it('does not reorder dragged item on click', () => { const initialList = ['eugpoloz', 'arthurstam', 'xyz']; let updatedList = [...initialList]; diff --git a/packages/vkui/src/components/Cell/Cell.tsx b/packages/vkui/src/components/Cell/Cell.tsx index f0033a906a..7bd6fa68f5 100644 --- a/packages/vkui/src/components/Cell/Cell.tsx +++ b/packages/vkui/src/components/Cell/Cell.tsx @@ -1,15 +1,15 @@ import * as React from 'react'; import { classNames, noop } from '@vkontakte/vkjs'; +import type { SwappedItemRange } from '../../hooks/useDraggableWithDomApi'; import { useExternRef } from '../../hooks/useExternRef'; import { usePlatform } from '../../hooks/usePlatform'; import { Platform } from '../../lib/platform'; -import { HasRootRef } from '../../types'; -import { ListContext } from '../List/ListContext'; -import { Removable, RemovableProps } from '../Removable/Removable'; -import { SimpleCell, SimpleCellProps } from '../SimpleCell/SimpleCell'; -import { CellCheckbox, CellCheckboxProps } from './CellCheckbox/CellCheckbox'; +import type { HasRootRef } from '../../types'; +import { Removable, type RemovableProps } from '../Removable/Removable'; +import { SimpleCell, type SimpleCellProps } from '../SimpleCell/SimpleCell'; +import { CellCheckbox, type CellCheckboxProps } from './CellCheckbox/CellCheckbox'; import { CellDragger } from './CellDragger/CellDragger'; -import { useDraggable } from './useDraggable'; +import { DEFAULT_DRAGGABLE_LABEL } from './constants'; import styles from './Cell.module.css'; export interface CellProps @@ -39,7 +39,7 @@ export interface CellProps * Эти числа нужны для того, чтобы разработчик понимал, с какого индекса на какой произошел переход. В песочнице * есть рабочий пример с обработкой этих чисел и перерисовкой списка. */ - onDragFinish?: ({ from, to }: { from: number; to: number }) => void; + onDragFinish?(swappedItemRange: SwappedItemRange): void; /** * aria-label для кнопки перетаскивания ячейки */ @@ -65,11 +65,12 @@ export const Cell = ({ checked, defaultChecked, getRootRef, - draggerLabel = 'Перенести ячейку', + draggerLabel = DEFAULT_DRAGGABLE_LABEL, className, style, ...restProps }: CellProps) => { + const [dragging, setDragging] = React.useState(false); const selectable = mode === 'selectable'; const removable = mode === 'removable'; const Component = selectable ? 'label' : ComponentProps; @@ -78,40 +79,26 @@ export const Cell = ({ const rootElRef = useExternRef(getRootRef); - const { dragging, ...draggableProps } = useDraggable({ - rootElRef, - onDragFinish, - }); - - const { toggleDrag } = React.useContext(ListContext); - React.useEffect(() => { - if (dragging) { - toggleDrag(true); - return () => toggleDrag(false); - } - return undefined; - }, [dragging, toggleDrag]); - - let dragger; - if (draggable) { - dragger = ( - - ); - } + const dragger = draggable ? ( + + ) : null; let checkbox; if (selectable) { const checkboxProps: CellCheckboxProps = { name, value, - onChange, defaultChecked, checked, disabled, + onChange, }; checkbox = ; } @@ -122,8 +109,8 @@ export const Cell = ({ const cellClasses = classNames( styles['Cell'], - platform === Platform.IOS && styles['Cell--ios'], dragging && styles['Cell--dragging'], + platform === Platform.IOS && styles['Cell--ios'], removable && styles['Cell--removable'], Component === 'label' && styles['Cell--selectable'], disabled && styles['Cell--disabled'], diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css b/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css index 2cfdd8a1b0..c87ff8a562 100644 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css +++ b/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css @@ -1,4 +1,11 @@ +/* stylelint-disable @project-tools/stylelint-atomic, selector-max-universal */ .CellDragger { cursor: ns-resize; color: var(--vkui--color_icon_secondary); + user-select: none; + touch-action: manipulation; +} + +.CellDragger__icon { + pointer-events: none; } diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx b/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx index 06092090a9..1e8639b25a 100644 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx +++ b/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx @@ -1,43 +1,56 @@ import * as React from 'react'; import { Icon24Reorder, Icon24ReorderIos } from '@vkontakte/icons'; import { classNames } from '@vkontakte/vkjs'; +import { + type DraggableProps, + UseDraggableProps, + useDraggableWithDomApi, +} from '../../../hooks/useDraggableWithDomApi'; import { usePlatform } from '../../../hooks/usePlatform'; import { Platform } from '../../../lib/platform'; +import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; import { HTMLAttributesWithRootRef } from '../../../types'; import { Touch } from '../../Touch/Touch'; -import { DraggableProps } from '../useDraggable'; import styles from './CellDragger.module.css'; -type CellDraggerProps = DraggableProps & - Omit, keyof DraggableProps>; +interface CellDraggerProps + extends UseDraggableProps, + Omit, keyof DraggableProps> { + disabled?: boolean; + onDragStateChange?(dragging: boolean): void; +} export const CellDragger = ({ - onDragStart, - onDragMove, - onDragEnd, - onClick, + elRef, + disabled, className, + onDragStateChange, + onDragFinish, ...restProps }: CellDraggerProps) => { const platform = usePlatform(); + const Icon = platform === Platform.IOS ? Icon24ReorderIos : Icon24Reorder; - const handleClick = (event: React.MouseEvent) => { - event.preventDefault(); - if (onClick) { - onClick(event); + const { dragging, onDragStart, onDragMove, onDragEnd } = useDraggableWithDomApi({ + elRef, + onDragFinish, + }); + + useIsomorphicLayoutEffect(() => { + if (onDragStateChange) { + onDragStateChange(dragging); } - }; + }, [dragging, onDragStateChange]); return ( - {platform === Platform.IOS ? : } + ); }; diff --git a/packages/vkui/src/components/Cell/constants.ts b/packages/vkui/src/components/Cell/constants.ts new file mode 100644 index 0000000000..79b09006ef --- /dev/null +++ b/packages/vkui/src/components/Cell/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_DRAGGABLE_LABEL = 'Перенести ячейку'; diff --git a/packages/vkui/src/components/Cell/useDraggable.tsx b/packages/vkui/src/components/Cell/useDraggable.tsx deleted file mode 100644 index 31ef843e88..0000000000 --- a/packages/vkui/src/components/Cell/useDraggable.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from 'react'; -import { TouchEvent } from '../Touch/Touch'; -import { CellProps } from './Cell'; - -export interface DraggableProps { - onDragStart(event: TouchEvent): void; - onDragEnd(event: TouchEvent): void; - onDragMove(event: TouchEvent): void; -} - -interface UseDraggableProps extends DraggableProps { - dragging: boolean; -} - -export const useDraggable = ({ - rootElRef, - onDragFinish, -}: Pick & { - rootElRef: React.MutableRefObject; -}) => { - const [dragging, setDragging] = React.useState(false); - - const [siblings, setSiblings] = React.useState([]); - const [dragStartIndex, setDragStartIndex] = React.useState(0); - const [dragEndIndex, setDragEndIndex] = React.useState(0); - const [dragShift, setDragShift] = React.useState(0); - const [dragDirection, setDragDirection] = React.useState<'down' | 'up' | undefined>(undefined); - - const onDragStart = (event: TouchEvent) => { - const rootEl = rootElRef.current; - if (!rootEl) { - return; - } - event.originalEvent.stopPropagation(); - event.originalEvent.preventDefault(); - - setDragging(true); - - let _siblings: HTMLElement[] = []; - if (rootEl.parentElement?.childNodes) { - _siblings = Array.from(rootEl.parentElement.children) as HTMLElement[]; - } - const idx = _siblings.indexOf(rootEl); - - setDragStartIndex(idx); - setDragEndIndex(idx); - setSiblings(_siblings); - setDragShift(0); - }; - - const onDragMove = (event: TouchEvent) => { - event.originalEvent.stopPropagation(); - event.originalEvent.preventDefault(); - - const rootEl = rootElRef.current; - - if (rootEl) { - rootEl.style.transform = `translateY(${event.shiftY}px)`; - const rootGesture = rootEl.getBoundingClientRect(); - - setDragDirection(dragShift - event.shiftY < 0 ? 'down' : 'up'); - setDragShift(event.shiftY); - setDragEndIndex(dragStartIndex); - - siblings.forEach((sibling: HTMLElement, siblingIndex: number) => { - const siblingGesture = sibling.getBoundingClientRect(); - const siblingHalfHeight = siblingGesture.height / 2; - - const rootOverSibling = rootGesture.bottom > siblingGesture.top + siblingHalfHeight; - const rootUnderSibling = rootGesture.top < siblingGesture.bottom - siblingHalfHeight; - - if (dragStartIndex < siblingIndex) { - if (rootOverSibling) { - if (dragDirection === 'down') { - sibling.style.transform = 'translateY(-100%)'; - } - - setDragEndIndex((dragEndIndex) => dragEndIndex + 1); - } - if (rootUnderSibling && dragDirection === 'up') { - sibling.style.transform = 'translateY(0)'; - } - } else if (dragStartIndex > siblingIndex) { - if (rootUnderSibling) { - if (dragDirection === 'up') { - sibling.style.transform = 'translateY(100%)'; - } - - setDragEndIndex((dragEndIndex) => dragEndIndex - 1); - } - if (rootOverSibling && dragDirection === 'down') { - sibling.style.transform = 'translateY(0)'; - } - } - }); - } - }; - - const onDragEnd = (event: TouchEvent) => { - event.originalEvent.stopPropagation(); - event.originalEvent.preventDefault(); - - const [from, to] = [dragStartIndex, dragEndIndex]; - - siblings.forEach((sibling: HTMLElement) => { - sibling.style.transform = ''; - }); - - setSiblings([]); - setDragEndIndex(0); - setDragStartIndex(0); - setDragDirection(undefined); - setDragShift(0); - - setDragging(false); - - onDragFinish && onDragFinish({ from, to }); - }; - - const useDraggableProps: UseDraggableProps = { - onDragStart, - onDragMove, - onDragEnd, - dragging, - }; - - return useDraggableProps; -}; diff --git a/packages/vkui/src/components/List/List.module.css b/packages/vkui/src/components/List/List.module.css deleted file mode 100644 index 15186091a5..0000000000 --- a/packages/vkui/src/components/List/List.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.List { - isolation: isolate; -} diff --git a/packages/vkui/src/components/List/List.tsx b/packages/vkui/src/components/List/List.tsx index b7aeee5db0..d61f64b3dd 100644 --- a/packages/vkui/src/components/List/List.tsx +++ b/packages/vkui/src/components/List/List.tsx @@ -1,9 +1,7 @@ import * as React from 'react'; -import { classNames } from '@vkontakte/vkjs'; +import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../hooks/useDraggableWithDomApi'; import { HTMLAttributesWithRootRef } from '../../types'; import { RootComponent } from '../RootComponent/RootComponent'; -import { ListContext } from './ListContext'; -import styles from './List.module.css'; export type ListProps = HTMLAttributesWithRootRef; @@ -11,17 +9,10 @@ export type ListProps = HTMLAttributesWithRootRef; * @see https://vkcom.github.io/VKUI/#/List */ export const List = ({ children, ...restProps }: ListProps) => { - const [isDragging, toggleDrag] = React.useState(false); - return ( - - ({ toggleDrag }), [])}> - {children} - + + {children} +
); }; diff --git a/packages/vkui/src/components/List/ListContext.ts b/packages/vkui/src/components/List/ListContext.ts deleted file mode 100644 index 55fe46d265..0000000000 --- a/packages/vkui/src/components/List/ListContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as React from 'react'; -import { noop } from '@vkontakte/vkjs'; - -export const ListContext = React.createContext({ - toggleDrag: noop as (value: boolean) => void, -}); diff --git a/packages/vkui/src/components/Touch/Touch.tsx b/packages/vkui/src/components/Touch/Touch.tsx index d23d4e1623..84d1871ff4 100644 --- a/packages/vkui/src/components/Touch/Touch.tsx +++ b/packages/vkui/src/components/Touch/Touch.tsx @@ -42,6 +42,8 @@ export interface Gesture { isSlideX: boolean; isSlideY: boolean; isSlide: boolean; + clientX: number; + clientY: number; shiftX: number; shiftY: number; shiftXAbs: number; @@ -131,9 +133,12 @@ export const Touch = ({ const { isPressed, isX, isY, startX = 0, startY = 0 } = gesture.current ?? {}; if (isPressed) { + const clientX = coordX(e); + const clientY = coordY(e); + // смещения - const shiftX = coordX(e) - startX; - const shiftY = coordY(e) - startY; + const shiftX = clientX - startX; + const shiftY = clientY - startY; // абсолютные значения смещений const shiftXAbs = Math.abs(shiftX); @@ -164,6 +169,8 @@ export const Touch = ({ if (gesture.current?.isSlide) { Object.assign(gesture.current, { + clientX, + clientY, shiftX, shiftY, shiftXAbs, @@ -275,6 +282,8 @@ function initGesture(startX: number, startY: number): Gesture { isSlideX: false, isSlideY: false, isSlide: false, + clientX: 0, + clientY: 0, shiftX: 0, shiftY: 0, shiftXAbs: 0, diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.test.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.test.ts new file mode 100644 index 0000000000..ab43ddd097 --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.test.ts @@ -0,0 +1,148 @@ +import { requestAnimationFrameMock } from '../../testing/utils'; +import { + createAutoScrollController, + EDGE_SIZE, + getAutoScrollingData, + OUTBOX_OFFSET, +} from './autoScroll'; + +const VIEWPORT_WIDTH = 1280; +const VIEWPORT_HEIGHT = 768; +const SCROLL_HEIGHT = 2000; +const MAX_SCROLL_Y = SCROLL_HEIGHT - VIEWPORT_HEIGHT; + +const setScrollTop = (scrollEl: HTMLElement, scrollTop: number) => { + window.scrollY = scrollEl.scrollTop = scrollTop; + scrollEl.getBoundingClientRect = jest.fn( + () => new DOMRect(0, scrollTop > 0 ? -1 * scrollTop : 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT), + ); +}; + +const initScrollElData = (scrollEl: HTMLElement, scrollTop: number) => { + setScrollTop(scrollEl, scrollTop); + + jest.spyOn(scrollEl, 'scrollHeight', 'get').mockImplementation(() => SCROLL_HEIGHT); + + Object.defineProperty(scrollEl, 'scrollBy', { + value: (_: number, y: number) => { + setScrollTop(scrollEl, scrollTop + y); + }, + writable: false, + }); +}; + +describe('getAutoScrollingData', () => { + let scrollEl = document.createElement('div'); + + describe('top edge', () => { + beforeEach(() => { + scrollEl = document.createElement('div'); + }); + + it('should be falsy for scrollTop is top', () => { + initScrollElData(scrollEl, 0); + const { shouldScrolling } = getAutoScrollingData(0, scrollEl); + expect(shouldScrolling).toBeFalsy(); + }); + + test.each([ + [0, 1], + [EDGE_SIZE - 1, 1], + [OUTBOX_OFFSET, 1], + [OUTBOX_OFFSET + 10, 1], + ])('should be truthy for scrollTop is not on top', (clientY, scrollTop) => { + initScrollElData(scrollEl, scrollTop); + const { shouldScrolling } = getAutoScrollingData(clientY, scrollEl); + expect(shouldScrolling).toBeTruthy(); + }); + + test.each([ + [EDGE_SIZE + 1, 1], + [OUTBOX_OFFSET - 1, 1], + ])( + `should be falsy for clientY (%i) is not equal edge size (${EDGE_SIZE}) or outbox size (${OUTBOX_OFFSET})`, + (clientY, scrollTop) => { + initScrollElData(scrollEl, scrollTop); + const { shouldScrolling } = getAutoScrollingData(clientY, scrollEl); + expect(shouldScrolling).toBeFalsy(); + }, + ); + }); + + describe('bottom edge', () => { + beforeEach(() => { + scrollEl = document.createElement('div'); + }); + + it('should be falsy for scrollTop is bigger than max scroll y', () => { + initScrollElData(scrollEl, SCROLL_HEIGHT - VIEWPORT_HEIGHT); + const { shouldScrolling } = getAutoScrollingData(900, scrollEl); + expect(shouldScrolling).toBeFalsy(); + }); + + test.each([ + [VIEWPORT_HEIGHT - EDGE_SIZE - 1, 0], + [VIEWPORT_HEIGHT + -1 * OUTBOX_OFFSET + 1, 0], + ])( + `should be falsy for clientY (%i) is not equal edge size (${EDGE_SIZE}) or outbox size (${OUTBOX_OFFSET})`, + (clientY, scrollTop) => { + initScrollElData(scrollEl, scrollTop); + const { shouldScrolling } = getAutoScrollingData(clientY, scrollEl); + expect(shouldScrolling).toBeFalsy(); + }, + ); + }); +}); + +const getInitialAutoScrollData = (initialScrollTop: number) => { + const scrollEl = document.createElement('div'); + initScrollElData(scrollEl, initialScrollTop); + return { scrollEl, controller: createAutoScrollController(scrollEl) }; +}; + +describe('createAutoScrollController', () => { + beforeEach(() => { + requestAnimationFrameMock.init(); + }); + const SCROLL_TOP_OFFSET = 2; + it('should scroll to top', async () => { + const { scrollEl, controller } = getInitialAutoScrollData(SCROLL_TOP_OFFSET); + + controller.tryAutoScroll(() => getAutoScrollingData(0, scrollEl)); + requestAnimationFrameMock.triggerNextAnimationFrame(); + expect(controller.isRunning).toBeTruthy(); + requestAnimationFrameMock.triggerNextAnimationFrame(); + expect(controller.isRunning).toBeFalsy(); + expect(scrollEl.scrollTop).toBeLessThanOrEqual(0); + }); + + const SCROLL_BOTTOM_OFFSET = MAX_SCROLL_Y - 2; + it('should scroll to bottom', async () => { + const { scrollEl, controller } = getInitialAutoScrollData(SCROLL_BOTTOM_OFFSET); + + controller.tryAutoScroll(() => getAutoScrollingData(VIEWPORT_HEIGHT, scrollEl)); + + requestAnimationFrameMock.triggerNextAnimationFrame(); + expect(controller.isRunning).toBeTruthy(); + + requestAnimationFrameMock.triggerNextAnimationFrame(); + expect(controller.isRunning).toBeFalsy(); + + expect(scrollEl.scrollTop).toBeGreaterThanOrEqual(MAX_SCROLL_Y); + }); + + it('should stop scroll', async () => { + const { scrollEl, controller } = getInitialAutoScrollData(100); + + controller.tryAutoScroll(() => getAutoScrollingData(0, scrollEl)); + + requestAnimationFrameMock.triggerNextAnimationFrame(); + expect(controller.isRunning).toBeTruthy(); + + controller.stop(); + requestAnimationFrameMock.triggerAllAnimationFrames(); + expect(controller.isRunning).toBeFalsy(); + + expect(scrollEl.scrollTop).toBeLessThanOrEqual(90); + }); +}); diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.ts new file mode 100644 index 0000000000..8a5426cb14 --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/autoScroll.ts @@ -0,0 +1,75 @@ +import { getNodeScroll, getScrollHeight, getScrollRect } from '../../lib/dom'; +import { rafSchd } from '../../lib/rafSchd'; + +const SCROLL_SPEED = 10; +export const EDGE_SIZE = 50; +export const OUTBOX_OFFSET = -30; + +export const getAutoScrollingData = (clientY: number, scrollEl: Element | Window) => { + const scrollTop = Math.floor(getNodeScroll(scrollEl).scrollTop); + + const { relative, edges } = getScrollRect(scrollEl); + const viewportHeight = relative.height; + const documentHeight = getScrollHeight(scrollEl); + const maxScrollY = documentHeight - viewportHeight; + const canScrollUp = scrollTop > 0; + const canScrollDown = scrollTop < maxScrollY; + + const [edgeTop, edgeBottom] = edges.y; + const topDistance = clientY - edgeTop; + const bottomDistance = edgeBottom - clientY; + const isInTopEdge = topDistance <= EDGE_SIZE; + const isInBottomEdge = bottomDistance <= EDGE_SIZE; + + const result = { + shouldScrolling: + (canScrollUp && isInTopEdge && topDistance >= OUTBOX_OFFSET) || + (canScrollDown && isInBottomEdge && bottomDistance >= OUTBOX_OFFSET), + y: 0, + }; + + // Inspired by https://github.com/SortableJS/Sortable/issues/1907#issuecomment-1495403785 + if (isInTopEdge) { + result.y = -1 * ((EDGE_SIZE - topDistance) / EDGE_SIZE) * SCROLL_SPEED; + } else if (isInBottomEdge) { + result.y = ((EDGE_SIZE - bottomDistance) / EDGE_SIZE) * SCROLL_SPEED; + } + + return result; +}; + +export type AutoScrollingDataFn = () => { shouldScrolling: boolean; y: number }; + +export const createAutoScrollController = (scrollEl: Element | Window) => { + let isRunning = false; + const scheduledScroll = rafSchd(scroll); + + function scroll(fn: AutoScrollingDataFn) { + const { shouldScrolling, y } = fn(); + if (shouldScrolling) { + isRunning = true; + scrollEl.scrollBy(0, y); + scheduledScroll(fn); + } else { + isRunning = false; + scheduledScroll.cancel(); + } + } + + const tryAutoScroll = (fn: AutoScrollingDataFn) => { + scheduledScroll(fn); + }; + + const stop = () => { + isRunning = false; + scheduledScroll.cancel(); + }; + + return { + tryAutoScroll, + stop, + get isRunning() { + return isRunning; + }, + }; +}; diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/constants.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/constants.ts new file mode 100644 index 0000000000..63611d033b --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/constants.ts @@ -0,0 +1,7 @@ +export const AUTO_SCROLL_START_DELAY = 300; + +export const ITEM_INITIAL_INDEX = -1; + +export const DATA_DRAGGABLE_PLACEHOLDER_KEY = 'data-draggable-placeholder'; + +export const DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP = { [DATA_DRAGGABLE_PLACEHOLDER_KEY]: 'true' }; diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/index.tsx b/packages/vkui/src/hooks/useDraggableWithDomApi/index.tsx new file mode 100644 index 0000000000..10ee44bc30 --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/index.tsx @@ -0,0 +1,5 @@ +export type * from './types'; + +export { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from './constants'; + +export { useDraggableWithDomApi } from './useDraggableWithDomApi'; diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/types.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/types.ts new file mode 100644 index 0000000000..e4526b93f3 --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/types.ts @@ -0,0 +1,40 @@ +import * as React from 'react'; +import type { TouchEvent } from '../../components/Touch/Touch'; + +export type Direction = 'up' | 'down'; + +export type DraggingItem = { + index: number; + el: HTMLElement; + draggingElRect: DOMRect; +}; + +export type PlaceholderItem = { + index: number; + el: HTMLElement; + draggingElRect: DOMRect; +}; + +export type SiblingItem = { + index: number; + el: HTMLElement; + shifted: boolean; + draggingElRect: DOMRect; +}; + +export type SwappedItemRange = { from: number; to: number }; + +export interface UseDraggableProps { + elRef: React.MutableRefObject; + onDragFinish?(value: SwappedItemRange): void; +} + +export interface DraggableProps { + onDragStart(this: void, event: TouchEvent): void; + onDragEnd(this: void, event: TouchEvent): void; + onDragMove(this: void, event: TouchEvent): void; +} + +export interface UseDraggable extends DraggableProps { + dragging: boolean; +} diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts new file mode 100644 index 0000000000..68e5381c75 --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts @@ -0,0 +1,278 @@ +import * as React from 'react'; +import type { TouchEvent } from '../../components/Touch/Touch'; +import { getBoundingClientRect, getNearestOverflowAncestor, getNodeScroll } from '../../lib/dom'; +import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; +import { createAutoScrollController, getAutoScrollingData } from './autoScroll'; +import { + AUTO_SCROLL_START_DELAY, + DATA_DRAGGABLE_PLACEHOLDER_KEY, + ITEM_INITIAL_INDEX, +} from './constants'; +import type { + Direction, + DraggingItem, + PlaceholderItem, + SiblingItem, + UseDraggable, + UseDraggableProps, +} from './types'; +import { + getTargetIsOverOrUnderElData, + setDraggingItemShiftStyles, + setInitialDraggingItemStyles, + setInitialPlaceholderItemStyles, + setInitialSiblingItemStyles, + setSiblingItemsShiftStyles, + unsetInitialDraggingItemStyles, + unsetInitialPlaceholderItemStyles, + unsetInitialSiblingItemStyles, +} from './utils'; + +export const useDraggableWithDomApi = ({ + elRef: draggingElRef, + onDragFinish, +}: UseDraggableProps): UseDraggable => { + const [dragging, setDragging] = React.useState(false); + const lastClientYRef = React.useRef(0); + const lastDragShiftYRef = React.useRef(0); + + const scrollElRef = React.useRef(null); + const lastScrollTopRef = React.useRef(0); + const scrollControllerRef = React.useRef | null>( + null, + ); + const initializeScrollRefs = (draggableEl: HTMLElement) => { + const node = getNearestOverflowAncestor(draggableEl); + if (node) { + scrollElRef.current = node; + lastScrollTopRef.current = getNodeScroll(node).scrollTop; + scrollControllerRef.current = createAutoScrollController(scrollElRef.current); + } + }; + const cleanupScrollRefs = () => { + lastScrollTopRef.current = 0; + scrollControllerRef.current?.stop(); + scrollElRef.current = scrollControllerRef.current = null; + }; + + const lastDragDirectionRef = React.useRef(undefined); + const toggleDragDirection = (prevShiftY: number, nextShiftY: number) => { + const shiftYDiff = prevShiftY - nextShiftY; + if (shiftYDiff < 0) { + return 'down'; + } + if (shiftYDiff > 0) { + return 'up'; + } + return lastDragDirectionRef.current; + }; + + const itemStartIndexRef = React.useRef(ITEM_INITIAL_INDEX); + const itemEndIndexRef = React.useRef(ITEM_INITIAL_INDEX); + const draggingItemRef = React.useRef(null); + const placeholderItemRef = React.useRef(null); + const siblingItemsRef = React.useRef([]); + const initializeItems = (draggingEl: HTMLElement) => { + const draggingElRect = getBoundingClientRect(draggingEl, true); + const { children } = draggingEl.parentElement || { children: [] }; + Array.prototype.forEach.call(children, (el: HTMLElement, index) => { + if (el === draggingEl) { + itemStartIndexRef.current = itemEndIndexRef.current = index; + draggingItemRef.current = { index, el, draggingElRect }; + } else if (el.getAttribute(DATA_DRAGGABLE_PLACEHOLDER_KEY) !== null) { + placeholderItemRef.current = { index, el, draggingElRect }; + } else { + siblingItemsRef.current.push({ index, el, shifted: itemStartIndexRef.current !== ITEM_INITIAL_INDEX && itemStartIndexRef.current < index, draggingElRect }); // prettier-ignore + } + }); + if (placeholderItemRef.current) { + setInitialPlaceholderItemStyles(placeholderItemRef.current); // 1. reflow + } + if (draggingItemRef.current) { + setInitialDraggingItemStyles(draggingItemRef.current); // 2. repaint + } + siblingItemsRef.current.forEach(setInitialSiblingItemStyles); // 2. repaint + }; + const cleanupItems = () => { + if (placeholderItemRef.current) { + unsetInitialPlaceholderItemStyles(placeholderItemRef.current); // 1. reflow + } + if (draggingItemRef.current) { + unsetInitialDraggingItemStyles(draggingItemRef.current); // 2. repaint + } + siblingItemsRef.current.forEach(unsetInitialSiblingItemStyles); // 2. repaint + siblingItemsRef.current = []; + placeholderItemRef.current = draggingItemRef.current = null; + + const swappedItemIndexRange = { from: itemStartIndexRef.current, to: itemEndIndexRef.current }; + itemStartIndexRef.current = itemEndIndexRef.current = ITEM_INITIAL_INDEX; + return swappedItemIndexRange; + }; + const getShiftAndUnshiftItemsPreparedData = ( + clientY: number, + ): [Array<[SiblingItem, Direction]>, Array<[SiblingItem, Direction]>] => { + const shiftItemEls: Array<[SiblingItem, Direction]> = []; + const unshiftItemEls: Array<[SiblingItem, Direction]> = []; + itemEndIndexRef.current = itemStartIndexRef.current; + siblingItemsRef.current.forEach((siblingItem) => { + const { isOverEl, isUnderEl } = getTargetIsOverOrUnderElData( + clientY, + getBoundingClientRect(siblingItem.el), + ); + if (itemStartIndexRef.current < siblingItem.index) { + if (isOverEl) { + itemEndIndexRef.current = itemEndIndexRef.current + 1; + if (lastDragDirectionRef.current === 'down' && siblingItem.shifted) { + siblingItem.shifted = false; + shiftItemEls.push([siblingItem, 'up']); + } + } + if (isUnderEl) { + if (lastDragDirectionRef.current === 'up' && !siblingItem.shifted) { + siblingItem.shifted = true; + unshiftItemEls.push([siblingItem, 'down']); + } + } + } else if (itemStartIndexRef.current > siblingItem.index) { + if (isUnderEl) { + itemEndIndexRef.current = itemEndIndexRef.current - 1; + if (lastDragDirectionRef.current === 'up' && !siblingItem.shifted) { + siblingItem.shifted = true; + shiftItemEls.push([siblingItem, 'down']); + } + } + if (isOverEl) { + if (lastDragDirectionRef.current === 'down' && siblingItem.shifted) { + siblingItem.shifted = false; + unshiftItemEls.push([siblingItem, 'up']); + } + } + } + }); + return [shiftItemEls, unshiftItemEls]; + }; + const setShiftAndUnshiftItemStyles = ( + shiftItemEls: Array<[SiblingItem, Direction]>, + unshiftItemEls: Array<[SiblingItem, Direction]>, + ) => { + shiftItemEls.forEach(setSiblingItemsShiftStyles); + unshiftItemEls.forEach(setSiblingItemsShiftStyles); + }; + + const schedulingAutoScrollTimeoutIdRef = React.useRef | null>(null); + const clearSchedulingAutoScrollTimeout = () => { + if (schedulingAutoScrollTimeoutIdRef.current) { + clearTimeout(schedulingAutoScrollTimeoutIdRef.current); + schedulingAutoScrollTimeoutIdRef.current = null; + } + }; + const tryAutoScroll = () => { + if (scrollControllerRef.current) { + scrollControllerRef.current.tryAutoScroll(() => { + return scrollElRef.current + ? getAutoScrollingData(lastClientYRef.current, scrollElRef.current) + : { + shouldScrolling: false, + y: 0, + }; + }); + } + }; + const schedulingAutoScroll = () => { + clearSchedulingAutoScrollTimeout(); + schedulingAutoScrollTimeoutIdRef.current = setTimeout(() => { + schedulingAutoScrollTimeoutIdRef.current = null; + tryAutoScroll(); + }, AUTO_SCROLL_START_DELAY); + }; + + const onDragStart = (event: TouchEvent) => { + event.originalEvent.stopPropagation(); + event.originalEvent.preventDefault(); + }; + + const onDragMove = (event: TouchEvent) => { + event.originalEvent.stopPropagation(); + event.originalEvent.preventDefault(); + + const draggingEl = draggingElRef.current; + + if (!draggingEl) { + return; + } + + if (dragging) { + lastDragDirectionRef.current = toggleDragDirection(lastDragShiftYRef.current, event.shiftY); + lastDragShiftYRef.current = event.shiftY; + lastClientYRef.current = event.clientY; + + if (scrollControllerRef.current && scrollControllerRef.current.isRunning) { + setDraggingItemShiftStyles(draggingEl, lastDragShiftYRef.current); + } else { + const [shiftItemEls, unshiftItemEls] = getShiftAndUnshiftItemsPreparedData( + lastClientYRef.current, + ); + setDraggingItemShiftStyles(draggingEl, lastDragShiftYRef.current); + setShiftAndUnshiftItemStyles(shiftItemEls, unshiftItemEls); + schedulingAutoScroll(); + } + } else { + initializeScrollRefs(draggingEl); + initializeItems(draggingEl); + setDragging(true); + } + }; + + const onDragEnd = (event: TouchEvent) => { + event.originalEvent.stopPropagation(); + event.originalEvent.preventDefault(); + + clearSchedulingAutoScrollTimeout(); + cleanupScrollRefs(); + + lastClientYRef.current = lastDragShiftYRef.current = 0; + lastDragDirectionRef.current = undefined; + + if (dragging) { + const swappedItemRange = cleanupItems(); + if (onDragFinish) { + onDragFinish(swappedItemRange); + } + setDragging(false); + } + }; + + const handleScroll = React.useCallback(() => { + if (!draggingElRef.current || !scrollElRef.current) { + return; + } + + const nextScrollTop = getNodeScroll(scrollElRef.current).scrollTop; + lastDragDirectionRef.current = toggleDragDirection(lastScrollTopRef.current, nextScrollTop); + const scrollDiff = lastScrollTopRef.current - nextScrollTop; + const clientYWithScrollOffset = lastClientYRef.current + scrollDiff; + lastScrollTopRef.current = nextScrollTop; + + const [shiftItemEls, unshiftItemEls] = + getShiftAndUnshiftItemsPreparedData(clientYWithScrollOffset); + setShiftAndUnshiftItemStyles(shiftItemEls, unshiftItemEls); + }, [draggingElRef]); + + useIsomorphicLayoutEffect( + function recalculateOnScroll() { + const scrollEl = scrollElRef.current; + if (!dragging || !scrollEl) { + return; + } + scrollEl.addEventListener('scroll', handleScroll); + return () => { + if (scrollEl) { + scrollEl.removeEventListener('scroll', handleScroll); + } + }; + }, + [dragging, handleScroll], + ); + + return { dragging, onDragStart, onDragMove, onDragEnd }; +}; diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/utils.test.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/utils.test.ts new file mode 100644 index 0000000000..506dabdf4d --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/utils.test.ts @@ -0,0 +1,157 @@ +// import '../../testing/utils'; +import { waitRAF } from '../../testing/utils'; +import { + getTargetIsOverOrUnderElData, + setDraggingItemShiftStyles, + setInitialDraggingItemStyles, + setInitialPlaceholderItemStyles, + setInitialSiblingItemStyles, + setSiblingItemsShiftStyles, + unsetInitialDraggingItemStyles, + unsetInitialPlaceholderItemStyles, + unsetInitialSiblingItemStyles, +} from './utils'; + +const getMockedEl = () => ({ + el: document.createElement('div'), + domRect: new DOMRect(0, 10, 100, 50), +}); + +describe('getTargetIsOverOrUnderElData', () => { + it('should return expected predicates', () => { + const { domRect } = getMockedEl(); + let result = getTargetIsOverOrUnderElData(0, domRect); + expect(result).toEqual({ isUnderEl: true, isOverEl: false }); + result = getTargetIsOverOrUnderElData(10, domRect); + expect(result).toEqual({ isUnderEl: true, isOverEl: false }); + result = getTargetIsOverOrUnderElData(60, domRect); + expect(result).toEqual({ isUnderEl: false, isOverEl: true }); + result = getTargetIsOverOrUnderElData(200, domRect); + expect(result).toEqual({ isUnderEl: false, isOverEl: true }); + }); +}); + +describe('Dragging item', () => { + const { el, domRect } = getMockedEl(); + + it('should set initial styles', async () => { + setInitialDraggingItemStyles({ index: 0, el, draggingElRect: domRect }); + await waitRAF(); + expect(el.style).toEqual( + expect.objectContaining({ + pointerEvents: 'none', + position: 'fixed', + top: `${domRect.top}px`, + left: `${domRect.left}px`, + width: `${domRect.width}px`, + height: `${domRect.height}px`, + zIndex: 'var(--vkui_internal--z_index_cell_dragging)', + boxSizing: 'border-box', + transform: 'translateY(0)', + }), + ); + }); + + test.each([[0, 10, 20, -20]])('should set translateY styles to %i', async (nextShiftY) => { + setDraggingItemShiftStyles(el, nextShiftY); + await waitRAF(); + expect(el.style.transform).toEqual(`translateY(${nextShiftY}px)`); + }); + + it('should unset initial styles', async () => { + unsetInitialDraggingItemStyles({ index: 0, el, draggingElRect: domRect }); + await waitRAF(); + expect(el.style).toEqual( + expect.objectContaining({ + pointerEvents: '', + position: '', + top: '', + left: '', + width: '', + height: '', + zIndex: '', + boxSizing: '', + transform: '', + }), + ); + }); +}); + +describe('Placeholder item', () => { + const { el, domRect } = getMockedEl(); + + it('should add placeholder with styles', async () => { + setInitialPlaceholderItemStyles({ index: 0, el, draggingElRect: domRect }); + expect(el).toContainHTML( + `
`, + ); + }); + + it('should remove placeholder with styles', async () => { + unsetInitialPlaceholderItemStyles({ index: 0, el, draggingElRect: domRect }); + expect(el).toContainHTML(`
`); + }); +}); + +describe('Sibling items', () => { + it('should set initial styles without transform', async () => { + const { el, domRect } = getMockedEl(); + setInitialSiblingItemStyles({ index: 0, el, shifted: false, draggingElRect: domRect }); + await waitRAF(); + expect(el.style).toEqual( + expect.objectContaining({ + pointerEvents: 'none', + transition: 'none 0s ease 0s', + transform: '', + }), + ); + }); + + it('should set initial styles', async () => { + const { el, domRect } = getMockedEl(); + setInitialSiblingItemStyles({ index: 0, el, shifted: true, draggingElRect: domRect }); + await waitRAF(); + expect(el.style).toEqual( + expect.objectContaining({ + pointerEvents: 'none', + transition: 'none 0s ease 0s', + transform: `translateY(${domRect.height}px)`, + }), + ); + }); + + it('should set shift styles', async () => { + const { el, domRect } = getMockedEl(); + const expectedForDirectionUp = { transition: 'transform 0.3s ease-in 0s', transform: '' }; + + setSiblingItemsShiftStyles([{ index: 0, el, draggingElRect: domRect }, 'up']); + await waitRAF(); + expect(el.style).toEqual(expect.objectContaining(expectedForDirectionUp)); + + setSiblingItemsShiftStyles([{ index: 0, el, draggingElRect: domRect }, 'down']); + await waitRAF(); + expect(el.style).toEqual( + expect.objectContaining({ + transition: 'transform 0.3s ease-out 0s', + transform: `translateY(${domRect.height}px)`, + }), + ); + + setSiblingItemsShiftStyles([{ index: 0, el, draggingElRect: domRect }, 'up']); + await waitRAF(); + expect(el.style).toEqual(expect.objectContaining(expectedForDirectionUp)); + }); + + it('should unset initial styles', async () => { + const { el, domRect } = getMockedEl(); + unsetInitialSiblingItemStyles({ index: 0, el, draggingElRect: domRect }); + await waitRAF(); + expect(el.style).toEqual( + expect.objectContaining({ + pointerEvents: '', + transition: '', + transform: '', + }), + ); + }); +}); diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/utils.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/utils.ts new file mode 100644 index 0000000000..777a00fb28 --- /dev/null +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/utils.ts @@ -0,0 +1,99 @@ +import type { Direction, DraggingItem, PlaceholderItem, SiblingItem } from './types'; + +export const getTargetIsOverOrUnderElData = (clientY: number, elRect: DOMRect) => { + const elRectHalfHeight = elRect.height / 2; + return { + isUnderEl: clientY <= elRect.bottom - elRectHalfHeight, + isOverEl: clientY >= elRect.top + elRectHalfHeight, + }; +}; + +export const setDraggingItemShiftStyles = (draggingEl: HTMLElement, nextShiftY: number) => { + requestAnimationFrame(() => { + draggingEl.style.transform = `translateY(${nextShiftY}px)`; + }); +}; + +export const setSiblingItemsShiftStyles = ([ + { + el, + draggingElRect: { height }, + }, + direction, +]: [Omit, Direction]) => { + requestAnimationFrame(() => { + if (direction === 'up') { + el.style.setProperty('transition', 'transform 0.3s ease-in 0s'); + el.style.removeProperty('transform'); + } else { + el.style.setProperty('transition', 'transform 0.3s ease-out 0s'); + el.style.setProperty('transform', `translateY(${height}px)`); + } + }); +}; + +export const setInitialDraggingItemStyles = ({ el, draggingElRect }: DraggingItem) => { + const { top, left, width, height } = draggingElRect; + requestAnimationFrame(() => { + // Inspired by https://github.com/hello-pangea/dnd + el.style.setProperty('pointer-events', 'none'); + el.style.setProperty('position', 'fixed'); + el.style.setProperty('top', `${top}px`); + el.style.setProperty('left', `${left}px`); + el.style.setProperty('width', `${width}px`); + el.style.setProperty('height', `${height}px`); + el.style.setProperty('z-index', 'var(--vkui_internal--z_index_cell_dragging)'); + el.style.setProperty('box-sizing', 'border-box'); + el.style.setProperty('transform', 'translateY(0)'); + }); +}; + +export const unsetInitialDraggingItemStyles = ({ el }: DraggingItem) => { + requestAnimationFrame(() => { + el.style.removeProperty('pointer-events'); + el.style.removeProperty('position'); + el.style.removeProperty('top'); + el.style.removeProperty('left'); + el.style.removeProperty('width'); + el.style.removeProperty('height'); + el.style.removeProperty('z-index'); + el.style.removeProperty('box-sizing'); + el.style.removeProperty('transform'); + }); +}; + +export const setInitialPlaceholderItemStyles = ({ el, draggingElRect }: PlaceholderItem) => { + const { width, height } = draggingElRect; + const node = el.cloneNode() as HTMLElement; + node.style.setProperty('display', 'block'); + node.style.setProperty('width', `${width}px`); + node.style.setProperty('height', `${height}px`); + node.style.setProperty('pointer-events', 'none'); + el.appendChild(node); +}; + +export const unsetInitialPlaceholderItemStyles = ({ el }: PlaceholderItem) => { + if (el.firstElementChild) { + el.firstElementChild.remove(); + } +}; + +export const setInitialSiblingItemStyles = ({ el, shifted, draggingElRect }: SiblingItem) => { + const { height } = draggingElRect; + requestAnimationFrame(() => { + el.style.setProperty('pointer-events', 'none'); + el.style.setProperty('transition', 'none 0s ease 0s'); + + if (shifted) { + el.style.setProperty('transform', `translateY(${height}px)`); + } + }); +}; + +export const unsetInitialSiblingItemStyles = ({ el }: Omit) => { + requestAnimationFrame(() => { + el.style.removeProperty('pointer-events'); + el.style.removeProperty('transition'); + el.style.removeProperty('transform'); + }); +}; diff --git a/packages/vkui/src/lib/dom.test.ts b/packages/vkui/src/lib/dom.test.ts new file mode 100644 index 0000000000..223979922c --- /dev/null +++ b/packages/vkui/src/lib/dom.test.ts @@ -0,0 +1,144 @@ +import { + getBoundingClientRect, + getScrollHeight, + getScrollRect, + getTransformedParentCoords, + TRANSFORM_DEFAULT_VALUES, + WILL_CHANGE_DEFAULT_VALUES, +} from './dom'; + +const getChildElOfParentWithTransformedStyle = ( + stylesProp: Partial> = {}, +) => { + const rootEl = document.createElement('div'); + const parentEl = document.createElement('div'); + const parentElRect = new DOMRect(0, 100, 1280, 768); + parentEl.getBoundingClientRect = jest.fn(() => new DOMRect(0, 100, 1280, 768)); + Object.entries({ transform: 'none', willChange: 'auto', ...stylesProp }).forEach( + ([key, value]) => (parentEl.style[key as any] = value), + ); + const childEl = document.createElement('div'); + const childElRect = new DOMRect(10, 10, 100, 50); + childEl.getBoundingClientRect = jest.fn(() => childElRect); + parentEl.appendChild(childEl); + rootEl.appendChild(parentEl); + return { parentEl, parentElRect, childEl, childElRect }; +}; + +describe('getTransformedParentCoords', () => { + const transformDefault = TRANSFORM_DEFAULT_VALUES.map((v) => ({ transform: v })); + const willChangeDefault = WILL_CHANGE_DEFAULT_VALUES.map((v) => ({ willChange: v })); + + it('should return default values if parent has not styles', () => { + const { childEl } = getChildElOfParentWithTransformedStyle(); + expect(getTransformedParentCoords(childEl)).toEqual({ x: 0, y: 0 }); + }); + + it('should return default values if parent has not transformed styles', () => { + const { childEl } = getChildElOfParentWithTransformedStyle(); + expect(getTransformedParentCoords(childEl)).toEqual({ x: 0, y: 0 }); + }); + + test.each(transformDefault)('should return default values if parent has styles %j', (styles) => { + const { childEl } = getChildElOfParentWithTransformedStyle(styles); + expect(getTransformedParentCoords(childEl)).toEqual({ x: 0, y: 0 }); + }); + + test.each(willChangeDefault)('should return default values if parent has styles %j', (styles) => { + const { childEl } = getChildElOfParentWithTransformedStyle(styles); + expect(getTransformedParentCoords(childEl)).toEqual({ x: 0, y: 0 }); + }); + + it('should return default values if parent is null', () => { + expect(getTransformedParentCoords(document.createElement('div'))).toEqual({ x: 0, y: 0 }); + }); + + test.each([ + { transform: 'translateY(0)' }, + { transform: 'translateY(0)', willChange: 'auto' }, + { willChange: 'transform' }, + { transform: 'none', willChange: 'transform' }, + { transform: 'translateY(0)', willChange: 'transform' }, + ])('should return offset values if parent has styles %j', (styles) => { + const { childEl } = getChildElOfParentWithTransformedStyle(styles); + expect(getTransformedParentCoords(childEl)).toEqual({ x: 0, y: 100 }); + }); +}); + +describe('getScrollRect', () => { + test.each([ + { scrollTop: 0, viewportHeight: 100 }, + { scrollTop: 10, viewportHeight: 100 }, + { scrollTop: 0, viewportHeight: 768 }, + { scrollTop: 10, viewportHeight: 768 }, + ])('[window] should return correct y edges for %j', ({ scrollTop, viewportHeight }) => { + const rect = new DOMRect(0, scrollTop > 0 ? -1 * scrollTop : scrollTop, 1280, viewportHeight); + window.scrollY = document.documentElement.scrollTop = scrollTop; + document.documentElement.getBoundingClientRect = jest.fn(() => rect); + const { relative, edges } = getScrollRect(window); + expect(relative).toEqual(rect); + expect(edges).toEqual({ y: [0, viewportHeight] }); + }); + + test.each([ + { scrollTop: 0, viewportHeight: 100 }, + { scrollTop: 10, viewportHeight: 100 }, + { scrollTop: 0, viewportHeight: 768 }, + { scrollTop: 10, viewportHeight: 768 }, + ])('[element] should return correct y edges for %j', ({ scrollTop, viewportHeight }) => { + const rect = new DOMRect(0, scrollTop > 0 ? -1 * scrollTop : scrollTop, 1280, viewportHeight); + const scrollEl = document.createElement('div'); + window.scrollY = scrollEl.scrollTop = scrollTop; + scrollEl.getBoundingClientRect = jest.fn(() => rect); + const { relative, edges } = getScrollRect(scrollEl); + expect(relative).toEqual(rect); + expect(edges).toEqual({ y: [0, viewportHeight] }); + }); +}); + +describe('getScrollHeight', () => { + const getScrollHeightMock = () => 1000; + const scrollEl = document.createElement('div'); + beforeEach(() => { + jest + .spyOn(document.documentElement, 'scrollHeight', 'get') + .mockImplementation(getScrollHeightMock); + jest.spyOn(scrollEl, 'scrollHeight', 'get').mockImplementation(getScrollHeightMock); + }); + test.each([ + ['window', window], + ['element', scrollEl], + ])('should return scroll height of %s', (_, node) => { + expect(getScrollHeight(node)).toBe(getScrollHeightMock()); + }); +}); + +describe('getBoundingClientRect', () => { + it('should return rect without offset', () => { + const { childEl, childElRect } = getChildElOfParentWithTransformedStyle(); + expect(getBoundingClientRect(childEl)).toEqual(childElRect); + }); + + it('should return rect with offset relative parent with transformed styles', () => { + const { parentElRect, childEl, childElRect } = getChildElOfParentWithTransformedStyle({ + transform: 'translate3d(0, 25%, 0)', + }); + expect(getBoundingClientRect(childEl)).toEqual(childElRect); + const { x, y, width, height } = getBoundingClientRect(childEl, true); + expect( + DOMRect.fromRect({ + x, + y, + width, + height, + }), + ).toEqual( + DOMRect.fromRect({ + x: childElRect.x, + y: childElRect.y - parentElRect.y, + width: childElRect.width, + height: childElRect.height, + }), + ); + }); +}); diff --git a/packages/vkui/src/lib/dom.tsx b/packages/vkui/src/lib/dom.tsx index 45f0fe78c7..ca1cc4bade 100644 --- a/packages/vkui/src/lib/dom.tsx +++ b/packages/vkui/src/lib/dom.tsx @@ -1,7 +1,15 @@ import * as React from 'react'; import { canUseDOM } from '@vkontakte/vkjs'; -export { canUseDOM, canUseEventListeners, onDOMLoaded } from '@vkontakte/vkjs'; +import { rectToClientRect } from '@vkontakte/vkui-floating-ui/core'; +import { + getNearestOverflowAncestor as getNearestOverflowAncestorLib, + getWindow, + isHTMLElement, +} from '@vkontakte/vkui-floating-ui/utils/dom'; + +export { getWindow, getNodeScroll } from '@vkontakte/vkui-floating-ui/utils/dom'; +export { canUseDOM, canUseEventListeners, onDOMLoaded } from '@vkontakte/vkjs'; export interface DOMContextInterface { /** * @ignore @@ -28,6 +36,22 @@ export const useDOM = () => { return React.useContext(DOMContext); }; +/** + * В случае, если используется DOMContext, при проверке 'node instanceOf Window' – Window может быть + * другим объектом. + */ +export const isWindow = ( + node: Element | Window | VisualViewport | undefined | null, +): node is Window => { + return node !== null && node !== undefined && 'navigator' in node; +}; + +export const isBody = ( + node: Element | Window | VisualViewport | undefined | null, +): node is HTMLBodyElement => { + return node !== null && node !== undefined && 'tagName' in node && node.tagName === 'BODY'; +}; + export function withDOM( Component: React.ComponentType, ): React.ComponentType { @@ -43,3 +67,76 @@ export function blurActiveElement(document: Document | undefined) { (document.activeElement as HTMLElement).blur(); } } + +export const TRANSFORM_DEFAULT_VALUES = ['none', 'initial', 'inherit', 'unset']; +export const WILL_CHANGE_DEFAULT_VALUES = ['auto', 'initial', 'inherit', 'unset']; +export function getTransformedParentCoords(element: Element) { + let parentNode = element.parentNode; + while (parentNode !== null) { + if (isHTMLElement(parentNode)) { + const { transform, willChange } = getComputedStyle(parentNode); + if ( + !TRANSFORM_DEFAULT_VALUES.includes(transform) || + !WILL_CHANGE_DEFAULT_VALUES.includes(willChange) + ) { + const { x, y } = parentNode.getBoundingClientRect(); + return { x, y }; + } + } + parentNode = parentNode.parentNode; + } + return { x: 0, y: 0 }; +} + +export const getBoundingClientRect = (node: Element | Window, isFixedStrategy = false) => { + const element = isWindow(node) ? node.document.documentElement : node; + const clientRect = element.getBoundingClientRect(); + + let offsetX = 0; + let offsetY = 0; + if (isFixedStrategy) { + const { x, y } = getTransformedParentCoords(element); + offsetX = x; + offsetY = y; + } + + return rectToClientRect({ + x: clientRect.left - offsetX, + y: clientRect.top - offsetY, + width: clientRect.width, + height: clientRect.height, + }) as DOMRect; +}; + +/** + * Адаптер над getNearestOverflowAncestor из @floating-ui/utils/dom. + * + * document.body подменяем на window, т.к. на document.body нельзя применить скролл. + */ +export const getNearestOverflowAncestor = (childEl: Node): HTMLElement | Window | null => { + const foundAncestor = getNearestOverflowAncestorLib(childEl); + + return isBody(foundAncestor) + ? getWindow(foundAncestor) + : isHTMLElement(childEl) + ? foundAncestor + : null; +}; + +export const getScrollHeight = (node: Element | Window) => { + return isWindow(node) ? node.document.documentElement.scrollHeight : node.scrollHeight; +}; + +export const getScrollRect = (node: Element | Window) => { + const window = node instanceof Element ? getWindow(node) : node; + const scrollElRect = getBoundingClientRect(node); + + const edgeTop = window.scrollY + scrollElRect.top; + const edgeBottom = edgeTop + scrollElRect.height; + const y: [number, number] = [edgeTop, edgeBottom]; + + return { + relative: scrollElRect, + edges: { y }, + }; +}; diff --git a/packages/vkui/src/lib/rafSchd.ts b/packages/vkui/src/lib/rafSchd.ts new file mode 100644 index 0000000000..489af3d7af --- /dev/null +++ b/packages/vkui/src/lib/rafSchd.ts @@ -0,0 +1,48 @@ +/** + * https://github.com/alexreardon/raf-schd + * + * Copyright (c) 2021 Alex Reardon + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +type AnyFn = (...args: any[]) => void; + +export interface RafSchedule { + (...args: Parameters): void; + cancel(): void; +} + +export const rafSchd = (fn: T): RafSchedule => { + let lastArgs: any = []; + let frameId: number | null = null; + + const wrapperFn = (...args: Parameters) => { + // Always capture the latest value + lastArgs = args; + + // There is already a frame queued + if (frameId) { + return; + } + + // Schedule a new frame + frameId = requestAnimationFrame(() => { + frameId = null; + fn(...lastArgs); + }); + }; + + // Adding cancel property to result function + wrapperFn.cancel = () => { + if (!frameId) { + return; + } + + cancelAnimationFrame(frameId); + frameId = null; + }; + + return wrapperFn; +}; diff --git a/packages/vkui/src/styles/constants.css b/packages/vkui/src/styles/constants.css index 1c43443cc8..ba7b1c0413 100644 --- a/packages/vkui/src/styles/constants.css +++ b/packages/vkui/src/styles/constants.css @@ -31,7 +31,7 @@ --vkui_internal--duration: 0.7s; /* z_index */ - --vkui_internal--z_index_cell_dragging: 1; + --vkui_internal--z_index_cell_dragging: 100; --vkui_internal--z_index_tabs: 2; --vkui_internal--z_index_fixed_layout: 3; --vkui_internal--z_index_panel_header_context: 4; diff --git a/packages/vkui/src/testing/e2e/index.playwright.ts b/packages/vkui/src/testing/e2e/index.playwright.ts index 821b417aab..53c55f2b43 100644 --- a/packages/vkui/src/testing/e2e/index.playwright.ts +++ b/packages/vkui/src/testing/e2e/index.playwright.ts @@ -32,6 +32,7 @@ export const test = testBase.extend { - const skipPlatform = Array.isArray(onlyForPlatforms) && !onlyForPlatforms.includes(platform); - const skipAppearance = - Array.isArray(onlyForAppearances) && !onlyForAppearances.includes(appearance); - let descriptions = []; - if (skipPlatform) { - descriptions.push(`${onlyForPlatforms.join(', ')} platforms`); - } - if (skipAppearance) { - descriptions.push(`${onlyForAppearances.join(', ')} appearances`); - } - testInfo.skip( - skipPlatform || skipAppearance, - `Because test only for ${descriptions.join(' and ')}`, - ); + async ( + { + platform, + appearance, + defaultBrowserType, + onlyForBrowsers, + onlyForPlatforms, + onlyForAppearances, + }, + use, + testInfo, + ) => { + const skipReasons = [ + { type: 'browser', matchList: onlyForBrowsers || [], value: defaultBrowserType }, + { type: 'platform', matchList: onlyForPlatforms || [], value: platform }, + { type: 'appearance', matchList: onlyForAppearances || [], value: appearance }, + ] + .filter( + ({ matchList, value }) => matchList.length > 0 && matchList.every((i) => i !== value), + ) + .map(({ type, matchList }) => `${matchList.join(', ')} ${type}`); + + testInfo.skip(skipReasons.length > 0, `Because test only for ${skipReasons.join(' and ')}`); await use(); }, { auto: true }, diff --git a/packages/vkui/src/testing/e2e/types.ts b/packages/vkui/src/testing/e2e/types.ts index dd8f20fd6c..ad71c5176e 100644 --- a/packages/vkui/src/testing/e2e/types.ts +++ b/packages/vkui/src/testing/e2e/types.ts @@ -1,3 +1,4 @@ +import type { PlaywrightWorkerOptions } from '@playwright/test'; import type { AdaptivityProps } from '../../components/AdaptivityProvider/AdaptivityContext'; import type { AppearanceType } from '../../lib/appearance'; import { Platform } from '../../lib/platform'; @@ -13,6 +14,7 @@ export interface VKUITestOptions { export interface InternalVKUITestOptions { adaptivityProviderProps?: null | Partial; + onlyForBrowsers?: null | Array; onlyForPlatforms?: null | Platform[]; onlyForAppearances?: null | AppearanceType[]; } diff --git a/packages/vkui/src/testing/e2e/utils.tsx b/packages/vkui/src/testing/e2e/utils.tsx index bda3cf04e5..7c95bdc6eb 100644 --- a/packages/vkui/src/testing/e2e/utils.tsx +++ b/packages/vkui/src/testing/e2e/utils.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { Locator } from '@playwright/test'; import type { AdaptivityProps, SizeProps, @@ -132,3 +133,10 @@ export function generateCustomScreenshotName( .join(' ') .toLocaleLowerCase(); } + +export const getLocatorMouseCoords = async (locator: Locator): Promise<[number, number]> => { + const boundingBox = await locator.boundingBox(); + return boundingBox + ? [boundingBox.x + boundingBox.width / 2, boundingBox.y + boundingBox.height / 2] + : [0, 0]; +}; diff --git a/packages/vkui/src/testing/utils.tsx b/packages/vkui/src/testing/utils.tsx index 94ebc9c657..88163f8e41 100644 --- a/packages/vkui/src/testing/utils.tsx +++ b/packages/vkui/src/testing/utils.tsx @@ -239,18 +239,40 @@ export async function waitForFloatingPosition() { await act(async () => void 0); } -// Не реализован в JSDOM. -// Объявление скопировано с документации https://jestjs.io/ru/docs/26.x/manual-mocks -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // устарело - removeListener: jest.fn(), // устарело - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), -}); +export const waitRAF = async () => await new Promise((resolve) => requestAnimationFrame(resolve)); + +// Решение отсюда https://stackoverflow.com/a/62282721/2903061 +export const requestAnimationFrameMock = { + handleCounter: 0, + queue: new Map(), + requestAnimationFrame(callback: FrameRequestCallback) { + const handle = this.handleCounter++; + this.queue.set(handle, callback); + return handle; + }, + cancelAnimationFrame(handle: number) { + this.queue.delete(handle); + }, + triggerNextAnimationFrame(time = performance.now()) { + const nextEntry = this.queue.entries().next().value; + if (nextEntry === undefined) { + return; + } + + const [nextHandle, nextCallback] = nextEntry; + + nextCallback(time); + this.queue.delete(nextHandle); + }, + triggerAllAnimationFrames(time = performance.now()) { + while (this.queue.size > 0) { + this.triggerNextAnimationFrame(time); + } + }, + init() { + this.queue.clear(); + this.handleCounter = 0; + window.requestAnimationFrame = this.requestAnimationFrame.bind(this); + window.cancelAnimationFrame = this.cancelAnimationFrame.bind(this); + }, +}; diff --git a/packages/vkui/tsconfig.json b/packages/vkui/tsconfig.json index 7c8d8f9ab9..75a69d45d5 100644 --- a/packages/vkui/tsconfig.json +++ b/packages/vkui/tsconfig.json @@ -4,7 +4,8 @@ "baseUrl": ".", "paths": { "@vkui-e2e/test": ["./src/testing/e2e/index.playwright"], - "@vkui-e2e/playground-helpers": ["./src/testing/e2e/index.playground"] + "@vkui-e2e/playground-helpers": ["./src/testing/e2e/index.playground"], + "@vkui-e2e/utils": ["./src/testing/e2e/utils"] }, "plugins": [ { diff --git a/tsconfig.json b/tsconfig.json index 2750c1e978..b8f2441ba2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ // FIXME дублируем, чтобы не падал `yarn run lint:types`. // Не выкупается `paths`, который указан в `./packages/vkui` "@vkui-e2e/test": ["./packages/vkui/src/testing/e2e/index.playwright"], - "@vkui-e2e/playground-helpers": ["./packages/vkui/src/testing/e2e/index.playground"] + "@vkui-e2e/playground-helpers": ["./packages/vkui/src/testing/e2e/index.playground"], + "@vkui-e2e/utils": ["./packages/vkui/src/testing/e2e/utils"] } }, "exclude": ["**/node_modules", "**/dist", "**/coverage/**", "**/storybook-static"]