From 35675fd2930d0753dc734e1a3af56bddd28954d6 Mon Sep 17 00:00:00 2001 From: doki- <1335902682@qq.com> Date: Mon, 10 Nov 2025 18:13:09 +0800 Subject: [PATCH 1/6] feat(Sender): tag slot add clear btn --- packages/x/components/sender/SlotTextArea.tsx | 42 ++++++++++++++++++- .../__snapshots__/demo-extend.test.ts.snap | 30 ++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/x/components/sender/SlotTextArea.tsx b/packages/x/components/sender/SlotTextArea.tsx index 95a723d41..893500fe4 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]; @@ -195,7 +218,22 @@ const SlotTextArea = React.forwardRef((_, ref) => { ); case 'tag': - return
{node.props?.label || ''}
; + return ( +
+ {node.props?.label || ''} + {!readOnly && ( + e.preventDefault()} + onClick={(e) => { + removeTagSlot(node.key as string, e as unknown as EventType); + }} + > + + + )} +
+ ); case 'custom': return node.customRender?.( value, 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..cacf98a57 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 From 3d20d90ac7380e3cfecfe8b73508604612ec513e Mon Sep 17 00:00:00 2001 From: doki- <1335902682@qq.com> Date: Tue, 11 Nov 2025 14:51:36 +0800 Subject: [PATCH 2/6] docs(Sender): update tag slot docs --- packages/x/components/sender/SlotTextArea.tsx | 30 ++++++++++++++++--- .../__snapshots__/demo-extend.test.ts.snap | 2 +- .../x/components/sender/demo/slot-filling.tsx | 2 +- packages/x/components/sender/index.en-US.md | 9 +++--- packages/x/components/sender/index.zh-CN.md | 9 +++--- packages/x/components/sender/interface.ts | 1 + .../components/sender/style/slot-textarea.ts | 6 ++++ 7 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/x/components/sender/SlotTextArea.tsx b/packages/x/components/sender/SlotTextArea.tsx index 893500fe4..5443b5c61 100644 --- a/packages/x/components/sender/SlotTextArea.tsx +++ b/packages/x/components/sender/SlotTextArea.tsx @@ -217,23 +217,38 @@ const SlotTextArea = React.forwardRef((_, ref) => { ); - case 'tag': + 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 && ( + {!readOnly && allowClear && ( e.preventDefault()} onClick={(e) => { removeTagSlot(node.key as string, e as unknown as EventType); }} > - + {clearIcon} )}
); + } case 'custom': return node.customRender?.( value, @@ -356,6 +371,10 @@ const SlotTextArea = React.forwardRef((_, ref) => { } const currentRange = selection?.rangeCount > 0 ? selection?.getRangeAt?.(0) : null; const range = lastSelectionRef.current || currentRange; + console.log(range); + + console.log(range?.endContainer, '-------'); + if (range) { if ((range.endContainer as HTMLElement)?.className?.includes(`${prefixCls}-slot`)) { return { @@ -506,6 +525,9 @@ const SlotTextArea = React.forwardRef((_, ref) => { if (!editableDom || !selection) return; const slotNode = getSlotListNode(slotConfig); const { type, range: lastRage } = getInsertPosition(position); + console.log(type, '-------'); + console.log(lastRage, '-------'); + 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 cacf98a57..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 @@ -2380,7 +2380,7 @@ exports[`renders components/sender/demo/slot-filling.tsx extend context correctl @ Chuck = (token) => { position: 'relative', cursor: 'default', }, + [`${slotTagCls}-clear-icon`]: { + marginInlineStart: token.marginXXS, + fontSize: token.fontSize, + lineHeight: token.lineHeight, + cursor: 'pointer', + }, }; }; From ad19484a852cd59a8ce0244d9ba57f3f09590fde Mon Sep 17 00:00:00 2001 From: doki- <1335902682@qq.com> Date: Tue, 11 Nov 2025 14:54:37 +0800 Subject: [PATCH 3/6] fix(docs): fix docs --- packages/x/components/sender/index.en-US.md | 10 +++++----- packages/x/components/sender/index.zh-CN.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/x/components/sender/index.en-US.md b/packages/x/components/sender/index.en-US.md index 9826b9c5b..76520fcbd 100644 --- a/packages/x/components/sender/index.en-US.md +++ b/packages/x/components/sender/index.en-US.md @@ -122,11 +122,11 @@ type ActionsComponents = { ##### tag node properties -| 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 | - | +| 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 4741634c0..52373879f 100644 --- a/packages/x/components/sender/index.zh-CN.md +++ b/packages/x/components/sender/index.zh-CN.md @@ -123,11 +123,11 @@ type ActionsComponents = { ##### tag 节点属性 -| 属性 | 说明 | 类型 | 默认值 | 版本 | -| ---------------- | -------------- | --------- | ------------------------ | ----- | --- | -| props.label | 标签内容,必填 | ReactNode | - | - | -| props.value | 标签值 | string | - | - | -| props.allowClear | 是否可关闭 | boolean | { clearIcon: ReactNode } | false | - | +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| ---------------- | -------------- | ----------------------------------- | ------ | ---- | +| props.label | 标签内容,必填 | ReactNode | - | - | +| props.value | 标签值 | string | - | - | +| props.allowClear | 是否可关闭 | boolean \| { clearIcon: ReactNode } | false | - | ##### custom 节点属性 From b8be7f8ef7688c544684d5786a3ee693e89268d1 Mon Sep 17 00:00:00 2001 From: doki- <1335902682@qq.com> Date: Tue, 11 Nov 2025 15:26:55 +0800 Subject: [PATCH 4/6] test: add test case --- .../components/sender/__tests__/slot.test.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index d32b23052..dbcf2897d 100644 --- a/packages/x/components/sender/__tests__/slot.test.tsx +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -688,6 +688,84 @@ describe('Sender.SlotTextArea', () => { }); }); + 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(); From 59dfe1a43150c647e90de028b03ae59b2a10c444 Mon Sep 17 00:00:00 2001 From: doki- <1335902682@qq.com> Date: Tue, 11 Nov 2025 18:00:58 +0800 Subject: [PATCH 5/6] fix: delete log --- packages/x/components/sender/SlotTextArea.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/x/components/sender/SlotTextArea.tsx b/packages/x/components/sender/SlotTextArea.tsx index 5443b5c61..bf0772c76 100644 --- a/packages/x/components/sender/SlotTextArea.tsx +++ b/packages/x/components/sender/SlotTextArea.tsx @@ -371,9 +371,6 @@ const SlotTextArea = React.forwardRef((_, ref) => { } const currentRange = selection?.rangeCount > 0 ? selection?.getRangeAt?.(0) : null; const range = lastSelectionRef.current || currentRange; - console.log(range); - - console.log(range?.endContainer, '-------'); if (range) { if ((range.endContainer as HTMLElement)?.className?.includes(`${prefixCls}-slot`)) { @@ -525,8 +522,6 @@ const SlotTextArea = React.forwardRef((_, ref) => { if (!editableDom || !selection) return; const slotNode = getSlotListNode(slotConfig); const { type, range: lastRage } = getInsertPosition(position); - console.log(type, '-------'); - console.log(lastRage, '-------'); let range: Range = document.createRange(); slotConfigRef.current = [...slotConfigRef.current, ...slotConfig]; From 6e3d0a40c49f856b4e8076c6cbc91a1ddba1f842 Mon Sep 17 00:00:00 2001 From: doki- <1335902682@qq.com> Date: Tue, 11 Nov 2025 19:32:52 +0800 Subject: [PATCH 6/6] test: add test case --- .../components/sender/__tests__/slot.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index dbcf2897d..1c8503c94 100644 --- a/packages/x/components/sender/__tests__/slot.test.tsx +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -688,6 +688,89 @@ 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();