diff --git a/packages/vkui/src/components/Cell/Cell.tsx b/packages/vkui/src/components/Cell/Cell.tsx index 4bcc2231779..ae7d9ff0521 100644 --- a/packages/vkui/src/components/Cell/Cell.tsx +++ b/packages/vkui/src/components/Cell/Cell.tsx @@ -7,9 +7,9 @@ import { useExternRef } from '../../hooks/useExternRef'; import { usePlatform } from '../../hooks/usePlatform'; import type { HasRootRef } from '../../types'; import { Removable, type RemovableProps } from '../Removable/Removable'; +import { Reorder } from '../Reorder/Reorder'; import { SimpleCell, type SimpleCellProps } from '../SimpleCell/SimpleCell'; import { CellCheckbox, type CellCheckboxProps } from './CellCheckbox/CellCheckbox'; -import { CellDragger } from './CellDragger/CellDragger'; import { DEFAULT_DRAGGABLE_LABEL } from './constants'; import styles from './Cell.module.css'; @@ -98,15 +98,13 @@ export const Cell: React.FC & { const rootElRef = useExternRef(getRootRef); const dragger = draggable ? ( - - {draggerLabel} - + {draggerLabel} + ) : null; let checkbox; @@ -166,6 +164,7 @@ export const Cell: React.FC & { if (removable) { return ( & { ); } - return ( + return draggable ? ( + + + + ) : (
diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css b/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css deleted file mode 100644 index 2ced2fff51d..00000000000 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css +++ /dev/null @@ -1,11 +0,0 @@ -/* stylelint-disable selector-max-universal */ -.host { - color: var(--vkui--color_icon_secondary); - touch-action: manipulation; - cursor: ns-resize; - user-select: none; -} - -.icon { - pointer-events: none; -} diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.test.tsx b/packages/vkui/src/components/Cell/CellDragger/CellDragger.test.tsx deleted file mode 100644 index 7d81dcb3372..00000000000 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.test.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { baselineComponent } from '../../../testing/utils'; -import { CellDragger } from './CellDragger'; - -describe('CellDragger', () => { - baselineComponent(CellDragger); -}); diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx b/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx deleted file mode 100644 index 4ffd33be6c8..00000000000 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; -/* eslint-disable jsdoc/require-jsdoc */ - -import { Icon24Reorder, Icon24ReorderIos } from '@vkontakte/icons'; -import { classNames } from '@vkontakte/vkjs'; -import { - type DraggableProps, - type UseDraggableProps, - useDraggableWithDomApi, -} from '../../../hooks/useDraggableWithDomApi'; -import { usePlatform } from '../../../hooks/usePlatform'; -import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; -import type { HTMLAttributesWithRootRef } from '../../../types'; -import { Touch } from '../../Touch/Touch'; -import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; -import styles from './CellDragger.module.css'; - -interface CellDraggerProps - extends UseDraggableProps, - Omit, keyof DraggableProps> { - disabled?: boolean; - onDragStateChange?: (dragging: boolean) => void; -} - -export const CellDragger = ({ - elRef, - disabled, - className, - onDragStateChange, - onDragFinish, - children, - ...restProps -}: CellDraggerProps): React.ReactNode => { - const platform = usePlatform(); - const Icon = platform === 'ios' ? Icon24ReorderIos : Icon24Reorder; - - const { dragging, onDragStart, onDragMove, onDragEnd } = useDraggableWithDomApi({ - elRef, - onDragFinish, - }); - - useIsomorphicLayoutEffect(() => { - if (onDragStateChange) { - onDragStateChange(dragging); - } - }, [dragging, onDragStateChange]); - - return ( - - {children && {children}} - - - ); -}; diff --git a/packages/vkui/src/components/List/List.module.css b/packages/vkui/src/components/List/List.module.css index 22adec1210a..f87fbd2d408 100644 --- a/packages/vkui/src/components/List/List.module.css +++ b/packages/vkui/src/components/List/List.module.css @@ -2,7 +2,3 @@ display: grid; grid-template-columns: minmax(0, 1fr); } - -.placeholder { - display: none; -} diff --git a/packages/vkui/src/components/List/List.tsx b/packages/vkui/src/components/List/List.tsx index 99d66d47bef..00f60ba3655 100644 --- a/packages/vkui/src/components/List/List.tsx +++ b/packages/vkui/src/components/List/List.tsx @@ -1,35 +1,36 @@ import * as React from 'react'; -import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../hooks/useDraggableWithDomApi'; +import { classNames } from '@vkontakte/vkjs'; import type { HTMLAttributesWithRootRef } from '../../types'; -import { RootComponent } from '../RootComponent/RootComponent'; +import { Reorder, type ReorderProps } from '../Reorder/Reorder'; import styles from './List.module.css'; -export type ListProps = HTMLAttributesWithRootRef & { - /** - * Задает отступ между элементами. - */ - gap?: number; -}; +export type ListProps = HTMLAttributesWithRootRef & + Pick & { + /** + * Задает отступ между элементами. + */ + gap?: number; + }; /** * @see https://vkui.io/components/cell#list */ -export const List = ({ children, gap = 0, ...restProps }: ListProps): React.ReactNode => { +export const List = ({ + children, + gap = 0, + onReorder, + className, + ...restProps +}: ListProps): React.ReactNode => { return ( - {children} -
-
+ ); }; diff --git a/packages/vkui/src/components/Removable/Removable.tsx b/packages/vkui/src/components/Removable/Removable.tsx index 01865dfdbdd..0c5254b70a9 100644 --- a/packages/vkui/src/components/Removable/Removable.tsx +++ b/packages/vkui/src/components/Removable/Removable.tsx @@ -5,7 +5,7 @@ import { Icon24Cancel } from '@vkontakte/icons'; import { classNames } from '@vkontakte/vkjs'; import { usePlatform } from '../../hooks/usePlatform'; import { getTextFromChildren } from '../../lib/children'; -import type { HTMLAttributesWithRootRef } from '../../types'; +import type { HasComponent, HTMLAttributesWithRootRef } from '../../types'; import { IconButton } from '../IconButton/IconButton'; import { RootComponent } from '../RootComponent/RootComponent'; import { RemovableIos } from './RemovableIos'; @@ -95,6 +95,7 @@ export interface RemovableIosRenderProps { interface RemovableOwnProps extends Omit, 'children'>, + HasComponent, RemovableProps { /** * Расположение кнопки удаления. diff --git a/packages/vkui/src/components/Reorder/Reorder.stories.tsx b/packages/vkui/src/components/Reorder/Reorder.stories.tsx new file mode 100644 index 00000000000..0a1790c455c --- /dev/null +++ b/packages/vkui/src/components/Reorder/Reorder.stories.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Icon28MessageOutline } from '@vkontakte/icons'; +import { noop } from '@vkontakte/vkjs'; +import { withSinglePanel, withVKUILayout } from '../../storybook/VKUIDecorators'; +import { CanvasFullLayout, DisableCartesianParam } from '../../storybook/constants'; +import { createStoryParameters } from '../../testing/storybook/createStoryParameters'; +import { Avatar } from '../Avatar/Avatar.tsx'; +import { Flex } from '../Flex/Flex.tsx'; +import { Group } from '../Group/Group'; +import { IconButton } from '../IconButton/IconButton.tsx'; +import { SimpleCell } from '../SimpleCell/SimpleCell.tsx'; +import { Reorder, type ReorderProps } from './Reorder'; + +const story: Meta = { + title: 'Utils/Reorder', + component: Reorder, + parameters: createStoryParameters('Reorder', CanvasFullLayout, DisableCartesianParam), + tags: ['Утилиты'], +}; + +export default story; + +type Story = StoryObj< + ReorderProps<{ + avatarUrl: string; + name: string; + screenName: string; + }> +>; + +export const Playground: Story = { + render: function Render({ items, ...args }) { + const [draggingList, updateDraggingList] = React.useState(items); + + const onDragFinish: ReorderProps['onReorder'] = (swappedItems) => { + updateDraggingList(Reorder.onReorder(swappedItems, draggingList)); + }; + + return ( + + {draggingList.map((item) => ( + + + Перенести ячейку + + + + } + after={ + + + + } + subtitle={item.screenName} + onClick={noop} + > + {item.name} + + ))} + + ); + }, + args: { + items: [ + { + avatarUrl: 'https://avatars.githubusercontent.com/u/61377022', + name: 'Эльдар Мухаметханов', + screenName: 'e.muhamethanov', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/5850354', + name: 'Ином Мирджамолов', + screenName: 'inomdzhon', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/7431217', + name: 'Вика Жижонкова', + screenName: 'BlackySoul', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/14944123', + name: 'Даниил Суворов', + screenName: 'SevereCloud', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/32414396', + name: 'Никита Денисов', + screenName: 'qurle', + }, + ], + }, + decorators: [ + (Component, context) => ( + + + + ), + withSinglePanel, + withVKUILayout, + ], +}; diff --git a/packages/vkui/src/components/Reorder/Reorder.test.tsx b/packages/vkui/src/components/Reorder/Reorder.test.tsx new file mode 100644 index 00000000000..24359c53132 --- /dev/null +++ b/packages/vkui/src/components/Reorder/Reorder.test.tsx @@ -0,0 +1,376 @@ +import { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { SwappedItemRange } from '../../hooks/useDraggableWithDomApi'; +import { + ADOPTED_TOUCH_EVENTS_HANDLERS, + baselineComponent, + MOUSE_EVENTS_HANDLERS, + withFakeTimers, +} from '../../testing/utils.tsx'; +import { Reorder } from './Reorder.tsx'; + +const setupItem = (element: HTMLElement, index: number) => { + let transform = ''; + vi.spyOn(element.style, 'setProperty').mockImplementation( + (property: string, value: string | null) => { + if (property === 'transform') { + transform = value || ''; + } + }, + ); + vi.spyOn(element.style, 'removeProperty').mockImplementation((property: string) => { + if (property === 'transform') { + transform = ''; + } + return ''; + }); + + const itemHeight = 50; + let rect: DOMRect = DOMRect.fromRect({ + y: itemHeight * index, + height: itemHeight, + }); + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => rect); + + return { + get transform() { + return transform; + }, + get rect() { + return rect; + }, + set rect(newRect: DOMRect) { + rect = newRect; + }, + }; +}; + +const defaultRenderItemContent = (index: number) => ( + <> + + Переместить пункт {index + 1} + +
Пункт {index + 1}
+ +); + +const setupWithoutSubcomponents = ({ itemsCount = 3 }: { itemsCount?: number }) => { + let list; + let swappedItems: SwappedItemRange | null = null; + + const onDragFinish = (swappedItemRange: SwappedItemRange) => { + swappedItems = swappedItemRange; + }; + const itemToMockedData: Record> = {}; + + const component = render( + { + if (!element) { + return; + } + list = element; + }} + onReorder={onDragFinish} + items={new Array(itemsCount).fill(0).map((_, index) => index)} + renderItem={(item, index, dragger) => { + return ( +
{ + if (!element) { + return; + } + const parent = element.parentElement!; + const draggerElement = element.firstElementChild!; + draggerElement.setAttribute('data-testid', `dragger-${index}`); + itemToMockedData[`item-${index}`] = setupItem(parent, index); + }} + > + {dragger} +
Пункт {index + 1}
+
+ ); + }} + />, + ); + + return { + component, + get list() { + return list!; + }, + get swappedItems() { + return swappedItems; + }, + getItemSetup: (id: string) => { + return itemToMockedData[id]; + }, + }; +}; + +const setup = ({ + itemsCount = 3, + renderItemContent = defaultRenderItemContent, +}: { + itemsCount?: number; + renderItemContent?: (index: number) => React.ReactNode; +}) => { + let list; + let swappedItems: SwappedItemRange | null = null; + + const onDragFinish = (swappedItemRange: SwappedItemRange) => { + swappedItems = swappedItemRange; + }; + const itemToMockedData: Record> = {}; + + const component = render( + { + if (!element) { + return; + } + list = element; + }} + onReorder={onDragFinish} + > + {new Array(itemsCount).fill(0).map((_, index) => { + return ( + { + if (!element) { + return; + } + itemToMockedData[`item-${index}`] = setupItem(element, index); + }} + data-testid={`item-${index}`} + > + {renderItemContent(index)} + + ); + })} + , + ); + + return { + component, + get list() { + return list!; + }, + get swappedItems() { + return swappedItems; + }, + getItemSetup: (id: string) => { + return itemToMockedData[id]; + }, + }; +}; + +const dragItem = ({ + testId, + breakPoints, + afterDragging, + afterMove = {}, + mouseEvents = [fireEvent.mouseDown, fireEvent.mouseMove, fireEvent.mouseUp], +}: { + testId: string; + breakPoints: number[]; + afterMove?: Record; + afterDragging?: VoidFunction; + mouseEvents?: Array; +}) => { + const [mouseDown, mouseMove, mouseUp] = mouseEvents; + const dragger = screen.getByTestId(testId); + mouseDown(dragger); + + void act(vi.runOnlyPendingTimers); + + breakPoints.forEach((breakPoint, index) => { + mouseMove(dragger, { + clientY: breakPoint, + }); + void act(vi.runOnlyPendingTimers); + afterMove[index]?.(); + }); + void act(vi.runOnlyPendingTimers); + afterDragging && afterDragging(); + + mouseUp(dragger); +}; + +describe('Reorder', () => { + baselineComponent((props) =>
} {...props} />); + + describe.each<{ handlers: Array }>([ + { handlers: MOUSE_EVENTS_HANDLERS }, + { handlers: ADOPTED_TOUCH_EVENTS_HANDLERS }, + ])('check dnd is working', ({ + handlers: mouseEvents, + }: { + handlers: Array; + }) => { + it( + 'dnd working', + withFakeTimers(() => { + const setupData = setup({}); + const { getItemSetup } = setupData; + + dragItem({ + testId: 'dragger-0', + breakPoints: [5, 140, 140, 124], + afterDragging: () => { + const item1Data = getItemSetup('item-1'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-2'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + afterMove: { + 0: () => { + const item1Data = getItemSetup('item-1'); + expect(item1Data.transform).toBe('translateY(50px)'); + const item2Data = getItemSetup('item-2'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + }, + mouseEvents, + }); + + expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); + + dragItem({ + testId: 'dragger-2', + breakPoints: [140, 140, 75, 140, 75], + afterDragging: () => { + const item1Data = getItemSetup('item-0'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-1'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + afterMove: { + 0: () => { + const item1Data = getItemSetup('item-0'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-1'); + expect(item2Data.transform).toBe(''); + }, + }, + mouseEvents, + }); + + expect(setupData.swappedItems).toEqual({ from: 2, to: 1 }); + }), + ); + + it( + 'use list element like a trigger', + withFakeTimers(() => { + const setupData = setup({ + renderItemContent: (index) => ( + +
Пункт {index + 1}
+
+ ), + }); + const { getItemSetup } = setupData; + + dragItem({ + testId: 'dragger-0', + breakPoints: [5, 140, 140, 124], + afterDragging: () => { + const item1Data = getItemSetup('item-1'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-2'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + afterMove: { + 0: () => { + const item1Data = getItemSetup('item-1'); + expect(item1Data.transform).toBe('translateY(50px)'); + const item2Data = getItemSetup('item-2'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + }, + mouseEvents, + }); + + expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); + + dragItem({ + testId: 'dragger-2', + breakPoints: [140, 140, 75, 140, 75], + afterDragging: () => { + const item1Data = getItemSetup('item-0'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-1'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + afterMove: { + 0: () => { + const item1Data = getItemSetup('item-0'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-1'); + expect(item2Data.transform).toBe(''); + }, + }, + mouseEvents, + }); + + expect(setupData.swappedItems).toEqual({ from: 2, to: 1 }); + }), + ); + }); + + it( + 'dnd without subcomponents working', + withFakeTimers(() => { + const setupData = setupWithoutSubcomponents({}); + const { getItemSetup } = setupData; + + dragItem({ + testId: 'dragger-0', + breakPoints: [5, 140, 140, 124], + afterDragging: () => { + const item1Data = getItemSetup('item-1'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-2'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + afterMove: { + 0: () => { + const item1Data = getItemSetup('item-1'); + expect(item1Data.transform).toBe('translateY(50px)'); + const item2Data = getItemSetup('item-2'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + }, + }); + + expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); + + dragItem({ + testId: 'dragger-2', + breakPoints: [140, 140, 75, 140, 75], + afterDragging: () => { + const item1Data = getItemSetup('item-0'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-1'); + expect(item2Data.transform).toBe('translateY(50px)'); + }, + afterMove: { + 0: () => { + const item1Data = getItemSetup('item-0'); + expect(item1Data.transform).toBe(''); + const item2Data = getItemSetup('item-1'); + expect(item2Data.transform).toBe(''); + }, + }, + }); + + expect(setupData.swappedItems).toEqual({ from: 2, to: 1 }); + }), + ); +}); diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx new file mode 100644 index 00000000000..1fbfeefc828 --- /dev/null +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -0,0 +1,102 @@ +'use client'; + +import * as React from 'react'; +import { type SwappedItemRange } from '../../hooks/useDraggableWithDomApi'; +import { callMultiple } from '../../lib/callMultiple'; +import { ReorderItem } from './components/ReorderItem'; +import { ReorderRoot, type ReorderRootProps } from './components/ReorderRoot'; +import { ReorderTrigger } from './components/ReorderTrigger'; +import { ReorderTriggerIcon, type ReorderTriggerIconProps } from './components/ReorderTriggerIcon'; + +export type ReorderProps = Omit & { + /** + * Массив элементов для рендера. + */ + items: ITEM[]; + /** + * Функция для изменения порядка элементов. + */ + setItems?: (items: ITEM[]) => void; + /** + * Функция, которая по элементу массива возвращает `id`. + */ + getItemId?: (item: ITEM) => string | number; + /** + * Функция для рендера элемента. + */ + renderItem: (item: ITEM, index: number, dragger: React.ReactNode) => React.ReactNode; + /** + * Текст для кнопки перетаскивания элемента. + */ + triggerLabel?: string; + /** + * Иконка для кнопки перетаскивания элемента. + */ + TriggerIcon?: ReorderTriggerIconProps['Icon']; +}; + +const onReorder = ({ from, to }: SwappedItemRange, list: ITEM[]): ITEM[] => { + const _list = [...list]; + _list.splice(from, 1); + _list.splice(to, 0, list[from]); + return _list; +}; + +const defaultGetId = (item: ITEM): string | number => { + if (typeof item === 'string' || typeof item === 'number') { + return item; + } + return JSON.stringify(item); +}; + +/* eslint-disable jsdoc/require-jsdoc */ +type ReorderComponent = { + (props: ReorderProps): React.ReactElement | null; + Trigger: typeof ReorderTrigger; + Item: typeof ReorderItem; + Root: typeof ReorderRoot; + TriggerIcon: typeof ReorderTriggerIcon; + onReorder: typeof onReorder; +}; +/* eslint-enable jsdoc/require-jsdoc */ + +/** + * @see https://vkui.io/components/reorder + */ +export const Reorder: ReorderComponent = function Reorder({ + items, + setItems, + getItemId = defaultGetId, + renderItem, + triggerLabel = 'Перенести элемент', + TriggerIcon, + onReorder: onReorderProp, + ...restProps +}: ReorderProps) { + const onReorder = React.useCallback( + (swappedItems: SwappedItemRange) => { + setItems?.(Reorder.onReorder(swappedItems, items)); + }, + [items, setItems], + ); + + return ( + + {items.map((item, index) => { + const dragger = ( + + {triggerLabel} + + ); + const key = getItemId(item); + return {renderItem(item, index, dragger)}; + })} + + ); +}; + +Reorder.Trigger = ReorderTrigger; +Reorder.Item = ReorderItem; +Reorder.Root = ReorderRoot; +Reorder.TriggerIcon = ReorderTriggerIcon; +Reorder.onReorder = onReorder; diff --git a/packages/vkui/src/components/Reorder/components/ReorderItem.tsx b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx new file mode 100644 index 00000000000..c7068208f10 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx @@ -0,0 +1,41 @@ +'use client'; + +import * as React from 'react'; +import { useExternRef } from '../../../hooks/useExternRef'; +import type { HasComponent, HasRootRef } from '../../../types.ts'; +import { RootComponent } from '../../RootComponent/RootComponent'; +import { type ReorderProps } from '../Reorder'; +import { ItemContext, ReorderContext } from '../context'; + +export type ReorderItemProps = React.AllHTMLAttributes & + HasRootRef & + HasComponent & + Pick; + +export function ReorderItem({ children, getRootRef, onReorder, ...restProps }: ReorderItemProps) { + const context = React.useContext(ReorderContext); + const itemRef = React.useRef(null); + const rootRef = useExternRef(getRootRef, itemRef); + + const updatedContext = React.useMemo( + () => (onReorder ? { ...context, onReorder: onReorder } : context), + [context, onReorder], + ); + + const itemContext = React.useMemo( + () => ({ + itemRef, + }), + [], + ); + + return ( + + + + {children} + + + + ); +} diff --git a/packages/vkui/src/components/Reorder/components/ReorderRoot.module.css b/packages/vkui/src/components/Reorder/components/ReorderRoot.module.css new file mode 100644 index 00000000000..019da1f9e0f --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.module.css @@ -0,0 +1,8 @@ +.host { + display: flex; + flex-direction: column; +} + +.placeholder { + display: none; +} diff --git a/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx new file mode 100644 index 00000000000..29b3e805db3 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx @@ -0,0 +1,68 @@ +'use client'; + +import * as React from 'react'; +import { noop } from '@vkontakte/vkjs'; +import { + DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP, + type SwappedItemRange, +} from '../../../hooks/useDraggableWithDomApi'; +import { useStableCallback } from '../../../hooks/useStableCallback'; +import type { HasComponent, HasRootRef } from '../../../types.ts'; +import { RootComponent } from '../../RootComponent/RootComponent'; +import { ReorderContext } from '../context'; +import styles from './ReorderRoot.module.css'; + +export interface ReorderRootProps + extends React.AllHTMLAttributes, + HasRootRef, + HasComponent { + /** + * Обработчик, срабатывающий при завершении перетаскивания. + * **Важно:** режим перетаскивания не меняет порядок ячеек в DOM. В обработчике есть объект с полями `from` и `to`. + * Эти числа нужны для того, чтобы разработчик понимал, с какого индекса на какой произошел переход. В песочнице + * есть рабочий пример с обработкой этих чисел и перерисовкой списка. + */ + onReorder?: (value: SwappedItemRange) => void; + /** + * Отступ между элементами списка. + */ + gap?: number | string; +} + +export function ReorderRoot({ + children, + onReorder, + disabled, + gap = 0, + ...restProps +}: ReorderRootProps) { + const onReorderCb = useStableCallback(onReorder || noop); + + const context = React.useMemo( + () => ({ + onReorder: onReorderCb, + disabled, + }), + [onReorderCb, disabled], + ); + + return ( + + + {children} +
+
+
+ ); +} diff --git a/packages/vkui/src/components/Reorder/components/ReorderTrigger.module.css b/packages/vkui/src/components/Reorder/components/ReorderTrigger.module.css new file mode 100644 index 00000000000..4853187e2e3 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderTrigger.module.css @@ -0,0 +1,5 @@ +.host { + touch-action: manipulation; + cursor: ns-resize; + user-select: none; +} diff --git a/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx new file mode 100644 index 00000000000..287e482be6f --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx @@ -0,0 +1,56 @@ +'use client'; + +import * as React from 'react'; +import { classNames } from '@vkontakte/vkjs'; +import { useDraggableWithDomApi } from '../../../hooks/useDraggableWithDomApi'; +import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; +import type { HasComponent, HasRootRef } from '../../../types'; +import { Touch } from '../../Touch/Touch'; +import { ItemContext, ReorderContext } from '../context'; +import styles from './ReorderTrigger.module.css'; + +export interface ReorderTriggerProps + extends React.AllHTMLAttributes, + HasRootRef, + HasComponent { + /** + * Обработчик, срабатывающий при изменении состояния перетаскивания. + */ + onDragStateChange?: (dragging: boolean) => void; +} + +export function ReorderTrigger({ + children, + className, + disabled: disabledProp, + onDragStateChange, + ...restProps +}: ReorderTriggerProps) { + const { itemRef } = React.useContext(ItemContext); + const { onReorder, disabled: disabledFromContext } = React.useContext(ReorderContext); + const { dragging, onDragStart, onDragMove, onDragEnd } = useDraggableWithDomApi({ + elRef: itemRef, + onDragFinish: onReorder, + }); + + const disabled = disabledFromContext === undefined ? disabledProp : disabledFromContext; + + useIsomorphicLayoutEffect(() => { + if (onDragStateChange) { + onDragStateChange(dragging); + } + }, [dragging, onDragStateChange]); + + return ( + + {children} + + ); +} diff --git a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css new file mode 100644 index 00000000000..743724cae95 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css @@ -0,0 +1,4 @@ +.icon { + color: var(--vkui--color_icon_secondary); + pointer-events: none; +} diff --git a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx new file mode 100644 index 00000000000..b58217c97d5 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type * as React from 'react'; +import { Icon24Reorder, Icon24ReorderIos } from '@vkontakte/icons'; +import { usePlatform } from '../../../hooks/usePlatform'; +import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; +import styles from './ReorderTriggerIcon.module.css'; + +type IconType = typeof Icon24Reorder; + +type PropsOfComponent = T extends React.ComponentType ? P : never; + +type IconsProps = PropsOfComponent; + +export interface ReorderTriggerIconProps + extends IconsProps, + Omit, keyof IconsProps> { + /** + * Иконка, которая будет отрисована. + */ + Icon?: IconType; +} + +export function ReorderTriggerIcon({ + Icon: IconProp, + children, + className, + ...restProps +}: ReorderTriggerIconProps) { + const platform = usePlatform(); + const Icon = IconProp || (platform === 'ios' ? Icon24ReorderIos : Icon24Reorder); + + return ( + + {children && {children}} + + ); +} diff --git a/packages/vkui/src/components/Reorder/context.tsx b/packages/vkui/src/components/Reorder/context.tsx new file mode 100644 index 00000000000..8b663630f98 --- /dev/null +++ b/packages/vkui/src/components/Reorder/context.tsx @@ -0,0 +1,18 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { createRef, type RefObject } from 'react'; +import * as React from 'react'; +import { noop } from '@vkontakte/vkjs'; +import { type SwappedItemRange } from '../../hooks/useDraggableWithDomApi'; + +export interface ReorderContextData { + disabled?: boolean; + onReorder?: (value: SwappedItemRange) => void; +} + +export const ReorderContext = React.createContext({ + onReorder: noop, +}); + +export const ItemContext = React.createContext<{ itemRef: RefObject }>({ + itemRef: createRef(), +}); diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts index e2d4db2acbb..9456456952f 100644 --- a/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts @@ -73,10 +73,18 @@ export const useDraggableWithDomApi = ({ const placeholderItemRef = React.useRef(null); const siblingItemsRef = React.useRef([]); const itemsGapRef = React.useRef(0); + + const getGap = (element: HTMLElement) => { + if (element.style.gap) { + parseInt(element.style.gap); + } + return parseInt(element.style.gridGap); + }; + const initializeItems = (draggingEl: HTMLElement) => { const draggingElRect = getBoundingClientRect(draggingEl, true); const parentElement = draggingEl.parentElement; - itemsGapRef.current = parentElement ? parseInt(parentElement.style.gridGap) : 0; + itemsGapRef.current = parentElement ? getGap(parentElement) : 0; const { children } = parentElement || { children: [] }; Array.prototype.forEach.call(children, (el: HTMLElement, index) => { diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index 253252a3fb8..4b88defeeed 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -34,6 +34,8 @@ export { Caption } from './components/Typography/Caption/Caption'; export type { CaptionProps } from './components/Typography/Caption/Caption'; export { EllipsisText } from './components/Typography/EllipsisText/EllipsisText'; export type { EllipsisTextProps } from './components/Typography/EllipsisText/EllipsisText'; +export { Reorder } from './components/Reorder/Reorder'; +export type { ReorderProps } from './components/Reorder/Reorder'; /** * Service */ diff --git a/website/components/mdx/Playground/scope.ts b/website/components/mdx/Playground/scope.ts index d7bbd9ff20f..9e4d9e1c89c 100644 --- a/website/components/mdx/Playground/scope.ts +++ b/website/components/mdx/Playground/scope.ts @@ -105,6 +105,7 @@ import { PullToRefresh, Radio, RadioGroup, + Reorder, RichCell, Root, ScreenSpinner, @@ -309,6 +310,7 @@ export const scope: Record = { ScrollArrow, SelectionControl, Touch, + Reorder, useAdaptivityConditionalRender, useAdaptivityWithJSMediaQueries, useFocusVisible, diff --git a/website/content/components/_meta.tsx b/website/content/components/_meta.tsx index 6d0be8f01e9..c4c1422a697 100644 --- a/website/content/components/_meta.tsx +++ b/website/content/components/_meta.tsx @@ -159,6 +159,7 @@ const meta: MetaRecord = { 'selection-control': 'SelectionControl', 'tappable': 'Tappable', 'touch': 'Touch', + 'reorder': 'Reorder', 'focus-trap': 'FocusTrap', 'unstyled-text-field': 'UnstyledTextField', 'use-snackbar-manager': 'useSnackbarManager', diff --git a/website/content/components/reorder.mdx b/website/content/components/reorder.mdx new file mode 100644 index 00000000000..f48092022d1 --- /dev/null +++ b/website/content/components/reorder.mdx @@ -0,0 +1,249 @@ +--- +description: Компонент для реализации drag-and-drop-перестановки элементов вертикального списка. +--- + + + +# Reorder [tag:component] + +Компонент для реализации drag-and-drop-перестановки элементов вертикального списка. + + + +import { BlockWrapper } from '@/components/wrappers'; + + + +```jsx +const [draggingList, updateDraggingList] = React.useState([ + 'Пункт 1', + 'Пункт 2', + 'Пункт 3', + 'Пункт 4', + 'Пункт 5', +]); + +return ( + ( +
+ {dragger} +
{item}
+
+ )} + /> +); +``` + +
+ +> Несколько важных моментов: +> +> - В качестве `items` можно передать массив элементов типа `string`, `number` или объекта с полями. +> Если вы используете объекты с полями, рекомендуется использовать свойство `getItemId` для определения корректного `id` элемента. +> Элементы массива, а также `id` должны быть уникальными в рамках списка. +> Это нужно для корректного определения `key` у элемента. +> - В `renderItem` третьим параметром передается готовый тригер для перетаскивания, который вы можете встроить в любое место элемента. +> Если вам нужно кастомизировать компонент, вы можете использовать свойства `triggerLabel` и `TriggerIcon`. +> Если этого не достаточно, то вы можете самостоятельно отрендерить тригер для перетаскивания. Подробнее можно увидеть в разделе ["Подкомпонентный подход"](/components/reorder#subcomponents) +> - Вы можете использовать функцию `Reorder.onReorder`, чтобы не реализовывать логику изменения порядка элементов. + +## Подкомпонентный подход [#subcomponents] + +Собрать `Reorder` самостоятельно можно с помощью следующих подкомпонентов: + +- `Reorder.Root` – служит flex-оберткой для элементов списка. + Принимает все основные свойства `Reorder`, кроме `items` и `renderItem`. + +- `Reorder.Item` – отвечает за отрисовку элемента списка. + +- `Reorder.Trigger` – элемент, на котором обрабатываются жесты/drag. Обычно внутри него ставят `Reorder.TriggerIcon`. + +- `Reorder.TriggerIcon` — иконка для визуального хэндла. Поддерживает передачу кастомной иконки через `Icon`. + + + +```jsx +const items = [ + { + avatarUrl: 'https://avatars.githubusercontent.com/u/61377022', + name: 'Эльдар Мухаметханов', + screenName: 'e.muhamethanov', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/5850354', + name: 'Ином Мирджамолов', + screenName: 'inomdzhon', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/7431217', + name: 'Вика Жижонкова', + screenName: 'BlackySoul', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/14944123', + name: 'Даниил Суворов', + screenName: 'SevereCloud', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/32414396', + name: 'Никита Денисов', + screenName: 'qurle', + }, +]; + +const SimpleCellWrapper = React.useCallback(({ avatarUrl, name, screenName }) => { + const [dragging, setDragging] = React.useState(false); + + return ( + + + + Перенести ячейку + + + + } + after={ + + + + } + subtitle={screenName} + style={dragging ? { background: 'var(--vkui--color_background_secondary)' } : undefined} + > + {name} + + + ); +}, []); + +const [draggingList, updateDraggingList] = React.useState(items); + +const onDragFinish = (swappedItems) => + updateDraggingList(Reorder.onReorder(swappedItems, draggingList)); + +return ( + + {draggingList.map((item) => ( + + ))} + +); +``` + + + +## Перетаскивание всего элемента целиком + +Иногда удобно, чтобы область перетаскивания занимала весь элемент списка — тогда пользователь может начать перемещение, нажав в любом месте карточки +(полезно для мобильных устройств и больших touch-таргетов). Ниже — готовый пример, показывающий этот подход и объясняющий важные нюансы. + +Когда использовать: + +- Когда элемент списка представляет собой цельный интерактивный блок (карточка, контакт, запись) и вы хотите упростить UX перетаскивания. +- Когда важно обеспечить широкий touch-таргет для перетаскивания на мобильных устройствах. + + + +```jsx +const items = [ + { + avatarUrl: 'https://avatars.githubusercontent.com/u/61377022', + name: 'Эльдар Мухаметханов', + screenName: 'e.muhamethanov', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/5850354', + name: 'Ином Мирджамолов', + screenName: 'inomdzhon', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/7431217', + name: 'Вика Жижонкова', + screenName: 'BlackySoul', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/14944123', + name: 'Даниил Суворов', + screenName: 'SevereCloud', + }, + { + avatarUrl: 'https://avatars.githubusercontent.com/u/32414396', + name: 'Никита Денисов', + screenName: 'qurle', + }, +]; + +const SimpleCellWrapper = React.useCallback(({ avatarUrl, name, screenName }) => { + const [dragging, setDragging] = React.useState(false); + + return ( + + + } + after={ + + + + } + subtitle={screenName} + style={dragging ? { background: 'var(--vkui--color_background_secondary)' } : undefined} + > + {name} + + + + ); +}, []); + +const [draggingList, updateDraggingList] = React.useState(items); + +const onDragFinish = (swappedItems) => + updateDraggingList(Reorder.onReorder(swappedItems, draggingList)); + +return ( + + {draggingList.map((item) => ( + + ))} + +); +``` + + + +## Пользовательская иконка перетаскивания + +Чтобы изменить иконку перетаскивания можно использовать свойство `Icon` компонента `Reorder.TriggerIcon`. + +```jsx + + + Переместить {item} + +
{item}
+
+``` + +## Свойства и методы [#api] + + + +### Reorder [#reorder-api] + +### Reorder.Root [#reorder-root-api] + +### Reorder.Item [#reorder-item-api] + +### Reorder.Trigger [#reorder-trigger-api] + +### Reorder.TriggerIcon [#reorder-trigger-icon-api] + +