Last active
January 15, 2026 14:08
-
-
Save symant233/22306e9a200a2f48999748bbbc34ed92 to your computer and use it in GitHub Desktop.
DragFlow pure component, using grid layout and DOM draggable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import './index.less'; | |
| import React, { useRef } from 'react'; | |
| import cs from 'classnames'; | |
| export interface DragFlowProps<T> { | |
| data: T[]; | |
| renderItem: (item: T, index?: number) => React.ReactNode; | |
| /** | |
| * `onSortEnd` 拖拽结束触发,将排序结果传入该函数 | |
| */ | |
| onSortEnd?: (data: T[]) => void; | |
| /** | |
| * `itemBasis` 限定元素的最小宽度 pixel, 不提供则撑占全部空间 | |
| */ | |
| itemBasis?: number; | |
| /** | |
| * `containerClass` 为拖拽容器增加的额外类名,可用于样式自定义 | |
| */ | |
| containerClass?: string; | |
| /** | |
| * `itemClass` 为拖拽元素增加的额外类名,可用于样式自定义 | |
| */ | |
| itemClass?: string; | |
| /** | |
| * `itemGap` 定义排列的间距大小 | |
| */ | |
| itemGap?: number; | |
| onDragStart?: (e: React.DragEvent<HTMLElement>) => void; | |
| } | |
| export enum FlowClass { | |
| CONTAINER = 'dragflow-container', | |
| ITEM = 'dragflow-item', | |
| DRAGGING = 'dragflow-dragging', | |
| } | |
| /** | |
| * 部分重要参数说明 (其余参数参考 `DragFlowProps`) | |
| * - `data: T[];` 渲染每个元素的数据数组(即 `renderItem` 的参数数组) | |
| * - `renderItem: (item: T, index?: number) => React.ReactNode;` 渲染元素的组件 | |
| * - `itemBasis?: number;` 元素的最小宽度 pixel,不提供则撑占全部空间,成为纵向列表 | |
| */ | |
| const DragFlow = (props: DragFlowProps<any>) => { | |
| const { data, renderItem, itemBasis } = props; | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| function onDragStart(e: React.DragEvent<HTMLElement>) { | |
| e.dataTransfer.effectAllowed = 'move'; | |
| e.dataTransfer.setDragImage(new Image(), 0, 0); | |
| (e.target as HTMLElement).classList.add(FlowClass.DRAGGING); | |
| props?.onDragStart?.(e); | |
| } | |
| function onDragEnd(e: React.DragEvent<HTMLElement>) { | |
| (e.target as HTMLDivElement).classList.remove(FlowClass.DRAGGING); | |
| if (props?.onSortEnd) { | |
| try { | |
| const items = Array.from( | |
| containerRef.current.querySelectorAll(`.${FlowClass.ITEM}`), | |
| ); | |
| const dataIndices = items.map(item => | |
| parseInt((item as HTMLElement).dataset?.index, 10), | |
| ); | |
| const result = dataIndices.map(index => data[index]); | |
| props.onSortEnd(result); | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| } | |
| } | |
| function onDragOver(e: React.DragEvent<HTMLDivElement>) { | |
| e.preventDefault(); | |
| e.dataTransfer.dropEffect = 'move'; | |
| if (!containerRef.current) { | |
| return; | |
| } | |
| const draggingItem = containerRef.current.querySelector(`.${FlowClass.DRAGGING}`); | |
| const items = Array.from( | |
| containerRef.current.querySelectorAll(`.${FlowClass.ITEM}`), | |
| ); | |
| const current = items.find(item => { | |
| return e.target === item || item.contains(e.target as Node); | |
| }) as HTMLElement; | |
| if (!current || !draggingItem || current === draggingItem) { | |
| return; | |
| } | |
| if (current.nextElementSibling === draggingItem) { | |
| containerRef.current.insertBefore(draggingItem, current); | |
| } else { | |
| if (current.offsetTop < (draggingItem as HTMLElement).offsetTop) { | |
| containerRef.current.insertBefore(draggingItem, current); | |
| } else { | |
| containerRef.current.insertBefore( | |
| draggingItem, | |
| current.nextElementSibling, | |
| ); | |
| } | |
| } | |
| } | |
| return ( | |
| <div | |
| className={cs(FlowClass.CONTAINER, props?.containerClass)} | |
| style={ | |
| { | |
| '--dragflow-item-basis': itemBasis ? `${itemBasis}px` : '1fr', | |
| gridGap: props?.itemGap && `${props.itemGap}px`, | |
| } as React.CSSProperties | |
| } | |
| ref={containerRef} | |
| onDragOver={onDragOver} | |
| onDragEnter={e => e.preventDefault()} | |
| > | |
| {data.map((item, index) => { | |
| return ( | |
| <div | |
| className={cs(FlowClass.ITEM, props?.itemClass)} | |
| key={index} | |
| data-index={index} | |
| draggable={true} | |
| onDragStart={onDragStart} | |
| onDragEnd={onDragEnd} | |
| > | |
| {renderItem(item, index)} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| }; | |
| export default React.memo(DragFlow); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .dragflow-container { | |
| position: relative; | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(var(--dragflow-item-basis), 1fr)); | |
| grid-auto-rows: auto; | |
| grid-gap: 4px; | |
| width: 100%; | |
| overflow: hidden; | |
| .dragflow-item[draggable="true"] { | |
| cursor: grab; | |
| &:active { | |
| cursor: grabbing; | |
| } | |
| } | |
| .dragflow-dragging { | |
| opacity: 0.6; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment