diff --git a/packages/x/components/choice/Item.tsx b/packages/x/components/choice/Item.tsx new file mode 100644 index 0000000000..a4e27ce006 --- /dev/null +++ b/packages/x/components/choice/Item.tsx @@ -0,0 +1,145 @@ +import { CheckCircleFilled } from '@ant-design/icons'; +import { Radio, Tooltip } from 'antd'; +import { clsx } from 'clsx'; +import React from 'react'; +import { ChoiceContext } from './context'; +import type { ChoiceItemType } from './interface'; + +export interface ChoiceItemProps { + item: ChoiceItemType; + index: number; +} + +const ChoiceItem: React.FC = ({ item, index }) => { + const context = React.useContext(ChoiceContext); + const { + prefixCls, + mode, + disabled: groupDisabled, + indicator, + isSelected, + onItemClick, + classNames, + styles, + } = context; + + const selected = isSelected(item.key); + const disabled = groupDisabled || item.disabled; + + const itemCls = clsx(`${prefixCls}-item`, classNames?.item, { + [`${prefixCls}-item-selected`]: selected, + [`${prefixCls}-item-disabled`]: disabled, + [`${prefixCls}-item-recommended`]: item.recommended, + [`${prefixCls}-item-recommended-primary`]: + item.recommended === 'primary' || item.recommended === true, + [`${prefixCls}-item-recommended-secondary`]: item.recommended === 'secondary', + }); + + const renderIndicator = () => { + if (indicator === 'none') return null; + + const indicatorCls = clsx(`${prefixCls}-indicator`, classNames?.indicator); + + if (indicator === 'check') { + return ( + + {selected && } + + ); + } + + if (indicator === 'radio') { + return ( + + + + ); + } + + if (indicator === 'number') { + return ( + + {selected ? ( + + ) : ( + {index + 1} + )} + + ); + } + + return null; + }; + + const itemNode = ( +
{ + if (disabled) return; + onItemClick(item, index); + }} + onKeyDown={(e) => { + if (disabled) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onItemClick(item, index); + } + }} + > + {/* Indicator */} + {renderIndicator()} + + {/* Content */} +
+ {/* Icon + Label row */} + {item.icon &&
{item.icon}
} + + {item.label &&
{item.label}
} + + {item.description &&
{item.description}
} + + {item.extra &&
{item.extra}
} +
+ + {/* Recommended badge */} + {(item.recommended === true || item.recommended === 'primary') && ( +
+ {item.recommendedReason || 'AI Recommended'} +
+ )} + {item.recommended === 'secondary' && ( +
+ {item.recommendedReason || 'Recommended'} +
+ )} + + {/* Disabled reason (inline) */} + {disabled && item.disabledReason && ( +
{item.disabledReason}
+ )} +
+ ); + + // Wrap with Tooltip for disabled reason on hover + if (disabled && item.disabledReason) { + return ( + + {itemNode} + + ); + } + + return itemNode; +}; + +export default ChoiceItem; diff --git a/packages/x/components/choice/__tests__/__snapshots__/demo-extend.test.ts.snap b/packages/x/components/choice/__tests__/__snapshots__/demo-extend.test.ts.snap new file mode 100644 index 0000000000..3513f521c1 --- /dev/null +++ b/packages/x/components/choice/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -0,0 +1,2478 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders components/choice/demo/basic.tsx extend context correctly 1`] = ` +
+
+
+
+ 请选择数据分析维度 +
+
+ 选择最符合您需求的维度 +
+
+
+
+
+ + + +
+
+ + + +
+
+ 按时间 +
+
+ 按时间趋势分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按地区 +
+
+ 按地域分布分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按品类 +
+
+ 按商品类别分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按渠道 +
+
+ 按销售渠道分析 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/basic.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/card-layout.tsx extend context correctly 1`] = ` +
+
+
+
+ 推荐方案 +
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+ 安全加固 +
+
+ 修复已知漏洞,增强安全策略 +
+
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+ 技术债清理 +
+
+ 重构遗留代码,改善可维护性 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/card-layout.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/confirmable.tsx extend context correctly 1`] = ` +
+
+
+
+ 下一步您希望如何继续? +
+
+ 诊断完成,以下是建议的后续步骤 +
+
+
+
+
+ +
+
+ 查看详细报告 +
+
+ 查看完整的分析报告 +
+
+
+
+
+ +
+
+
+ +
+
+ 加入排期 +
+
+ 加入后续冲刺排期 +
+
+
+
+
+
+ +
+
+ 暂时忽略 +
+
+ 跳过本次处理 +
+
+
+
+
+ +
+
+`; + +exports[`renders components/choice/demo/confirmable.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/controlled.tsx extend context correctly 1`] = ` +
+
+
+
+ 受控模式 +
+
+ 当前选中值由外部 state 控制 +
+
+
+
+
+ + + +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ + + +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ + + +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/controlled.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/disabled.tsx extend context correctly 1`] = ` +
+
+
+
+ 请选择数据分析维度 +
+
+
+
+
+ + + +
+
+ 按时间 +
+
+ 按时间趋势分析 +
+
+
+
+
+
+ + + +
+
+ 按渠道 +
+
+ 按销售渠道分析 +
+
+
+ 当前冲刺无排期 +
+
+
+
+ +
+ +
+
+
+
+ + + +
+
+ 按地区 +
+
+ 按地域分布分析 +
+
+
+
+
+
+ + + +
+
+ 手动配置 +
+
+ 自定义分析维度 +
+
+
+ 需要管理员权限 +
+
+
+
+ +
+ +
+
+
+
+
+`; + +exports[`renders components/choice/demo/disabled.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/grid-layout.tsx extend context correctly 1`] = ` +
+
+
+
+ 选择您感兴趣的方向 +
+
+
+
+
+ + + +
+
+ + + +
+
+ 按时间 +
+
+ 趋势分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按地区 +
+
+ 地域分布 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按品类 +
+
+ 类别分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按渠道 +
+
+ 渠道分析 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/grid-layout.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/in-bubble.tsx extend context correctly 1`] = ` +
+
+
+
+
+
+
+
+ 我已完成文档处理,接下来您希望如何继续? +
+
+
+
+
+
+
+
+
+ 请选择后续操作 +
+
+
+
+ +
+
+
+ + + +
+
+ 翻译全文 +
+
+ 将内容翻译为目标语言 +
+
+
+
+
+
+ + + +
+
+ 深度分析 +
+
+ 对内容进行深度结构化分析 +
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/in-bubble.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/indicator.tsx extend context correctly 1`] = ` +
+
+
+
+
+
+ indicator: radio (默认单选) +
+
+
+
+
+ + + +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ + + +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ + + +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+
+
+ indicator: check +
+
+
+
+
+ +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+
+
+ indicator: number +
+
+
+
+
+ + + 1 + + +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ + + 2 + + +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ + + 3 + + +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+
+
+ indicator: none +
+
+
+
+
+
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/indicator.tsx extend context correctly 2`] = ` +[ + "Warning: [antd: Space] \`direction\` is deprecated. Please use \`orientation\` instead.", +] +`; + +exports[`renders components/choice/demo/loading.tsx extend context correctly 1`] = ` +
+
+
+ 正在加载选项... +
+
+ 请稍候 +
+
+
+
+
+
+

+
    +
  • +
  • +
+

+
+
+
+
+`; + +exports[`renders components/choice/demo/loading.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/multiple.tsx extend context correctly 1`] = ` +
+
+
+
+ 选择优化方向(最多 2 项) +
+
+
+
+
+ +
+
+ 性能优化 +
+
+ 提升系统响应速度和吞吐量 +
+
+
+
+
+
+ +
+
+ 安全加固 +
+
+ 修复已知漏洞,增强安全策略 +
+
+
+
+
+
+ +
+
+ 功能迭代 +
+
+ 按需求优先级推进新功能 +
+
+
+
+
+
+ +
+
+ 技术债清理 +
+
+ 重构遗留代码,改善可维护性 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/multiple.tsx extend context correctly 2`] = `[]`; + +exports[`renders components/choice/demo/recommended.tsx extend context correctly 1`] = ` +
+
+
+
+ 推荐方案 +
+
+
+
+ +
+
+
+ + + +
+
+ 安全加固 +
+
+ 修复已知漏洞,增强安全策略 +
+
+
+
+
+ +
+
+
+ + + +
+
+ 技术债清理 +
+
+ 重构遗留代码,改善可维护性 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/recommended.tsx extend context correctly 2`] = `[]`; diff --git a/packages/x/components/choice/__tests__/__snapshots__/demo.test.ts.snap b/packages/x/components/choice/__tests__/__snapshots__/demo.test.ts.snap new file mode 100644 index 0000000000..df3f43eb5d --- /dev/null +++ b/packages/x/components/choice/__tests__/__snapshots__/demo.test.ts.snap @@ -0,0 +1,2407 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders components/choice/demo/basic.tsx correctly 1`] = ` +
+
+
+
+ 请选择数据分析维度 +
+
+ 选择最符合您需求的维度 +
+
+
+
+
+ + + +
+
+ + + +
+
+ 按时间 +
+
+ 按时间趋势分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按地区 +
+
+ 按地域分布分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按品类 +
+
+ 按商品类别分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按渠道 +
+
+ 按销售渠道分析 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/card-layout.tsx correctly 1`] = ` +
+
+
+
+ 推荐方案 +
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+ 安全加固 +
+
+ 修复已知漏洞,增强安全策略 +
+
+
+
+
+ +
+
+
+ +
+
+ + + +
+
+ 技术债清理 +
+
+ 重构遗留代码,改善可维护性 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/confirmable.tsx correctly 1`] = ` +
+
+
+
+ 下一步您希望如何继续? +
+
+ 诊断完成,以下是建议的后续步骤 +
+
+
+
+
+ +
+
+ 查看详细报告 +
+
+ 查看完整的分析报告 +
+
+
+
+
+ +
+
+
+ +
+
+ 加入排期 +
+
+ 加入后续冲刺排期 +
+
+
+
+
+
+ +
+
+ 暂时忽略 +
+
+ 跳过本次处理 +
+
+
+
+
+ +
+
+`; + +exports[`renders components/choice/demo/controlled.tsx correctly 1`] = ` +
+
+
+
+ 受控模式 +
+
+ 当前选中值由外部 state 控制 +
+
+
+
+
+ + + +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ + + +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ + + +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/disabled.tsx correctly 1`] = ` +
+
+
+
+ 请选择数据分析维度 +
+
+
+
+
+ + + +
+
+ 按时间 +
+
+ 按时间趋势分析 +
+
+
+
+
+
+ + + +
+
+ 按渠道 +
+
+ 按销售渠道分析 +
+
+
+ 当前冲刺无排期 +
+
+
+
+
+ + + +
+
+ 按地区 +
+
+ 按地域分布分析 +
+
+
+
+
+
+ + + +
+
+ 手动配置 +
+
+ 自定义分析维度 +
+
+
+ 需要管理员权限 +
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/grid-layout.tsx correctly 1`] = ` +
+
+
+
+ 选择您感兴趣的方向 +
+
+
+
+
+ + + +
+
+ + + +
+
+ 按时间 +
+
+ 趋势分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按地区 +
+
+ 地域分布 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按品类 +
+
+ 类别分析 +
+
+
+
+
+
+ + + +
+
+ + + +
+
+ 按渠道 +
+
+ 渠道分析 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/in-bubble.tsx correctly 1`] = ` +
+
+
+
+
+
+
+ 我已完成文档处理,接下来您希望如何继续? +
+
+
+
+
+
+
+
+
+ 请选择后续操作 +
+
+
+
+ +
+
+
+ + + +
+
+ 翻译全文 +
+
+ 将内容翻译为目标语言 +
+
+
+
+
+
+ + + +
+
+ 深度分析 +
+
+ 对内容进行深度结构化分析 +
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/indicator.tsx correctly 1`] = ` +
+
+
+
+
+
+ indicator: radio (默认单选) +
+
+
+
+
+ + + +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ + + +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ + + +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+
+
+ indicator: check +
+
+
+
+
+ +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+
+
+ indicator: number +
+
+
+
+
+ + + 1 + + +
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+ + + 2 + + +
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+ + + 3 + + +
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+
+
+ indicator: none +
+
+
+
+
+
+
+ 选项 A +
+
+ 描述 A +
+
+
+
+
+
+
+
+ 选项 B +
+
+ 描述 B +
+
+
+
+
+
+
+
+ 选项 C +
+
+ 描述 C +
+
+
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/loading.tsx correctly 1`] = ` +
+
+
+ 正在加载选项... +
+
+ 请稍候 +
+
+
+
+
+
+

+
    +
  • +
  • +
+

+
+
+
+
+`; + +exports[`renders components/choice/demo/multiple.tsx correctly 1`] = ` +
+
+
+
+ 选择优化方向(最多 2 项) +
+
+
+
+
+ +
+
+ 性能优化 +
+
+ 提升系统响应速度和吞吐量 +
+
+
+
+
+
+ +
+
+ 安全加固 +
+
+ 修复已知漏洞,增强安全策略 +
+
+
+
+
+
+ +
+
+ 功能迭代 +
+
+ 按需求优先级推进新功能 +
+
+
+
+
+
+ +
+
+ 技术债清理 +
+
+ 重构遗留代码,改善可维护性 +
+
+
+
+
+
+
+`; + +exports[`renders components/choice/demo/recommended.tsx correctly 1`] = ` +
+
+
+
+ 推荐方案 +
+
+
+
+ +
+
+
+ + + +
+
+ 安全加固 +
+
+ 修复已知漏洞,增强安全策略 +
+
+
+
+
+ +
+
+
+ + + +
+
+ 技术债清理 +
+
+ 重构遗留代码,改善可维护性 +
+
+
+
+
+
+
+`; diff --git a/packages/x/components/choice/__tests__/__snapshots__/index.test.tsx.snap b/packages/x/components/choice/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..27ebe4a69f --- /dev/null +++ b/packages/x/components/choice/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`choice rtl render component should be rendered correctly in RTL direction 1`] = ` +
+
+
+
+ + + +
+
+ Option 1 +
+
+ Description 1 +
+
+
+
+
+
+ + + +
+
+ Option 2 +
+
+ Description 2 +
+
+
+
+
+
+ + + +
+
+ Option 3 +
+
+ Description 3 +
+
+
+ Not available +
+
+
+
+
+`; diff --git a/packages/x/components/choice/__tests__/demo-extend.test.ts b/packages/x/components/choice/__tests__/demo-extend.test.ts new file mode 100644 index 0000000000..19c39e1801 --- /dev/null +++ b/packages/x/components/choice/__tests__/demo-extend.test.ts @@ -0,0 +1,3 @@ +import { extendTest } from '../../../tests/shared/demoTest'; + +extendTest('choice'); diff --git a/packages/x/components/choice/__tests__/demo.test.ts b/packages/x/components/choice/__tests__/demo.test.ts new file mode 100644 index 0000000000..398598070f --- /dev/null +++ b/packages/x/components/choice/__tests__/demo.test.ts @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('choice'); diff --git a/packages/x/components/choice/__tests__/index.test.tsx b/packages/x/components/choice/__tests__/index.test.tsx new file mode 100644 index 0000000000..fde2fa734e --- /dev/null +++ b/packages/x/components/choice/__tests__/index.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import mountTest from '../../../tests/shared/mountTest'; +import rtlTest from '../../../tests/shared/rtlTest'; +import { fireEvent, render } from '../../../tests/utils'; +import Choice from '..'; + +const mockItems = [ + { + key: '1', + label: 'Option 1', + description: 'Description 1', + disabled: false, + }, + { + key: '2', + label: 'Option 2', + description: 'Description 2', + disabled: false, + }, + { + key: '3', + label: 'Option 3', + description: 'Description 3', + disabled: true, + disabledReason: 'Not available', + }, +]; + +describe('choice', () => { + mountTest(() => ); + rtlTest(() => ); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should render the correct number of items', () => { + const { container } = render(); + expect(container.querySelectorAll('.ant-choice-item')).toHaveLength(mockItems.length); + }); + + it('should render title and description', () => { + const { getByText } = render( + , + ); + expect(getByText('Test Title')).toBeInTheDocument(); + expect(getByText('Test Description')).toBeInTheDocument(); + }); + + it('should render labels and descriptions', () => { + const { getByText } = render(); + mockItems.forEach((item) => { + expect(getByText(item.label)).toBeInTheDocument(); + expect(getByText(item.description)).toBeInTheDocument(); + }); + }); + + it('should select an item on click in single mode', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('Option 1')); + expect(container.querySelector('.ant-choice-item-selected')).toBeTruthy(); + }); + + it('should deselect an item on click in single mode', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('Option 1')); + expect(container.querySelector('.ant-choice-item-selected')).toBeTruthy(); + fireEvent.click(getByText('Option 1')); + expect(container.querySelector('.ant-choice-item-selected')).toBeFalsy(); + }); + + it('should call onChange when selection changes', () => { + const handleChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('Option 1')); + expect(handleChange).toHaveBeenCalledWith('1', expect.objectContaining({ type: 'select' })); + }); + + it('should call onItemClick when an item is clicked', () => { + const handleItemClick = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('Option 1')); + expect(handleItemClick).toHaveBeenCalledWith({ + data: mockItems[0], + index: 0, + }); + }); + + it('should not trigger click for disabled items', () => { + const handleChange = jest.fn(); + const { getByText, container } = render(); + fireEvent.click(getByText('Option 3')); + expect(handleChange).not.toHaveBeenCalled(); + expect(container.querySelector('.ant-choice-item-disabled')).toBeTruthy(); + }); + + it('should render disabled reason', () => { + const { getByText } = render(); + expect(getByText('Not available')).toBeInTheDocument(); + }); + + it('should support multiple mode', () => { + const handleChange = jest.fn(); + const { getByText, container } = render( + , + ); + fireEvent.click(getByText('Option 1')); + fireEvent.click(getByText('Option 2')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(2); + expect(handleChange).toHaveBeenCalledWith( + ['1', '2'], + expect.objectContaining({ type: 'select' }), + ); + }); + + it('should respect maxCount in multiple mode', () => { + const handleChange = jest.fn(); + const { getByText, container } = render( + , + ); + fireEvent.click(getByText('Option 1')); + fireEvent.click(getByText('Option 2')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + }); + + it('should render different layouts', () => { + const { container: listContainer } = render(); + expect(listContainer.querySelector('.ant-choice-layout-list')).toBeTruthy(); + + const { container: gridContainer } = render(); + expect(gridContainer.querySelector('.ant-choice-layout-grid')).toBeTruthy(); + + const { container: cardContainer } = render(); + expect(cardContainer.querySelector('.ant-choice-layout-card')).toBeTruthy(); + }); + + it('should render recommended badge', () => { + const items = [ + { key: '1', label: 'A', recommended: true as const }, + { key: '2', label: 'B', recommended: 'secondary' as const }, + ]; + const { container } = render(); + expect(container.querySelector('.ant-choice-item-recommended-primary')).toBeTruthy(); + expect(container.querySelector('.ant-choice-item-recommended-secondary')).toBeTruthy(); + }); + + it('should render confirm button when confirmable is true', () => { + const { container } = render(); + const btn = container.querySelector('button'); + expect(btn).toBeTruthy(); + expect(btn?.textContent).toContain('Confirm'); + }); + + it('should call onConfirm when confirm button is clicked', () => { + const handleConfirm = jest.fn(); + const { getByText } = render( + , + ); + fireEvent.click(getByText('Option 1')); + fireEvent.click(getByText('Confirm')); + expect(handleConfirm).toHaveBeenCalled(); + }); + + it('should render loading skeleton', () => { + const { container } = render(); + expect(container.querySelector('.ant-choice-loading')).toBeTruthy(); + }); + + it('should apply controlled value', () => { + const { container } = render(); + const selectedItems = container.querySelectorAll('.ant-choice-item-selected'); + expect(selectedItems).toHaveLength(1); + }); + + it('should apply defaultValue', () => { + const { container } = render(); + expect(container.querySelector('.ant-choice-item-selected')).toBeTruthy(); + }); + + it('should apply fadeIn animation class', () => { + const { container } = render(); + expect(container.querySelector('.ant-x-fade')).toBeTruthy(); + }); + + it('should apply fadeInLeft animation class', () => { + const { container } = render(); + expect(container.querySelector('.ant-x-fade-left')).toBeTruthy(); + }); + + it('should apply custom classNames and styles', () => { + const customClassNames = { + root: 'custom-root', + header: 'custom-header', + list: 'custom-list', + item: 'custom-item', + }; + + const { container } = render( + , + ); + + expect(container.querySelector('.custom-root')).toBeTruthy(); + expect(container.querySelector('.custom-list')).toBeTruthy(); + expect(container.querySelector('.custom-item')).toBeTruthy(); + }); + + it('should render different indicators', () => { + const { container: radioContainer } = render(); + expect(radioContainer.querySelector('.ant-choice-indicator')).toBeTruthy(); + + const { container: checkContainer } = render(); + expect(checkContainer.querySelector('.ant-choice-indicator')).toBeTruthy(); + + const { container: numberContainer } = render(); + expect(numberContainer.querySelector('.ant-choice-indicator-number')).toBeTruthy(); + + const { container: noneContainer } = render(); + expect(noneContainer.querySelector('.ant-choice-indicator')).toBeFalsy(); + }); + + it('should render footer', () => { + const { getByText } = render(Custom Footer
} />); + expect(getByText('Custom Footer')).toBeInTheDocument(); + }); + + it('should render maxCount text in footer for multiple mode', () => { + const { container } = render( + , + ); + expect(container.querySelector('.ant-choice-footer-count')).toBeTruthy(); + }); + + it('should support keyboard navigation', () => { + const handleChange = jest.fn(); + const { getByText } = render(); + const item = getByText('Option 1').closest('.ant-choice-item')!; + fireEvent.keyDown(item, { key: 'Enter' }); + expect(handleChange).toHaveBeenCalled(); + }); + + it('should set correct data roles', () => { + const { container } = render(); + const list = container.querySelector('[data-role="radiogroup"]'); + expect(list).toBeTruthy(); + + const { container: multiContainer } = render(); + const group = multiContainer.querySelector('[data-role="group"]'); + expect(group).toBeTruthy(); + }); +}); diff --git a/packages/x/components/choice/__tests__/select.test.tsx b/packages/x/components/choice/__tests__/select.test.tsx new file mode 100644 index 0000000000..a4d22ccf74 --- /dev/null +++ b/packages/x/components/choice/__tests__/select.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { fireEvent, render } from '../../../tests/utils'; +import Choice from '..'; +import type { ChoiceItemType } from '../interface'; + +const mockItems: ChoiceItemType[] = [ + { key: '1', label: 'A' }, + { key: '2', label: 'B' }, + { key: '3', label: 'C', disabled: true }, +]; + +describe('Choice select behavior', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + // ======================== Single Mode ======================== + it('single mode: select an item', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('A')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + }); + + it('single mode: deselect by clicking again', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('A')); + expect(container.querySelector('.ant-choice-item-selected')).toBeTruthy(); + fireEvent.click(getByText('A')); + expect(container.querySelector('.ant-choice-item-selected')).toBeFalsy(); + }); + + it('single mode: selecting one replaces previous selection', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('A')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + + fireEvent.click(getByText('B')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + }); + + it('single mode: onChange called with single value', () => { + const onChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('A')); + expect(onChange).toHaveBeenCalledWith('1', expect.objectContaining({ type: 'select' })); + }); + + it('single mode: onChange called with deselect', () => { + const onChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('A')); + expect(onChange).toHaveBeenCalledWith(undefined, expect.objectContaining({ type: 'deselect' })); + }); + + // ======================== Multiple Mode ======================== + it('multiple mode: select multiple items', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('A')); + fireEvent.click(getByText('B')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(2); + }); + + it('multiple mode: deselect an item', () => { + const { getByText, container } = render( + , + ); + fireEvent.click(getByText('A')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + }); + + it('multiple mode: onChange called with array value', () => { + const onChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('A')); + expect(onChange).toHaveBeenCalledWith(['1'], expect.objectContaining({ type: 'select' })); + }); + + // ======================== maxCount ======================== + it('multiple mode: maxCount limits selection', () => { + const { getByText, container } = render( + , + ); + fireEvent.click(getByText('A')); + fireEvent.click(getByText('B')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(2); + }); + + it('multiple mode: cannot select beyond maxCount', () => { + const items: ChoiceItemType[] = [ + { key: '1', label: 'A' }, + { key: '2', label: 'B' }, + { key: '3', label: 'C' }, + ]; + const { getByText, container } = render(); + fireEvent.click(getByText('A')); + fireEvent.click(getByText('B')); + fireEvent.click(getByText('C')); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(2); + }); + + // ======================== Disabled ======================== + it('disabled item cannot be selected', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('C')); + expect(container.querySelector('.ant-choice-item-selected')).toBeFalsy(); + }); + + it('group disabled prevents all selection', () => { + const { getByText, container } = render(); + fireEvent.click(getByText('A')); + expect(container.querySelector('.ant-choice-item-selected')).toBeFalsy(); + }); + + // ======================== Controlled ======================== + it('controlled: value prop controls selection', () => { + const { container } = render(); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + }); + + it('controlled: array value in multiple mode', () => { + const { container } = render(); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(2); + }); + + it('controlled: value change updates selection', () => { + const { container, rerender } = render(); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + + rerender(); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + expect( + container.querySelector('.ant-choice-item-selected .ant-choice-item-label')?.textContent, + ).toBe('B'); + }); + + // ======================== Nested ======================== + it('nested children render correctly', () => { + const items: ChoiceItemType[] = [ + { + key: 'parent', + label: 'Parent', + children: [ + { key: 'child1', label: 'Child 1' }, + { key: 'child2', label: 'Child 2' }, + ], + }, + ]; + const { getByText, container } = render(); + expect(getByText('Parent')).toBeInTheDocument(); + expect(getByText('Child 1')).toBeInTheDocument(); + expect(getByText('Child 2')).toBeInTheDocument(); + expect(container.querySelector('.ant-choice-nested')).toBeTruthy(); + }); + + // ======================== ChangeInfo ======================== + it('ChangeInfo contains correct changedItems', () => { + const onChange = jest.fn(); + const { getByText } = render(); + fireEvent.click(getByText('A')); + expect(onChange).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + changedItems: [mockItems[0]], + items: mockItems, + }), + ); + }); + + // ======================== defaultValue ======================== + it('defaultValue with array in multiple mode', () => { + const { container } = render( + , + ); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(2); + }); + + it('defaultValue with single value in single mode', () => { + const { container } = render(); + expect(container.querySelectorAll('.ant-choice-item-selected')).toHaveLength(1); + }); +}); diff --git a/packages/x/components/choice/context.ts b/packages/x/components/choice/context.ts new file mode 100644 index 0000000000..877a345243 --- /dev/null +++ b/packages/x/components/choice/context.ts @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ChoiceProps } from './interface'; + +export interface ChoiceContextProps { + prefixCls: string; + mode: 'single' | 'multiple'; + disabled: boolean; + indicator: 'check' | 'radio' | 'number' | 'none'; + selectedKeys: ChoiceProps['value']; + isSelected: (key: string | number) => boolean; + onItemClick: (item: import('./interface').ChoiceItemType, index: number) => void; + classNames?: ChoiceProps['classNames']; + styles?: ChoiceProps['styles']; +} + +export const ChoiceContext = React.createContext(null!); diff --git a/packages/x/components/choice/demo/_semantic.tsx b/packages/x/components/choice/demo/_semantic.tsx new file mode 100644 index 0000000000..d91709aaf4 --- /dev/null +++ b/packages/x/components/choice/demo/_semantic.tsx @@ -0,0 +1,85 @@ +import { ClockCircleOutlined, GlobalOutlined, TagOutlined } from '@ant-design/icons'; +import { Choice, type ChoiceProps } from '@ant-design/x'; +import { Flex } from 'antd'; +import React from 'react'; +import SemanticPreview from '../../../.dumi/components/SemanticPreview'; +import useLocale from '../../../.dumi/hooks/useLocale'; + +const locales = { + cn: { + root: '根节点', + header: '头部区域(标题 + 描述)', + title: '标题', + description: '描述文本', + list: '列表容器', + item: '选项项', + itemContent: '选项内容', + indicator: '选择指示器', + footer: '底部操作区', + }, + en: { + root: 'Root', + header: 'Header (title + description)', + title: 'Title', + description: 'Description text', + list: 'List container', + item: 'Item', + itemContent: 'Item content', + indicator: 'Indicator', + footer: 'Footer', + }, +}; + +const items: ChoiceProps['items'] = [ + { + key: '1', + icon: , + label: '按时间', + description: '按时间趋势分析', + }, + { + key: '2', + icon: , + label: '按地区', + description: '按地域分布分析', + }, + { + key: '3', + icon: , + label: '按品类', + description: '按商品类别分析', + }, +]; + +const App: React.FC = () => { + const [locale] = useLocale(locales); + + return ( + + + + + + ); +}; + +export default App; diff --git a/packages/x/components/choice/demo/basic.md b/packages/x/components/choice/demo/basic.md new file mode 100644 index 0000000000..89b332703d --- /dev/null +++ b/packages/x/components/choice/demo/basic.md @@ -0,0 +1,7 @@ +## zh-CN + +基础单选用法。点击选项即可选中,再次点击取消选中。 + +## en-US + +Basic single selection. Click an option to select it, click again to deselect. diff --git a/packages/x/components/choice/demo/basic.tsx b/packages/x/components/choice/demo/basic.tsx new file mode 100644 index 0000000000..0ae1c9c643 --- /dev/null +++ b/packages/x/components/choice/demo/basic.tsx @@ -0,0 +1,58 @@ +import { + AppstoreOutlined, + ClockCircleOutlined, + GlobalOutlined, + TagOutlined, +} from '@ant-design/icons'; +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'time', + label: '按时间', + description: '按时间趋势分析', + icon: , + }, + { + key: 'region', + label: '按地区', + description: '按地域分布分析', + icon: , + }, + { + key: 'category', + label: '按品类', + description: '按商品类别分析', + icon: , + }, + { + key: 'channel', + label: '按渠道', + description: '按销售渠道分析', + icon: , + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${value}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/card-layout.md b/packages/x/components/choice/demo/card-layout.md new file mode 100644 index 0000000000..e2b060aabc --- /dev/null +++ b/packages/x/components/choice/demo/card-layout.md @@ -0,0 +1,7 @@ +## zh-CN + +卡片布局,适合展示带有图标、描述和推荐标记的选项。 + +## en-US + +Card layout, suitable for displaying options with icons, descriptions and recommendation markers. diff --git a/packages/x/components/choice/demo/card-layout.tsx b/packages/x/components/choice/demo/card-layout.tsx new file mode 100644 index 0000000000..edaf39245f --- /dev/null +++ b/packages/x/components/choice/demo/card-layout.tsx @@ -0,0 +1,63 @@ +import { + FireOutlined, + RocketOutlined, + SafetyCertificateOutlined, + ToolOutlined, +} from '@ant-design/icons'; +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'performance', + label: '性能优化', + description: '提升系统响应速度和吞吐量', + icon: , + recommended: true, + recommendedReason: 'AI 评分: 92', + }, + { + key: 'security', + label: '安全加固', + description: '修复已知漏洞,增强安全策略', + icon: , + }, + { + key: 'feature', + label: '功能迭代', + description: '按需求优先级推进新功能', + icon: , + recommended: 'secondary', + }, + { + key: 'debt', + label: '技术债清理', + description: '重构遗留代码,改善可维护性', + icon: , + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${(value as Array).join(', ')}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/confirmable.md b/packages/x/components/choice/demo/confirmable.md new file mode 100644 index 0000000000..e906e16daa --- /dev/null +++ b/packages/x/components/choice/demo/confirmable.md @@ -0,0 +1,7 @@ +## zh-CN + +通过 `confirmable` 属性显示确认按钮,用户完成选择后点击确认触发 `onConfirm` 回调。 + +## en-US + +Use the `confirmable` property to show a confirm button. After users finish selecting, click confirm to trigger the `onConfirm` callback. diff --git a/packages/x/components/choice/demo/confirmable.tsx b/packages/x/components/choice/demo/confirmable.tsx new file mode 100644 index 0000000000..6f50dca952 --- /dev/null +++ b/packages/x/components/choice/demo/confirmable.tsx @@ -0,0 +1,54 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'detail', + label: '查看详细报告', + description: '查看完整的分析报告', + }, + { + key: 'fix', + label: '立即修复', + description: '自动修复检测到的问题', + recommended: true, + recommendedReason: '检测到 3 个高危问题', + }, + { + key: 'schedule', + label: '加入排期', + description: '加入后续冲刺排期', + }, + { + key: 'ignore', + label: '暂时忽略', + description: '跳过本次处理', + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.success(`Confirmed: ${(value as Array).join(', ')}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/controlled.md b/packages/x/components/choice/demo/controlled.md new file mode 100644 index 0000000000..983ab33807 --- /dev/null +++ b/packages/x/components/choice/demo/controlled.md @@ -0,0 +1,7 @@ +## zh-CN + +受控模式,通过 `value` 和 `onChange` 控制选中值。 + +## en-US + +Controlled mode, using `value` and `onChange` to control the selected value. diff --git a/packages/x/components/choice/demo/controlled.tsx b/packages/x/components/choice/demo/controlled.tsx new file mode 100644 index 0000000000..26c06d0f2f --- /dev/null +++ b/packages/x/components/choice/demo/controlled.tsx @@ -0,0 +1,35 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { key: '1', label: '选项 A', description: '描述 A' }, + { key: '2', label: '选项 B', description: '描述 B' }, + { key: '3', label: '选项 C', description: '描述 C' }, +]; + +const Demo = () => { + const [value, setValue] = React.useState('2'); + + const { message } = App.useApp(); + + return ( + { + setValue(val); + message.info(`Changed to: ${val}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/disabled.md b/packages/x/components/choice/demo/disabled.md new file mode 100644 index 0000000000..f55eef8ec8 --- /dev/null +++ b/packages/x/components/choice/demo/disabled.md @@ -0,0 +1,7 @@ +## zh-CN + +禁用选项时,可以通过 `disabledReason` 展示禁用原因。 + +## en-US + +When an option is disabled, you can use `disabledReason` to show the reason. diff --git a/packages/x/components/choice/demo/disabled.tsx b/packages/x/components/choice/demo/disabled.tsx new file mode 100644 index 0000000000..b408720589 --- /dev/null +++ b/packages/x/components/choice/demo/disabled.tsx @@ -0,0 +1,51 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'time', + label: '按时间', + description: '按时间趋势分析', + }, + { + key: 'channel', + label: '按渠道', + description: '按销售渠道分析', + disabled: true, + disabledReason: '当前冲刺无排期', + }, + { + key: 'region', + label: '按地区', + description: '按地域分布分析', + }, + { + key: 'manual', + label: '手动配置', + description: '自定义分析维度', + disabled: true, + disabledReason: '需要管理员权限', + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${value}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/grid-layout.md b/packages/x/components/choice/demo/grid-layout.md new file mode 100644 index 0000000000..f7e7676201 --- /dev/null +++ b/packages/x/components/choice/demo/grid-layout.md @@ -0,0 +1,7 @@ +## zh-CN + +网格布局,以两列网格形式排列选项。 + +## en-US + +Grid layout, options arranged in a two-column grid. diff --git a/packages/x/components/choice/demo/grid-layout.tsx b/packages/x/components/choice/demo/grid-layout.tsx new file mode 100644 index 0000000000..a9111f741e --- /dev/null +++ b/packages/x/components/choice/demo/grid-layout.tsx @@ -0,0 +1,58 @@ +import { + AppstoreOutlined, + ClockCircleOutlined, + GlobalOutlined, + TagOutlined, +} from '@ant-design/icons'; +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'time', + label: '按时间', + description: '趋势分析', + icon: , + }, + { + key: 'region', + label: '按地区', + description: '地域分布', + icon: , + }, + { + key: 'category', + label: '按品类', + description: '类别分析', + icon: , + }, + { + key: 'channel', + label: '按渠道', + description: '渠道分析', + icon: , + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${value}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/in-bubble.md b/packages/x/components/choice/demo/in-bubble.md new file mode 100644 index 0000000000..7a905c64dc --- /dev/null +++ b/packages/x/components/choice/demo/in-bubble.md @@ -0,0 +1,7 @@ +## zh-CN + +在 `Bubble.List` 对话气泡中使用 Choice 组件,实现 AI 对话中的选择交互。 + +## en-US + +Using the Choice component within `Bubble.List` conversation bubbles to enable selection interactions in AI conversations. diff --git a/packages/x/components/choice/demo/in-bubble.tsx b/packages/x/components/choice/demo/in-bubble.tsx new file mode 100644 index 0000000000..eccdf8b8fc --- /dev/null +++ b/packages/x/components/choice/demo/in-bubble.tsx @@ -0,0 +1,58 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Bubble, Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'summary', + label: '生成摘要', + description: '提取关键信息生成摘要', + recommended: true, + }, + { + key: 'translate', + label: '翻译全文', + description: '将内容翻译为目标语言', + }, + { + key: 'analyze', + label: '深度分析', + description: '对内容进行深度结构化分析', + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${value}`); + }} + /> + ), + }, + ]} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/indicator.md b/packages/x/components/choice/demo/indicator.md new file mode 100644 index 0000000000..d0d51add09 --- /dev/null +++ b/packages/x/components/choice/demo/indicator.md @@ -0,0 +1,7 @@ +## zh-CN + +通过 `indicator` 属性设置不同的选择指示器类型:`radio`、`check`、`number`、`none`。 + +## en-US + +Use the `indicator` property to set different indicator types: `radio`, `check`, `number`, `none`. diff --git a/packages/x/components/choice/demo/indicator.tsx b/packages/x/components/choice/demo/indicator.tsx new file mode 100644 index 0000000000..96caf9538a --- /dev/null +++ b/packages/x/components/choice/demo/indicator.tsx @@ -0,0 +1,52 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App, Space } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { key: '1', label: '选项 A', description: '描述 A' }, + { key: '2', label: '选项 B', description: '描述 B' }, + { key: '3', label: '选项 C', description: '描述 C' }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + + message.info(`Selected: ${value}`)} + /> + + message.info(`Selected: ${(value as Array).join(', ')}`) + } + /> + message.info(`Selected: ${value}`)} + /> + message.info(`Selected: ${value}`)} + /> + + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/loading.md b/packages/x/components/choice/demo/loading.md new file mode 100644 index 0000000000..27557025dc --- /dev/null +++ b/packages/x/components/choice/demo/loading.md @@ -0,0 +1,7 @@ +## zh-CN + +通过 `loading` 属性显示加载骨架屏。 + +## en-US + +Use the `loading` property to show a loading skeleton. diff --git a/packages/x/components/choice/demo/loading.tsx b/packages/x/components/choice/demo/loading.tsx new file mode 100644 index 0000000000..aa09385357 --- /dev/null +++ b/packages/x/components/choice/demo/loading.tsx @@ -0,0 +1,8 @@ +import { Choice } from '@ant-design/x'; +import React from 'react'; + +const Demo = () => { + return ; +}; + +export default Demo; diff --git a/packages/x/components/choice/demo/multiple.md b/packages/x/components/choice/demo/multiple.md new file mode 100644 index 0000000000..f1cc7b546e --- /dev/null +++ b/packages/x/components/choice/demo/multiple.md @@ -0,0 +1,7 @@ +## zh-CN + +多选模式。通过 `maxCount` 限制最大可选数量。 + +## en-US + +Multiple selection mode. Use `maxCount` to limit the maximum selectable count. diff --git a/packages/x/components/choice/demo/multiple.tsx b/packages/x/components/choice/demo/multiple.tsx new file mode 100644 index 0000000000..7142475dbc --- /dev/null +++ b/packages/x/components/choice/demo/multiple.tsx @@ -0,0 +1,49 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'performance', + label: '性能优化', + description: '提升系统响应速度和吞吐量', + }, + { + key: 'security', + label: '安全加固', + description: '修复已知漏洞,增强安全策略', + }, + { + key: 'feature', + label: '功能迭代', + description: '按需求优先级推进新功能', + }, + { + key: 'debt', + label: '技术债清理', + description: '重构遗留代码,改善可维护性', + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${(value as Array).join(', ')}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/demo/recommended.md b/packages/x/components/choice/demo/recommended.md new file mode 100644 index 0000000000..7aca202808 --- /dev/null +++ b/packages/x/components/choice/demo/recommended.md @@ -0,0 +1,7 @@ +## zh-CN + +通过 `recommended` 属性标记 AI 推荐选项。支持 `true`/`'primary'`(主推荐)和 `'secondary'`(次推荐)。 + +## en-US + +Use the `recommended` property to mark AI recommended options. Supports `true`/`'primary'` (primary recommendation) and `'secondary'` (secondary recommendation). diff --git a/packages/x/components/choice/demo/recommended.tsx b/packages/x/components/choice/demo/recommended.tsx new file mode 100644 index 0000000000..f11b9929a7 --- /dev/null +++ b/packages/x/components/choice/demo/recommended.tsx @@ -0,0 +1,51 @@ +import type { ChoiceProps } from '@ant-design/x'; +import { Choice } from '@ant-design/x'; +import { App } from 'antd'; +import React from 'react'; + +const items: ChoiceProps['items'] = [ + { + key: 'performance', + label: '性能优化', + description: '提升系统响应速度和吞吐量', + recommended: true, + recommendedReason: 'AI 评分: 92', + }, + { + key: 'security', + label: '安全加固', + description: '修复已知漏洞,增强安全策略', + }, + { + key: 'feature', + label: '功能迭代', + description: '按需求优先级推进新功能', + recommended: 'secondary', + recommendedReason: '次级推荐', + }, + { + key: 'debt', + label: '技术债清理', + description: '重构遗留代码,改善可维护性', + }, +]; + +const Demo = () => { + const { message } = App.useApp(); + + return ( + { + message.info(`Selected: ${value}`); + }} + /> + ); +}; + +export default () => ( + + + +); diff --git a/packages/x/components/choice/hooks/use-select.ts b/packages/x/components/choice/hooks/use-select.ts new file mode 100644 index 0000000000..bb71324fe0 --- /dev/null +++ b/packages/x/components/choice/hooks/use-select.ts @@ -0,0 +1,99 @@ +import { useControlledState } from '@rc-component/util'; +import React from 'react'; +import type { ChangeInfo, ChoiceItemType, ChoiceValueType } from '../interface'; + +export interface UseSelectOptions { + mode: 'single' | 'multiple'; + value?: ChoiceValueType | ChoiceValueType[]; + defaultValue?: ChoiceValueType | ChoiceValueType[]; + onChange?: (value: ChoiceValueType | ChoiceValueType[], info: ChangeInfo) => void; + maxCount?: number; + disabled?: boolean; + items: ChoiceItemType[]; +} + +function toArray(value: ChoiceValueType | ChoiceValueType[] | undefined): ChoiceValueType[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +export default function useSelect(options: UseSelectOptions) { + const { mode, value, defaultValue, onChange, maxCount, disabled, items } = options; + + const normalizedDefaultValue = React.useMemo(() => toArray(defaultValue), [defaultValue]); + + const normalizedValue = React.useMemo(() => { + if (value === undefined) return undefined; + return toArray(value); + }, [value]); + + // Normalize to array internally + const [mergedValue, setMergedValue] = useControlledState( + normalizedDefaultValue, + normalizedValue, + ); + + const isSelected = React.useCallback( + (key: ChoiceValueType) => mergedValue.includes(key), + [mergedValue], + ); + + const isMaxReached = React.useMemo(() => { + if (mode !== 'multiple' || !maxCount) return false; + return mergedValue.length >= maxCount; + }, [mode, maxCount, mergedValue.length]); + + const handleItemClick = React.useCallback( + (item: ChoiceItemType, _index: number) => { + if (disabled || item.disabled) return; + + const key = item.key; + let nextValue: ChoiceValueType[]; + let changeType: 'select' | 'deselect'; + let changedItems: ChoiceItemType[]; + + if (mode === 'single') { + if (isSelected(key)) { + nextValue = []; + changeType = 'deselect'; + changedItems = [item]; + } else { + nextValue = [key]; + changeType = 'select'; + changedItems = [item]; + } + } else { + if (isSelected(key)) { + nextValue = mergedValue.filter((v) => v !== key); + changeType = 'deselect'; + changedItems = [item]; + } else { + if (maxCount && mergedValue.length >= maxCount) return; + nextValue = [...mergedValue, key]; + changeType = 'select'; + changedItems = [item]; + } + } + + setMergedValue(nextValue); + + if (onChange) { + const info: ChangeInfo = { + value: mode === 'single' ? nextValue[0] : nextValue, + items, + changedItems, + type: changeType, + }; + onChange(info.value, info); + } + }, + [disabled, mode, isSelected, mergedValue, maxCount, setMergedValue, onChange, items], + ); + + return { + mergedValue, + handleItemClick, + isSelected, + isMaxReached, + }; +} diff --git a/packages/x/components/choice/index.en-US.md b/packages/x/components/choice/index.en-US.md new file mode 100644 index 0000000000..12590ae78b --- /dev/null +++ b/packages/x/components/choice/index.en-US.md @@ -0,0 +1,101 @@ +--- +category: Components +group: + title: Wake + order: 1 +title: Choice +order: 10 +description: Present a set of structured options for users to select in AI conversation scenarios. +cover: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original +coverDark: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original +--- + +## When To Use + +The Choice component is used to present a set of structured options for users to select in AI conversation scenarios. Unlike antd's `Select`, `Radio`, and `Checkbox`, Choice is designed specifically for AI conversation contexts, supporting AI-specific features such as recommendation markers, disable reasons, multiple layout modes, and loading states. + +## Examples + + +Basic +Multiple +Card Layout +Grid Layout +Recommended +Disabled +Indicator +Confirmable +Loading +In Bubble +Controlled + +## API + +### ChoiceProps + +| Property | Description | Type | Default | Version | +| --- | --- | --- | --- | --- | +| items | List of options | ChoiceItemType[] | - | - | +| mode | Selection mode | 'single' \| 'multiple' | 'single' | - | +| layout | Layout mode | 'list' \| 'grid' \| 'card' | 'list' | - | +| value | Current selected value (controlled) | ChoiceValueType \| ChoiceValueType[] | - | - | +| defaultValue | Default selected value | ChoiceValueType \| ChoiceValueType[] | - | - | +| onChange | Callback when selection changes | (value, info: ChangeInfo) => void | - | - | +| onItemClick | Callback when an option is clicked | (info: { data: ChoiceItemType, index: number }) => void | - | - | +| onConfirm | Confirm callback | (value, info: ChangeInfo) => void | - | - | +| title | Title | React.ReactNode | - | - | +| description | Description text | React.ReactNode | - | - | +| footer | Footer action area | React.ReactNode | - | - | +| disabled | Whether to disable all options | boolean | false | - | +| loading | Whether to show loading state | boolean | false | - | +| maxCount | Maximum selectable count in multiple mode | number | - | - | +| indicator | Indicator type | 'check' \| 'radio' \| 'number' \| 'none' | 'radio'(single) / 'check'(multiple) | - | +| confirmable | Whether to show confirm button | boolean | false | - | +| confirmText | Confirm button text | React.ReactNode | - | - | +| fadeIn | Fade-in effect | boolean | - | - | +| fadeInLeft | Fade-in from left effect | boolean | - | - | +| classNames | Semantic structure class names | Record | - | - | +| styles | Semantic structure styles | Record | - | - | +| prefixCls | Prefix for style class names | string | - | - | +| rootClassName | Root node style class name | string | - | - | + +### ChoiceItemType + +| Property | Description | Type | Default | Version | +| --- | --- | --- | --- | --- | +| key | Unique identifier, also used as selected value | string \| number | - | - | +| label | Option label | React.ReactNode | - | - | +| description | Option description | React.ReactNode | - | - | +| icon | Option icon | React.ReactNode | - | - | +| disabled | Whether the option is disabled | boolean | false | - | +| disabledReason | Reason for disabling | React.ReactNode | - | - | +| recommended | AI recommended marker | boolean \| 'primary' \| 'secondary' | - | - | +| recommendedReason | Reason for recommendation | React.ReactNode | - | - | +| extra | Extra information | React.ReactNode | - | - | +| meta | Additional metadata | Record | - | - | +| children | Nested child options | ChoiceItemType[] | - | - | + +### ChangeInfo + +| Property | Description | Type | Version | +| ------------ | ----------------------------- | ------------------------------------ | ------- | +| value | Value after change | ChoiceValueType \| ChoiceValueType[] | - | +| items | All current items | ChoiceItemType[] | - | +| changedItems | Items involved in this change | ChoiceItemType[] | - | +| type | Change type | 'select' \| 'deselect' | - | + +### ChoiceRef + +| Property | Description | Type | Version | +| --- | --- | --- | --- | +| nativeElement | Native DOM element | HTMLDivElement | - | +| scrollTo | Scroll to a specific option | (key: ChoiceValueType) => void | - | +| getValue | Get current selected value | () => ChoiceValueType \| ChoiceValueType[] \| undefined | - | + +## Semantic DOM + + + +## Design Token + + diff --git a/packages/x/components/choice/index.tsx b/packages/x/components/choice/index.tsx new file mode 100644 index 0000000000..cfe1890cb1 --- /dev/null +++ b/packages/x/components/choice/index.tsx @@ -0,0 +1,263 @@ +import CSSMotion from '@rc-component/motion'; +import { composeRef } from '@rc-component/util/lib/ref'; +import { Button, Skeleton, Typography } from 'antd'; +import { clsx } from 'clsx'; +import React from 'react'; +import useProxyImperativeHandle from '../_util/hooks/use-proxy-imperative-handle'; +import useXComponentConfig from '../_util/hooks/use-x-component-config'; +import { useLocale } from '../locale'; +import enUS from '../locale/en_US'; +import { useXProviderContext } from '../x-provider'; +import { ChoiceContext } from './context'; +import useSelect from './hooks/use-select'; +import ChoiceItem from './Item'; +import type { ChoiceProps, ChoiceRef, ChoiceValueType } from './interface'; +import useStyle from './style'; + +const ForwardChoice = React.forwardRef((props, ref) => { + const { + prefixCls: customizePrefixCls, + items: rawItems, + mode = 'single', + layout = 'list', + value, + defaultValue, + onChange, + onItemClick, + onConfirm, + title, + description, + footer, + disabled = false, + loading = false, + maxCount, + indicator: customIndicator, + confirmable = false, + confirmText, + fadeIn, + fadeInLeft, + className, + rootClassName, + classNames = {}, + styles = {}, + style, + ...htmlProps + } = props; + + const items = rawItems || []; + + // ============================ PrefixCls ============================ + const { getPrefixCls, direction } = useXProviderContext(); + const prefixCls = getPrefixCls('choice', customizePrefixCls); + + // ===================== Component Config ========================= + const contextConfig = useXComponentConfig('choice'); + + // ============================ Style ============================ + const [hashId, cssVarCls] = useStyle(prefixCls); + + // ============================ Locale ============================ + const [locale] = useLocale('Choice', enUS.Choice); + + // ============================ Indicator ========================= + const indicator = customIndicator ?? (mode === 'single' ? 'radio' : 'check'); + + // ============================ Select ============================ + const { mergedValue, handleItemClick, isSelected } = useSelect({ + mode, + value, + defaultValue, + onChange, + maxCount, + disabled, + items, + }); + + // ============================= Refs ============================= + const containerRef = React.useRef(null); + const listRef = React.useRef(null); + + useProxyImperativeHandle(ref, () => ({ + nativeElement: containerRef.current!, + scrollTo: (key: ChoiceValueType) => { + const itemEl = listRef.current?.querySelector(`[${'data-key'}="${key}"]`); + itemEl?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, + getValue: () => { + if (mode === 'single') return mergedValue[0]; + return mergedValue; + }, + })); + + // ============================= Motion ============================= + const rootPrefixCls = getPrefixCls(); + const motionName = + fadeInLeft || fadeIn ? `${rootPrefixCls}-x-fade${fadeInLeft ? '-left' : ''}` : ''; + + // ============================ Render ============================ + const mergedCls = clsx( + prefixCls, + contextConfig.className, + className, + rootClassName, + classNames.root, + hashId, + cssVarCls, + { + [`${prefixCls}-rtl`]: direction === 'rtl', + }, + ); + + const mergedListCls = clsx( + `${prefixCls}-list`, + `${prefixCls}-layout-${layout}`, + contextConfig.classNames.list, + classNames.list, + ); + + const renderHeader = () => { + if (!title && !description) return null; + return ( +
+ {title && ( + + {title} + + )} + {description && ( +
+ {description} +
+ )} +
+ ); + }; + + const renderFooter = () => { + if (!footer && !confirmable) return null; + + const selectedCount = mergedValue.length; + const maxText = mode === 'multiple' && maxCount ? `${selectedCount}/${maxCount}` : null; + + return ( +
+ {footer} + {maxText && {maxText}} + {confirmable && ( + + )} +
+ ); + }; + + return ( + + {({ className: motionClassName }, motionRef) => { + return ( +
+ {/* Header */} + {renderHeader()} + + {/* List */} +
+ {loading ? ( +
+ +
+ ) : ( + { + handleItemClick(item, index); + onItemClick?.({ data: item, index }); + }, + classNames, + styles, + }} + > + {items.map((item, index) => { + return ( +
+ + {/* Nested children */} + {item.children && item.children.length > 0 && ( +
+ {item.children.map((child, childIndex) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Footer */} + {renderFooter()} +
+ ); + }} +
+ ); +}); + +type CompoundedChoice = typeof ForwardChoice; + +const Choice = ForwardChoice as CompoundedChoice; + +if (process.env.NODE_ENV !== 'production') { + Choice.displayName = 'Choice'; +} + +export default Choice; diff --git a/packages/x/components/choice/index.zh-CN.md b/packages/x/components/choice/index.zh-CN.md new file mode 100644 index 0000000000..1cc7a7e40b --- /dev/null +++ b/packages/x/components/choice/index.zh-CN.md @@ -0,0 +1,104 @@ +--- +category: Components +group: + title: 唤醒 + order: 1 +title: Choice +order: 10 +subtitle: 选择交互 +description: 用于在 AI 对话场景中呈现一组结构化选项供用户选择。 +cover: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original +coverDark: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original +--- + +## 何时使用 + +Choice 组件用于在 AI 对话场景中呈现一组结构化选项供用户选择。与 antd 的 `Select`、`Radio`、`Checkbox` 不同,Choice 专为 AI 对话上下文设计,支持推荐标记、禁用原因、多种布局模式、加载态等 AI 场景特有需求。 + +## 代码演示 + + +基本单选 +多选模式 +卡片布局 +网格布局 +推荐标记 +禁用与禁用原因 +指示器类型 +确认按钮 +加载态 +在对话气泡中使用 +受控模式 + +## API + +通用属性参考:[通用属性](/docs/react/common-props) + +### ChoiceProps + +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| --- | --- | --- | --- | --- | +| items | 选项列表 | ChoiceItemType[] | - | - | +| mode | 选择模式 | 'single' \| 'multiple' | 'single' | - | +| layout | 布局模式 | 'list' \| 'grid' \| 'card' | 'list' | - | +| value | 当前选中值(受控) | ChoiceValueType \| ChoiceValueType[] | - | - | +| defaultValue | 默认选中值 | ChoiceValueType \| ChoiceValueType[] | - | - | +| onChange | 选中变化回调 | (value, info: ChangeInfo) => void | - | - | +| onItemClick | 选项点击回调 | (info: { data: ChoiceItemType, index: number }) => void | - | - | +| onConfirm | 确认回调 | (value, info: ChangeInfo) => void | - | - | +| title | 标题 | React.ReactNode | - | - | +| description | 描述文本 | React.ReactNode | - | - | +| footer | 底部操作区 | React.ReactNode | - | - | +| disabled | 是否禁用 | boolean | false | - | +| loading | 是否显示加载态 | boolean | false | - | +| maxCount | 多选模式下最大可选数量 | number | - | - | +| indicator | 选择指示器类型 | 'check' \| 'radio' \| 'number' \| 'none' | 'radio'(single) / 'check'(multiple) | - | +| confirmable | 是否显示确认按钮 | boolean | false | - | +| confirmText | 确认按钮文案 | React.ReactNode | - | - | +| fadeIn | 渐入效果 | boolean | - | - | +| fadeInLeft | 从左到右渐入效果 | boolean | - | - | +| classNames | 语义化结构类名 | Record | - | - | +| styles | 语义化结构样式 | Record | - | - | +| prefixCls | 样式类名前缀 | string | - | - | +| rootClassName | 根节点样式类名 | string | - | - | + +### ChoiceItemType + +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| --- | --- | --- | --- | --- | +| key | 唯一标识,同时作为选中值 | string \| number | - | - | +| label | 选项标签 | React.ReactNode | - | - | +| description | 选项描述 | React.ReactNode | - | - | +| icon | 选项图标 | React.ReactNode | - | - | +| disabled | 是否禁用 | boolean | false | - | +| disabledReason | 禁用原因提示 | React.ReactNode | - | - | +| recommended | AI 推荐标记 | boolean \| 'primary' \| 'secondary' | - | - | +| recommendedReason | 推荐原因 | React.ReactNode | - | - | +| extra | 额外信息 | React.ReactNode | - | - | +| meta | 附加元数据 | Record | - | - | +| children | 嵌套子选项 | ChoiceItemType[] | - | - | + +### ChangeInfo + +| 属性 | 说明 | 类型 | 版本 | +| ------------ | ------------------ | ------------------------------------ | ---- | +| value | 变更后的值 | ChoiceValueType \| ChoiceValueType[] | - | +| items | 当前所有选项 | ChoiceItemType[] | - | +| changedItems | 本次变更涉及的选项 | ChoiceItemType[] | - | +| type | 变更类型 | 'select' \| 'deselect' | - | + +### ChoiceRef + +| 属性 | 说明 | 类型 | 版本 | +| ------------- | -------------- | ------------------------------------------------------- | ---- | +| nativeElement | 原生 DOM 元素 | HTMLDivElement | - | +| scrollTo | 滚动到指定选项 | (key: ChoiceValueType) => void | - | +| getValue | 获取当前选中值 | () => ChoiceValueType \| ChoiceValueType[] \| undefined | - | + +## Semantic DOM + + + +## 主题变量(Design Token) + + diff --git a/packages/x/components/choice/interface.ts b/packages/x/components/choice/interface.ts new file mode 100644 index 0000000000..21af13810d --- /dev/null +++ b/packages/x/components/choice/interface.ts @@ -0,0 +1,273 @@ +import type React from 'react'; + +export type ChoiceValueType = string | number; + +export type SemanticType = + | 'root' + | 'header' + | 'title' + | 'description' + | 'list' + | 'item' + | 'itemContent' + | 'indicator' + | 'footer'; + +export interface ChoiceItemType { + /** + * @desc 唯一标识,同时作为选中值 + * @descEN Unique identifier, also used as the selected value + */ + key: ChoiceValueType; + + /** + * @desc 选项标签 + * @descEN Option label + */ + label: React.ReactNode; + + /** + * @desc 选项描述,提供补充信息 + * @descEN Option description providing additional information + */ + description?: React.ReactNode; + + /** + * @desc 选项图标 + * @descEN Option icon + */ + icon?: React.ReactNode; + + /** + * @desc 是否禁用 + * @descEN Whether the option is disabled + */ + disabled?: boolean; + + /** + * @desc 禁用原因提示 + * @descEN Reason for disabling the option + */ + disabledReason?: React.ReactNode; + + /** + * @desc AI 推荐标记 + * @descEN AI recommended marker + */ + recommended?: boolean | 'primary' | 'secondary'; + + /** + * @desc 推荐原因 + * @descEN Reason for recommendation + */ + recommendedReason?: React.ReactNode; + + /** + * @desc 选项额外信息(如价格、标签等) + * @descEN Extra information for the option (e.g. price, tags) + */ + extra?: React.ReactNode; + + /** + * @desc 选项附加元数据,用于 LLM 结构化数据透传 + * @descEN Additional metadata for LLM structured data passthrough + */ + meta?: Record; + + /** + * @desc 嵌套子选项(用于分组场景) + * @descEN Nested child options for grouping scenarios + */ + children?: ChoiceItemType[]; +} + +export interface ChangeInfo { + /** + * @desc 变更后的值 + * @descEN Value after change + */ + value: ChoiceValueType | ChoiceValueType[]; + + /** + * @desc 当前所有选项 + * @descEN All current items + */ + items: ChoiceItemType[]; + + /** + * @desc 本次变更涉及的选项 + * @descEN Items involved in this change + */ + changedItems: ChoiceItemType[]; + + /** + * @desc 变更类型 + * @descEN Change type + */ + type: 'select' | 'deselect'; +} + +export interface ChoiceProps + extends Omit< + React.HTMLAttributes, + 'onSelect' | 'onChange' | 'defaultValue' | 'title' | 'description' + > { + /** + * @desc 选项列表 + * @descEN List of options + */ + items: ChoiceItemType[]; + + /** + * @desc 选择模式 + * @descEN Selection mode + * @default 'single' + */ + mode?: 'single' | 'multiple'; + + /** + * @desc 布局模式 + * @descEN Layout mode + * @default 'list' + */ + layout?: 'list' | 'grid' | 'card'; + + /** + * @desc 当前选中值(受控) + * @descEN Current selected value (controlled) + */ + value?: ChoiceValueType | ChoiceValueType[]; + + /** + * @desc 默认选中值 + * @descEN Default selected value + */ + defaultValue?: ChoiceValueType | ChoiceValueType[]; + + /** + * @desc 选中变化回调 + * @descEN Callback when selection changes + */ + onChange?: (value: ChoiceValueType | ChoiceValueType[], info: ChangeInfo) => void; + + /** + * @desc 选项点击回调(不改变选中状态) + * @descEN Callback when an option is clicked (does not change selection) + */ + onItemClick?: (info: { data: ChoiceItemType; index: number }) => void; + + /** + * @desc 确认回调,用户完成选择后触发 + * @descEN Confirm callback, triggered when user finishes selection + */ + onConfirm?: (value: ChoiceValueType | ChoiceValueType[], info: ChangeInfo) => void; + + /** + * @desc 标题 + * @descEN Title + */ + title?: React.ReactNode; + + /** + * @desc 描述文本,标题下方的辅助说明 + * @descEN Description text below the title + */ + description?: React.ReactNode; + + /** + * @desc 底部操作区(如确认按钮) + * @descEN Footer action area (e.g. confirm button) + */ + footer?: React.ReactNode; + + /** + * @desc 是否禁用 + * @descEN Whether to disable all options + * @default false + */ + disabled?: boolean; + + /** + * @desc 是否显示 loading 态 + * @descEN Whether to show loading state + * @default false + */ + loading?: boolean; + + /** + * @desc 多选模式下最大可选数量 + * @descEN Maximum selectable count in multiple mode + */ + maxCount?: number; + + /** + * @desc 选择指示器类型 + * @descEN Indicator type + */ + indicator?: 'check' | 'radio' | 'number' | 'none'; + + /** + * @desc 是否显示确认按钮 + * @descEN Whether to show confirm button + * @default false + */ + confirmable?: boolean; + + /** + * @desc 确认按钮文案 + * @descEN Confirm button text + */ + confirmText?: React.ReactNode; + + /** + * @desc 渐入动画 + * @descEN Fade-in animation + */ + fadeIn?: boolean; + + /** + * @desc 从左渐入动画 + * @descEN Fade-in from left animation + */ + fadeInLeft?: boolean; + + /** + * @desc 语义化结构 className + * @descEN Semantic structure class names + */ + classNames?: Partial>; + + /** + * @desc 语义化结构 style + * @descEN Semantic structure styles + */ + styles?: Partial>; + + /** + * @desc 样式类名的前缀 + * @descEN Prefix for style class names + */ + prefixCls?: string; + + /** + * @desc 根节点的样式类名 + * @descEN Root node style class name + */ + rootClassName?: string; +} + +export interface ChoiceRef { + nativeElement: HTMLDivElement; + + /** + * @desc 滚动到指定选项 + * @descEN Scroll to a specific option + */ + scrollTo: (key: ChoiceValueType) => void; + + /** + * @desc 获取当前选中值 + * @descEN Get current selected value + */ + getValue: () => ChoiceValueType | ChoiceValueType[] | undefined; +} diff --git a/packages/x/components/choice/style/index.ts b/packages/x/components/choice/style/index.ts new file mode 100644 index 0000000000..ad5430184d --- /dev/null +++ b/packages/x/components/choice/style/index.ts @@ -0,0 +1,443 @@ +import { unit } from '@ant-design/cssinjs'; +import { mergeToken } from '@ant-design/cssinjs-utils'; +import { initFadeLeftMotion, initFadeMotion } from '../../style'; +import { genStyleHooks } from '../../theme/genStyleUtils'; +import type { FullToken, GenerateStyle, GetDefaultToken } from '../../theme/interface'; + +export interface ComponentToken { + /** + * @desc 选项背景色 + * @descEN Item background color + */ + itemBg?: string; + /** + * @desc 选项悬停背景色 + * @descEN Item hover background color + */ + itemHoverBg?: string; + /** + * @desc 选项激活背景色 + * @descEN Item active background color + */ + itemActiveBg?: string; + /** + * @desc 选项选中背景色 + * @descEN Item selected background color + */ + itemSelectedBg?: string; + /** + * @desc 选项禁用背景色 + * @descEN Item disabled background color + */ + itemDisabledBg?: string; + /** + * @desc 选项边框色 + * @descEN Item border color + */ + itemBorderColor?: string; + /** + * @desc 选项选中边框色 + * @descEN Item selected border color + */ + itemSelectedBorderColor?: string; + /** + * @desc 推荐选项边框色 + * @descEN Recommended item border color + */ + itemRecommendedBorderColor?: string; + /** + * @desc 禁用选项边框色 + * @descEN Disabled item border color + */ + itemDisabledBorderColor?: string; + /** + * @desc 选项圆角 + * @descEN Item border radius + */ + itemBorderRadius?: number; + /** + * @desc 选项间距 + * @descEN Item gap + */ + itemGap?: number; + /** + * @desc 选项内边距 + * @descEN Item padding + */ + itemPadding?: number; + /** + * @desc 标题字号 + * @descEN Title font size + */ + titleFontSize?: number; + /** + * @desc 标题行高 + * @descEN Title line height + */ + titleLineHeight?: number; + /** + * @desc 标签字号 + * @descEN Label font size + */ + labelFontSize?: number; + /** + * @desc 标签行高 + * @descEN Label line height + */ + labelLineHeight?: number; + /** + * @desc 描述字号 + * @descEN Description font size + */ + descFontSize?: number; + /** + * @desc 描述行高 + * @descEN Description line height + */ + descLineHeight?: number; + /** + * @desc 头部内边距 + * @descEN Header padding + */ + headerPadding?: number; + /** + * @desc 列表内边距 + * @descEN List padding + */ + listPadding?: number; + /** + * @desc 底部内边距 + * @descEN Footer padding + */ + footerPadding?: number; + /** + * @desc 整体圆角 + * @descEN Container border radius + */ + borderRadius?: number; + /** + * @desc 网格布局列数 + * @descEN Grid columns + */ + gridColumns?: number; + /** + * @desc 动画时长 + * @descEN Motion duration + */ + motionDuration?: string; +} + +export interface ChoiceToken extends FullToken<'Choice'> { + itemBg: string; + itemHoverBg: string; + itemActiveBg: string; + itemSelectedBg: string; + itemSelectedBorderColor: string; + itemRecommendedBorderColor: string; + itemDisabledBorderColor: string; + itemDisabledBg: string; + itemBorderColor: string; + itemBorderRadius: number; + itemGap: number; + itemPadding: number; + titleFontSize: number; + titleLineHeight: number; + labelFontSize: number; + labelLineHeight: number; + descFontSize: number; + descLineHeight: number; + headerPadding: number; + listPadding: number; + footerPadding: number; + borderRadius: number; + gridColumns: number; + motionDuration: string; +} + +const genChoiceStyle: GenerateStyle = (token) => { + const { componentCls } = token; + + return { + [componentCls]: { + '&, & *': { + boxSizing: 'border-box', + }, + + maxWidth: '100%', + + [`&${componentCls}-rtl`]: { + direction: 'rtl', + }, + + // ======================== Header ======================== + [`${componentCls}-header`]: { + marginBottom: token.paddingSM, + padding: unit(token.headerPadding), + }, + + [`${componentCls}-title`]: { + marginBlockStart: 0, + marginBlockEnd: token.paddingXXS, + fontWeight: 'normal', + color: token.colorTextHeading, + fontSize: unit(token.titleFontSize), + lineHeight: unit(token.titleLineHeight), + }, + + [`${componentCls}-description`]: { + color: token.colorTextTertiary, + fontSize: unit(token.descFontSize), + lineHeight: unit(token.descLineHeight), + }, + + // ======================== List ======================== + [`${componentCls}-list`]: { + display: 'flex', + flexDirection: 'column', + gap: unit(token.itemGap), + listStyle: 'none', + padding: unit(token.listPadding), + margin: 0, + }, + + // ======================== Layouts ======================== + // Grid layout + [`${componentCls}-layout-grid`]: { + display: 'grid', + gridTemplateColumns: `repeat(${token.gridColumns}, 1fr)`, + gap: unit(token.itemGap), + }, + + // Card layout + [`${componentCls}-layout-card`]: { + display: 'grid', + gridTemplateColumns: `repeat(${token.gridColumns}, 1fr)`, + gap: unit(token.itemGap), + }, + + // ======================== Item ======================== + [`${componentCls}-item`]: { + position: 'relative', + display: 'flex', + alignItems: 'flex-start', + gap: token.paddingXS, + padding: unit(token.itemPadding), + background: token.itemBg, + border: `${unit(token.lineWidth)} ${token.lineType} ${token.itemBorderColor}`, + borderRadius: unit(token.itemBorderRadius), + cursor: 'pointer', + transition: `all ${token.motionDuration} ${token.motionEaseInOut}`, + minHeight: token.controlHeight, + + '&:hover': { + background: token.itemHoverBg, + }, + + '&:active': { + background: token.itemActiveBg, + }, + + // Selected state + [`&${componentCls}-item-selected`]: { + background: token.itemSelectedBg, + borderColor: token.itemSelectedBorderColor, + }, + + // Disabled state + [`&${componentCls}-item-disabled`]: { + cursor: 'not-allowed', + background: token.itemDisabledBg, + borderColor: token.itemDisabledBorderColor, + opacity: 0.6, + pointerEvents: 'none', + }, + + // Recommended - primary + [`&${componentCls}-item-recommended-primary`]: { + borderColor: token.itemRecommendedBorderColor, + [`&${componentCls}-item-selected`]: { + borderColor: token.itemSelectedBorderColor, + }, + }, + + // Recommended - secondary + [`&${componentCls}-item-recommended-secondary`]: { + borderColor: token.colorInfo, + }, + }, + + // ======================== Indicator ======================== + [`${componentCls}-indicator`]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + color: token.colorPrimary, + fontSize: token.fontSize, + marginTop: 2, + }, + + [`${componentCls}-indicator-number`]: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: token.controlHeightSM, + height: token.controlHeightSM, + borderRadius: '50%', + border: `${unit(token.lineWidth)} ${token.lineType} ${token.colorBorder}`, + fontSize: token.fontSizeSM, + color: token.colorTextTertiary, + }, + + // ======================== Item Content ======================== + [`${componentCls}-item-content`]: { + flex: 'auto', + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: token.paddingXXS, + }, + + [`${componentCls}-item-icon`]: { + display: 'inline-flex', + alignItems: 'center', + fontSize: token.fontSizeLG, + }, + + [`${componentCls}-item-label`]: { + color: token.colorTextHeading, + fontWeight: 500, + fontSize: unit(token.labelFontSize), + lineHeight: unit(token.labelLineHeight), + }, + + [`${componentCls}-item-desc`]: { + color: token.colorTextTertiary, + fontSize: unit(token.descFontSize), + lineHeight: unit(token.descLineHeight), + }, + + [`${componentCls}-item-extra`]: { + marginTop: token.paddingXXS, + }, + + // ======================== Recommend Badge ======================== + [`${componentCls}-item-recommend-badge`]: { + position: 'absolute', + top: 0, + insetInlineEnd: 0, + padding: `${unit(token.paddingXXS)} ${unit(token.paddingXS)}`, + fontSize: token.fontSizeSM, + color: token.colorPrimary, + background: token.colorPrimaryBg, + borderRadius: `0 ${unit(token.itemBorderRadius)} 0 ${unit(token.borderRadiusSM)}`, + fontWeight: 500, + lineHeight: token.lineHeight, + + [`&${componentCls}-item-recommend-badge-secondary`]: { + color: token.colorInfo, + background: token.colorInfoBg, + }, + }, + + // ======================== Disabled Reason ======================== + [`${componentCls}-item-disabled-reason`]: { + marginTop: token.paddingXXS, + fontSize: token.fontSizeSM, + color: token.colorTextQuaternary, + }, + + // ======================== Nested ======================== + [`${componentCls}-nested`]: { + marginLeft: token.controlHeightSM, + marginTop: token.paddingXS, + display: 'flex', + flexDirection: 'column', + gap: token.paddingXS, + }, + + // ======================== Footer ======================== + [`${componentCls}-footer`]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: token.paddingSM, + marginTop: token.paddingSM, + padding: unit(token.footerPadding), + }, + + [`${componentCls}-footer-count`]: { + color: token.colorTextTertiary, + fontSize: token.fontSizeSM, + marginInlineEnd: 'auto', + }, + + // ======================== Loading ======================== + [`${componentCls}-loading`]: { + padding: token.padding, + }, + + // ======================== Card Layout Item ======================== + [`${componentCls}-layout-card`]: { + [`${componentCls}-item`]: { + flexDirection: 'column', + alignItems: 'stretch', + minHeight: 'auto', + padding: token.paddingMD, + + [`${componentCls}-indicator`]: { + position: 'absolute', + top: token.paddingSM, + insetInlineStart: token.paddingSM, + }, + + [`${componentCls}-item-content`]: { + alignItems: 'center', + textAlign: 'center', + marginTop: token.paddingSM, + }, + }, + }, + }, + }; +}; + +export const prepareComponentToken: GetDefaultToken<'Choice'> = (token) => ({ + itemBg: token.colorBgContainer, + itemHoverBg: token.colorFillTertiary, + itemActiveBg: token.colorFill, + itemSelectedBg: token.colorPrimaryBg, + itemSelectedBorderColor: token.colorPrimary, + itemRecommendedBorderColor: token.colorPrimary, + itemDisabledBorderColor: token.colorBorderSecondary, + itemDisabledBg: token.colorBgContainerDisabled, + itemBorderColor: token.colorBorderSecondary, + itemBorderRadius: token.borderRadiusLG, + itemGap: token.paddingXS, + itemPadding: token.paddingSM, + titleFontSize: token.fontSizeLG, + titleLineHeight: token.lineHeightLG, + labelFontSize: token.fontSize, + labelLineHeight: token.lineHeight, + descFontSize: token.fontSizeSM, + descLineHeight: token.lineHeight, + headerPadding: 0, + listPadding: 0, + footerPadding: 0, + borderRadius: token.borderRadius, + gridColumns: 2, + motionDuration: token.motionDurationMid, +}); + +export default genStyleHooks( + 'Choice', + (token) => { + const compToken = mergeToken(token, {}); + return [ + genChoiceStyle(compToken), + initFadeLeftMotion(compToken, true), + initFadeMotion(compToken, true), + ]; + }, + prepareComponentToken, +); diff --git a/packages/x/components/index.ts b/packages/x/components/index.ts index 2babc737b5..22d8dae777 100644 --- a/packages/x/components/index.ts +++ b/packages/x/components/index.ts @@ -6,6 +6,14 @@ export type { AttachmentsProps } from './attachments'; export { default as Attachments } from './attachments'; export type { BubbleItemType, BubbleListProps, BubbleProps } from './bubble'; export { default as Bubble } from './bubble'; +export { default as Choice } from './choice'; +export type { + ChangeInfo, + ChoiceItemType, + ChoiceProps, + ChoiceRef, + ChoiceValueType, +} from './choice/interface'; export type { CodeHighlighterProps } from './code-highlighter'; export { default as CodeHighlighter } from './code-highlighter'; export type { ConversationItemType, ConversationsProps } from './conversations'; diff --git a/packages/x/components/locale/en_US.ts b/packages/x/components/locale/en_US.ts index 682c939884..a920b2af74 100644 --- a/packages/x/components/locale/en_US.ts +++ b/packages/x/components/locale/en_US.ts @@ -35,6 +35,12 @@ const localeValues: Required = { noService: 'File content service not configured', loadFailed: 'Failed to load file', }, + Choice: { + confirmText: 'Confirm', + recommended: 'Recommended', + maxSelected: '{count}/{max} selected', + disabledHint: 'Unavailable', + }, }; export default localeValues; diff --git a/packages/x/components/locale/index.tsx b/packages/x/components/locale/index.tsx index 7a96ccafd6..516cd26a5c 100644 --- a/packages/x/components/locale/index.tsx +++ b/packages/x/components/locale/index.tsx @@ -43,6 +43,12 @@ export interface xLocale { noService: string; loadFailed: string; }; + Choice?: { + confirmText: string; + recommended: string; + maxSelected: string; + disabledHint: string; + }; } export type Locale = xLocale & antdLocale; diff --git a/packages/x/components/locale/zh_CN.ts b/packages/x/components/locale/zh_CN.ts index bfb45773b3..ea7ce06237 100644 --- a/packages/x/components/locale/zh_CN.ts +++ b/packages/x/components/locale/zh_CN.ts @@ -35,6 +35,12 @@ const localeValues: Required = { noService: '未配置文件内容服务', loadFailed: '加载文件失败', }, + Choice: { + confirmText: '确认', + recommended: '推荐', + maxSelected: '已选 {count}/{max} 项', + disabledHint: '暂不可用', + }, }; export default localeValues; diff --git a/packages/x/components/theme/interface/components.ts b/packages/x/components/theme/interface/components.ts index 60a191f865..8bcadaad3f 100644 --- a/packages/x/components/theme/interface/components.ts +++ b/packages/x/components/theme/interface/components.ts @@ -1,6 +1,7 @@ import type { ComponentToken as ActionsToken } from '../../actions/style'; import type { ComponentToken as AttachmentsToken } from '../../attachments/style'; import type { ComponentToken as BubbleComponentToken } from '../../bubble/style'; +import type { ComponentToken as ChoiceComponentToken } from '../../choice/style'; import type { ComponentToken as CodeHighlighterComponentToken } from '../../code-highlighter/style'; import type { ComponentToken as ConversationsComponentToken } from '../../conversations/style'; import type { ComponentToken as FileCardComponentToken } from '../../file-card/style'; @@ -17,6 +18,7 @@ import type { ComponentToken as WelcomeComponentToken } from '../../welcome/styl export interface ComponentTokenMap { Attachments?: AttachmentsToken; Bubble?: BubbleComponentToken; + Choice?: ChoiceComponentToken; Conversations?: ConversationsComponentToken; Prompts?: PromptsComponentToken; Sender?: SenderComponentToken; diff --git a/packages/x/components/x-provider/context.ts b/packages/x/components/x-provider/context.ts index 594e84d8da..499910ff40 100644 --- a/packages/x/components/x-provider/context.ts +++ b/packages/x/components/x-provider/context.ts @@ -4,6 +4,7 @@ import type { AnyObject, ShortcutKeys } from '../_util/type'; import type { ActionsProps } from '../actions/interface'; import type { AttachmentsProps } from '../attachments'; import type { BubbleProps } from '../bubble'; +import type { ChoiceProps } from '../choice/interface'; import type { CodeHighlighterProps } from '../code-highlighter'; import type { ConversationsProps } from '../conversations'; import type { FileCardProps } from '../file-card'; @@ -36,6 +37,7 @@ type ComponentConfig< export interface XComponentsConfig { bubble?: ComponentConfig; + choice?: ComponentConfig; conversations?: ComponentConfig; prompts?: ComponentConfig; sender?: ComponentConfig;