官网
GitHub
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);
.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;
}
}
// 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:
- 线性的复选框是又封装了一层,贴的代码里换成了antd的