diff --git a/packages/x/components/sender/SlotTextArea.tsx b/packages/x/components/sender/SlotTextArea.tsx index 95a723d41..bf0772c76 100644 --- a/packages/x/components/sender/SlotTextArea.tsx +++ b/packages/x/components/sender/SlotTextArea.tsx @@ -1,4 +1,4 @@ -import { CaretDownFilled } from '@ant-design/icons'; +import { CaretDownFilled, CloseOutlined } from '@ant-design/icons'; import { Dropdown, Input, InputRef } from 'antd'; import classnames from 'classnames'; import pickAttrs from 'rc-util/lib/pickAttrs'; @@ -137,6 +137,29 @@ const SlotTextArea = React.forwardRef((_, ref) => { } }; + const removeTagSlot = (key: string, e?: EventType) => { + const span = getSlotDom(key); + if (span && editableRef.current && editableRef.current.contains(span)) { + editableRef.current.removeChild(span); + } + slotDomMap.current.delete(key); + // 移除配置与值 + slotConfigRef.current = (slotConfigRef.current || []).filter((item) => item.key !== key); + setSlotValues((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + setSlotPlaceholders((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + // 触发 onChange + const newValue = getEditorValue(); + onChange?.(newValue.value, e, newValue.config); + }; + const renderSlot = (node: SlotConfigType, slotSpan: HTMLSpanElement) => { if (!node.key) return null; const value = getSlotValues()[node.key]; @@ -194,8 +217,38 @@ const SlotTextArea = React.forwardRef((_, ref) => { ); - case 'tag': - return
{node.props?.label || ''}
; + case 'tag': { + const allowClear = + typeof node.props?.allowClear === 'boolean' + ? node.props?.allowClear + : node.props?.allowClear?.clearIcon; + const clearIcon = + typeof node.props?.allowClear === 'object' ? ( + node.props?.allowClear?.clearIcon ? ( + node.props?.allowClear?.clearIcon + ) : ( + + ) + ) : ( + + ); + return ( +
+ {node.props?.label || ''} + {!readOnly && allowClear && ( + e.preventDefault()} + onClick={(e) => { + removeTagSlot(node.key as string, e as unknown as EventType); + }} + > + {clearIcon} + + )} +
+ ); + } case 'custom': return node.customRender?.( value, @@ -318,6 +371,7 @@ const SlotTextArea = React.forwardRef((_, ref) => { } const currentRange = selection?.rangeCount > 0 ? selection?.getRangeAt?.(0) : null; const range = lastSelectionRef.current || currentRange; + if (range) { if ((range.endContainer as HTMLElement)?.className?.includes(`${prefixCls}-slot`)) { return { @@ -468,6 +522,7 @@ const SlotTextArea = React.forwardRef((_, ref) => { if (!editableDom || !selection) return; const slotNode = getSlotListNode(slotConfig); const { type, range: lastRage } = getInsertPosition(position); + let range: Range = document.createRange(); slotConfigRef.current = [...slotConfigRef.current, ...slotConfig]; setSlotValues(slotConfig); diff --git a/packages/x/components/sender/__tests__/__snapshots__/demo-extend.test.ts.snap b/packages/x/components/sender/__tests__/__snapshots__/demo-extend.test.ts.snap index d1c696c66..b58357425 100644 --- a/packages/x/components/sender/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/packages/x/components/sender/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -2374,7 +2374,35 @@ exports[`renders components/sender/demo/slot-filling.tsx extend context correctl
- @ Chuck + + @ Chuck + + + + + +
, the date is diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index d32b23052..1c8503c94 100644 --- a/packages/x/components/sender/__tests__/slot.test.tsx +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -688,6 +688,167 @@ describe('Sender.SlotTextArea', () => { }); }); + describe('Tag allowClear', () => { + it('renders clear icon and removes tag when allowClear=true', () => { + const onChange = jest.fn(); + const ref = React.createRef>(); + const { container, getByText } = render( + , + ); + // 标签与清除按钮 + expect(getByText('T1')).toBeInTheDocument(); + const clear = container.querySelector('.ant-sender-slot-tag-clear-icon') as HTMLElement; + expect(clear).toBeInTheDocument(); + // 点击清除 + fireEvent.click(clear); + // 触发变更,且配置中不再包含该 tag + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect((last[2] as any[]).some((c) => c.key === 't1')).toBeFalsy(); + }); + + it('renders custom clearIcon when allowClear is object with clearIcon', () => { + const onChange = jest.fn(); + const CustomIcon = () => X; + const { container, getByTestId } = render( + } }, + }, + ]} + />, + ); + expect(getByTestId('custom-x')).toBeInTheDocument(); + const clear = container.querySelector('.ant-sender-slot-tag-clear-icon') as HTMLElement; + fireEvent.click(clear); + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect((last[2] as any[]).some((c) => c.key === 't2')).toBeFalsy(); + }); + + it('does not render clear icon when allowClear is object without clearIcon', () => { + const { container, getByText } = render( + , + ); + expect(getByText('T3')).toBeInTheDocument(); + expect(container.querySelector('.ant-sender-slot-tag-clear-icon')).toBeNull(); + }); + + it('does not render clear icon when readOnly=true even if allowClear=true', () => { + const { container } = render( + , + ); + expect(container.querySelector('.ant-sender-slot-tag-clear-icon')).toBeNull(); + }); + }); + + describe('Tag allowClear', () => { + it('should render remove when allowClear=true and remove tag on click', () => { + const onChange = jest.fn(); + const ref = React.createRef>(); + const { container, getByText } = render( + , + ); + + expect(getByText('T1')).toBeInTheDocument(); + const clear = container.querySelector('.ant-sender-slot-tag-clear-icon') as HTMLElement; + expect(clear).toBeInTheDocument(); + + fireEvent.click(clear); + + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0]).toContain('A '); + expect(lastCall[0]).toContain(' B'); + expect((lastCall[2] as any[]).some((c) => c.key === 't1')).toBeFalsy(); + }); + + it('should render custom clearIcon and work when allowClear is object', () => { + const onChange = jest.fn(); + const CustomIcon = () => X; + const { container, getByTestId } = render( + } }, + }, + ]} + />, + ); + + expect(getByTestId('custom-x')).toBeInTheDocument(); + const clear = container.querySelector('.ant-sender-slot-tag-clear-icon') as HTMLElement; + fireEvent.click(clear); + + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect((lastCall[2] as any[]).some((c) => c.key === 't2')).toBeFalsy(); + }); + + it('should not render clear when readOnly', () => { + const { container } = render( + , + ); + expect(container.querySelector('.ant-sender-slot-tag-clear-icon')).toBeNull(); + }); + + it('should not render clear when allowClear is false', () => { + const { container } = render( + , + ); + expect(container.querySelector('.ant-sender-slot-tag-clear-icon')).toBeNull(); + }); + }); + describe('Edge Cases & Error Handling', () => { it('should handle null/undefined slotConfig', () => { const { rerender } = render(); diff --git a/packages/x/components/sender/demo/slot-filling.tsx b/packages/x/components/sender/demo/slot-filling.tsx index 90b3853fc..205dcd6c7 100644 --- a/packages/x/components/sender/demo/slot-filling.tsx +++ b/packages/x/components/sender/demo/slot-filling.tsx @@ -16,7 +16,7 @@ const otherSlotConfig: SlotConfig = [ }, }, { type: 'text', value: 'for a trip with ' }, - { type: 'tag', key: 'tag', props: { label: '@ Chuck', value: 'a man' } }, + { type: 'tag', key: 'tag', props: { label: '@ Chuck', value: 'a man', allowClear: true } }, { type: 'text', value: ', the date is ' }, { type: 'input', diff --git a/packages/x/components/sender/index.en-US.md b/packages/x/components/sender/index.en-US.md index 31087a050..76520fcbd 100644 --- a/packages/x/components/sender/index.en-US.md +++ b/packages/x/components/sender/index.en-US.md @@ -122,10 +122,11 @@ type ActionsComponents = { ##### tag node properties -| Property | Description | Type | Default | Version | -| ----------- | --------------------- | --------- | ------- | ------- | -| props.label | Tag content, required | ReactNode | - | - | -| props.value | Tag value | string | - | - | +| Property | Description | Type | Default | Version | +| --- | --- | --- | --- | --- | +| props.label | Tag content, required | ReactNode | - | - | +| props.value | Tag value | string | - | - | +| props.allowClear | Whether it can be closed | boolean \| { clearIcon: ReactNode } | false | - | ##### custom node properties diff --git a/packages/x/components/sender/index.zh-CN.md b/packages/x/components/sender/index.zh-CN.md index 84a8d4a00..52373879f 100644 --- a/packages/x/components/sender/index.zh-CN.md +++ b/packages/x/components/sender/index.zh-CN.md @@ -123,10 +123,11 @@ type ActionsComponents = { ##### tag 节点属性 -| 属性 | 说明 | 类型 | 默认值 | 版本 | -| ----------- | -------------- | --------- | ------ | ---- | -| props.label | 标签内容,必填 | ReactNode | - | - | -| props.value | 标签值 | string | - | - | +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| ---------------- | -------------- | ----------------------------------- | ------ | ---- | +| props.label | 标签内容,必填 | ReactNode | - | - | +| props.value | 标签值 | string | - | - | +| props.allowClear | 是否可关闭 | boolean \| { clearIcon: ReactNode } | false | - | ##### custom 节点属性 diff --git a/packages/x/components/sender/interface.ts b/packages/x/components/sender/interface.ts index 69a0bb072..c63ee2373 100644 --- a/packages/x/components/sender/interface.ts +++ b/packages/x/components/sender/interface.ts @@ -66,6 +66,7 @@ interface SlotConfigTagType extends SlotConfigBaseType { props?: { label: React.ReactNode; value?: string; + allowClear?: boolean | { clearIcon?: React.ReactNode }; }; } diff --git a/packages/x/components/sender/style/slot-textarea.ts b/packages/x/components/sender/style/slot-textarea.ts index 7e5d4debd..23fdf19ed 100644 --- a/packages/x/components/sender/style/slot-textarea.ts +++ b/packages/x/components/sender/style/slot-textarea.ts @@ -134,6 +134,12 @@ const genSlotTextAreaStyle: GenerateStyle = (token) => { position: 'relative', cursor: 'default', }, + [`${slotTagCls}-clear-icon`]: { + marginInlineStart: token.marginXXS, + fontSize: token.fontSize, + lineHeight: token.lineHeight, + cursor: 'pointer', + }, }; };