1
0
Эх сурвалжийг харах

refactor: change react-sortable-hoc to dnd-kit for Transfer/Taginput … (#1738)

* refactor: change react-sortable-hoc to dnd-kit for Transfer/Taginput drag & drop

* chore: update yarn.lock file

---------

Co-authored-by: pointhalo <[email protected]>
YyumeiZhang 2 жил өмнө
parent
commit
ef375fb64e

+ 283 - 2
content/input/transfer/index-en-US.md

@@ -710,8 +710,15 @@ class CustomRenderDemo extends React.Component {
 ### Fully custom rendering, drag and drop sorting
 
 In a completely custom rendering scene, since the rendering of the drag area has also been completely taken over by you, you do not need to declare draggable.
-But you need to implement the drag and drop logic yourself, we recommend using `react-sortable-hoc` directly
-To support drag sorting, you need to call onSortEnd with oldIndex and newIndex as the input parameters after the drag sorting is over
+But you need to implement the drag and drop logic yourself, You can use the drag-and-drop tool library [dnd-kit](https://github.com/clauderic/dnd-kit) or [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc), quickly realize the function. Regarding the selection of the two, here are some of our suggestions.
+
+- Both are maintained by the same author, dnd-kit is the successor of react-sortable-hoc
+- The API design of react-sortable-hoc is more cohesive, and the code is more concise in simple scenarios. But it strongly relies on the findDOMNode API, which will be deprecated in future React versions. At the same time, the library has not been maintained for the past two years.
+- Relatively speaking, dnd-kit has a certain threshold for getting started, but it has a higher degree of freedom, stronger scalability, and is still under maintenance. we recommend it.
+
+Besides, To support drag sorting, you need to call onSortEnd with oldIndex and newIndex as the input parameters after the drag sorting is over
+
+Example using react-sortable-hoc:
 
 ```jsx live=true dir="column"
 import React from 'react';
@@ -874,6 +881,280 @@ class CustomRenderDragDemo extends React.Component {
 }
 ```
 
+Example using dnd-kit,The core dependencies that need to be used are @dnd-kit/sortable, @dnd-kit/core. The core hooks are useSortable, and the usage instructions of useSortable are as follows
+
+```
+1. Function: Obtain the necessary information during the drag and drop process through the unique id
+2. Core input parameters:
+    - id: unique identifier, which can be a number or a string, but cannot be a number 0
+3. Core return value description:
+    - setNodeRef: Associate the dom node to make it a draggable item
+    - listeners: Contains onKeyDown, onPointerDown and other methods, mainly to allow nodes to be dragged
+    - transform: the movement change value when the node is dragged
+    - transition: transition effect
+    - active: information about the dragged node, including id
+```
+
+```jsx live=true dir="column" noInline=true
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Transfer, Input, Spin, Button } from '@douyinfe/semi-ui';
+import { IconSearch, IconHandle } from '@douyinfe/semi-icons';
+import { useSortable, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import { CSS as cssDndKit } from '@dnd-kit/utilities';
+import { closestCenter, DragOverlay, DndContext, MouseSensor, TouchSensor, useSensor, useSensors, KeyboardSensor, TraversalOrder } from '@dnd-kit/core';
+
+function SortableList({
+    items,
+    onSortEnd,
+    renderItem,
+}) {
+    const [activeId, setActiveId] = useState(null);
+    // sensors determine which external input is affected by the drag operation (such as mouse, keyboard, touchpad)
+    const sensors = useSensors(
+        useSensor(MouseSensor),
+        useSensor(TouchSensor),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        })
+    );
+    const getIndex = useCallback((id) => items.indexOf(id), [items]);
+    const activeIndex = useMemo(() => activeId ? getIndex(activeId) : -1, [getIndex, activeId]);
+
+    const onDragStart = useCallback(({ active }) => {
+        if (!active) { return; }
+        setActiveId(active.id);
+    }, []);
+
+    // Drag end callback
+    const onDragEnd = useCallback(({ over }) => {
+        setActiveId(null);
+        if (over) {
+            const overIndex = getIndex(over.id);
+            if (activeIndex !== overIndex) {
+                onSortEnd({ oldIndex: activeIndex, newIndex: overIndex });
+            }
+        }
+    }, [activeIndex, getIndex, onSortEnd]);
+
+    const onDragCancel = useCallback(() => {
+        setActiveId(null);
+    }, []);
+
+    return (
+        <DndContext
+            sensors={sensors}
+            collisionDetection={closestCenter}
+            onDragStart={onDragStart}
+            onDragEnd={onDragEnd}
+            onDragCancel={onDragCancel}
+            // Set the scrolling when dragging to start from the ancestor element closest to the dragged element
+            autoScroll={{ order: TraversalOrder.ReversedTreeOrder }}
+        >
+            <SortableContext items={items} strategy={verticalListSortingStrategy}>
+                <div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', rowGap: '8px' }}>
+                    {items.map((value, index) => (
+                        <SortableItem
+                            key={value}
+                            id={value}
+                            index={index}
+                            renderItem={renderItem}
+                        />
+                    ))}
+                </div>
+                {ReactDOM.createPortal(
+                    <DragOverlay>
+                        {activeId ? (
+                            renderItem({
+                                id: activeId,
+                                sortableHandle: (WrapperComponent) => WrapperComponent
+                            })
+                        ) : null}
+                    </DragOverlay>,
+                    document.body
+                )}
+            </SortableContext>
+        </DndContext>
+    );
+}
+
+function SortableItem({ id, renderItem }) {
+    const {
+        listeners,
+        setNodeRef,
+        transform,
+        transition,
+        active,
+    } = useSortable({
+        id,
+    });
+
+    const sortableHandle = useCallback((WrapperComponent) => {
+        return () => <span {...listeners} style={{ lineHeight: 0 }}><WrapperComponent /></span>;
+    }, [listeners]);
+
+    const wrapperStyle = {
+        transform: cssDndKit.Transform.toString({
+            ...transform,
+            scaleX: 1,
+            scaleY: 1,
+        }),
+        transition: transition,
+        opacity: active && active.id === id ? 0 : undefined,
+    };
+
+    return <div 
+        ref={setNodeRef}
+        style={wrapperStyle}
+    >
+        {renderItem({ id, sortableHandle })}
+    </div>;
+}
+
+class CustomRenderDragDemo extends React.Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            dataSource: Array.from({ length: 100 }, (v, i) => ({
+                label: `Hdl Store ${i}`,
+                value: i,
+                disabled: false,
+                key: `key-${i}`,
+            })),
+        };
+        this.renderSourcePanel = this.renderSourcePanel.bind(this);
+        this.renderSelectedPanel = this.renderSelectedPanel.bind(this);
+        this.renderItem = this.renderItem.bind(this);
+    }
+
+    renderItem(type, item, onItemAction, selectedItems, sortableHandle) {
+        let buttonText = 'delete';
+
+        if (type === 'source') {
+            let checked = selectedItems.has(item.key);
+            buttonText = checked ? 'delete' : 'add';
+        }
+
+        const DragHandle = (sortableHandle && sortableHandle(() => <IconHandle className="pane-item-drag-handler" />));
+
+        return (
+            <div className="semi-transfer-item panel-item" key={item.label}>
+                {type === 'source' ? null : ( DragHandle ? <DragHandle /> : null) }
+                <div className="panel-item-main" style={{ flexGrow: 1 }}>
+                    <p style={{ margin: '0 12px' }}>{item.label}</p>
+                    <Button
+                        theme="borderless"
+                        type="primary"
+                        onClick={() => onItemAction(item)}
+                        className="panel-item-remove"
+                        size="small"
+                    >
+                        {buttonText}
+                    </Button>
+                </div>
+            </div>
+        );
+    }
+
+    renderSourcePanel(props) {
+        const {
+            loading,
+            noMatch,
+            filterData,
+            selectedItems,
+            allChecked,
+            onAllClick,
+            inputValue,
+            onSearch,
+            onSelectOrRemove,
+        } = props;
+        let content;
+        switch (true) {
+            case loading:
+                content = <Spin loading />;
+                break;
+            case noMatch:
+                content = <div className="empty sp-font">{inputValue ? 'No search results' : 'No content yet'}</div>;
+                break;
+            case !noMatch:
+                content = filterData.map(item => this.renderItem('source', item, onSelectOrRemove, selectedItems));
+                break;
+            default:
+                content = null;
+                break;
+        }
+        return (
+            <section className="source-panel">
+                <div className="panel-header sp-font">Store list</div>
+                <div className="panel-main">
+                    <Input
+                        style={{ width: 454, margin: '12px 14px' }}
+                        prefix={<IconSearch />}
+                        onChange={onSearch}
+                        showClear
+                    />
+                    <div className="panel-controls sp-font">
+                        <span>Store to be selected: {filterData.length}</span>
+                        <Button onClick={onAllClick} theme="borderless" size="small">
+                            {allChecked ? 'Unselect all' : 'Select all'}
+                        </Button>
+                    </div>
+                    <div className="panel-list">{content}</div>
+                </div>
+            </section>
+        );
+    }
+
+    renderSelectedPanel(props) {
+        const { selectedData, onClear, clearText, onRemove, onSortEnd } = props;
+        let mainContent = null;
+
+        if (!selectedData.length) {
+            mainContent = <div className="empty sp-font">No data, please filter from the left</div>;
+        }
+
+        const renderSelectItem = ({ id, sortableHandle }) => {
+            const item = selectedData.find(item => id === item.key);
+            return this.renderItem('selected', item, onRemove, null, sortableHandle);
+        };
+
+        const sortData = selectedData.map(item => item.key);
+
+        mainContent = <div className="panel-main" style={{ display: 'block' }}>
+            <SortableList onSortEnd={onSortEnd} items={sortData} renderItem={renderSelectItem}></SortableList>
+        </div>;
+
+        return (
+            <section className="selected-panel">
+                <div className="panel-header sp-font">
+                    <div>Selected: {selectedData.length}</div>
+                    <Button theme="borderless" type="primary" onClick={onClear} size="small">
+                        {clearText || 'Clear '}
+                    </Button>
+                </div>
+                {mainContent}
+            </section>
+        );
+    }
+
+    render() {
+        const { dataSource } = this.state;
+        return (
+            <Transfer
+                defaultValue={[2, 4]}
+                onChange={values => console.log(values)}
+                className="component-transfer-demo-custom-panel"
+                renderSourcePanel={this.renderSourcePanel}
+                renderSelectedPanel={this.renderSelectedPanel}
+                dataSource={dataSource}
+            />
+        );
+    }
+}
+
+render(CustomRenderDragDemo);
+```
+
 ### Tree Transfer
 
 The input type is `treeList`, and the [`Tree`](/en-US/navigation/tree) component is used as a custom rendering list. **v1.20.0 available**

+ 286 - 3
content/input/transfer/index.md

@@ -711,9 +711,18 @@ class CustomRenderDemo extends React.Component {
 
 ### 完全自定义渲染 、 拖拽排序
 
-在完全自定义渲染的场景下,由于拖拽区的渲染也已由你完全接管,因此你不声明 draggable 亦可。  
-但你需要自行实现拖拽逻辑,我们推荐直接使用`react-sortable-hoc`  
-要支持拖拽排序,你需要在拖拽排序结束后,将 oldIndex、newIndex 作为入参,调用 onSortEnd
+在完全自定义渲染的场景下,由于拖拽区的渲染也已由你完全接管,因此你不声明 draggable 亦可。
+但你需要自行实现拖拽逻辑,你可以借助社区中拖拽类工具库 [dnd-kit](https://github.com/clauderic/dnd-kit) 或者 [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc),快速实现功能。关于两者选型,这是我们的一些建议
+
+- 两者均由同一作者维护, dnd-kit 是 react-sortable-hoc 的接任产品
+- react-sortable-hoc 的 API 设计更加高内聚,在简单场景上代码更加简洁。但它强依赖了 findDOMNode API,在未来的 React 版本中会被废弃。同时该库最近两年已经处于不维护的状态。
+- dnd-kit 相对而言,有一定上手门槛,但它的自由度更高,扩展性更强,并且仍处于维护状态。我们更推荐使用
+
+更多 DIff 信息可查阅 [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc) 的 Github 主页
+
+另外,要支持拖拽排序,你需要在拖拽排序结束后,将 oldIndex、newIndex 作为入参,调用 onSortEnd
+
+使用 react-sortable-hoc 的示例:
 
 ```jsx live=true dir="column"
 import React from 'react';
@@ -876,6 +885,280 @@ class CustomRenderDragDemo extends React.Component {
 }
 ```
 
+使用 dnd-kit 的示例如下,需要用到的核心依赖有 @dnd-kit/sortable, @dnd-kit/core,其中核心 hooks 为 useSortable,使用说明如下
+
+```
+1. 作用:通过唯一标志 id 获取拖拽过程中必要信息
+2. 核心输入参数:
+    - id: 唯一标识, 以为数字或者字符串,但是不能为数字 0
+3. 核心返回值说明:
+	- setNodeRef: 关联 dom 节点,使其成为一个可拖拽的项
+	- listeners: 包含 onKeyDown,onPointerDown 等方法,主要让节点可以进行拖拽
+	- transform:该节点被拖动时候的移动变化值
+	- transition:过渡效果
+    - active: 被拖拽节点的相关信息,包括 id
+```
+
+```jsx live=true dir="column" noInline=true
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Transfer, Input, Spin, Button } from '@douyinfe/semi-ui';
+import { IconSearch, IconHandle } from '@douyinfe/semi-icons';
+import { useSortable, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import { CSS as cssDndKit } from '@dnd-kit/utilities';
+import { closestCenter, DragOverlay, DndContext, MouseSensor, TouchSensor, useSensor, useSensors, KeyboardSensor, TraversalOrder } from '@dnd-kit/core';
+
+function SortableList({
+    items,
+    onSortEnd,
+    renderItem,
+}) {
+    const [activeId, setActiveId] = useState(null);
+    // sensors 确定拖拽操作受哪些外部输入影响(如鼠标,键盘,触摸板)
+    const sensors = useSensors(
+        useSensor(MouseSensor),
+        useSensor(TouchSensor),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        })
+    );
+    const getIndex = useCallback((id) => items.indexOf(id), [items]);
+    const activeIndex = useMemo(() => activeId ? getIndex(activeId) : -1, [getIndex, activeId]);
+
+    const onDragStart = useCallback(({ active }) => {
+        if (!active) { return; }
+        setActiveId(active.id);
+    }, []);
+
+    // 拖拽结束回调
+    const onDragEnd = useCallback(({ over }) => {
+        setActiveId(null);
+        if (over) {
+            const overIndex = getIndex(over.id);
+            if (activeIndex !== overIndex) {
+                onSortEnd({ oldIndex: activeIndex, newIndex: overIndex });
+            }
+        }
+    }, [activeIndex, getIndex, onSortEnd]);
+
+    const onDragCancel = useCallback(() => {
+        setActiveId(null);
+    }, []);
+
+    return (
+        <DndContext
+            sensors={sensors}
+            collisionDetection={closestCenter}
+            onDragStart={onDragStart}
+            onDragEnd={onDragEnd}
+            onDragCancel={onDragCancel}
+            // 设置拖拽时候滚动从最靠近被拖拽元素的祖先元素开始
+            autoScroll={{ order: TraversalOrder.ReversedTreeOrder }}
+        >
+            <SortableContext items={items} strategy={verticalListSortingStrategy}>
+                <div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', rowGap: '8px' }}>
+                    {items.map((value, index) => (
+                        <SortableItem
+                            key={value}
+                            id={value}
+                            index={index}
+                            renderItem={renderItem}
+                        />
+                    ))}
+                </div>
+                {ReactDOM.createPortal(
+                    <DragOverlay>
+                        {activeId ? (
+                            renderItem({
+                                id: activeId,
+                                sortableHandle: (WrapperComponent) => WrapperComponent
+                            })
+                        ) : null}
+                    </DragOverlay>,
+                    document.body
+                )}
+            </SortableContext>
+        </DndContext>
+    );
+}
+
+function SortableItem({ id, renderItem }) {
+    const {
+        listeners,
+        setNodeRef,
+        transform,
+        transition,
+        active,
+    } = useSortable({
+        id,
+    });
+
+    const sortableHandle = useCallback((WrapperComponent) => {
+        return () => <span {...listeners} style={{ lineHeight: 0 }}><WrapperComponent /></span>;
+    }, [listeners]);
+
+    const wrapperStyle = {
+        transform: cssDndKit.Transform.toString({
+            ...transform,
+            scaleX: 1,
+            scaleY: 1,
+        }),
+        transition: transition,
+        opacity: active && active.id === id ? 0 : undefined,
+    };
+
+    return <div 
+        ref={setNodeRef}
+        style={wrapperStyle}
+    >
+        {renderItem({ id, sortableHandle })}
+    </div>;
+}
+
+class CustomRenderDragDemo extends React.Component {
+    constructor(props) {
+        super(props);
+        this.state = {
+            dataSource: Array.from({ length: 100 }, (v, i) => ({
+                label: `海底捞门店 ${i}`,
+                value: i,
+                disabled: false,
+                key: `key-${i}`,
+            })),
+        };
+        this.renderSourcePanel = this.renderSourcePanel.bind(this);
+        this.renderSelectedPanel = this.renderSelectedPanel.bind(this);
+        this.renderItem = this.renderItem.bind(this);
+    }
+
+    renderItem(type, item, onItemAction, selectedItems, sortableHandle) {
+        let buttonText = '删除';
+
+        if (type === 'source') {
+            let checked = selectedItems.has(item.key);
+            buttonText = checked ? '删除' : '添加';
+        }
+
+        const DragHandle = (sortableHandle && sortableHandle(() => <IconHandle className="pane-item-drag-handler" />));
+
+        return (
+            <div className="semi-transfer-item panel-item" key={item.label}>
+                {type === 'source' ? null : ( DragHandle ? <DragHandle /> : null) }
+                <div className="panel-item-main" style={{ flexGrow: 1 }}>
+                    <p style={{ margin: '0 12px' }}>{item.label}</p>
+                    <Button
+                        theme="borderless"
+                        type="primary"
+                        onClick={() => onItemAction(item)}
+                        className="panel-item-remove"
+                        size="small"
+                    >
+                        {buttonText}
+                    </Button>
+                </div>
+            </div>
+        );
+    }
+
+    renderSourcePanel(props) {
+        const {
+            loading,
+            noMatch,
+            filterData,
+            selectedItems,
+            allChecked,
+            onAllClick,
+            inputValue,
+            onSearch,
+            onSelectOrRemove,
+        } = props;
+        let content;
+        switch (true) {
+            case loading:
+                content = <Spin loading />;
+                break;
+            case noMatch:
+                content = <div className="empty sp-font">{inputValue ? '无搜索结果' : '暂无内容'}</div>;
+                break;
+            case !noMatch:
+                content = filterData.map(item => this.renderItem('source', item, onSelectOrRemove, selectedItems));
+                break;
+            default:
+                content = null;
+                break;
+        }
+        return (
+            <section className="source-panel">
+                <div className="panel-header sp-font">门店列表</div>
+                <div className="panel-main">
+                    <Input
+                        style={{ width: 454, margin: '12px 14px' }}
+                        prefix={<IconSearch />}
+                        onChange={onSearch}
+                        showClear
+                    />
+                    <div className="panel-controls sp-font">
+                        <span>待选门店: {filterData.length}</span>
+                        <Button onClick={onAllClick} theme="borderless" size="small">
+                            {allChecked ? '取消全选' : '全选'}
+                        </Button>
+                    </div>
+                    <div className="panel-list">{content}</div>
+                </div>
+            </section>
+        );
+    }
+
+    renderSelectedPanel(props) {
+        const { selectedData, onClear, clearText, onRemove, onSortEnd } = props;
+        let mainContent = null;
+
+        if (!selectedData.length) {
+            mainContent = <div className="empty sp-font">暂无数据,请从左侧筛选</div>;
+        }
+
+        const renderSelectItem = ({ id, sortableHandle }) => {
+            const item = selectedData.find(item => id === item.key);
+            return this.renderItem('selected', item, onRemove, null, sortableHandle);
+        };
+
+        const sortData = selectedData.map(item => item.key);
+
+        mainContent = <div className="panel-main" style={{ display: 'block' }}>
+            <SortableList onSortEnd={onSortEnd} items={sortData} renderItem={renderSelectItem}></SortableList>
+        </div>;
+
+        return (
+            <section className="selected-panel">
+                <div className="panel-header sp-font">
+                    <div>已选同步门店: {selectedData.length}</div>
+                    <Button theme="borderless" type="primary" onClick={onClear} size="small">
+                        {clearText || '清空 '}
+                    </Button>
+                </div>
+                {mainContent}
+            </section>
+        );
+    }
+
+    render() {
+        const { dataSource } = this.state;
+        return (
+            <Transfer
+                defaultValue={[2, 4]}
+                onChange={values => console.log(values)}
+                className="component-transfer-demo-custom-panel"
+                renderSourcePanel={this.renderSourcePanel}
+                renderSelectedPanel={this.renderSelectedPanel}
+                dataSource={dataSource}
+            />
+        );
+    }
+}
+
+render(CustomRenderDragDemo);
+```
+
 ### 树穿梭框
 
 传入 type 为`treeList`,使用[`Tree`](/zh-CN/navigation/tree)组件作为自定义渲染列表。**v1.20.0 提供**

+ 0 - 78
content/show/list/index-en-US.md

@@ -916,84 +916,6 @@ class DraggableList extends React.Component {
 render(DraggableList);
 ```
 
-
-If you use [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc), here is also an example
-
-```jsx live=true dir="column" hideInDSM
-import React, { useState } from 'react';
-import { List } from '@douyinfe/semi-ui';
-import { IconHandle } from '@douyinfe/semi-icons';
-import { SortableContainer, SortableElement, sortableHandle } from 'react-sortable-hoc';
-
-() => {
-    const data = [
-        'Siege',
-        'The ordinary world',
-        'Three Body',
-        'Snow in the Snow',
-        'Saharan story',
-        'Those things',
-        'A little monk of Zen',
-        'Dune',
-        'The courage to be hated',
-        'Crime and Punishment',
-        'Moon and sixpence',
-        'The silent majority',
-        'First person singular',
-    ];
-
-    const [list, setList] = useState(data.slice(0, 6));
-
-    const renderItem = (props) => {
-        const { item } = props;
-        const DragHandle = sortableHandle(() => <IconHandle className={`list-item-drag-handler`} style={{ marginRight: 4 }} />);
-        return (
-            <List.Item className='component-list-demo-drag-item list-item'>
-                <DragHandle />
-                {item}
-            </List.Item>
-        );
-    };
-
-    const arrayMove = (array, from, to) => {
-        let newArray = array.slice();
-        newArray.splice(to < 0 ? newArray.length + to : to, 0, newArray.splice(from, 1)[0]);
-        return newArray;
-    };
-
-    const onSortEnd = (callbackProps) => {
-        let { oldIndex, newIndex } = callbackProps;
-        let newList = arrayMove(list, oldIndex, newIndex);
-        setList(newList);
-    };
-    
-    const SortableItem = SortableElement(props => renderItem(props));
-    const SortableList = SortableContainer(
-        ({ items }) => {
-            return (
-                <div className="sortable-list-main">
-                    {items.map((item, index) => (
-                        <SortableItem key={item} index={index} item={item}></SortableItem>
-                    ))}
-                </div>
-            );
-        },
-        { distance: 10 }
-    );
-
-    return (
-        <div>
-            <div style={{ marginRight: 16, width: 280, display: 'flex', flexWrap: 'wrap', border: '1px solid var(--semi-color-border)' }}>
-                <List style={{ width: '100%' }} className='component-list-demo-booklist'>
-                    <SortableList useDragHandle onSortEnd={onSortEnd} items={list}></SortableList>
-                </List>
-            </div>
-
-        </div>
-    );
-};
-```
-
 ### With Pagination
 
 You can use Pagination in combination to achieve a paged List

+ 0 - 77
content/show/list/index.md

@@ -919,83 +919,6 @@ class DraggableList extends React.Component {
 render(DraggableList);
 ```
 
-如果你使用 [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc),这里也有一个例子
-
-```jsx live=true dir="column" hideInDSM
-import React, { useState } from 'react';
-import { List } from '@douyinfe/semi-ui';
-import { IconHandle } from '@douyinfe/semi-icons';
-import { SortableContainer, SortableElement, sortableHandle } from 'react-sortable-hoc';
-
-
-() => {
-    const data = [
-        '围城',
-        '平凡的世界(全三册)',
-        '三体(全集)',
-        '雪中悍刀行(全集)',
-        '撒哈拉的故事',
-        '明朝那些事',
-        '一禅小和尚',
-        '沙丘',
-        '被讨厌的勇气',
-        '罪与罚',
-        '月亮与六便士',
-        '沉默的大多数',
-        '第一人称单数',
-    ];
-
-    const [list, setList] = useState(data.slice(0, 6));
-
-    const renderItem = (props) => {
-        const { item } = props;
-        const DragHandle = sortableHandle(() => <IconHandle className={`list-item-drag-handler`} style={{ marginRight: 4 }} />);
-        return (
-            <List.Item className='component-list-demo-drag-item list-item'>
-                <DragHandle />
-                {item}
-            </List.Item>
-        );
-    };
-
-    const arrayMove = (array, from, to) => {
-        let newArray = array.slice();
-        newArray.splice(to < 0 ? newArray.length + to : to, 0, newArray.splice(from, 1)[0]);
-        return newArray;
-    };
-
-    const onSortEnd = (callbackProps) => {
-        let { oldIndex, newIndex } = callbackProps;
-        let newList = arrayMove(list, oldIndex, newIndex);
-        setList(newList);
-    };
-    
-    const SortableItem = SortableElement(props => renderItem(props));
-    const SortableList = SortableContainer(
-        ({ items }) => {
-            return (
-                <div className="sortable-list-main">
-                    {items.map((item, index) => (
-                        <SortableItem key={item} index={index} item={item}></SortableItem>
-                    ))}
-                </div>
-            );
-        },
-        { distance: 10 }
-    );
-
-    return (
-        <div>
-            <div style={{ marginRight: 16, width: 280, display: 'flex', flexWrap: 'wrap', border: '1px solid var(--semi-color-border)' }}>
-                <List style={{ width: '100%' }} className='component-list-demo-booklist'>
-                    <SortableList useDragHandle onSortEnd={onSortEnd} items={list}></SortableList>
-                </List>
-            </div>
-
-        </div>
-    );
-};
-```
 
 ### 带分页器 
 

+ 21 - 0
packages/semi-foundation/tagInput/tagInput.scss

@@ -42,6 +42,27 @@ $module: #{$prefix}-tagInput;
         }
     }
 
+    &-sortable-item {
+        position: relative;
+        &-over {
+            overflow: visible;
+            &::before {
+                content: "";
+                display: block;
+                height: 100%;
+                width: $width-tagInput_sortable_item_over;
+                background-color: $color-tagInput_sortable_item_over-bg;
+                position: absolute;
+                left: -$width-tagInput_sortable_item_over;
+                top: 0;
+            }
+        }
+
+        &-active {
+            opacity: 0.5;
+        }
+    }
+
     &-hover {
         background-color: $color-tagInput_default-bg-hover;
         border: $width-tagInput-border-hover $color-tagInput-border-hover solid;

+ 2 - 0
packages/semi-foundation/tagInput/variables.scss

@@ -27,6 +27,7 @@ $color-tagInput_danger-bg-hover: var(--semi-color-danger-light-hover); // 危险
 $color-tagInput_danger-border-hover: var(--semi-color-danger-light-hover); // 危险标签输入框描边颜色 - 悬浮
 $color-tagInput_danger-bg-focus: var(--semi-color-danger-light-default); // 危险标签输入框背景颜色 - 选中
 $color-tagInput_danger-border-focus: var(--semi-color-danger); // 危险标签输入框描边颜色 - 选中
+$color-tagInput_sortable_item_over-bg: var(--semi-color-primary); // 拖拽经过的元素前竖线背景色
 
 $color-tagInput_handler-icon-default: var(--semi-color-text-2); // 可拖拽的标签拖拽按钮颜色
 
@@ -48,6 +49,7 @@ $width-tagInput-clear-medium: $width-icon-medium * 2; // 标签输入框清空
 $width-tagInput-border-default: $border-thickness-control; // 标签输入框描边描边宽度 - 默认
 $width-tagInput-border-hover: $width-tagInput-border-default; // 标签输入框描边描边宽度 - 悬浮
 $width-tagInput-border-focus: $border-thickness-control-focus; // 标签输入框描边宽度 - 选中态
+$width-tagInput_sortable_item_over: 2px; // 拖拽经过的元素前竖线宽度
 
 $radius-tagInput: var(--semi-border-radius-small); // 标签输入框圆角
 

+ 7 - 0
packages/semi-foundation/transfer/transfer.scss

@@ -169,12 +169,19 @@ $module: #{$prefix}-transfer;
                 &-handler {
                     margin-right: $spacing-transfer_right_item_drag_handler-marginRight;
                     flex-shrink: 0;
+                    cursor: move;
                 }
 
                 &-item-move {
                     z-index: $z-transfer_right_item_drag_item_move;
                 }            
             }
+
+            &-sortable-item {
+                &-active {
+                    opacity: 0;
+                }
+            }
         }
 
         &-empty {

+ 255 - 0
packages/semi-ui/_sortable/index.tsx

@@ -0,0 +1,255 @@
+import React, { ReactNode, useState, useCallback, useMemo } from 'react';
+import { createPortal } from 'react-dom';
+import { CSS as cssDndKit } from '@dnd-kit/utilities';
+import cls from 'classnames';
+
+import {
+    closestCenter,
+    DragOverlay,
+    DndContext,
+    MouseSensor,
+    TouchSensor,
+    useSensor,
+    useSensors,
+    KeyboardSensor,
+    TraversalOrder,
+} from '@dnd-kit/core';
+import type {
+    UniqueIdentifier,
+    PointerActivationConstraint,
+    CollisionDetection,
+} from '@dnd-kit/core';
+import {
+    useSortable,
+    SortableContext,
+    rectSortingStrategy,
+    sortableKeyboardCoordinates,
+} from '@dnd-kit/sortable';
+import type {
+    SortingStrategy,
+    AnimateLayoutChanges,
+    NewIndexGetter,
+} from '@dnd-kit/sortable';
+import type { SortableTransition } from '@dnd-kit/sortable/dist/hooks/types';
+import { isNull } from 'lodash';
+
+const defaultPrefix = 'semi-sortable';
+const defaultConstraint = {
+    delay: 150,
+    tolerance: 5,
+};
+
+interface OnSortEndProps {
+    oldIndex: number;
+    newIndex: number
+}
+export type OnSortEnd = (props: OnSortEndProps) => void;
+
+export interface RenderItemProps {
+    id?: string | number;
+    sortableHandle?: any;
+    [x: string]: any
+}
+export interface SortableProps {
+    onSortEnd?: OnSortEnd;
+    // Set drag and drop trigger conditions
+    activationConstraint?: PointerActivationConstraint;
+    // Collision detection algorithm, for drag and drop sorting, use closestCenter to meet most scenarios
+    collisionDetection?: CollisionDetection;
+    // the dragged items,The content in items cannot be the number 0
+    items?: any[];
+    // Function that renders the item that is allowed to be dragged
+    renderItem?: (props: RenderItemProps) => React.ReactNode;
+    // Drag and drop strategy
+    strategy?: SortingStrategy;
+    // Whether to use a separate drag layer for items that move with the mouse
+    useDragOverlay?: boolean;
+    // A container for all elements that are allowed to be dragged
+    container?: any;
+    // Whether to change the size of the item being dragged
+    adjustScale?: boolean;
+    // Whether to use animation during dragging
+    transition?: SortableTransition | null;
+    // prefix
+    prefix?: string;
+    // The className of the item that moves with the mouse during the drag
+    dragOverlayCls?: string
+}
+
+interface SortableItemProps {
+    animateLayoutChanges?: AnimateLayoutChanges;
+    getNewIndex?: NewIndexGetter;
+    id: UniqueIdentifier;
+    index: number;
+    useDragOverlay?: boolean;
+    renderItem?: (props: RenderItemProps) => ReactNode;
+    prefix?: string;
+    transition?: SortableTransition | null
+}
+
+function DefaultContainer(props) {
+    return <div style={{ overflow: 'auto' }} {...props}></div>;
+}
+
+export function Sortable({
+    items,
+    onSortEnd,
+    adjustScale,
+    renderItem,
+    transition,
+    activationConstraint = defaultConstraint,
+    collisionDetection = closestCenter,
+    strategy = rectSortingStrategy,
+    useDragOverlay = true,
+    dragOverlayCls,
+    container: Container = DefaultContainer,
+    prefix = defaultPrefix,
+}: SortableProps) {
+
+    const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
+    const sensors = useSensors(
+        useSensor(MouseSensor, {
+            activationConstraint, 
+        }),
+        useSensor(TouchSensor, {
+            activationConstraint,
+        }),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        })
+    );
+    const getIndex = useCallback((id: UniqueIdentifier) => items.indexOf(id), [items]);
+    const activeIndex = useMemo(() => activeId ? getIndex(activeId) : -1, [getIndex, activeId]);
+
+    const onDragStart = useCallback(({ active }) => {
+        if (!active) { return; }
+        setActiveId(active.id);
+    }, []);
+    
+    const onDragEnd = useCallback(({ over }) => {
+        setActiveId(null);
+        if (over) {
+            const overIndex = getIndex(over.id);
+            if (activeIndex !== overIndex) {
+                onSortEnd({ oldIndex: activeIndex, newIndex: overIndex });
+            }
+        }
+    }, [activeIndex, getIndex, onSortEnd]);
+    
+    const onDragCancel = useCallback(() => {
+        setActiveId(null);
+    }, []);
+
+    return (
+        <DndContext
+            sensors={sensors}
+            collisionDetection={collisionDetection}
+            onDragStart={onDragStart}
+            onDragEnd={onDragEnd}
+            onDragCancel={onDragCancel}
+            autoScroll={{ order: TraversalOrder.ReversedTreeOrder }}
+        >
+            <SortableContext items={items} strategy={strategy}>
+                <Container>
+                    {items.map((value, index) => (
+                        <SortableItem
+                            key={value}
+                            id={value}
+                            index={index}
+                            renderItem={renderItem}
+                            useDragOverlay={useDragOverlay}
+                            prefix={prefix}
+                            transition={transition}
+                        />
+                    ))}
+                </Container>
+            </SortableContext>
+            {useDragOverlay
+                ? createPortal(
+                    <DragOverlay
+                        adjustScale={adjustScale}
+                        // Set zIndex in style to undefined to override the default zIndex in DragOverlay, 
+                        // So that the zIndex of DragOverlay can be set by className
+                        style={{ zIndex: undefined }}
+                        className={dragOverlayCls}
+                    >
+                        {activeId ? (
+                            renderItem({
+                                id: activeId,
+                                sortableHandle: (WrapperComponent) => WrapperComponent
+                            })
+                        ) : null}
+                    </DragOverlay>,
+                    document.body
+                )
+                : null}
+        </DndContext>
+    );
+}
+
+export function SortableItem({
+    animateLayoutChanges,
+    id,
+    renderItem,
+    prefix,
+    transition: animation,
+}: SortableItemProps) {
+    const {
+        listeners,
+        setNodeRef,
+        transform,
+        transition,
+        active,
+        isOver,
+        attributes,
+    } = useSortable({
+        id,
+        animateLayoutChanges,
+        transition: animation,
+    });
+
+    const sortableHandle = useCallback((WrapperComponent) => {
+        // console.log('listeners', listeners);
+        // 保证给出的接口的一致性,使用 span 包一层,保证用户能够通过同样的方式使用 handler
+        // To ensure the consistency of the given interface
+        // use a span package layer to ensure that users can use the handler in the same way
+        // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+        return () => <span {...listeners} style={{ lineHeight: 0 }} onMouseDown={(e) => {
+            listeners.onMouseDown(e);
+            // 阻止onMousedown的事件传递,
+            // 防止元素在点击后被卸载导致tooltip/popover的弹出层意外关闭
+            // Prevent the onMousedown event from being delivered, 
+            // preventing the element from being unloaded after being clicked, 
+            // causing the tooltip/popover pop-up layer to close unexpectedly
+            e.preventDefault();
+            e.stopPropagation();
+        }}
+        ><WrapperComponent /></span>;
+    }, [listeners]);
+
+    const itemCls = cls(
+        `${prefix}-sortable-item`,
+        {
+            [`${prefix}-sortable-item-over`]: isOver,
+            [`${prefix}-sortable-item-active`]: active?.id === id,
+        }
+    );
+
+    const wrapperStyle = (!isNull(animation)) ? {
+        transform: cssDndKit.Transform.toString({
+            ...transform,
+            scaleX: 1,
+            scaleY: 1,
+        }),
+        transition: transition,
+    } : undefined;
+
+    return <div 
+        ref={setNodeRef}
+        style={wrapperStyle}
+        className={itemCls} 
+        {...attributes}
+    >
+        {renderItem({ id, sortableHandle }) as JSX.Element}
+    </div>;
+}

+ 9 - 7
packages/semi-ui/package.json

@@ -17,12 +17,15 @@
         "lib/*"
     ],
     "dependencies": {
-        "@douyinfe/semi-animation": "2.40.0",
-        "@douyinfe/semi-animation-react": "2.40.0",
-        "@douyinfe/semi-foundation": "2.40.0",
-        "@douyinfe/semi-icons": "2.40.0",
-        "@douyinfe/semi-illustrations": "2.40.0",
-        "@douyinfe/semi-theme-default": "2.40.0",
+        "@douyinfe/semi-animation": "2.40.0-beta.0",
+        "@douyinfe/semi-animation-react": "2.40.0-beta.0",
+        "@douyinfe/semi-foundation": "2.40.0-beta.0",
+        "@douyinfe/semi-icons": "2.40.0-beta.0",
+        "@douyinfe/semi-illustrations": "2.40.0-beta.0",
+        "@douyinfe/semi-theme-default": "2.40.0-beta.0",
+        "@dnd-kit/core": "^6.0.8",
+        "@dnd-kit/sortable": "^7.0.2",
+        "@dnd-kit/utilities": "^3.2.1",
         "async-validator": "^3.5.0",
         "classnames": "^2.2.6",
         "copy-text-to-clipboard": "^2.1.1",
@@ -31,7 +34,6 @@
         "lodash": "^4.17.21",
         "prop-types": "^15.7.2",
         "react-resizable": "^1.8.0",
-        "react-sortable-hoc": "^2.0.0",
         "react-window": "^1.8.2",
         "resize-observer-polyfill": "^1.5.1",
         "scroll-into-view-if-needed": "^2.2.24",

+ 38 - 13
packages/semi-ui/tagInput/_story/tagInput.stories.jsx

@@ -77,19 +77,44 @@ ShowClear.story = {
   name: 'showClear',
 };
 
-export const Draggable = () => (
-  <>
-    <TagInput draggable defaultValue={['抖音', '火山', '西瓜视频', 'AI Lab', '花亦山', '水之月','轻颜','醒图']} showClear style={style} />
-    <br />
-    <TagInput 
-      draggable
-      defaultValue={['抖音', '火山', '西瓜视频', 'AI Lab', '花亦山', '水之月','轻颜','醒图']} 
-      maxTagCount={5} 
-      showClear 
-      style={style} 
-    />
-  </>
-);
+export const Draggable = () => {
+  const renderTagItem = useCallback((value, index, onClose) => (
+    <div 
+        key={value} 
+        style={{ display: 'flex', alignItems: 'center', fontSize: 14, marginRight: 4 }}
+    >
+        <span style={{ marginLeft: 8 }}>
+            {`${value}`}
+        </span>
+        <IconClose onClick={(e) => { 
+            onClose(e);
+            e.stopPropagation();
+            e.nativeEvent.stopImmediatePropagation();
+        }} />
+    </div>), []);
+
+  return (
+    <>
+      <TagInput draggable defaultValue={['抖音', '火山', '西瓜视频', 'AI Lab', '花亦山', '水之月','轻颜','醒图']} showClear style={style} />
+      <br />
+      <TagInput 
+        draggable
+        defaultValue={['抖音', '火山', '西瓜视频', 'AI Lab', '花亦山', '水之月','轻颜','醒图']} 
+        maxTagCount={5} 
+        showClear 
+        style={style} 
+      />
+      <br />
+      <TagInput 
+        draggable
+        defaultValue={['抖音', '火山', '西瓜视频', 'AI Lab', '花亦山', '水之月','轻颜','醒图']} 
+        renderTagItem={renderTagItem}
+        maxTagCount={5} 
+        showClear 
+        style={style} 
+      />
+    </>);
+};
 
 Draggable.story = {
   name: 'draggable',

+ 58 - 52
packages/semi-ui/tagInput/index.tsx

@@ -20,8 +20,8 @@ import Input from '../input';
 import Popover, { PopoverProps } from '../popover';
 import Paragraph from '../typography/paragraph';
 import { IconClear, IconHandle } from '@douyinfe/semi-icons';
-import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
 import { ShowTooltip } from '../typography';
+import { RenderItemProps, Sortable } from '../_sortable';
 
 const prefixCls = cssClasses.PREFIX;
 
@@ -29,19 +29,9 @@ export type Size = ArrayElement<typeof strings.SIZE_SET>;
 export type RestTagsPopoverProps = PopoverProps;
 type ValidateStatus = "default" | "error" | "warning";
 
-const SortableItem = SortableElement(props => props.item);
-
-const SortableList = SortableContainer(
-    ({ items }) => {
-        return (
-            <div className={`${prefixCls}-sortable-list`}>
-                {items.map((item, index) => (
-                    // @ts-ignore skip SortableItem type check
-                    <SortableItem key={item.key} index={index} item={item.item}></SortableItem>
-                ))}
-            </div>
-        );
-    });
+function SortContainer(props) {
+    return <div className={`${prefixCls}-sortable-list`} {...props}></div>;
+}
 
 export interface TagInputProps {
     className?: string;
@@ -406,6 +396,11 @@ class TagInput extends BaseComponent<TagInputProps, TagInputState> {
     }
 
     getAllTags = () => {
+        const { tagsArray } = this.state;
+        return tagsArray.map((value, index) => this.renderTag(value, index));
+    }
+
+    renderTag = (value: any, index: number, sortableHandle?: any) => {
         const {
             size,
             disabled,
@@ -413,7 +408,7 @@ class TagInput extends BaseComponent<TagInputProps, TagInputState> {
             showContentTooltip,
             draggable,
         } = this.props;
-        const { tagsArray, active } = this.state;
+        const { active } = this.state;
         const showIconHandler = active && draggable;
         const tagCls = cls(`${prefixCls}-wrapper-tag`, {
             [`${prefixCls}-wrapper-tag-size-${size}`]: size,
@@ -426,41 +421,46 @@ class TagInput extends BaseComponent<TagInputProps, TagInputState> {
             [`${prefixCls}-drag-item`]: showIconHandler,
             [`${prefixCls}-wrapper-tag-icon`]: showIconHandler,
         });
-        const DragHandle = SortableHandle(() => <IconHandle className={`${prefixCls}-drag-handler`}></IconHandle>);
-        return tagsArray.map((value, index) => {
-            const elementKey = showIconHandler ? value : `${index}${value}`;
-            const onClose = () => {
-                !disabled && this.handleTagClose(index);
-            };
-            if (isFunction(renderTagItem)) {
-                return showIconHandler? (<div className={itemWrapperCls} key={elementKey}>
-                    <DragHandle />
-                    {renderTagItem(value, index, onClose)}
-                </div>) : renderTagItem(value, index, onClose);
-            } else {
-                return (
-                    <Tag
-                        className={tagCls}
-                        color="white"
-                        size={size === 'small' ? 'small' : 'large'}
-                        type="light"
-                        onClose={onClose}
-                        closable={!disabled}
-                        key={elementKey}
-                        visible
-                        aria-label={`${!disabled ? 'Closable ' : ''}Tag: ${value}`}
+        const DragHandle = sortableHandle && sortableHandle(() => <IconHandle className={`${prefixCls}-drag-handler`}></IconHandle>);
+        const elementKey = showIconHandler ? value : `${index}${value}`;
+        const onClose = () => {
+            !disabled && this.handleTagClose(index);
+        };
+        if (isFunction(renderTagItem)) {
+            return (<div className={itemWrapperCls} key={elementKey}>
+                {showIconHandler && sortableHandle ? <DragHandle /> : null}
+                {renderTagItem(value, index, onClose)}
+            </div>);
+        } else {
+            return (
+                <Tag
+                    className={tagCls}
+                    color="white"
+                    size={size === 'small' ? 'small' : 'large'}
+                    type="light"
+                    onClose={onClose}
+                    closable={!disabled}
+                    key={elementKey}
+                    visible
+                    aria-label={`${!disabled ? 'Closable ' : ''}Tag: ${value}`}
+                >
+                    {showIconHandler && sortableHandle ? <DragHandle /> : null}
+                    <Paragraph
+                        className={typoCls}
+                        ellipsis={{ showTooltip: showContentTooltip, rows: 1 }}
                     >
-                        {showIconHandler && <DragHandle />}
-                        <Paragraph
-                            className={typoCls}
-                            ellipsis={{ showTooltip: showContentTooltip, rows: 1 }}
-                        >
-                            {value}
-                        </Paragraph>
-                    </Tag>
-                );
-            }
-        });
+                        {value}
+                    </Paragraph>
+                </Tag>
+            );
+        }
+    }
+
+    renderSortTag = (props: RenderItemProps) => {
+        const { id: item, sortableHandle } = props;
+        const { tagsArray } = this.state;
+        const index = tagsArray.indexOf(item as string);
+        return this.renderTag(item, index, sortableHandle);
     }
 
     onSortEnd = (callbackProps: OnSortEndProps) => {
@@ -498,9 +498,15 @@ class TagInput extends BaseComponent<TagInputProps, TagInputState> {
         }));
 
         if (active && draggable && sortableListItems.length > 0) {
-            // helperClass:add styles to the helper(item being dragged) https://github.com/clauderic/react-sortable-hoc/issues/87
-            // @ts-ignore skip SortableItem type check
-            return <SortableList useDragHandle helperClass={`${prefixCls}-drag-item-move`} items={sortableListItems} onSortEnd={this.onSortEnd} axis={"xy"} />;
+            return <Sortable 
+                items={tagsArray} 
+                onSortEnd={this.onSortEnd} 
+                renderItem={this.renderSortTag} 
+                container={SortContainer}
+                prefix={prefixCls}
+                transition={null}
+                dragOverlayCls={`${prefixCls}-right-item-drag-item-move`}
+            />;
         }
         return (
             <>

+ 260 - 148
packages/semi-ui/transfer/_story/transfer.stories.jsx

@@ -1,9 +1,29 @@
-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
+import { createPortal } from 'react-dom';
 import { Transfer, Button, Popover, SideSheet, Avatar, Checkbox, Tree, Input, Tag } from '../../index';
-import { omit, values } from 'lodash';
+import { omit, values, isNull } from 'lodash';
 import './transfer.scss';
-import { SortableContainer, SortableElement, sortableHandle } from 'react-sortable-hoc';
 import { IconClose, IconSearch, IconHandle } from '@douyinfe/semi-icons';
+import {
+  useSortable,
+  SortableContext,
+  sortableKeyboardCoordinates,
+  verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+
+import { CSS as cssDndKit } from '@dnd-kit/utilities';
+
+import {
+  closestCenter,
+  DragOverlay,
+  DndContext,
+  MouseSensor,
+  TouchSensor,
+  useSensor,
+  useSensors,
+  KeyboardSensor,
+  TraversalOrder
+} from '@dnd-kit/core';
 
 export default {
   title: 'Transfer'
@@ -28,12 +48,12 @@ const commonProps = {
   },
 };
 
-const data = Array.from({ length: 100 }, (v, i) => {
+const data = Array.from({ length: 20 }, (v, i) => {
   return {
     label: `选项名称${i}`,
     value: i,
     disabled: false,
-    key: i,
+    key: `key-${i}`,
   };
 });
 
@@ -165,7 +185,7 @@ export const TransferDraggableAndDisabled = () => {
       return {
           label: `选项名称 ${i}`,
           value: i,
-          key: i,
+          key: `key-${i}`,
           disabled: true,
       };
   });
@@ -306,8 +326,11 @@ export const CustomFilterRenderSourceItemRenderSelectedItem = () => {
     );
   };
   const renderSelectedItem = item => {
+    const { sortableHandle } = item;
+    const DragHandle = sortableHandle(() => <IconHandle className={`semi-transfer-right-item-drag-handler`} />); 
     return (
       <div className="components-transfer-demo-selected-item" key={item.label}>
+        <DragHandle />
         <Avatar color={item.color} size="small">
           {item.abbr}
         </Avatar>
@@ -322,6 +345,7 @@ export const CustomFilterRenderSourceItemRenderSelectedItem = () => {
   return (
     <div style={{ margin: 10, padding: 10, width: 600 }}>
       <Transfer
+        draggable
         {...commonProps}
         dataSource={data}
         filter={customFilter}
@@ -611,166 +635,254 @@ CustomRender.story = {
   name: 'customRender',
 };
 
+function SortableList({
+  items,
+  onSortEnd,
+  renderItem,
+}) {
+  const [activeId, setActiveId] = useState(null);
+  const sensors = useSensors(
+      useSensor(MouseSensor),
+      useSensor(TouchSensor),
+      useSensor(KeyboardSensor, {
+          coordinateGetter: sortableKeyboardCoordinates,
+      })
+  );
+  const getIndex = useCallback((id) => items.indexOf(id), [items]);
+  const activeIndex = useMemo(() => activeId ? getIndex(activeId) : -1, [getIndex, activeId]);
+
+  const onDragStart = useCallback(({ active }) => {
+      if (!active) { return; }
+      setActiveId(active.id);
+  }, []);
+
+  const onDragEnd = useCallback(({ over }) => {
+      setActiveId(null);
+      if (over) {
+          const overIndex = getIndex(over.id);
+          if (activeIndex !== overIndex) {
+              onSortEnd({ oldIndex: activeIndex, newIndex: overIndex });
+          }
+      }
+  }, [activeIndex, getIndex, onSortEnd]);
+
+  const onDragCancel = useCallback(() => {
+      setActiveId(null);
+  }, []);
+
+  return (
+      <DndContext
+          sensors={sensors}
+          collisionDetection={closestCenter}
+          onDragStart={onDragStart}
+          onDragEnd={onDragEnd}
+          onDragCancel={onDragCancel}
+          autoScroll={{ order: TraversalOrder.ReversedTreeOrder }}
+      >
+          <SortableContext items={items} strategy={verticalListSortingStrategy}>
+              <div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', rowGap: '8px' }}>
+                  {items.map((value, index) => (
+                      <SortableItem
+                          key={value}
+                          id={value}
+                          index={index}
+                          renderItem={renderItem}
+                      />
+                  ))}
+              </div>
+              {createPortal(
+                  <DragOverlay
+                      style={{ zIndex: undefined }}
+                  >
+                      {activeId ? (
+                          renderItem({
+                              id: activeId,
+                              sortableHandle: (WrapperComponent) => WrapperComponent
+                          })
+                      ) : null}
+                  </DragOverlay>,
+                  document.body
+              )}
+          </SortableContext>
+      </DndContext>
+  );
+}
+
+function SortableItem({ getNewIndex, id, renderItem }) {
+  const {
+      listeners,
+      setNodeRef,
+      transform,
+      transition,
+      active,
+      isOver,
+      attributes,
+  } = useSortable({
+      id,
+      getNewIndex,
+  });
+
+  const sortableHandle = useCallback((WrapperComponent) => {
+      return () => <span {...listeners} style={{ lineHeight: 0 }}><WrapperComponent /></span>;
+  }, [listeners]);
+
+  const wrapperStyle = {
+      transform: cssDndKit.Transform.toString({
+          ...transform,
+          scaleX: 1,
+          scaleY: 1,
+      }),
+      transition: transition,
+      opacity: active && active.id === id ? 0 : undefined,
+  };
+
+  return <div 
+      ref={setNodeRef}
+      style={wrapperStyle}
+      {...attributes}
+  >
+      {renderItem({ id, sortableHandle })}
+  </div>;
+}
+
 class CustomRenderDragDemo extends React.Component {
   constructor(props) {
-    super(props);
-    this.state = {
-      dataSource: Array.from({ length: 100 }, (v, i) => ({
-        label: `海底捞门店 ${i}`,
-        value: i,
-        disabled: false,
-        key: i,
-      })),
-    };
-    this.renderSourcePanel = this.renderSourcePanel.bind(this);
-    this.renderSelectedPanel = this.renderSelectedPanel.bind(this);
-    this.renderItem = this.renderItem.bind(this);
+      super(props);
+      this.state = {
+          dataSource: Array.from({ length: 10 }, (v, i) => ({
+              label: `海底捞门店 ${i}`,
+              value: i,
+              disabled: false,
+              key: `key-${i}`,
+          })),
+      };
+      this.renderSourcePanel = this.renderSourcePanel.bind(this);
+      this.renderSelectedPanel = this.renderSelectedPanel.bind(this);
+      this.renderItem = this.renderItem.bind(this);
   }
 
-  renderItem(type, item, onItemAction, selectedItems) {
-    let buttonText = '删除';
-    let newItem = item;
+  renderItem(type, item, onItemAction, selectedItems, sortableHandle) {
+      let buttonText = '删除';
 
-    if (type === 'source') {
-      let checked = selectedItems.has(item.key);
-      buttonText = checked ? '删除' : '添加';
-    } else {
-      // delete newItem._optionKey;
-      newItem = { ...item, key: item._optionKey };
-      delete newItem._optionKey;
-    }
-
-    const DragHandle = sortableHandle(() => <IconHandle className="pane-item-drag-handler" />);
+      if (type === 'source') {
+          let checked = selectedItems.has(item.key);
+          buttonText = checked ? '删除' : '添加';
+      }
 
-    return (
-      <div className="semi-transfer-item panel-item" key={item.label}>
-        {type === 'source' ? null : <DragHandle />}
-        <div className="panel-item-main" style={{ flexGrow: 1 }}>
-          <p>{item.label}</p>
-          <Button
-            theme="borderless"
-            type="primary"
-            onClick={() => onItemAction(newItem)}
-            className="panel-item-remove"
-            size="small"
-          >
-            {buttonText}
-          </Button>
-        </div>
-      </div>
-    );
+      const DragHandle = (sortableHandle && sortableHandle(() => <IconHandle className="pane-item-drag-handler" />));
+
+      return (
+          <div className="semi-transfer-item panel-item" key={item.label}>
+              {type === 'source' ? null : ( DragHandle ? <DragHandle /> : null) }
+              <div className="panel-item-main" style={{ flexGrow: 1 }}>
+                  <p style={{ margin: '0 12px' }}>{item.label}</p>
+                  <Button
+                      theme="borderless"
+                      type="primary"
+                      onClick={() => onItemAction(item)}
+                      className="panel-item-remove"
+                      size="small"
+                  >
+                      {buttonText}
+                  </Button>
+              </div>
+          </div>
+      );
   }
 
   renderSourcePanel(props) {
-    const {
-      loading,
-      noMatch,
-      filterData,
-      selectedItems,
-      allChecked,
-      onAllClick,
-      inputValue,
-      onSearch,
-      onSelectOrRemove,
-    } = props;
-    let content;
-    switch (true) {
-      case loading:
-        content = <Spin loading />;
-        break;
-      case noMatch:
-        content = <div className="empty sp-font">{inputValue ? '无搜索结果' : '暂无内容'}</div>;
-        break;
-      case !noMatch:
-        content = filterData.map(item =>
-          this.renderItem('source', item, onSelectOrRemove, selectedItems)
-        );
-        break;
-      default:
-        content = null;
-        break;
-    }
-    return (
-      <section className="source-panel">
-        <div className="panel-header sp-font">门店列表</div>
-        <div className="panel-main">
-          <Input
-            style={{ width: 454, margin: '12px 14px' }}
-            prefix={<IconSearch />}
-            onChange={onSearch}
-            showClear
-          />
-          <div className="panel-controls sp-font">
-            <span>待选门店: {filterData.length}</span>
-            <Button onClick={onAllClick} theme="borderless" size="small">
-              {allChecked ? '取消全选' : '全选'}
-            </Button>
-          </div>
-          <div className="panel-list">{content}</div>
-        </div>
-      </section>
-    );
+      const {
+          loading,
+          noMatch,
+          filterData,
+          selectedItems,
+          allChecked,
+          onAllClick,
+          inputValue,
+          onSearch,
+          onSelectOrRemove,
+      } = props;
+      let content;
+      switch (true) {
+          case loading:
+              content = <Spin loading />;
+              break;
+          case noMatch:
+              content = <div className="empty sp-font">{inputValue ? '无搜索结果' : '暂无内容'}</div>;
+              break;
+          case !noMatch:
+              content = filterData.map(item => this.renderItem('source', item, onSelectOrRemove, selectedItems));
+              break;
+          default:
+              content = null;
+              break;
+      }
+      return (
+          <section className="source-panel">
+              <div className="panel-header sp-font">门店列表</div>
+              <div className="panel-main">
+                  <Input
+                      style={{ width: 454, margin: '12px 14px' }}
+                      prefix={<IconSearch />}
+                      onChange={onSearch}
+                      showClear
+                  />
+                  <div className="panel-controls sp-font">
+                      <span>待选门店: {filterData.length}</span>
+                      <Button onClick={onAllClick} theme="borderless" size="small">
+                          {allChecked ? '取消全选' : '全选'}
+                      </Button>
+                  </div>
+                  <div className="panel-list">{content}</div>
+              </div>
+          </section>
+      );
   }
 
   renderSelectedPanel(props) {
-    const { selectedData, onClear, clearText, onRemove, onSortEnd } = props;
-
-    let mainContent = null;
-
-    if (!selectedData.length) {
-      mainContent = <div className="empty sp-font">暂无数据,请从左侧筛选</div>;
-    }
+      const { selectedData, onClear, clearText, onRemove, onSortEnd } = props;
+      let mainContent = null;
 
-    const SortableItem = SortableElement(item => this.renderItem('selected', item, onRemove));
-    const SortableList = SortableContainer(
-      ({ items }) => {
-        return (
-          <div className="panel-main">
-            {items.map((item, index) => (
-              // sortableElement will take over the property 'key', so use another '_optionKey' to pass
-              // otherwise you can't get `key` property in this.renderItem
-              <SortableItem
-                key={item.label}
-                index={index}
-                {...item}
-                _optionKey={item.key}
-              ></SortableItem>
-            ))}
-          </div>
-        );
-      },
-      { distance: 10 }
-    );
+      if (!selectedData.length) {
+          mainContent = <div className="empty sp-font">暂无数据,请从左侧筛选</div>;
+      }
 
-    mainContent = (
-      <SortableList useDragHandle onSortEnd={onSortEnd} items={selectedData}></SortableList>
-    );
+      const renderSelectItem = ({ id, sortableHandle }) => {
+          const item = selectedData.find(item => id === item.key);
+          return this.renderItem('selected', item, onRemove, null, sortableHandle);
+      };
 
-    return (
-      <section className="selected-panel">
-        <div className="panel-header sp-font">
-          <div>已选同步门店: {selectedData.length}</div>
-          <Button theme="borderless" type="primary" onClick={onClear} size="small">
-            {clearText || '清空 '}
-          </Button>
-        </div>
-        {mainContent}
-      </section>
-    );
+      const sortData = selectedData.map(item => item.key);
+
+      mainContent = <div className="panel-main" style={{ display: 'block' }}>
+          <SortableList onSortEnd={onSortEnd} items={sortData} renderItem={renderSelectItem}></SortableList>
+      </div>;
+
+      return (
+          <section className="selected-panel">
+              <div className="panel-header sp-font">
+                  <div>已选同步门店: {selectedData.length}</div>
+                  <Button theme="borderless" type="primary" onClick={onClear} size="small">
+                      {clearText || '清空 '}
+                  </Button>
+              </div>
+              {mainContent}
+          </section>
+      );
   }
 
   render() {
-    const { dataSource } = this.state;
-    return (
-      <Transfer
-        defaultValue={[2, 4]}
-        onChange={values => console.log(values)}
-        className="component-transfer-demo-custom-panel"
-        renderSourcePanel={this.renderSourcePanel}
-        renderSelectedPanel={this.renderSelectedPanel}
-        dataSource={dataSource}
-      />
-    );
+      const { dataSource } = this.state;
+      return (
+          <Transfer
+              defaultValue={[2, 4]}
+              onChange={values => console.log(values)}
+              className="component-transfer-demo-custom-panel"
+              renderSourcePanel={this.renderSourcePanel}
+              renderSelectedPanel={this.renderSelectedPanel}
+              dataSource={dataSource}
+          />
+      );
   }
 }
 

+ 24 - 30
packages/semi-ui/transfer/index.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 import cls from 'classnames';
-import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
 import PropTypes from 'prop-types';
 import { isEqual, noop, omit, isEmpty, isArray, pick } from 'lodash';
 import TransferFoundation, { TransferAdapter, BasicDataItem, OnSortEndProps } from '@douyinfe/semi-foundation/transfer/foundation';
@@ -17,6 +16,8 @@ import Button from '../button';
 import Tree from '../tree';
 import { IconClose, IconSearch, IconHandle } from '@douyinfe/semi-icons';
 import { Value as TreeValue, TreeProps } from '../tree/interface';
+import { RenderItemProps, Sortable } from '../_sortable';
+import { verticalListSortingStrategy } from '@dnd-kit/sortable';
 
 export interface DataItem extends BasicDataItem {
     label?: React.ReactNode;
@@ -39,7 +40,7 @@ export interface RenderSourceItemProps extends DataItem {
 
 export interface RenderSelectedItemProps extends DataItem {
     onRemove?: () => void;
-    sortableHandle?: typeof SortableHandle
+    sortableHandle?: any
 }
 
 export interface EmptyContent {
@@ -168,22 +169,6 @@ export interface TransferProps {
 }
 
 const prefixCls = cssClasses.PREFIX;
-
-// SortableItem & SortableList should not be assigned inside of the render function
-const SortableItem = SortableElement((
-    (props: DraggableResolvedDataItem) => (props.item.node as React.FC<DraggableResolvedDataItem>)
-));
-
-const SortableList = SortableContainer(({ items }: { items: Array<ResolvedDataItem> }) => (
-    <div className={`${prefixCls}-right-list`} role="list" aria-label="Selected list">
-        {items.map((item, index: number) => (
-            // @ts-ignore skip SortableItem type check
-            <SortableItem key={item.key} index={index} item={item} />
-        ))}
-    </div>
-    // @ts-ignore see reasons: https://github.com/clauderic/react-sortable-hoc/issues/206
-), { distance: 10 });
-
 class Transfer extends BaseComponent<TransferProps, TransferState> {
     static propTypes = {
         style: PropTypes.object,
@@ -569,7 +554,7 @@ class Transfer extends BaseComponent<TransferProps, TransferState> {
         return <div className={`${prefixCls}-left-list`} role="list" aria-label="Option list">{content}</div>;
     }
 
-    renderRightItem(item: ResolvedDataItem): React.ReactNode {
+    renderRightItem = (item: ResolvedDataItem, sortableHandle?: any): React.ReactNode => {
         const { renderSelectedItem, draggable, type, showPath } = this.props;
         const onRemove = () => this.foundation.handleSelectOrRemove(item);
         const rightItemCls = cls({
@@ -582,17 +567,17 @@ class Transfer extends BaseComponent<TransferProps, TransferState> {
         const label = shouldShowPath ? this.foundation._generatePath(item) : item.label;
 
         if (renderSelectedItem) {
-            return renderSelectedItem({ ...item, onRemove, sortableHandle: SortableHandle });
+            return renderSelectedItem({ ...item, onRemove, sortableHandle });
         }
 
-        const DragHandle = SortableHandle(() => (
+        const DragHandle = sortableHandle && sortableHandle(() => (
             <IconHandle role="button" aria-label="Drag and sort" className={`${prefixCls}-right-item-drag-handler`} />
         ));
 
         return (
             // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
             <div role="listitem" className={rightItemCls} key={item.key}>
-                {draggable ? <DragHandle /> : null}
+                {draggable && sortableHandle ? <DragHandle /> : null}
                 <div className={`${prefixCls}-right-item-text`}>{label}</div>
                 <IconClose
                     onClick={onRemove}
@@ -605,6 +590,14 @@ class Transfer extends BaseComponent<TransferProps, TransferState> {
         );
     }
 
+    renderSortItem = (props: RenderItemProps): React.ReactNode => {
+        const { id, sortableHandle } = props;
+        const { selectedItems } = this.state;
+        const selectedData = [...selectedItems.values()];
+        const item = selectedData.find(item => item.key === id);
+        return this.renderRightItem(item, sortableHandle);
+    }
+
     renderEmpty(type: string, emptyText: React.ReactNode) {
         const emptyCls = cls({
             [`${prefixCls}-empty`]: true,
@@ -615,14 +608,15 @@ class Transfer extends BaseComponent<TransferProps, TransferState> {
     }
 
     renderRightSortableList(selectedData: Array<ResolvedDataItem>) {
-        const sortableListItems = selectedData.map(item => ({
-            ...item,
-            node: this.renderRightItem(item)
-        }));
-
-        // helperClass:add styles to the helper(item being dragged) https://github.com/clauderic/react-sortable-hoc/issues/87
-        // @ts-ignore skip SortableItem type check
-        const sortList = <SortableList useDragHandle helperClass={`${prefixCls}-right-item-drag-item-move`} onSortEnd={this.onSortEnd} items={sortableListItems} />;
+        const sortItems = selectedData.map(item => item.key);
+        const sortList = <Sortable
+            strategy={verticalListSortingStrategy} 
+            onSortEnd={this.onSortEnd} 
+            items={sortItems} 
+            renderItem={this.renderSortItem} 
+            prefix={`${prefixCls}-right-item`}
+            dragOverlayCls={`${prefixCls}-right-item-drag-item-move`}
+        />;
         return sortList;
     }
 

+ 21 - 0
src/templates/scope.js

@@ -126,3 +126,24 @@ export {
 export { debounce, throttle, range, get, filter, map, some };
 
 export { zh_CN, en_GB, en_US, ko_KR, ja_JP, ar, vi_VN, ru_RU, id_ID, ms_MY, th_TH, tr_TR, pt_BR, zh_TW, nl_NL, pl_PL, sv_SE, es, de, it, fr, ro };
+
+export {
+    useSortable,
+    SortableContext,
+    sortableKeyboardCoordinates,
+    verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+  
+export { CSS as cssDndKit } from '@dnd-kit/utilities';
+  
+export {
+    closestCenter,
+    DragOverlay,
+    DndContext,
+    MouseSensor,
+    TouchSensor,
+    useSensor,
+    useSensors,
+    KeyboardSensor,
+    TraversalOrder
+} from '@dnd-kit/core';

+ 31 - 0
yarn.lock

@@ -1479,6 +1479,37 @@
   resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
   integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
 
+"@dnd-kit/accessibility@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c"
+  integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==
+  dependencies:
+    tslib "^2.0.0"
+
+"@dnd-kit/core@^6.0.8":
+  version "6.0.8"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.8.tgz#040ae13fea9787ee078e5f0361f3b49b07f3f005"
+  integrity sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==
+  dependencies:
+    "@dnd-kit/accessibility" "^3.0.0"
+    "@dnd-kit/utilities" "^3.2.1"
+    tslib "^2.0.0"
+
+"@dnd-kit/sortable@^7.0.2":
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.2.tgz#791d550872457f3f3c843e00d159b640f982011c"
+  integrity sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==
+  dependencies:
+    "@dnd-kit/utilities" "^3.2.0"
+    tslib "^2.0.0"
+
+"@dnd-kit/utilities@^3.2.0", "@dnd-kit/utilities@^3.2.1":
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.1.tgz#53f9e2016fd2506ec49e404c289392cfff30332a"
+  integrity sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==
+  dependencies:
+    tslib "^2.0.0"
+
 "@douyinfe/[email protected]":
   version "2.33.1"
   resolved "https://registry.npmjs.org/@douyinfe/semi-animation-react/-/semi-animation-react-2.33.1.tgz#353ce23968f27d4443bb2529cfd210f84d424034"