ant-design/components/transfer/index.tsx

511 lines
16 KiB
TypeScript
Raw Permalink Normal View History

import type { ChangeEvent, CSSProperties } from 'react';
import React, { useCallback, useContext } from 'react';
import classNames from 'classnames';
import type { PrevSelectedIndex } from '../_util/hooks/useMultipleSelect';
import useMultipleSelect from '../_util/hooks/useMultipleSelect';
2023-03-19 16:13:04 +08:00
import type { InputStatus } from '../_util/statusUtils';
import { getMergedStatus, getStatusClassNames } from '../_util/statusUtils';
import { groupDisabledKeysMap, groupKeysMap } from '../_util/transKeys';
import { devUseWarning } from '../_util/warning';
import type { ConfigConsumerProps } from '../config-provider';
import { ConfigContext } from '../config-provider';
import DefaultRenderEmpty from '../config-provider/defaultRenderEmpty';
import type { FormItemStatusContextProps } from '../form/context';
import { FormItemInputContext } from '../form/context';
import { useLocale } from '../locale';
import defaultLocale from '../locale/en_US';
import useData from './hooks/useData';
import useSelection from './hooks/useSelection';
import type { PaginationType, TransferKey } from './interface';
import type { TransferCustomListBodyProps, TransferListProps } from './list';
2022-06-22 14:57:09 +08:00
import List from './list';
import Operation from './operation';
import Search from './search';
import useStyle from './style';
docs: v5 site upgrade (#38328) * build: try to use dumi as doc tool * docs: migrate demo structure to dumi way * refactor: use type export & import * docs: migrate demo previewer to dumi * docs: create empty layout & components * docs: apply custom rehype plugin * docs: create empty extra pages * docs: Add Banner component * chore: move theme tsconfig.json * docs: home page init * docs: migrate header (#37896) * docs: header * docs: update * docs: home init * clean up * test: fix site lint * chore: tsc ignore demo * chore: dumi demo migrate script * chore: cards * docs: home layout * docs: Update locale logic * docs: fix getLink logic * chore: fix ci (#37899) * chore: fix ci * ci: remove check-ts-demo * ci: preview build * test: ignore demo.tsx * chore: update script * test: update snapshot * test: update node and image test * chore: add .surgeignore * docs: layout providers (#37908) * docs: add components sidebar (#37923) * docs: sidebar * docs: update docs title * docs: update design doc * chore: code clean * docs: handle changelog page * docs: add title * docs: add subtitle * docs: active header nav * chore: code clean * docs: overview * chore: code clean * docs: update intl (#37918) * docs: update intl * chore: code clean * docs: update favicons * chore: update testPathIgnorePatterns * chore: code clean * chore: code clean * chore: copy 404.html (#37996) * docs: Home page theme picker * chore: Update migrate script * docs: home page update * docs: theme editor style * docs: theme lang * chore: update migrate.js * docs: fix demo (#38094) * chore: update migrate.js * docs: update md * docs: update demo * test: fix snapshot * chore: move debug to code attr in migrate script * chore: update md Co-authored-by: PeachScript <scdzwyxst@gmail.com> * feat: overview page * feat: Migrate `404` page (#38118) * feat: migrate IconSearch component (#37916) * feat<site/IconSearch>: copy IconDisplay from site to .dumi * feat<site/IconSearch>: change docs of icon * feat<site/IconSearch>: tweak * feat<site/IconSearch>: use useIntl instead of injectIntl * feat<site/IconSearch>: fix ts type error * feat<site/IconSearch>: use intl.formatMessage to render text * docs: Adjust home btn sizw * docs: Update doc * feat: v5 site overview page (#38131) * feat: site * fix: fix * feat: v5 site overview page * fix: fix path * fix: fix * fix: fix * docs: fix margin logic * feat: v5 site change-log page (#38137) * feat: v5 site change-log page (#38162) * docs: site redirect to home pag * docs: theme picker * docs: use react-intl from dumi (#38183) * docs: Theme Picker * docs: update dumi config * docs: home back fix * docs: picker colorful * docs: locale of it * docs: update components desc * docs: site of links * docs: update components list * docs: update desc * feat: Migrate `DemoWrapper` component (#38166) * feat: Migrate `DemoWrapper` component * feat: remove invalid comments and add comment for `key` prop * docs: FloatButton pure panel * chore: update demo * chore: update dumi config * Revert "chore: update demo" This reverts commit 028265d3ba5987df5f13c3e9d42cf76cb1812b2e. * chore: test logic adjust to support cnpm modules * chore: add locale alias * docs: /index to / * docs: add locale redirect head script * chore: adjust compact * docs: fix missing token * feat: compact switch * chore: code clean * docs: update home * docs: fix radius token * docs: hash of it * chore: adjust home page * docs: Add background map * docs: site theme bac logic * docs: avatar * docs: update logo color * docs: home banner * docs: adjust tour size * docs: purepanl update * docs: transfooter * docs: update banner gif * docs: content (#38361) * docs: title & EditButton * docs: content * chore: fix toc * docs: resource page * docs: transform resource data from hast * docs: filename & Resource Card * chore: enable prerender * chore: remove less * docs: toc style * chore: fix lint * docs: fix Layout page * docs: fix CP page * chore: update demos * docs: workaround for export dynamic html * chore: enable demo eslint * docs: table style * fix: header shadow * chore: update snapshot * fix: toc style * docs: add title * docs: Adjust site * feat: helmet * docs: site css * fix: description * feat: toc debug * docs: update config-provider * feat: use colorPanel * fix: colorPanel value * feat: anchor ink ball style * feat: apply theme editor * fix: code block style * chore: update demo * chore: fix lint * chore: code clean * chore: update snapshot * feat: ts2js * chore: description * docs: site ready for ssr includes: - move client render logic to useEffect in site theme - extract antd cssinjs to a single css file like bisheng - workaround to support react@18 pipeableStream for emotion * chore: bump testing lib * docs: font size of title * chore: remove react-sortable-hoc * chore: update snapshot * chore: update script Co-authored-by: PeachScript <scdzwyxst@gmail.com> Co-authored-by: MadCcc <1075746765@qq.com> Co-authored-by: zqran <uuxnet@gmail.com> Co-authored-by: TrickyPi <530257315@qq.com> Co-authored-by: lijianan <574980606@qq.com>
2022-11-09 12:28:04 +08:00
export type { TransferListProps } from './list';
export type { TransferOperationProps } from './operation';
export type { TransferSearchProps } from './search';
2017-11-20 17:41:32 +08:00
export type TransferDirection = 'left' | 'right';
export interface RenderResultObject {
label: React.ReactElement;
value: string;
}
export type RenderResult = React.ReactElement | RenderResultObject | string | null;
export interface TransferItem {
key?: TransferKey;
title?: string;
2016-07-13 11:14:24 +08:00
description?: string;
disabled?: boolean;
[name: string]: any;
}
export type KeyWise<T> = T & { key: TransferKey };
export type KeyWiseTransferItem = KeyWise<TransferItem>;
type TransferRender<RecordType> = (item: RecordType) => RenderResult;
export interface ListStyle {
direction: TransferDirection;
}
export type SelectAllLabel =
| React.ReactNode
| ((info: { selectedCount: number; totalCount: number }) => React.ReactNode);
export interface TransferLocale {
titles?: React.ReactNode[];
notFoundContent?: React.ReactNode | React.ReactNode[];
searchPlaceholder: string;
itemUnit: string;
itemsUnit: string;
remove?: string;
selectAll?: string;
2024-04-22 12:03:12 +08:00
deselectAll?: string;
selectCurrent?: string;
selectInvert?: string;
removeAll?: string;
removeCurrent?: string;
}
export interface TransferProps<RecordType = any> {
2016-12-07 18:08:52 +08:00
prefixCls?: string;
className?: string;
rootClassName?: string;
2018-09-15 15:18:59 +08:00
disabled?: boolean;
dataSource?: RecordType[];
targetKeys?: TransferKey[];
selectedKeys?: TransferKey[];
render?: TransferRender<RecordType>;
onChange?: (
targetKeys: TransferKey[],
direction: TransferDirection,
moveKeys: TransferKey[],
) => void;
onSelectChange?: (sourceSelectedKeys: TransferKey[], targetSelectedKeys: TransferKey[]) => void;
2016-12-07 18:08:52 +08:00
style?: React.CSSProperties;
listStyle?: ((style: ListStyle) => CSSProperties) | CSSProperties;
operationStyle?: CSSProperties;
titles?: React.ReactNode[];
operations?: string[];
2016-07-13 11:14:24 +08:00
showSearch?: boolean;
filterOption?: (inputValue: string, item: RecordType, direction: TransferDirection) => boolean;
locale?: Partial<TransferLocale>;
footer?: (
props: TransferListProps<RecordType>,
info?: { direction: TransferDirection },
) => React.ReactNode;
rowKey?: (record: RecordType) => TransferKey;
onSearch?: (direction: TransferDirection, value: string) => void;
onScroll?: (direction: TransferDirection, e: React.SyntheticEvent<HTMLUListElement>) => void;
children?: (props: TransferCustomListBodyProps<RecordType>) => React.ReactNode;
showSelectAll?: boolean;
selectAllLabels?: SelectAllLabel[];
oneWay?: boolean;
pagination?: PaginationType;
status?: InputStatus;
selectionsIcon?: React.ReactNode;
2017-11-20 17:41:32 +08:00
}
const Transfer = <RecordType extends TransferItem = TransferItem>(
props: TransferProps<RecordType>,
) => {
const {
dataSource,
targetKeys = [],
selectedKeys,
selectAllLabels = [],
operations = [],
style = {},
listStyle = {},
locale = {},
titles,
disabled,
showSearch = false,
operationStyle,
showSelectAll,
oneWay,
pagination,
status: customStatus,
prefixCls: customizePrefixCls,
className,
rootClassName,
selectionsIcon,
filterOption,
render,
footer,
children,
rowKey,
onScroll,
onChange,
onSearch,
onSelectChange,
} = props;
const {
getPrefixCls,
renderEmpty,
direction: dir,
transfer,
} = useContext<ConfigConsumerProps>(ConfigContext);
const prefixCls = getPrefixCls('transfer', customizePrefixCls);
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
// Fill record with `key`
const [mergedDataSource, leftDataSource, rightDataSource] = useData(
dataSource,
rowKey,
targetKeys,
);
// Get direction selected keys
const [
// Keys
sourceSelectedKeys,
targetSelectedKeys,
// Setters
setSourceSelectedKeys,
setTargetSelectedKeys,
] = useSelection(leftDataSource as any, rightDataSource as any, selectedKeys);
const [leftMultipleSelect, updateLeftPrevSelectedIndex] = useMultipleSelect<
KeyWise<RecordType>,
TransferKey
>((item) => item.key);
const [rightMultipleSelect, updateRightPrevSelectedIndex] = useMultipleSelect<
KeyWise<RecordType>,
TransferKey
>((item) => item.key);
if (process.env.NODE_ENV !== 'production') {
const warning = devUseWarning('Transfer');
warning(!pagination || !children, 'usage', '`pagination` not support customize render list.');
}
const setStateKeys = useCallback(
(
direction: TransferDirection,
keys: TransferKey[] | ((prevKeys: TransferKey[]) => TransferKey[]),
) => {
if (direction === 'left') {
const nextKeys = typeof keys === 'function' ? keys(sourceSelectedKeys || []) : keys;
setSourceSelectedKeys(nextKeys);
} else {
const nextKeys = typeof keys === 'function' ? keys(targetSelectedKeys || []) : keys;
setTargetSelectedKeys(nextKeys);
}
},
[sourceSelectedKeys, targetSelectedKeys],
);
const setPrevSelectedIndex = (direction: TransferDirection, value: PrevSelectedIndex) => {
const isLeftDirection = direction === 'left';
const updatePrevSelectedIndex = isLeftDirection
? updateLeftPrevSelectedIndex
: updateRightPrevSelectedIndex;
updatePrevSelectedIndex(value);
};
const handleSelectChange = useCallback(
(direction: TransferDirection, holder: TransferKey[]) => {
if (direction === 'left') {
onSelectChange?.(holder, targetSelectedKeys);
} else {
onSelectChange?.(sourceSelectedKeys, holder);
}
},
[sourceSelectedKeys, targetSelectedKeys],
);
2016-08-08 14:42:29 +08:00
const getTitles = (transferLocale: TransferLocale): React.ReactNode[] =>
titles ?? transferLocale.titles ?? [];
const handleLeftScroll = (e: React.SyntheticEvent<HTMLUListElement>) => {
onScroll?.('left', e);
};
const handleRightScroll = (e: React.SyntheticEvent<HTMLUListElement>) => {
onScroll?.('right', e);
};
2015-12-16 23:02:49 +08:00
const moveTo = (direction: TransferDirection) => {
const moveKeys = direction === 'right' ? sourceSelectedKeys : targetSelectedKeys;
const dataSourceDisabledKeysMap = groupDisabledKeysMap(mergedDataSource);
// filter the disabled options
const newMoveKeys = moveKeys.filter((key) => !dataSourceDisabledKeysMap.has(key));
const newMoveKeysMap = groupKeysMap(newMoveKeys);
2015-12-21 15:29:02 +08:00
// move items to target box
2018-12-07 20:02:01 +08:00
const newTargetKeys =
direction === 'right'
? newMoveKeys.concat(targetKeys)
: targetKeys.filter((targetKey) => !newMoveKeysMap.has(targetKey));
2015-12-21 15:29:02 +08:00
// empty checked keys
const oppositeDirection = direction === 'right' ? 'left' : 'right';
setStateKeys(oppositeDirection, []);
handleSelectChange(oppositeDirection, []);
onChange?.(newTargetKeys, direction, newMoveKeys);
2018-12-07 20:02:01 +08:00
};
2015-11-25 23:17:06 +08:00
const moveToLeft = () => {
moveTo('left');
setPrevSelectedIndex('left', null);
};
2016-10-24 16:30:38 +08:00
const moveToRight = () => {
moveTo('right');
setPrevSelectedIndex('right', null);
};
const onItemSelectAll = (
direction: TransferDirection,
keys: string[],
checkAll: boolean | 'replace',
) => {
setStateKeys(direction, (prevKeys) => {
let mergedCheckedKeys: TransferKey[] = [];
if (checkAll === 'replace') {
mergedCheckedKeys = keys;
} else if (checkAll) {
// Merge current keys with origin key
mergedCheckedKeys = Array.from(new Set<TransferKey>([...prevKeys, ...keys]));
} else {
const selectedKeysMap = groupKeysMap(keys);
// Remove current keys from origin keys
mergedCheckedKeys = prevKeys.filter((key) => !selectedKeysMap.has(key));
}
handleSelectChange(direction, mergedCheckedKeys);
return mergedCheckedKeys;
});
setPrevSelectedIndex(direction, null);
2018-12-07 20:02:01 +08:00
};
2015-11-25 23:17:06 +08:00
const onLeftItemSelectAll = (keys: string[], checkAll: boolean) => {
onItemSelectAll('left', keys, checkAll);
2018-12-07 20:02:01 +08:00
};
2015-11-26 16:07:11 +08:00
const onRightItemSelectAll = (keys: string[], checkAll: boolean) => {
onItemSelectAll('right', keys, checkAll);
};
2019-08-05 18:38:10 +08:00
const leftFilter = (e: ChangeEvent<HTMLInputElement>) => onSearch?.('left', e.target.value);
2016-03-30 17:06:19 +08:00
const rightFilter = (e: ChangeEvent<HTMLInputElement>) => onSearch?.('right', e.target.value);
2015-11-25 23:17:06 +08:00
const handleLeftClear = () => onSearch?.('left', '');
2019-08-05 18:38:10 +08:00
const handleRightClear = () => onSearch?.('right', '');
2016-03-30 17:06:19 +08:00
const handleSingleSelect = (
direction: TransferDirection,
holder: Set<TransferKey>,
selectedKey: TransferKey,
checked: boolean,
currentSelectedIndex: number,
) => {
const isSelected = holder.has(selectedKey);
if (isSelected) {
holder.delete(selectedKey);
setPrevSelectedIndex(direction, null);
2015-12-21 15:29:02 +08:00
}
if (checked) {
holder.add(selectedKey);
setPrevSelectedIndex(direction, currentSelectedIndex);
2015-12-21 15:29:02 +08:00
}
};
const handleMultipleSelect = (
direction: TransferDirection,
data: KeyWise<RecordType>[],
holder: Set<TransferKey>,
currentSelectedIndex: number,
) => {
const isLeftDirection = direction === 'left';
const multipleSelect = isLeftDirection ? leftMultipleSelect : rightMultipleSelect;
multipleSelect(currentSelectedIndex, data, holder);
};
const onItemSelect = (
direction: TransferDirection,
selectedKey: TransferKey,
checked: boolean,
multiple?: boolean,
) => {
const isLeftDirection = direction === 'left';
const holder = [...(isLeftDirection ? sourceSelectedKeys : targetSelectedKeys)];
const holderSet = new Set(holder);
const data = [...(isLeftDirection ? leftDataSource : rightDataSource)].filter(
(item) => !item?.disabled,
);
const currentSelectedIndex = data.findIndex((item) => item.key === selectedKey);
// multiple select by hold down the shift key
if (multiple && holder.length > 0) {
handleMultipleSelect(direction, data as any, holderSet, currentSelectedIndex);
} else {
handleSingleSelect(direction, holderSet, selectedKey, checked, currentSelectedIndex);
}
const holderArr = Array.from(holderSet);
handleSelectChange(direction, holderArr);
if (!props.selectedKeys) {
setStateKeys(direction, holderArr);
}
2018-12-07 20:02:01 +08:00
};
2015-11-25 23:17:06 +08:00
const onLeftItemSelect: TransferListProps<KeyWise<RecordType>>['onItemSelect'] = (
selectedKey,
checked,
e,
) => {
onItemSelect('left', selectedKey, checked, e?.shiftKey);
};
const onRightItemSelect = (
selectedKey: TransferKey,
checked: boolean,
e?: React.MouseEvent<Element, MouseEvent>,
) => {
onItemSelect('right', selectedKey, checked, e?.shiftKey);
};
const onRightItemRemove = (keys: TransferKey[]) => {
setStateKeys('right', []);
onChange?.(
targetKeys.filter((key) => !keys.includes(key)),
'left',
[...keys],
);
};
const handleListStyle = (direction: TransferDirection): CSSProperties => {
if (typeof listStyle === 'function') {
return listStyle({ direction });
}
return listStyle || {};
};
const formItemContext = useContext<FormItemStatusContextProps>(FormItemInputContext);
const { hasFeedback, status } = formItemContext;
const getLocale = (transferLocale: TransferLocale) => ({
...transferLocale,
notFoundContent: renderEmpty?.('Transfer') || <DefaultRenderEmpty componentName="Transfer" />,
...locale,
});
const mergedStatus = getMergedStatus(status, customStatus);
const mergedPagination = !children && pagination;
const leftActive = targetSelectedKeys.length > 0;
const rightActive = sourceSelectedKeys.length > 0;
const cls = classNames(
prefixCls,
{
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-customize-list`]: !!children,
[`${prefixCls}-rtl`]: dir === 'rtl',
},
getStatusClassNames(prefixCls, mergedStatus, hasFeedback),
transfer?.className,
className,
rootClassName,
hashId,
cssVarCls,
);
const [contextLocale] = useLocale('Transfer', defaultLocale.Transfer);
const listLocale = getLocale(contextLocale!);
const [leftTitle, rightTitle] = getTitles(listLocale);
const mergedSelectionsIcon = selectionsIcon ?? transfer?.selectionsIcon;
return wrapCSSVar(
<div className={cls} style={{ ...transfer?.style, ...style }}>
<List<KeyWise<RecordType>>
prefixCls={`${prefixCls}-list`}
titleText={leftTitle}
dataSource={leftDataSource as any}
filterOption={filterOption}
style={handleListStyle('left')}
checkedKeys={sourceSelectedKeys}
handleFilter={leftFilter}
handleClear={handleLeftClear}
onItemSelect={onLeftItemSelect}
onItemSelectAll={onLeftItemSelectAll as any}
render={render}
showSearch={showSearch}
renderList={children as any}
footer={footer as any}
onScroll={handleLeftScroll}
disabled={disabled}
direction={dir === 'rtl' ? 'right' : 'left'}
showSelectAll={showSelectAll}
selectAllLabel={selectAllLabels[0]}
pagination={mergedPagination}
selectionsIcon={mergedSelectionsIcon}
{...listLocale}
/>
<Operation
className={`${prefixCls}-operation`}
rightActive={rightActive}
rightArrowText={operations[0]}
moveToRight={moveToRight}
leftActive={leftActive}
leftArrowText={operations[1]}
moveToLeft={moveToLeft}
style={operationStyle}
disabled={disabled}
direction={dir}
oneWay={oneWay}
/>
<List<KeyWise<RecordType>>
prefixCls={`${prefixCls}-list`}
titleText={rightTitle}
dataSource={rightDataSource as any}
filterOption={filterOption}
style={handleListStyle('right')}
checkedKeys={targetSelectedKeys}
handleFilter={rightFilter}
handleClear={handleRightClear}
onItemSelect={onRightItemSelect}
onItemSelectAll={onRightItemSelectAll as any}
onItemRemove={onRightItemRemove}
render={render}
showSearch={showSearch}
renderList={children as any}
footer={footer as any}
onScroll={handleRightScroll}
disabled={disabled}
direction={dir === 'rtl' ? 'left' : 'right'}
showSelectAll={showSelectAll}
selectAllLabel={selectAllLabels[1]}
showRemove={oneWay}
pagination={mergedPagination}
selectionsIcon={mergedSelectionsIcon}
{...listLocale}
/>
</div>,
);
};
if (process.env.NODE_ENV !== 'production') {
Transfer.displayName = 'Transfer';
}
2019-03-07 15:39:34 +08:00
Transfer.List = List;
Transfer.Search = Search;
Transfer.Operation = Operation;
2019-03-07 15:39:34 +08:00
export default Transfer;