diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index 58724e0024..0a3f879f89 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: +globs: alwaysApply: true --- # 项目背景 @@ -25,3 +25,4 @@ alwaysApply: true - 组件名使用大驼峰(PascalCase) - 属性名使用小驼峰(camelCase) - 合理使用 React.memo、useMemo 和 useCallback 优化性能 +- 不要内嵌使用三元表达式 diff --git a/components/transfer/Actions.tsx b/components/transfer/Actions.tsx index a9695253c5..7d32617337 100644 --- a/components/transfer/Actions.tsx +++ b/components/transfer/Actions.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; import LeftOutlined from '@ant-design/icons/LeftOutlined'; import RightOutlined from '@ant-design/icons/RightOutlined'; @@ -7,8 +7,7 @@ import type { DirectionType } from '../config-provider'; export interface TransferOperationProps { className?: string; - leftArrowText?: string; - rightArrowText?: string; + actions: React.ReactNode[]; moveToLeft?: React.MouseEventHandler; moveToRight?: React.MouseEventHandler; leftActive?: boolean; @@ -19,42 +18,80 @@ export interface TransferOperationProps { oneWay?: boolean; } +type ButtonElementType = React.ReactElement<{ + disabled?: boolean; + onClick?: React.MouseEventHandler; +}>; + +function getArrowIcon(type: 'left' | 'right', direction?: DirectionType) { + const isRight = type === 'right'; + if (direction !== 'rtl') { + return isRight ? : ; + } + return isRight ? : ; +} + +interface ActionProps { + type: 'left' | 'right'; + actions: React.ReactNode[]; + moveToLeft?: React.MouseEventHandler; + moveToRight?: React.MouseEventHandler; + leftActive?: boolean; + rightActive?: boolean; + direction?: DirectionType; + disabled?: boolean; +} + +const Action: React.FC = ({ + type, + actions, + moveToLeft, + moveToRight, + leftActive, + rightActive, + direction, + disabled, +}) => { + const isRight = type === 'right'; + const button = isRight ? actions[0] : actions[1]; + const moveHandler = isRight ? moveToRight : moveToLeft; + const active = isRight ? rightActive : leftActive; + const icon = getArrowIcon(type, direction); + + if (React.isValidElement(button)) { + const element = button as ButtonElementType; + const onClick: React.MouseEventHandler = (event) => { + element?.props?.onClick?.(event); + moveHandler?.(event); + }; + return React.cloneElement(element, { + disabled: disabled || !active, + onClick, + }); + } + return ( + + ); +}; + const Actions: React.FC = (props) => { - const { - disabled, - moveToLeft, - moveToRight, - leftArrowText = '', - rightArrowText = '', - leftActive, - rightActive, - className, - style, - direction, - oneWay, - } = props; + const { className, style, oneWay, actions, ...restProps } = props; + return (
- - {!oneWay && ( - - )} + + {!oneWay && } + {actions.slice(oneWay ? 1 : 2)}
); }; diff --git a/components/transfer/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/transfer/__tests__/__snapshots__/demo-extend.test.ts.snap index 3350df491b..e820d8173c 100644 --- a/components/transfer/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/transfer/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -1,4 +1,853 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders components/transfer/demo/actions.tsx extend context correctly 1`] = ` +
+
+
+ + + + +
+ + + + 11 items + + +
+
+
    +
  • + + + Content 1 + +
  • +
  • + + + Content 2 + +
  • +
  • + + + Content 3 + +
  • +
  • + + + Content 4 + +
  • +
  • + + + Content 5 + +
  • +
  • + + + Content 6 + +
  • +
  • + + + Content 7 + +
  • +
  • + + + Content 8 + +
  • +
  • + + + Content 9 + +
  • +
  • + + + Content 10 + +
  • +
  • + + + Content 11 + +
  • +
+
+
+
+ + +
+
+
+ + + + +
+ + + + 9 items + + +
+
+
    +
  • + + + Content 12 + +
  • +
  • + + + Content 13 + +
  • +
  • + + + Content 14 + +
  • +
  • + + + Content 15 + +
  • +
  • + + + Content 16 + +
  • +
  • + + + Content 17 + +
  • +
  • + + + Content 18 + +
  • +
  • + + + Content 19 + +
  • +
  • + + + Content 20 + +
  • +
+
+
+
+`; + +exports[`renders components/transfer/demo/actions.tsx extend context correctly 2`] = `[]`; exports[`renders components/transfer/demo/advanced.tsx extend context correctly 1`] = `
+
+
+ + + + + + 11 + + + items + + +
+
+
    +
  • + + + Content 1 + +
  • +
  • + + + Content 2 + +
  • +
  • + + + Content 3 + +
  • +
  • + + + Content 4 + +
  • +
  • + + + Content 5 + +
  • +
  • + + + Content 6 + +
  • +
  • + + + Content 7 + +
  • +
  • + + + Content 8 + +
  • +
  • + + + Content 9 + +
  • +
  • + + + Content 10 + +
  • +
  • + + + Content 11 + +
  • +
+
+
+
+ + +
+
+
+ + + + + + 9 + + + items + + +
+
+
    +
  • + + + Content 12 + +
  • +
  • + + + Content 13 + +
  • +
  • + + + Content 14 + +
  • +
  • + + + Content 15 + +
  • +
  • + + + Content 16 + +
  • +
  • + + + Content 17 + +
  • +
  • + + + Content 18 + +
  • +
  • + + + Content 19 + +
  • +
  • + + + Content 20 + +
  • +
+
+
+
+`; exports[`renders components/transfer/demo/advanced.tsx correctly 1`] = `
{ + it('should handle custom button click correctly via actions', () => { + const handleChange = jest.fn(); + const customButtonClick = jest.fn(); + + const CustomButton = ({ onClick }: { onClick: () => void }) => ( + + ); + + const { getByText } = render( + ]} + />, + ); + + fireEvent.click(getByText('Custom Button')); + expect(customButtonClick).toHaveBeenCalled(); + expect(handleChange).toHaveBeenCalled(); + }); + + it('should accept multiple actions >= 3', () => { + const { getByText } = render( + test, + , + , + ]} + />, + ); + + expect(getByText('test')).toBeInTheDocument(); + expect(getByText('test2')).toBeInTheDocument(); + expect(getByText('test3')).toBeInTheDocument(); + }); + + it('should accept multiple actions >= 2 when it is oneWay', () => { + const { getByText } = render( + test, ]} + />, + ); + + expect(getByText('test')).toBeInTheDocument(); + expect(getByText('test2')).toBeInTheDocument(); + }); + + it('should accept operations for compatibility', () => { + const { getByText } = render( + , + ); + expect(getByText('to right')).toBeInTheDocument(); + expect(getByText('to left')).toBeInTheDocument(); + }); +}); diff --git a/components/transfer/__tests__/index.test.tsx b/components/transfer/__tests__/index.test.tsx index af617238fa..5f3e16464a 100644 --- a/components/transfer/__tests__/index.test.tsx +++ b/components/transfer/__tests__/index.test.tsx @@ -88,6 +88,12 @@ const generateData = (n = 20) => { return data; }; +const ButtonRender = ({ onClick }: { onClick: () => void }) => ( + +); + describe('Transfer', () => { mountTest(Transfer); rtlTest(Transfer); @@ -864,10 +870,6 @@ describe('Transfer', () => { }); }); -const ButtonRender = ({ onClick }: { onClick: () => void }) => ( - -); - describe('immutable data', () => { // https://github.com/ant-design/ant-design/issues/28662 it('dataSource is frozen', () => { @@ -911,7 +913,6 @@ describe('immutable data', () => { return ( `test-${item}`} diff --git a/components/transfer/demo/actions.md b/components/transfer/demo/actions.md new file mode 100644 index 0000000000..980b086737 --- /dev/null +++ b/components/transfer/demo/actions.md @@ -0,0 +1,27 @@ +## zh-CN + +使用 `actions` 属性可以自定义操作按钮。 + +当 `actions` 传入字符串数组时,会使用默认的 Button 组件,并将字符串作为按钮文本。 + +当 `actions` 传入 React 元素数组时,会直接使用这些元素作为操作按钮,这样你可以使用自定义的按钮组件,如本例中的带有加载状态的按钮。 + +注意: + +1. 当使用自定义按钮时,Transfer 组件会自动处理按钮的禁用状态和点击事件。 +2. 你可以在自定义按钮上添加 `disabled` 属性来控制按钮的禁用状态。 +3. 你可以在自定义按钮上添加 `onClick` 事件处理函数,它会与 Transfer 组件的内部处理函数合并执行。 + +## en-US + +You can customize operations with the `actions` prop. This example demonstrates how to customize actions, including handling disabled and loading states. + +When `actions` is an array of strings, it will use the default Button component and set the strings as button text. + +When `actions` is an array of React elements, it will use these elements directly as action buttons, allowing you to use custom button components, such as buttons with loading state in this example. + +Note: + +1. When using custom buttons, the Transfer component will automatically handle the button's disabled state and click events. +2. You can add a `disabled` property to your custom button to control its disabled state. +3. You can add an `onClick` event handler to your custom button, which will be merged with the Transfer component's internal handler. diff --git a/components/transfer/demo/actions.tsx b/components/transfer/demo/actions.tsx new file mode 100644 index 0000000000..adc1c004fd --- /dev/null +++ b/components/transfer/demo/actions.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { Button, Transfer, message } from 'antd'; +import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons'; +import type { TransferProps } from 'antd'; + +interface RecordType { + key: string; + title: string; + description: string; +} + +const mockData: RecordType[] = Array.from({ length: 20 }).map((_, i) => ({ + key: i.toString(), + title: `Content ${i + 1}`, + description: `Description ${i + 1}`, +})); + +const initialTargetKeys = mockData.filter((item) => Number(item.key) > 10).map((item) => item.key); + +const App: React.FC = () => { + const [targetKeys, setTargetKeys] = useState(initialTargetKeys); + const [selectedKeys, setSelectedKeys] = useState([]); + const [loadingRight, setLoadingRight] = useState(false); + const [loadingLeft, setLoadingLeft] = useState(false); + + // Handle data transfer + const handleChange: TransferProps['onChange'] = (newTargetKeys, direction, moveKeys) => { + setTargetKeys(newTargetKeys as string[]); + + // Simulate async action + if (direction === 'right') { + setLoadingRight(true); + setTimeout(() => { + setLoadingRight(false); + message.success(`Successfully added ${moveKeys.length} items to the right`); + }, 1000); + } else { + setLoadingLeft(true); + setTimeout(() => { + setLoadingLeft(false); + message.success(`Successfully added ${moveKeys.length} items to the left`); + }, 1000); + } + }; + + // Handle selection change + const handleSelectChange: TransferProps['onSelectChange'] = ( + sourceSelectedKeys, + targetSelectedKeys, + ) => { + setSelectedKeys([...sourceSelectedKeys, ...targetSelectedKeys] as string[]); + }; + + // Right button is disabled (no selected items on the left or all selected items are already in the right list) + const rightButtonDisabled = + selectedKeys.length === 0 || selectedKeys.every((key) => targetKeys.includes(key)); + + // Left button is disabled (no selected items on the right) + const leftButtonDisabled = + selectedKeys.length === 0 || selectedKeys.every((key) => !targetKeys.includes(key)); + + // Custom right button click handler + const handleRightButtonClick = (event: React.MouseEvent) => { + // You can add custom logic here, such as showing a confirmation dialog + console.log('Right button clicked', event); + // The Transfer component will automatically handle data transfer + }; + + // Custom left button click handler + const handleLeftButtonClick = (event: React.MouseEvent) => { + // You can add custom logic here, such as showing a confirmation dialog + console.log('Left button clicked', event); + // The Transfer component will automatically handle data transfer + }; + + return ( + item.title} + actions={[ + // Custom right button (transfer data to the right) + , + // Custom left button (transfer data to the left) + , + ]} + /> + ); +}; + +export default App; diff --git a/components/transfer/index.en-US.md b/components/transfer/index.en-US.md index 08ef4610f3..abc400b115 100644 --- a/components/transfer/index.en-US.md +++ b/components/transfer/index.en-US.md @@ -26,6 +26,7 @@ One or more elements can be selected from either column, one click on the proper Search Advanced Custom datasource +Custom Actions Pagination Table Transfer Tree Transfer @@ -39,7 +40,7 @@ Common props ref:[Common props](/docs/react/common-props) | Property | Description | Type | Default | Version | | --- | --- | --- | --- | --- | -| actions | A set of operations that are sorted from top to bottom | string\[] | \[`>`, `<`] | | +| actions | A set of operations that are sorted from top to bottom. When an array of strings is provided, default buttons will be used; when an array of ReactNode is provided, custom elements will be used | ReactNode\[] | \[`>`, `<`] | 6.0.0 | | classNames | Semantic DOM class | [Record](#semantic-dom) | - | | | dataSource | Used for setting the source data. The elements that are part of this array will be present the left column. Except the elements whose keys are included in `targetKeys` prop | [RecordType extends TransferItem = TransferItem](https://github.com/ant-design/ant-design/blob/1bf0bab2a7bc0a774119f501806e3e0e3a6ba283/components/transfer/index.tsx#L12)\[] | \[] | | | disabled | Whether disabled transfer | boolean | false | | diff --git a/components/transfer/index.tsx b/components/transfer/index.tsx index 2546ed4fe7..5e1e0ab0df 100644 --- a/components/transfer/index.tsx +++ b/components/transfer/index.tsx @@ -117,7 +117,7 @@ export interface TransferProps { titles?: React.ReactNode[]; /** @deprecated Please use `actions` instead. */ operations?: string[]; - actions?: string[]; + actions?: React.ReactNode[]; showSearch?: boolean | TransferSearchOption; filterOption?: (inputValue: string, item: RecordType, direction: TransferDirection) => boolean; locale?: Partial; @@ -520,10 +520,9 @@ const Transfer = ( 带搜索框 高级用法 自定义渲染行数据 +自定义操作按钮 分页 表格穿梭框 树穿梭框 @@ -42,7 +43,7 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*g9vUQq2nkpEAAA | 参数 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | -| actions | 操作文案集合,顺序从上至下 | string\[] | \[`>`, `<`] | | +| actions | 操作文案集合,顺序从上至下。当为字符串数组时使用默认的按钮,当为 ReactNode 数组时直接使用自定义元素 | ReactNode\[] | \[`>`, `<`] | 6.0.0 | | classNames | 语义化结构 class | [Record](#semantic-dom) | - | | | dataSource | 数据源,其中的数据将会被渲染到左边一栏中,`targetKeys` 中指定的除外 | [RecordType extends TransferItem = TransferItem](https://github.com/ant-design/ant-design/blob/1bf0bab2a7bc0a774119f501806e3e0e3a6ba283/components/transfer/index.tsx#L12)\[] | \[] | | | disabled | 是否禁用 | boolean | false | |