推荐一个react拖拽排序的库,@dnd-kit

@dnd-kit

官网
GitHub

基于这个库封装了一个组件

效果图

在这里插入图片描述

代码

index
import { forwardRef, ForwardedRef, useState, useRef, useCallback, useMemo } from 'react';
import { Checkbox } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { Resizable, NumberSize, ResizeDirection } from 're-resizable';
import type { ERPTransferProps, ERPTransferRef } from './type';
import { LeftHeaderLeft, LeftHeaderRight, RightHeaderLeft, RightHeaderRight } from './components/headers';
import DraggableList from './components/draggable';
import { LeftSearch } from './components/search';
import './index.less';
import { debounce, isUndefined } from 'lodash-es';

type ResizeCallbackParams = [MouseEvent | TouchEvent, ResizeDirection, HTMLElement, NumberSize];

function Index<T>(props: ERPTransferProps<T>, ref: ForwardedRef<ERPTransferRef<T>>) {
  const {
    width = 688,
    height = 376,
    transferWidth = 336,
    allowResizable = false,
    dataSource,
    keyCode,
    nameCode,
    leftHeaderLeft,
    leftHeaderRight,
    rightHeaderLeft,
    rightHeaderRight,
    onChange,
  } = props;

  const [leftWidth, setLeftWidth] = useState(transferWidth);
  const [rightWidth, setRightWidth] = useState(transferWidth);
  const leftWidthRef = useRef(transferWidth);
  const rightWidthRef = useRef(transferWidth);
  const [selectedKeys, setSelectedKeys] = useState<(string | number)[]>([]);
  const [searchLeftData, setSearchLeftData] = useState<T[]>();
  const [searchRightData, setSearchRightData] = useState<T[]>();

  // 用map对象存一下dataSource
  const dataSourceMapMemo = useMemo(() => {
    return new Map(dataSource.map((i) => [i[keyCode], i]));
  }, [dataSource]);

  /* —————————————————————————拖拽的回调处理—————————————————————————————————— */
  const onLeftResize = useCallback((...rest: ResizeCallbackParams) => {
    const { width } = rest[3];
    setLeftWidth(leftWidthRef.current + width);
    setRightWidth(rightWidthRef.current - width);
  }, []);
  const onRightResize = useCallback((...rest: ResizeCallbackParams) => {
    const { width } = rest[3];
    setRightWidth(rightWidthRef.current + width);
    setLeftWidth(leftWidthRef.current - width);
  }, []);

  const onLeftResizeStop = useCallback((...rest: ResizeCallbackParams) => {
    const { width } = rest[3];
    leftWidthRef.current = leftWidthRef.current + width;
    rightWidthRef.current = rightWidthRef.current - width;
  }, []);
  const onRightResizeStop = useCallback((...rest: ResizeCallbackParams) => {
    const { width } = rest[3];
    leftWidthRef.current = leftWidthRef.current - width;
    rightWidthRef.current = rightWidthRef.current + width;
  }, []);

  /* —————————————————————————左侧—————————————————————————————————— */
  const handleClickItem = (e: CheckboxChangeEvent, keyCode: string) => {
    const checked = e.target.checked;
    if (checked) {
      const selectedKeysTemp = [...selectedKeys, keyCode];
      setSelectedKeys(selectedKeysTemp);
      handleOnchange(selectedKeysTemp);
    } else {
      const selectedKeysTemp = selectedKeys.filter((item) => item !== keyCode);
      setSelectedKeys(selectedKeysTemp);
      handleOnchange(selectedKeysTemp);
    }
  };
  const handleAllSelected = (all: boolean) => {
    if (all) {
      const selectedKeysTemp = dataSource.map((item) => item[keyCode]);
      setSelectedKeys(selectedKeysTemp);
      handleOnchange(selectedKeysTemp);
    } else {
      setSelectedKeys([]);
      handleOnchange([]);
    }
  };
  const handleLeftSearch = (searchText: string) => {
    if (searchText === '') {
      setSearchLeftData(undefined);
    } else {
      const searchResult = dataSource.filter((i) => i[nameCode].includes(searchText));
      setSearchLeftData(searchResult);
    }
  };
  /* —————————————————————————右侧—————————————————————————————————— */
  const handleOnClean = () => {
    setSelectedKeys([]);
    handleOnchange([]);
  };
  const handleRightSearch = (searchText: string) => {
    if (searchText === '') {
      setSearchRightData(undefined);
    } else {
      const searchResult = dataSource.filter((i) => i[nameCode].includes(searchText));
      setSearchRightData(searchResult);
    }
  };
  const handleDraggableListData = () => {
    if (!searchRightData) return selectedKeys;
    else {
      return searchRightData.filter((i) => selectedKeys.includes(i[keyCode])).map((i) => i[keyCode]);
    }
  };

  /* —————————————————————————onChange—————————————————————————————————— */
  const handleOnchange = (keys: (string | number)[]) => {
    let result: T[] = [];
    for (const key of keys) {
      if (dataSourceMapMemo.has(key)) {
        result.push(dataSourceMapMemo.get(key)!);
      }
    }
    onChange?.(result);
  };

  return (
    <div style={{ width, height }} className="panui-fi-transfer-erp-container">
      <Resizable
        size={{ width: leftWidth, height }}
        onResize={onLeftResize}
        onResizeStop={onLeftResizeStop}
        enable={{
          right: allowResizable,
        }}
      >
        <div className="left-wrapper">
          <div className="left-header">
            <LeftHeaderLeft
              numerator={selectedKeys.length}
              denominator={dataSource.length}
              leftHeaderLeft={leftHeaderLeft}
              onChange={handleAllSelected}
            />
            <LeftHeaderRight leftHeaderRight={leftHeaderRight} />
          </div>
          <LeftSearch onChange={debounce(handleLeftSearch, 300)} />
          <ul className="list">
            {(searchLeftData ?? dataSource).map((i) => (
              <li key={i[keyCode]} className="li-item">
                <Checkbox checked={selectedKeys.includes(i[keyCode])} onChange={(e) => handleClickItem(e, i[keyCode])}>
                  {i[nameCode]}
                </Checkbox>
              </li>
            ))}
            {searchLeftData?.length === 0 && <li className="li-item-no">无搜索结果</li>}
          </ul>
        </div>
      </Resizable>
      <Resizable
        size={{ width: rightWidth, height }}
        onResize={onRightResize}
        onResizeStop={onRightResizeStop}
        enable={{
          left: allowResizable,
        }}
      >
        <div className="right-wrapper">
          <div className="right-header">
            <RightHeaderLeft selected={selectedKeys.length} rightHeaderLeft={rightHeaderLeft} />
            <RightHeaderRight rightHeaderRight={rightHeaderRight} onClean={handleOnClean} />
          </div>
          <LeftSearch onChange={debounce(handleRightSearch, 300)} />
          <div className="list">
            <DraggableList
              data={handleDraggableListData()}
              setSortData={setSelectedKeys}
              dataSourceMap={dataSourceMapMemo}
              nameCode={nameCode}
              handleOnchange={handleOnchange}
              disabledDraggable={!isUndefined(searchRightData)}
            />
          </div>
        </div>
      </Resizable>
    </div>
  );
}

export default forwardRef(Index);
less
.panui-fi-transfer-erp-container {
  position: relative;
  display: flex;
  justify-content: space-between;
  .left-wrapper {
    width: 100%;
    height: 100%;
    border: 1px solid rgba(227, 231, 237, 1);
    border-radius: 4px;
    display: flex;
    flex-direction: column;
    .left-header {
      border-bottom: 1px solid #e3e7ed;
      color: #86909c;
      display: flex;
      justify-content: space-between;
      .left-header-left {
        height: 38px;
        line-height: 38px;
        padding-left: 12px;
        .ant-checkbox + span {
          color: #86909c;
        }
      }
      .left-header-right {
        line-height: 38px;
        padding-right: 12px;
      }
    }
    .list {
      margin: 0;
      padding: 0;
      list-style: none;
      flex: 1;
      overflow: hidden auto;
      .li-item {
        line-height: 32px;
        padding-left: 12px;
        cursor: pointer;
        &:hover {
          background-color: #e6ecfa;
        }
        .panui-base-checkbox-container {
          width: 100%;
          margin-right: 4px;
          .ant-checkbox {
            .ant-checkbox-inner {
              border-radius: 4px;
            }
            & + span {
              width: 100%;
            }
          }
        }
      }
      .li-item-no {
        text-align: center;
        color: #86909c;
        line-height: 64px;
      }
    }
  }
  .right-wrapper {
    width: 100%;
    height: 100%;
    border: 1px solid rgba(227, 231, 237, 1);
    border-radius: 4px;
    display: flex;
    flex-direction: column;
    .right-header {
      border-bottom: 1px solid #e3e7ed;
      color: #86909c;
      display: flex;
      justify-content: space-between;
      .right-header-left {
        line-height: 38px;
        padding-left: 12px;
      }
      .right-header-right {
        line-height: 38px;
        padding-right: 12px;
        color: #0e42d2;
        cursor: pointer;
      }
    }
    .list {
      padding: 0 12px;
      flex: 1;
      overflow: hidden auto;
      .li-item {
        width: 100%;
        line-height: 30px;
        display: flex;
        justify-content: space-between;
        border: 1px solid transparent;
        align-items: center;
        .li-item-name {
          height: 30px;
          display: flex;
          align-items: center;
          span {
            height: inherit;
          }
          .panui-icon-anticon {
            display: flex;
            align-items: center;
            cursor: grab;
          }
        }
        .panui-icon-anticon {
          cursor: pointer;
        }
      }
      .li-item[aria-pressed='true'] {
        background: rgba(223, 0, 36, 0.04);
        border: 1px solid rgba(223, 0, 36, 0.3);
        border-radius: 4px;
        .li-item-name {
          visibility: hidden;
        }
        .li-item-name + span {
          visibility: hidden;
        }
      }
      .li-item.li-item-overlay {
        background: rgba(255, 255, 255, 0.9);
        border: 1px solid rgba(223, 0, 36, 0.3);
        box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.1);
        border-radius: 4px;
        cursor: grabbing;
        .panui-icon-anticon {
          cursor: grabbing;
        }
        .li-item-name {
          color: #86909c;
        }
        .li-item-name + span {
          visibility: hidden;
        }
      }
      .li-item.li-item-disabled-draggable {
        .panui-icon-anticon {
          cursor: default;
        }
      }
    }
  }
  .search-container {
    padding: 8px 12px;
  }
}
components
// header
import React from 'react';
import { Checkbox } from 'antd';
interface LeftHeaderLeftProps {
  leftHeaderLeft?: (numerator: number, denominator: number) => React.ReactNode;
  numerator: number;
  denominator: number;
  onChange: (all: boolean) => void;
}

export const LeftHeaderLeft = (props: LeftHeaderLeftProps) => {
  const { leftHeaderLeft, numerator, denominator, onChange } = props;
  return (
    <div className="left-header-left">
      <Checkbox
        indeterminate={numerator > 0 && denominator !== numerator}
        checked={numerator === denominator}
        onChange={(e) => onChange(e.target.checked)}
      >
        {leftHeaderLeft ? leftHeaderLeft(numerator, denominator) : `${numerator}/${denominator}`}
      </Checkbox>
    </div>
  );
};

export const LeftHeaderRight = ({ leftHeaderRight }: { leftHeaderRight?: React.ReactNode }) => {
  return leftHeaderRight ? <div>{leftHeaderRight}</div> : <span className="left-header-right">可选择</span>;
};

export const RightHeaderLeft = ({
  selected,
  rightHeaderLeft,
}: {
  selected: number;
  rightHeaderLeft?: (selected: number) => React.ReactNode;
}) => {
  return rightHeaderLeft ? (
    <div>{rightHeaderLeft(selected)}</div>
  ) : (
    <span className="right-header-left">
      已选择 <span style={{ color: '#1D2129' }}>{selected}</span>
    </span>
  );
};

export const RightHeaderRight = ({
  rightHeaderRight,
  onClean,
}: {
  rightHeaderRight?: React.ReactNode;
  onClean: () => void;
}) => {
  return rightHeaderRight ? (
    <div>{rightHeaderRight}</div>
  ) : (
    <span className="right-header-right" onClick={onClean}>
      清空
    </span>
  );
};

// Search
import { Input } from 'antd';
export function LeftSearch({ onChange }: { onChange: (searchText: string) => void }) {
  return (
    <div className="search-container">
      <Input placeholder="搜索" suffix="放大镜" onChange={(e) => onChange(e.target.value)} />
    </div>
  );
}

// draggable
import React, { useState } from 'react';
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
  DragOverlay,
  DragStartEvent,
} from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';

import { SortableItem } from './sortItem';
import { isEmpty } from 'lodash-es';

interface DraggableListProps<T> {
  data: (string | number)[];
  setSortData: React.Dispatch<React.SetStateAction<(string | number)[]>>;
  dataSourceMap: Map<string | number, T>;
  nameCode: string;
  handleOnchange: (keys: (string | number)[]) => void;
  disabledDraggable?: boolean;
}

export default function Index<T>(props: DraggableListProps<T>) {
  const { data, setSortData, dataSourceMap, nameCode, handleOnchange, disabledDraggable } = props;
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const handleDeleteItem = (id: string | number) => {
    setSortData((items) => {
      const keys = items.filter((item) => item !== id);
      handleOnchange(keys);
      return keys;
    });
  };
  const [activeId, setActiveId] = useState<string | number | null>(null);

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragStart={handleDragStart}
      onDragEnd={handleDragEnd}
      modifiers={[restrictToParentElement]}
    >
      <SortableContext items={data} strategy={verticalListSortingStrategy} disabled={disabledDraggable}>
        {data.map((i) => (
          <SortableItem
            key={i}
            id={i}
            name={dataSourceMap.get(i)?.[nameCode]}
            deleteItem={handleDeleteItem}
            className={disabledDraggable ? 'li-item-disabled-draggable' : ''}
          />
        ))}
        {isEmpty(data) && disabledDraggable && (
          <span
            style={{
              display: 'inline-block',
              width: '100%',
              textAlign: 'center',
              color: '#86909c',
              lineHeight: '64px',
            }}
          >
            无搜索结果
          </span>
        )}
      </SortableContext>
      <DragOverlay>
        {activeId ? (
          <SortableItem
            key={activeId}
            id={activeId}
            name={dataSourceMap.get(activeId)?.[nameCode]}
            className="li-item-overlay"
          />
        ) : null}
      </DragOverlay>
    </DndContext>
  );

  function handleDragStart(event: DragStartEvent) {
    setActiveId(event.active.id);
  }
  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (active.id !== over?.id && over) {
      setSortData((items) => {
        const oldIndex = items.indexOf(active.id);
        const newIndex = items.indexOf(over?.id);
        const keys = arrayMove(items, oldIndex, newIndex);
        handleOnchange(keys);
        return keys;
      });
    }
  }
}
// SortableItem
import { useSortable } from '@dnd-kit/sortable';
import classNames from 'classnames';
import { CSS } from '@dnd-kit/utilities';
import { PWrong1Outlined, PDrag2Filled } from '@panui/icons';

interface SortableItemProps {
  id: string | number;
  name: string;
  deleteItem?: (id: string | number) => void;
  className?: string;
}

export function SortableItem(props: SortableItemProps) {
  const { id, name, deleteItem, className } = props;
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div ref={setNodeRef} style={style} className={classNames(['li-item', className])} {...attributes}>
      <div className="li-item-name">
        <span {...listeners}>
          <PDrag2Filled />
        </span>
        <span style={{ marginLeft: '8px' }}>{name}</span>
      </div>
      <span onClick={() => deleteItem?.(id)}>
        <PWrong1Outlined />
      </span>
    </div>
  );
}

PS:

  1. 线性的复选框是又封装了一层,贴的代码里换成了antd的

一些基本使用总结

  1. 基本demo地址
  2. 内容要嵌套在DndContext组件中
  3. DndContext的modifiers属性控制运动检测坐标,作用如:锁定x、y等
  4. listeners属性可以用来指定可拖拽的dom,setNodeRef给要拖拽的dom
  5. DragOverlay组件是拖拽覆盖的组件,通过handleDragStart来配合拖拽的是哪个
  6. 可以通过属性选择器[aria-pressed=‘true’]来判断当前拖拽的是哪个
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值