From 86d4c1f48b6bc86878b7477323601386acbab001 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 30 Sep 2025 18:16:14 +0300 Subject: [PATCH 01/18] feat(Reorder): add component reorder --- packages/vkui/src/components/Cell/Cell.tsx | 17 +++--- .../Cell/CellDragger/CellDragger.test.tsx | 6 -- packages/vkui/src/components/List/List.tsx | 37 +++++++------ packages/vkui/src/components/List/Readme.md | 4 +- .../src/components/Reorder/Reorder.module.css | 3 + .../vkui/src/components/Reorder/Reorder.tsx | 48 ++++++++++++++++ .../components/Reorder.module.css} | 1 - .../Reorder/components/ReorderItem.tsx | 25 +++++++++ .../components/ReorderTrigger.tsx} | 55 +++++++++++-------- .../vkui/src/components/Reorder/context.tsx | 18 ++++++ 10 files changed, 156 insertions(+), 58 deletions(-) delete mode 100644 packages/vkui/src/components/Cell/CellDragger/CellDragger.test.tsx create mode 100644 packages/vkui/src/components/Reorder/Reorder.module.css create mode 100644 packages/vkui/src/components/Reorder/Reorder.tsx rename packages/vkui/src/components/{Cell/CellDragger/CellDragger.module.css => Reorder/components/Reorder.module.css} (77%) create mode 100644 packages/vkui/src/components/Reorder/components/ReorderItem.tsx rename packages/vkui/src/components/{Cell/CellDragger/CellDragger.tsx => Reorder/components/ReorderTrigger.tsx} (52%) create mode 100644 packages/vkui/src/components/Reorder/context.tsx diff --git a/packages/vkui/src/components/Cell/Cell.tsx b/packages/vkui/src/components/Cell/Cell.tsx index 4bcc2231779..b7e67ae0430 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.tsx'; 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} - + ) : null; let checkbox; @@ -189,9 +187,14 @@ export const Cell: React.FC & { } return ( -
+ -
+ ); }; 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/List/List.tsx b/packages/vkui/src/components/List/List.tsx index 99d66d47bef..841203ee94c 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 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, + ...restProps +}: ListProps): React.ReactNode => { return ( - {children} -
-
+ ); }; diff --git a/packages/vkui/src/components/List/Readme.md b/packages/vkui/src/components/List/Readme.md index dc6b41e8bdc..a045dfecb38 100644 --- a/packages/vkui/src/components/List/Readme.md +++ b/packages/vkui/src/components/List/Readme.md @@ -23,9 +23,9 @@ const Example = () => { List - + {draggingList.map((item) => ( - } draggable onDragFinish={onDragFinish}> + } draggable> {item} ))} diff --git a/packages/vkui/src/components/Reorder/Reorder.module.css b/packages/vkui/src/components/Reorder/Reorder.module.css new file mode 100644 index 00000000000..645b527fece --- /dev/null +++ b/packages/vkui/src/components/Reorder/Reorder.module.css @@ -0,0 +1,3 @@ +.placeholder { + display: none; +} diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx new file mode 100644 index 00000000000..e0d49d3530c --- /dev/null +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../hooks/useDraggableWithDomApi'; +import { RootComponent, type RootComponentProps } from '../RootComponent/RootComponent'; +import { ReorderItem } from './components/ReorderItem'; +import { ReorderTrigger } from './components/ReorderTrigger'; +import { ReorderContext, type ReorderContextData } from './context'; +import styles from './Reorder.module.css'; + +export type ReorderProps = RootComponentProps & + Pick; + +function ReorderContainer({ children, onReorder, disabled, ...restProps }: ReorderProps) { + return ( + + + {children} +
+
+
+ ); +} + +/** + * @see https://vkui.io/components/custom-select + */ +export function Reorder( + props: ReorderProps & { + Trigger: typeof ReorderTrigger; + Item: typeof ReorderItem; + Container: typeof ReorderContainer; + }, +) { + return ; +} + +Reorder.Trigger = ReorderTrigger; +Reorder.Item = ReorderItem; +Reorder.Container = ReorderContainer; diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css b/packages/vkui/src/components/Reorder/components/Reorder.module.css similarity index 77% rename from packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css rename to packages/vkui/src/components/Reorder/components/Reorder.module.css index 2ced2fff51d..3f875174aa7 100644 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.module.css +++ b/packages/vkui/src/components/Reorder/components/Reorder.module.css @@ -1,4 +1,3 @@ -/* stylelint-disable selector-max-universal */ .host { color: var(--vkui--color_icon_secondary); touch-action: manipulation; 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..ca51b095916 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx @@ -0,0 +1,25 @@ +'use client'; + +import * as React from 'react'; +import { useExternRef } from '../../../hooks/useExternRef'; +import { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent'; +import { type ReorderProps } from '../Reorder.tsx'; +import { ItemContext, ReorderContext } from '../context'; + +export type ReorderItemProps = RootComponentProps & Pick; + +export function ReorderItem({ children, getRootRef, onReorder, ...restProps }: ReorderItemProps) { + const context = React.useContext(ReorderContext); + const itemRef = React.useRef(null); + const rootRef = useExternRef(getRootRef, itemRef); + + return ( + + + + {children} + + + + ); +} diff --git a/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx similarity index 52% rename from packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx rename to packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx index 4ffd33be6c8..b92731fd075 100644 --- a/packages/vkui/src/components/Cell/CellDragger/CellDragger.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx @@ -1,44 +1,50 @@ 'use client'; -/* eslint-disable jsdoc/require-jsdoc */ +import * as React from 'react'; import { Icon24Reorder, Icon24ReorderIos } from '@vkontakte/icons'; import { classNames } from '@vkontakte/vkjs'; -import { - type DraggableProps, - type UseDraggableProps, - useDraggableWithDomApi, -} from '../../../hooks/useDraggableWithDomApi'; +import { 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 { Touch, type TouchProps } from '../../Touch/Touch'; import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; -import styles from './CellDragger.module.css'; +import { ItemContext, ReorderContext } from '../context'; +import styles from './Reorder.module.css'; -interface CellDraggerProps - extends UseDraggableProps, - Omit, keyof DraggableProps> { - disabled?: boolean; +type VendorIconType = typeof Icon24ReorderIos; + +type IconType = React.ComponentType> | VendorIconType; + +export interface ReorderTriggerProps extends TouchProps { + /** + * + */ + Icon?: IconType; + /** + * + */ onDragStateChange?: (dragging: boolean) => void; } -export const CellDragger = ({ - elRef, - disabled, +export function ReorderTrigger({ + children, className, + disabled: disabledProp, + Icon: IconProp, onDragStateChange, - onDragFinish, - children, ...restProps -}: CellDraggerProps): React.ReactNode => { +}: ReorderTriggerProps) { + const { itemRef } = React.useContext(ItemContext); + const { onReorder, disabled: disabledFromContext } = React.useContext(ReorderContext); const platform = usePlatform(); - const Icon = platform === 'ios' ? Icon24ReorderIos : Icon24Reorder; - + const Icon = IconProp || (platform === 'ios' ? Icon24ReorderIos : Icon24Reorder); const { dragging, onDragStart, onDragMove, onDragEnd } = useDraggableWithDomApi({ - elRef, - onDragFinish, + elRef: itemRef, + onDragFinish: onReorder, }); + const disabled = disabledFromContext === undefined ? disabledProp : disabledFromContext; + useIsomorphicLayoutEffect(() => { if (onDragStateChange) { onDragStateChange(dragging); @@ -48,6 +54,7 @@ export const CellDragger = ({ return ( ); -}; +} 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(), +}); From 462c476301d5e9b76fc2b89a31dd7336faad60c3 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 30 Sep 2025 18:50:17 +0300 Subject: [PATCH 02/18] feat(Reorder): improve logic --- packages/vkui/src/components/Cell/Cell.tsx | 9 ++++- packages/vkui/src/components/List/List.tsx | 4 +- .../src/components/Removable/Removable.tsx | 3 +- .../vkui/src/components/Reorder/Reorder.tsx | 10 +++-- .../Reorder/components/Reorder.module.css | 4 -- .../Reorder/components/ReorderTrigger.tsx | 17 +-------- .../components/ReorderTriggerIcon.module.css | 3 ++ .../Reorder/components/ReorderTriggerIcon.tsx | 38 +++++++++++++++++++ 8 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css create mode 100644 packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx diff --git a/packages/vkui/src/components/Cell/Cell.tsx b/packages/vkui/src/components/Cell/Cell.tsx index b7e67ae0430..96ab857f00e 100644 --- a/packages/vkui/src/components/Cell/Cell.tsx +++ b/packages/vkui/src/components/Cell/Cell.tsx @@ -103,7 +103,7 @@ export const Cell: React.FC & { onDragStateChange={setDragging} data-testid={draggerTestId} > - {draggerLabel} + {draggerLabel} ) : null; @@ -164,6 +164,7 @@ export const Cell: React.FC & { if (removable) { return ( & { ); } - return ( + return draggable ? ( & { > + ) : ( +
+ +
); }; diff --git a/packages/vkui/src/components/List/List.tsx b/packages/vkui/src/components/List/List.tsx index 841203ee94c..f6cafccfb0b 100644 --- a/packages/vkui/src/components/List/List.tsx +++ b/packages/vkui/src/components/List/List.tsx @@ -21,7 +21,7 @@ export const List = ({ ...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.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index e0d49d3530c..bd2867bf5f7 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -4,13 +4,14 @@ import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../hooks/useDraggableW import { RootComponent, type RootComponentProps } from '../RootComponent/RootComponent'; import { ReorderItem } from './components/ReorderItem'; import { ReorderTrigger } from './components/ReorderTrigger'; +import { ReorderTriggerIcon } from './components/ReorderTriggerIcon.tsx'; import { ReorderContext, type ReorderContextData } from './context'; import styles from './Reorder.module.css'; export type ReorderProps = RootComponentProps & Pick; -function ReorderContainer({ children, onReorder, disabled, ...restProps }: ReorderProps) { +function ReorderRoot({ children, onReorder, disabled, ...restProps }: ReorderProps) { return ( ; + return ; } Reorder.Trigger = ReorderTrigger; +Reorder.TriggerIcon = ReorderTriggerIcon; Reorder.Item = ReorderItem; -Reorder.Container = ReorderContainer; +Reorder.Root = ReorderRoot; diff --git a/packages/vkui/src/components/Reorder/components/Reorder.module.css b/packages/vkui/src/components/Reorder/components/Reorder.module.css index 3f875174aa7..5c920d5f441 100644 --- a/packages/vkui/src/components/Reorder/components/Reorder.module.css +++ b/packages/vkui/src/components/Reorder/components/Reorder.module.css @@ -4,7 +4,3 @@ cursor: ns-resize; user-select: none; } - -.icon { - pointer-events: none; -} diff --git a/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx index b92731fd075..97f9c8d6e7a 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx @@ -1,25 +1,14 @@ 'use client'; import * as React from 'react'; -import { Icon24Reorder, Icon24ReorderIos } from '@vkontakte/icons'; import { classNames } from '@vkontakte/vkjs'; import { useDraggableWithDomApi } from '../../../hooks/useDraggableWithDomApi'; -import { usePlatform } from '../../../hooks/usePlatform'; import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; import { Touch, type TouchProps } from '../../Touch/Touch'; -import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; import { ItemContext, ReorderContext } from '../context'; import styles from './Reorder.module.css'; -type VendorIconType = typeof Icon24ReorderIos; - -type IconType = React.ComponentType> | VendorIconType; - export interface ReorderTriggerProps extends TouchProps { - /** - * - */ - Icon?: IconType; /** * */ @@ -30,14 +19,11 @@ export function ReorderTrigger({ children, className, disabled: disabledProp, - Icon: IconProp, onDragStateChange, ...restProps }: ReorderTriggerProps) { const { itemRef } = React.useContext(ItemContext); const { onReorder, disabled: disabledFromContext } = React.useContext(ReorderContext); - const platform = usePlatform(); - const Icon = IconProp || (platform === 'ios' ? Icon24ReorderIos : Icon24Reorder); const { dragging, onDragStart, onDragMove, onDragEnd } = useDraggableWithDomApi({ elRef: itemRef, onDragFinish: onReorder, @@ -60,8 +46,7 @@ export function ReorderTrigger({ onEnd={disabled ? undefined : onDragEnd} {...restProps} > - {children && {children}} - + {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..68e0859b461 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css @@ -0,0 +1,3 @@ +.icon { + 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..59e31392f37 --- /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; + +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}} + + ); +} From 180bb7ea1a9b8107cc221a2ed434b4b82caca2de Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 30 Sep 2025 18:51:51 +0300 Subject: [PATCH 03/18] feat(Reorder): fix type --- packages/vkui/src/components/Reorder/Reorder.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index bd2867bf5f7..8b1ff11efdf 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -39,6 +39,7 @@ export function Reorder( Trigger: typeof ReorderTrigger; Item: typeof ReorderItem; Container: typeof ReorderRoot; + TriggerIcon: typeof ReorderTriggerIcon; }, ) { return ; From 6beb26310bde6b944a9f0624b6da8474283de9e0 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 30 Sep 2025 18:58:48 +0300 Subject: [PATCH 04/18] feat(Reorder): add head reorder component logic --- .../vkui/src/components/Reorder/Reorder.tsx | 58 ++++++++----------- .../ReorderRoot.module.css} | 0 .../Reorder/components/ReorderRoot.tsx | 27 +++++++++ 3 files changed, 50 insertions(+), 35 deletions(-) rename packages/vkui/src/components/Reorder/{Reorder.module.css => components/ReorderRoot.module.css} (100%) create mode 100644 packages/vkui/src/components/Reorder/components/ReorderRoot.tsx diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index 8b1ff11efdf..02e660734c3 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -1,48 +1,36 @@ 'use client'; -import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../hooks/useDraggableWithDomApi'; -import { RootComponent, type RootComponentProps } from '../RootComponent/RootComponent'; +import * as React from 'react'; import { ReorderItem } from './components/ReorderItem'; +import { ReorderRoot, type ReorderRootProps } from './components/ReorderRoot.tsx'; import { ReorderTrigger } from './components/ReorderTrigger'; import { ReorderTriggerIcon } from './components/ReorderTriggerIcon.tsx'; -import { ReorderContext, type ReorderContextData } from './context'; -import styles from './Reorder.module.css'; -export type ReorderProps = RootComponentProps & - Pick; - -function ReorderRoot({ children, onReorder, disabled, ...restProps }: ReorderProps) { - return ( - - - {children} -
-
-
- ); -} +export type ReorderProps = Omit & { + /** + * + */ + items: ITEM[]; + /** + * + */ + renderItem: (item: ITEM) => React.ReactNode; +}; /** * @see https://vkui.io/components/custom-select */ -export function Reorder( - props: ReorderProps & { - Trigger: typeof ReorderTrigger; - Item: typeof ReorderItem; - Container: typeof ReorderRoot; - TriggerIcon: typeof ReorderTriggerIcon; - }, -) { - return ; +export function Reorder({ + items, + renderItem, + ...restProps +}: ReorderProps & { + Trigger: typeof ReorderTrigger; + Item: typeof ReorderItem; + Container: typeof ReorderRoot; + TriggerIcon: typeof ReorderTriggerIcon; +}) { + return {items.map(renderItem)}; } Reorder.Trigger = ReorderTrigger; diff --git a/packages/vkui/src/components/Reorder/Reorder.module.css b/packages/vkui/src/components/Reorder/components/ReorderRoot.module.css similarity index 100% rename from packages/vkui/src/components/Reorder/Reorder.module.css rename to packages/vkui/src/components/Reorder/components/ReorderRoot.module.css 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..0bed1685593 --- /dev/null +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx @@ -0,0 +1,27 @@ +import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../../hooks/useDraggableWithDomApi'; +import { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent.tsx'; +import { ReorderContext, type ReorderContextData } from '../context.tsx'; +import styles from './ReorderRoot.module.css'; + +export type ReorderRootProps = RootComponentProps & + Pick; + +export function ReorderRoot({ children, onReorder, disabled, ...restProps }: ReorderRootProps) { + return ( + + + {children} +
+
+
+ ); +} From b8b48453cb6002d39f4b9097f567337b4174ea8e Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 30 Sep 2025 18:59:17 +0300 Subject: [PATCH 05/18] fix(Reorder): fix comment --- packages/vkui/src/components/Reorder/Reorder.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index 02e660734c3..b9d0ef7575d 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -18,7 +18,7 @@ export type ReorderProps = Omit & { }; /** - * @see https://vkui.io/components/custom-select + * @see https://vkui.io/components/reorder */ export function Reorder({ items, From c8b421606178a6bbd47396b20db030e8d64d61b2 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Tue, 30 Sep 2025 19:03:25 +0300 Subject: [PATCH 06/18] fix(Reorder): fix gap calculating --- .../useDraggableWithDomApi/useDraggableWithDomApi.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts index 08ad4b5f1ea..b10d5d880d5 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) => { From 96d0571ad65a978a1174d426cc740726fe03e794 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Wed, 1 Oct 2025 12:08:11 +0300 Subject: [PATCH 07/18] feat(Reorder): add story --- .../vkui/src/components/List/List.module.css | 4 - .../components/Reorder/Reorder.stories.tsx | 118 ++++++++++++++++++ .../vkui/src/components/Reorder/Reorder.tsx | 42 ++++--- ...r.module.css => ReorderTrigger.module.css} | 1 - .../Reorder/components/ReorderTrigger.tsx | 10 +- .../components/ReorderTriggerIcon.module.css | 1 + 6 files changed, 149 insertions(+), 27 deletions(-) create mode 100644 packages/vkui/src/components/Reorder/Reorder.stories.tsx rename packages/vkui/src/components/Reorder/components/{Reorder.module.css => ReorderTrigger.module.css} (65%) 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/Reorder/Reorder.stories.tsx b/packages/vkui/src/components/Reorder/Reorder.stories.tsx new file mode 100644 index 00000000000..3646d1b3fd8 --- /dev/null +++ b/packages/vkui/src/components/Reorder/Reorder.stories.tsx @@ -0,0 +1,118 @@ +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 = ({ from, to }: { from: number; to: number }) => { + const _list = [...draggingList]; + _list.splice(from, 1); + _list.splice(to, 0, draggingList[from]); + updateDraggingList(_list); + }; + + 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.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index b9d0ef7575d..c7a37b91e38 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -8,32 +8,36 @@ import { ReorderTriggerIcon } from './components/ReorderTriggerIcon.tsx'; export type ReorderProps = Omit & { /** - * + * Массив элементов для рендера. */ items: ITEM[]; /** - * + * Функция для рендера элемента. */ - renderItem: (item: ITEM) => React.ReactNode; + renderItem: (item: ITEM, index: number) => React.ReactNode; }; -/** - * @see https://vkui.io/components/reorder - */ -export function Reorder({ - items, - renderItem, - ...restProps -}: ReorderProps & { +/* eslint-disable jsdoc/require-jsdoc */ +type ReorderComponent = { + (props: ReorderProps): React.ReactElement | null; Trigger: typeof ReorderTrigger; Item: typeof ReorderItem; - Container: typeof ReorderRoot; + Root: typeof ReorderRoot; TriggerIcon: typeof ReorderTriggerIcon; -}) { - return {items.map(renderItem)}; -} +}; +/* eslint-enable jsdoc/require-jsdoc */ -Reorder.Trigger = ReorderTrigger; -Reorder.TriggerIcon = ReorderTriggerIcon; -Reorder.Item = ReorderItem; -Reorder.Root = ReorderRoot; +/** + * @see https://vkui.io/components/reorder + */ +export const Reorder: ReorderComponent = Object.assign( + function Reorder({ items, renderItem, ...restProps }: ReorderProps) { + return {items.map(renderItem)}; + }, + { + Trigger: ReorderTrigger, + Item: ReorderItem, + Root: ReorderRoot, + TriggerIcon: ReorderTriggerIcon, + }, +); diff --git a/packages/vkui/src/components/Reorder/components/Reorder.module.css b/packages/vkui/src/components/Reorder/components/ReorderTrigger.module.css similarity index 65% rename from packages/vkui/src/components/Reorder/components/Reorder.module.css rename to packages/vkui/src/components/Reorder/components/ReorderTrigger.module.css index 5c920d5f441..4853187e2e3 100644 --- a/packages/vkui/src/components/Reorder/components/Reorder.module.css +++ b/packages/vkui/src/components/Reorder/components/ReorderTrigger.module.css @@ -1,5 +1,4 @@ .host { - color: var(--vkui--color_icon_secondary); 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 index 97f9c8d6e7a..bd837ed4675 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderTrigger.tsx @@ -4,11 +4,15 @@ import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; import { useDraggableWithDomApi } from '../../../hooks/useDraggableWithDomApi'; import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; -import { Touch, type TouchProps } from '../../Touch/Touch'; +import type { HasComponent, HasRootRef } from '../../../types.ts'; +import { Touch } from '../../Touch/Touch'; import { ItemContext, ReorderContext } from '../context'; -import styles from './Reorder.module.css'; +import styles from './ReorderTrigger.module.css'; -export interface ReorderTriggerProps extends TouchProps { +export interface ReorderTriggerProps + extends React.AllHTMLAttributes, + HasRootRef, + HasComponent { /** * */ diff --git a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css index 68e0859b461..743724cae95 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css +++ b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.module.css @@ -1,3 +1,4 @@ .icon { + color: var(--vkui--color_icon_secondary); pointer-events: none; } From fda0b9d71998dd995c5e3868b16b46b2c376ca53 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Wed, 1 Oct 2025 15:54:09 +0300 Subject: [PATCH 08/18] feat(Reorder): add documentation --- packages/vkui/src/components/Cell/Cell.tsx | 2 +- .../components/Reorder/Reorder.stories.tsx | 2 - .../vkui/src/components/Reorder/Reorder.tsx | 4 +- .../Reorder/components/ReorderItem.tsx | 18 +++++-- .../Reorder/components/ReorderRoot.module.css | 5 ++ .../Reorder/components/ReorderRoot.tsx | 44 ++++++++++++----- .../Reorder/components/ReorderTrigger.tsx | 4 +- .../Reorder/components/ReorderTriggerIcon.tsx | 4 +- .../useDraggableWithDomApi.ts | 1 + packages/vkui/src/index.ts | 2 + website/components/mdx/Playground/scope.ts | 2 + website/content/components/_meta.tsx | 1 + website/content/components/reorder.mdx | 48 +++++++++++++++++++ 13 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 website/content/components/reorder.mdx diff --git a/packages/vkui/src/components/Cell/Cell.tsx b/packages/vkui/src/components/Cell/Cell.tsx index 96ab857f00e..ae7d9ff0521 100644 --- a/packages/vkui/src/components/Cell/Cell.tsx +++ b/packages/vkui/src/components/Cell/Cell.tsx @@ -7,7 +7,7 @@ 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.tsx'; +import { Reorder } from '../Reorder/Reorder'; import { SimpleCell, type SimpleCellProps } from '../SimpleCell/SimpleCell'; import { CellCheckbox, type CellCheckboxProps } from './CellCheckbox/CellCheckbox'; import { DEFAULT_DRAGGABLE_LABEL } from './constants'; diff --git a/packages/vkui/src/components/Reorder/Reorder.stories.tsx b/packages/vkui/src/components/Reorder/Reorder.stories.tsx index 3646d1b3fd8..9d4bcdefd39 100644 --- a/packages/vkui/src/components/Reorder/Reorder.stories.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.stories.tsx @@ -43,8 +43,6 @@ export const Playground: Story = { return ( = Omit & { /** diff --git a/packages/vkui/src/components/Reorder/components/ReorderItem.tsx b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx index ca51b095916..5dd74a8c2c4 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderItem.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useExternRef } from '../../../hooks/useExternRef'; import { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent'; -import { type ReorderProps } from '../Reorder.tsx'; +import { type ReorderProps } from '../Reorder'; import { ItemContext, ReorderContext } from '../context'; export type ReorderItemProps = RootComponentProps & Pick; @@ -13,9 +13,21 @@ export function ReorderItem({ children, getRootRef, onReorder, ...restProps }: R 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 index 645b527fece..019da1f9e0f 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderRoot.module.css +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.module.css @@ -1,3 +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 index 0bed1685593..9bbd9f0c0c1 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx @@ -1,20 +1,40 @@ -import { DATA_DRAGGABLE_PLACEHOLDER_REACT_PROP } from '../../../hooks/useDraggableWithDomApi'; -import { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent.tsx'; -import { ReorderContext, type ReorderContextData } from '../context.tsx'; +'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 { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent'; +import { ReorderContext } from '../context'; import styles from './ReorderRoot.module.css'; -export type ReorderRootProps = RootComponentProps & - Pick; +export interface ReorderRootProps extends RootComponentProps { + /** + * Обработчик, срабатывающий при завершении перетаскивания. + * **Важно:** режим перетаскивания не меняет порядок ячеек в DOM. В обработчике есть объект с полями `from` и `to`. + * Эти числа нужны для того, чтобы разработчик понимал, с какого индекса на какой произошел переход. В песочнице + * есть рабочий пример с обработкой этих чисел и перерисовкой списка. + */ + onReorder?: (value: SwappedItemRange) => void; +} export function ReorderRoot({ children, onReorder, disabled, ...restProps }: ReorderRootProps) { + const onReorderCb = useStableCallback(onReorder || noop); + + const context = React.useMemo( + () => ({ + onReorder: onReorderCb, + disabled, + }), + [onReorderCb, disabled], + ); + return ( - - + + {children}
, HasComponent { /** - * + * Обработчик, срабатывающий при изменении состояния перетаскивания. */ onDragStateChange?: (dragging: boolean) => void; } diff --git a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx index 59e31392f37..dc5aa4ecafd 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx @@ -10,13 +10,13 @@ type IconType = typeof Icon24Reorder; type PropsOfComponent = T extends React.ComponentType ? P : never; -type IconsProps = PropsOfComponent; +type IconsProps = PropsOfComponent; interface ReorderTriggerIconProps extends IconsProps, Omit, keyof IconsProps> { /** - * + * Иконка, которая будет отрисована. */ Icon?: IconType; } diff --git a/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts index b10d5d880d5..62ecd3f3f83 100644 --- a/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts +++ b/packages/vkui/src/hooks/useDraggableWithDomApi/useDraggableWithDomApi.ts @@ -237,6 +237,7 @@ export const useDraggableWithDomApi = ({ if (prevDragging) { return prevDragging; } + cleanupItems(); initializeScrollRefs(draggingEl); initializeItems(draggingEl); return true; diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index 55cbbf8a15f..08f4667001c 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -33,6 +33,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 80edf6866a3..bfe249747e8 100644 --- a/website/components/mdx/Playground/scope.ts +++ b/website/components/mdx/Playground/scope.ts @@ -102,6 +102,7 @@ import { PullToRefresh, Radio, RadioGroup, + Reorder, RichCell, Root, ScreenSpinner, @@ -301,6 +302,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 86d38ed4d84..41a96519ce9 100644 --- a/website/content/components/_meta.tsx +++ b/website/content/components/_meta.tsx @@ -158,6 +158,7 @@ const meta: MetaRecord = { 'selection-control': 'SelectionControl', 'tappable': 'Tappable', 'touch': 'Touch', + 'reorder': 'Reorder', 'unstyled-text-field': 'UnstyledTextField', 'use-adaptivity-conditional-render': 'useAdaptivityConditionalRender', 'use-adaptivity-with-js-media-queries': 'useAdaptivityWithJSMediaQueries', diff --git a/website/content/components/reorder.mdx b/website/content/components/reorder.mdx new file mode 100644 index 00000000000..80475cd1801 --- /dev/null +++ b/website/content/components/reorder.mdx @@ -0,0 +1,48 @@ +--- +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']); + +const onDragFinish = ({ from, to }) => { + const _list = [...draggingList]; + _list.splice(from, 1); + _list.splice(to, 0, draggingList[from]); + updateDraggingList(_list); +}; + +return ( + ( + + + Переместить {item} + +
{item}
+
+ )} + /> +) +``` + +
From 60032b4a9e25d56c03898413310c8123134feb96 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Wed, 1 Oct 2025 17:28:59 +0300 Subject: [PATCH 09/18] docs(Reorder): add docs --- .../vkui/src/components/Reorder/Reorder.tsx | 23 +- .../Reorder/components/ReorderItem.tsx | 8 +- .../Reorder/components/ReorderRoot.tsx | 8 +- website/content/components/reorder.mdx | 217 +++++++++++++++++- 4 files changed, 235 insertions(+), 21 deletions(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index 279ae08587f..7c53c31181c 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -30,14 +30,15 @@ type ReorderComponent = { /** * @see https://vkui.io/components/reorder */ -export const Reorder: ReorderComponent = Object.assign( - function Reorder({ items, renderItem, ...restProps }: ReorderProps) { - return {items.map(renderItem)}; - }, - { - Trigger: ReorderTrigger, - Item: ReorderItem, - Root: ReorderRoot, - TriggerIcon: ReorderTriggerIcon, - }, -); +export const Reorder: ReorderComponent = function Reorder({ + items, + renderItem, + ...restProps +}: ReorderProps) { + return {items.map(renderItem)}; +}; + +Reorder.Trigger = ReorderTrigger; +Reorder.Item = ReorderItem; +Reorder.Root = ReorderRoot; +Reorder.TriggerIcon = ReorderTriggerIcon; diff --git a/packages/vkui/src/components/Reorder/components/ReorderItem.tsx b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx index 5dd74a8c2c4..c7068208f10 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderItem.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderItem.tsx @@ -2,11 +2,15 @@ import * as React from 'react'; import { useExternRef } from '../../../hooks/useExternRef'; -import { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent'; +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 = RootComponentProps & Pick; +export type ReorderItemProps = React.AllHTMLAttributes & + HasRootRef & + HasComponent & + Pick; export function ReorderItem({ children, getRootRef, onReorder, ...restProps }: ReorderItemProps) { const context = React.useContext(ReorderContext); diff --git a/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx index 9bbd9f0c0c1..4f0347cc0b8 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx @@ -7,11 +7,15 @@ import { type SwappedItemRange, } from '../../../hooks/useDraggableWithDomApi'; import { useStableCallback } from '../../../hooks/useStableCallback'; -import { RootComponent, type RootComponentProps } from '../../RootComponent/RootComponent'; +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 RootComponentProps { +export interface ReorderRootProps + extends React.AllHTMLAttributes, + HasRootRef, + HasComponent { /** * Обработчик, срабатывающий при завершении перетаскивания. * **Важно:** режим перетаскивания не меняет порядок ячеек в DOM. В обработчике есть объект с полями `from` и `to`. diff --git a/website/content/components/reorder.mdx b/website/content/components/reorder.mdx index 80475cd1801..36bf464c0c3 100644 --- a/website/content/components/reorder.mdx +++ b/website/content/components/reorder.mdx @@ -1,12 +1,12 @@ --- -description: Компонент для реализации drag-and-drop-перестановки элементов списка. +description: Компонент для реализации drag-and-drop-перестановки элементов вертикального списка. --- # Reorder [tag:component] -Компонент для реализации drag-and-drop-перестановки элементов списка. +Компонент для реализации drag-and-drop-перестановки элементов вертикального списка. @@ -15,7 +15,13 @@ import { BlockWrapper } from '@/components/wrappers'; ```jsx -const [draggingList, updateDraggingList] = React.useState(['Пункт 1', 'Пункт 2', 'Пункт 3', 'Пункт 4', 'Пункт 5']); +const [draggingList, updateDraggingList] = React.useState([ + 'Пункт 1', + 'Пункт 2', + 'Пункт 3', + 'Пункт 4', + 'Пункт 5', +]); const onDragFinish = ({ from, to }) => { const _list = [...draggingList]; @@ -28,8 +34,6 @@ return ( )} /> -) +); ``` + +## Подкомпонентный подход + +Собрать `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 [draggingList, updateDraggingList] = React.useState(items); + +const onDragFinish = ({ from, to }: { from: number; to: number }) => { + const _list = [...draggingList]; + _list.splice(from, 1); + _list.splice(to, 0, draggingList[from]); + updateDraggingList(_list); +}; + +return ( + + {draggingList.map((item) => ( + + + + Перенести ячейку + + + + } + after={ + {}}> + + + } + subtitle={item.screenName} + onClick={() => {}} + > + {item.name} + + + ))} + +); +``` + + + +## Перетаскивание всего элемента целиком + +Иногда удобно, чтобы область перетаскивания занимала весь элемент списка — тогда пользователь может начать перемещение, нажав в любом месте карточки +(полезно для мобильных устройств и больших touch-таргетов). Ниже — готовый пример, показывающий этот подход и объясняющий важные нюансы. + +Когда использовать: + +- Когда элемент списка представляет собой цельный интерактивный блок (карточка, контакт, запись) и вы хотите упростить UX перетаскивания. +- Когда важно обеспечить широкий touch-таргет для перетаскивания на мобильных устройствах. + + + +```jsx +const items = React.useMemo( + () => [ + { + 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 = React.useCallback( + ({ from, to }) => { + const _list = [...draggingList]; + _list.splice(from, 1); + _list.splice(to, 0, draggingList[from]); + updateDraggingList(_list); + }, + [draggingList], +); + +return ( + + {draggingList.map((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] + + From c0afbea081ebbaeee42b03b9752257565a3f6ffa Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 2 Oct 2025 10:55:40 +0300 Subject: [PATCH 10/18] fix(Reorder): fix baseline tests --- packages/vkui/src/components/List/List.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vkui/src/components/List/List.tsx b/packages/vkui/src/components/List/List.tsx index f6cafccfb0b..fc7df4fa3aa 100644 --- a/packages/vkui/src/components/List/List.tsx +++ b/packages/vkui/src/components/List/List.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { classNames } from '@vkontakte/vkjs'; import type { HTMLAttributesWithRootRef } from '../../types'; import { Reorder, type ReorderProps } from '../Reorder/Reorder'; import styles from './List.module.css'; @@ -18,14 +19,17 @@ export const List = ({ children, gap = 0, onReorder, + className, + style, ...restProps }: ListProps): React.ReactNode => { return ( Date: Thu, 2 Oct 2025 12:42:27 +0300 Subject: [PATCH 11/18] fix(Reorder): add tests --- packages/vkui/src/components/List/List.tsx | 6 +- .../components/Reorder/Reorder.stories.tsx | 8 +- .../src/components/Reorder/Reorder.test.tsx | 269 ++++++++++++++++++ .../Reorder/components/ReorderRoot.tsx | 21 +- website/content/components/reorder.mdx | 136 ++++----- 5 files changed, 362 insertions(+), 78 deletions(-) create mode 100644 packages/vkui/src/components/Reorder/Reorder.test.tsx diff --git a/packages/vkui/src/components/List/List.tsx b/packages/vkui/src/components/List/List.tsx index fc7df4fa3aa..00f60ba3655 100644 --- a/packages/vkui/src/components/List/List.tsx +++ b/packages/vkui/src/components/List/List.tsx @@ -20,17 +20,13 @@ export const List = ({ gap = 0, onReorder, className, - style, ...restProps }: ListProps): React.ReactNode => { return ( diff --git a/packages/vkui/src/components/Reorder/Reorder.stories.tsx b/packages/vkui/src/components/Reorder/Reorder.stories.tsx index 9d4bcdefd39..9c9797d1f17 100644 --- a/packages/vkui/src/components/Reorder/Reorder.stories.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.stories.tsx @@ -41,13 +41,7 @@ export const Playground: Story = { }; return ( - + {draggingList.map((item) => ( { + 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 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); + + act(vi.runOnlyPendingTimers); + + breakPoints.forEach((breakPoint, index) => { + mouseMove(dragger, { + clientY: breakPoint, + }); + act(vi.runOnlyPendingTimers); + afterMove[index]?.(); + }); + 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 }); + }), + ); + }, + ); +}); diff --git a/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx index 4f0347cc0b8..29b3e805db3 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderRoot.tsx @@ -23,9 +23,19 @@ export interface ReorderRootProps * есть рабочий пример с обработкой этих чисел и перерисовкой списка. */ onReorder?: (value: SwappedItemRange) => void; + /** + * Отступ между элементами списка. + */ + gap?: number | string; } -export function ReorderRoot({ children, onReorder, disabled, ...restProps }: ReorderRootProps) { +export function ReorderRoot({ + children, + onReorder, + disabled, + gap = 0, + ...restProps +}: ReorderRootProps) { const onReorderCb = useStableCallback(onReorder || noop); const context = React.useMemo( @@ -38,7 +48,14 @@ export function ReorderRoot({ children, onReorder, disabled, ...restProps }: Reo return ( - + {children}
{ return ( ( @@ -95,6 +93,34 @@ const items = [ }, ] +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 = ({ from, to }: { from: number; to: number }) => { @@ -106,34 +132,11 @@ const onDragFinish = ({ from, to }: { from: number; to: number }) => { return ( {draggingList.map((item) => ( - - - - Перенести ячейку - - - - } - after={ - {}}> - - - } - subtitle={item.screenName} - onClick={() => {}} - > - {item.name} - - + ))} ); @@ -154,36 +157,33 @@ return ( ```jsx -const items = React.useMemo( - () => [ - { - 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 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); @@ -221,12 +221,7 @@ const onDragFinish = React.useCallback( ); return ( - + {draggingList.map((item) => ( ))} @@ -236,6 +231,19 @@ return ( +## Пользовательская иконка перетаскивания + +Чтобы изменить иконку перетаскивания можно использовать свойство `Icon` компонента `Reorder.TriggerIcon`. + +```jsx + + + Переместить {item} + +
{item}
+
+``` + ## Свойства и методы [#api] From 353f5704218b945221e7fb43303f72c37098b540 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 2 Oct 2025 17:48:45 +0300 Subject: [PATCH 12/18] feat(Reorder): improve code --- .../components/Reorder/Reorder.stories.tsx | 15 ++-- .../vkui/src/components/Reorder/Reorder.tsx | 74 +++++++++++++++++-- .../Reorder/components/ReorderTriggerIcon.tsx | 2 +- website/content/components/reorder.mdx | 61 ++++++--------- 4 files changed, 102 insertions(+), 50 deletions(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.stories.tsx b/packages/vkui/src/components/Reorder/Reorder.stories.tsx index 9c9797d1f17..508fee2238a 100644 --- a/packages/vkui/src/components/Reorder/Reorder.stories.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.stories.tsx @@ -23,6 +23,7 @@ export default story; type Story = StoryObj< ReorderProps<{ + id: string; avatarUrl: string; name: string; screenName: string; @@ -33,18 +34,15 @@ export const Playground: Story = { render: function Render({ items, ...args }) { const [draggingList, updateDraggingList] = React.useState(items); - const onDragFinish = ({ from, to }: { from: number; to: number }) => { - const _list = [...draggingList]; - _list.splice(from, 1); - _list.splice(to, 0, draggingList[from]); - updateDraggingList(_list); + const onDragFinish: ReorderProps['onReorder'] = (swappedItems) => { + updateDraggingList(Reorder.onReorder(swappedItems, draggingList)); }; return ( {draggingList.map((item) => ( = Omit & { +/* eslint-disable jsdoc/require-jsdoc */ +interface ReorderItemType { + id: string; +} +/* eslint-enable jsdoc/require-jsdoc */ + +export type ReorderProps = Omit< + ReorderRootProps, + 'children' +> & { /** * Массив элементов для рендера. */ items: ITEM[]; + /** + * Функция для изменения порядка элементов. + */ + setItems?: (items: ITEM[]) => ITEM[]; /** * Функция для рендера элемента. */ - renderItem: (item: ITEM, index: number) => React.ReactNode; + 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; }; /* eslint-disable jsdoc/require-jsdoc */ type ReorderComponent = { - (props: ReorderProps): React.ReactElement | null; + ( + 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({ +export const Reorder: ReorderComponent = function Reorder< + ITEM extends string | number | ReorderItemType, +>({ items, + setItems, renderItem, + triggerLabel = 'Перенести элемент', + TriggerIcon, + onReorder: onReorderProp, ...restProps }: ReorderProps) { - return {items.map(renderItem)}; + const onReorder = React.useCallback( + (swappedItems: SwappedItemRange) => { + setItems?.(Reorder.onReorder(swappedItems, items)); + }, + [items, setItems], + ); + + return ( + + {items.map((item, index) => { + const dragger = ( + + {triggerLabel} + + ); + const key = typeof item === 'string' || typeof item === 'number' ? item : item.id; + 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/ReorderTriggerIcon.tsx b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx index dc5aa4ecafd..b58217c97d5 100644 --- a/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx +++ b/packages/vkui/src/components/Reorder/components/ReorderTriggerIcon.tsx @@ -12,7 +12,7 @@ type PropsOfComponent = T extends React.ComponentType ? P : never; type IconsProps = PropsOfComponent; -interface ReorderTriggerIconProps +export interface ReorderTriggerIconProps extends IconsProps, Omit, keyof IconsProps> { /** diff --git a/website/content/components/reorder.mdx b/website/content/components/reorder.mdx index 756bac489e9..659686b6459 100644 --- a/website/content/components/reorder.mdx +++ b/website/content/components/reorder.mdx @@ -23,25 +23,16 @@ const [draggingList, updateDraggingList] = React.useState([ 'Пункт 5', ]); -const onDragFinish = ({ from, to }) => { - const _list = [...draggingList]; - _list.splice(from, 1); - _list.splice(to, 0, draggingList[from]); - updateDraggingList(_list); -}; - return ( ( - - - Переместить {item} - + setItems={updateDraggingList} + renderItem={(item, index, dragger) => ( +
+ {dragger}
{item}
- +
)} /> ); @@ -49,7 +40,17 @@ return ( -## Подкомпонентный подход +> Несколько важных моментов: +> +> - В качестве `items` можно передать массив элементов типа `string`, `number` или объекта с полем `id`. +> Элементы массива, а также `id` должны быть уникальными в рамках списка. +> Это нужно для корректного определения `key` у элемента. +> - В `renderItem` третьим параметром передается готовый тригер для перетаскивания, который вы можете встроить в любое место элемента. +> Если вам нужно кастомизировать компонент, вы можете использовать свойства `triggerLabel` и `TriggerIcon`. +> Если этого не достаточно, то вы можете самостоятельно отрендерить тригер для перетаскивания. Подробнее можно увидеть в разделе ["Подкомпонентный подход"](/components/reorder#subcomponents) +> - Вы можете использовать функцию `Reorder.onReorder`, чтобы не реализовывать логику изменения порядка элементов. + +## Подкомпонентный подход [#subcomponents] Собрать `Reorder` самостоятельно можно с помощью следующих подкомпонентов: @@ -58,9 +59,9 @@ return ( - `Reorder.Item` – отвечает за отрисовку элемента списка. -- `Reorder.Trigger` – элемент, на котором обрабатываются жесты/drag. Обычно внутри него ставят Reorder.TriggerIcon. +- `Reorder.Trigger` – элемент, на котором обрабатываются жесты/drag. Обычно внутри него ставят `Reorder.TriggerIcon`. -- `Reorder.TriggerIcon` — иконка для визуального хэндла. Поддерживает передачу кастомной иконки через Icon. +- `Reorder.TriggerIcon` — иконка для визуального хэндла. Поддерживает передачу кастомной иконки через `Icon`. @@ -91,7 +92,7 @@ const items = [ name: 'Никита Денисов', screenName: 'qurle', }, -] +]; const SimpleCellWrapper = React.useCallback(({ avatarUrl, name, screenName }) => { const [dragging, setDragging] = React.useState(false); @@ -123,18 +124,11 @@ const SimpleCellWrapper = React.useCallback(({ avatarUrl, name, screenName }) => const [draggingList, updateDraggingList] = React.useState(items); -const onDragFinish = ({ from, to }: { from: number; to: number }) => { - const _list = [...draggingList]; - _list.splice(from, 1); - _list.splice(to, 0, draggingList[from]); - updateDraggingList(_list); -}; +const onDragFinish = (swappedItems) => + updateDraggingList(Reorder.onReorder(swappedItems, draggingList)); return ( - + {draggingList.map((item) => ( ))} @@ -210,15 +204,8 @@ const SimpleCellWrapper = React.useCallback(({ avatarUrl, name, screenName }) => const [draggingList, updateDraggingList] = React.useState(items); -const onDragFinish = React.useCallback( - ({ from, to }) => { - const _list = [...draggingList]; - _list.splice(from, 1); - _list.splice(to, 0, draggingList[from]); - updateDraggingList(_list); - }, - [draggingList], -); +const onDragFinish = (swappedItems) => + updateDraggingList(Reorder.onReorder(swappedItems, draggingList)); return ( From 1b3a1c5d9ffe33eefd3a24ddca928800b38e35d0 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Fri, 3 Oct 2025 10:44:59 +0300 Subject: [PATCH 13/18] feat(Reorder): improve code --- .../components/Reorder/Reorder.stories.tsx | 8 +--- .../vkui/src/components/Reorder/Reorder.tsx | 38 +++++++++---------- website/content/components/reorder.mdx | 3 +- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.stories.tsx b/packages/vkui/src/components/Reorder/Reorder.stories.tsx index 508fee2238a..0a1790c455c 100644 --- a/packages/vkui/src/components/Reorder/Reorder.stories.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.stories.tsx @@ -23,7 +23,6 @@ export default story; type Story = StoryObj< ReorderProps<{ - id: string; avatarUrl: string; name: string; screenName: string; @@ -42,7 +41,7 @@ export const Playground: Story = { {draggingList.map((item) => ( = Omit< - ReorderRootProps, - 'children' -> & { +export type ReorderProps = Omit & { /** * Массив элементов для рендера. */ @@ -26,6 +17,10 @@ export type ReorderProps ITEM[]; + /** + * Функция, которая по элементу массива возвращает `id`. + */ + getItemId?: (item: ITEM) => string | number; /** * Функция для рендера элемента. */ @@ -40,21 +35,23 @@ export type ReorderProps( - { from, to }: SwappedItemRange, - list: ITEM[], -): ITEM[] => { +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; + (props: ReorderProps): React.ReactElement | null; Trigger: typeof ReorderTrigger; Item: typeof ReorderItem; Root: typeof ReorderRoot; @@ -66,11 +63,10 @@ type ReorderComponent = { /** * @see https://vkui.io/components/reorder */ -export const Reorder: ReorderComponent = function Reorder< - ITEM extends string | number | ReorderItemType, ->({ +export const Reorder: ReorderComponent = function Reorder({ items, setItems, + getItemId = defaultGetId, renderItem, triggerLabel = 'Перенести элемент', TriggerIcon, @@ -92,7 +88,7 @@ export const Reorder: ReorderComponent = function Reorder< {triggerLabel} ); - const key = typeof item === 'string' || typeof item === 'number' ? item : item.id; + const key = getItemId(item); return {renderItem(item, index, dragger)}; })} diff --git a/website/content/components/reorder.mdx b/website/content/components/reorder.mdx index 659686b6459..f48092022d1 100644 --- a/website/content/components/reorder.mdx +++ b/website/content/components/reorder.mdx @@ -42,7 +42,8 @@ return ( > Несколько важных моментов: > -> - В качестве `items` можно передать массив элементов типа `string`, `number` или объекта с полем `id`. +> - В качестве `items` можно передать массив элементов типа `string`, `number` или объекта с полями. +> Если вы используете объекты с полями, рекомендуется использовать свойство `getItemId` для определения корректного `id` элемента. > Элементы массива, а также `id` должны быть уникальными в рамках списка. > Это нужно для корректного определения `key` у элемента. > - В `renderItem` третьим параметром передается готовый тригер для перетаскивания, который вы можете встроить в любое место элемента. From d5a6725b617e5e05c414fe7cfb21a37931d2f65a Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Fri, 3 Oct 2025 10:51:29 +0300 Subject: [PATCH 14/18] fix(Reorder): fix type --- packages/vkui/src/components/Reorder/Reorder.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index f191dda4a35..e5457fc49ac 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -16,7 +16,7 @@ export type ReorderProps = Omit & { /** * Функция для изменения порядка элементов. */ - setItems?: (items: ITEM[]) => ITEM[]; + setItems?: (items: ITEM[]) => void; /** * Функция, которая по элементу массива возвращает `id`. */ From ec094921f81b8d60f1fb055d63999ee9f63a6be6 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Mon, 6 Oct 2025 11:18:33 +0300 Subject: [PATCH 15/18] fix(Reorder): fix subcomponents --- packages/vkui/src/components/Reorder/Reorder.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index e5457fc49ac..83e8424594e 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -84,12 +84,12 @@ export const Reorder: ReorderComponent = function Reorder({ {items.map((item, index) => { const dragger = ( - - {triggerLabel} - + + {triggerLabel} + ); const key = getItemId(item); - return {renderItem(item, index, dragger)}; + return {renderItem(item, index, dragger)}; })} ); From f9406a31020fd82243f737329abc8bf3de1a0041 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 16 Oct 2025 11:39:06 +0300 Subject: [PATCH 16/18] test: add test without subcomponents --- .../src/components/Reorder/Reorder.test.tsx | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/packages/vkui/src/components/Reorder/Reorder.test.tsx b/packages/vkui/src/components/Reorder/Reorder.test.tsx index b0a499490b0..b46ad61445b 100644 --- a/packages/vkui/src/components/Reorder/Reorder.test.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.test.tsx @@ -54,6 +54,62 @@ const defaultRenderItemContent = (index: number) => ( ); +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, @@ -266,4 +322,54 @@ describe('Reorder', () => { ); }, ); + + 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 }); + }), + ); }); From 88687e7fa1795046d2213ddb63eb1bae62a170ea Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Fri, 21 Nov 2025 19:57:29 +0700 Subject: [PATCH 17/18] fix: fix format --- packages/vkui/src/components/Reorder/Reorder.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.tsx b/packages/vkui/src/components/Reorder/Reorder.tsx index 83e8424594e..1fbfeefc828 100644 --- a/packages/vkui/src/components/Reorder/Reorder.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.tsx @@ -35,14 +35,14 @@ export type ReorderProps = Omit & { TriggerIcon?: ReorderTriggerIconProps['Icon']; }; -const onReorder = ({ from, to }: SwappedItemRange, list: ITEM[]): ITEM[] => { +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 => { +const defaultGetId = (item: ITEM): string | number => { if (typeof item === 'string' || typeof item === 'number') { return item; } From 928ea77b490ad3aafc4d2d7489f9be74f0b6598b Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 15 Jan 2026 16:47:31 +0300 Subject: [PATCH 18/18] fix: fix tests --- .../src/components/Reorder/Reorder.test.tsx | 193 +++++++++--------- 1 file changed, 97 insertions(+), 96 deletions(-) diff --git a/packages/vkui/src/components/Reorder/Reorder.test.tsx b/packages/vkui/src/components/Reorder/Reorder.test.tsx index b46ad61445b..24359c53132 100644 --- a/packages/vkui/src/components/Reorder/Reorder.test.tsx +++ b/packages/vkui/src/components/Reorder/Reorder.test.tsx @@ -187,16 +187,16 @@ const dragItem = ({ const dragger = screen.getByTestId(testId); mouseDown(dragger); - act(vi.runOnlyPendingTimers); + void act(vi.runOnlyPendingTimers); breakPoints.forEach((breakPoint, index) => { mouseMove(dragger, { clientY: breakPoint, }); - act(vi.runOnlyPendingTimers); + void act(vi.runOnlyPendingTimers); afterMove[index]?.(); }); - act(vi.runOnlyPendingTimers); + void act(vi.runOnlyPendingTimers); afterDragging && afterDragging(); mouseUp(dragger); @@ -208,120 +208,121 @@ describe('Reorder', () => { 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: () => { + ])('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(''); + expect(item1Data.transform).toBe('translateY(50px)'); 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, - }); + }, + mouseEvents, + }); - expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); + expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); - dragItem({ - testId: 'dragger-2', - breakPoints: [140, 140, 75, 140, 75], - afterDragging: () => { + 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('translateY(50px)'); + expect(item2Data.transform).toBe(''); }, - 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: () => { + }, + 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(''); + expect(item1Data.transform).toBe('translateY(50px)'); 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, - }); + }, + mouseEvents, + }); - expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); + expect(setupData.swappedItems).toEqual({ from: 0, to: 1 }); - dragItem({ - testId: 'dragger-2', - breakPoints: [140, 140, 75, 140, 75], - afterDragging: () => { + 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('translateY(50px)'); - }, - afterMove: { - 0: () => { - const item1Data = getItemSetup('item-0'); - expect(item1Data.transform).toBe(''); - const item2Data = getItemSetup('item-1'); - expect(item2Data.transform).toBe(''); - }, + expect(item2Data.transform).toBe(''); }, - mouseEvents, - }); + }, + mouseEvents, + }); - expect(setupData.swappedItems).toEqual({ from: 2, to: 1 }); - }), - ); - }, - ); + expect(setupData.swappedItems).toEqual({ from: 2, to: 1 }); + }), + ); + }); it( 'dnd without subcomponents working',