Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions packages/x/components/sender/SlotTextArea.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -137,6 +137,29 @@ const SlotTextArea = React.forwardRef<SlotTextAreaRef>((_, 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];
Expand Down Expand Up @@ -194,8 +217,38 @@ const SlotTextArea = React.forwardRef<SlotTextAreaRef>((_, ref) => {
</span>
</Dropdown>
);
case 'tag':
return <div className={`${prefixCls}-slot-tag`}>{node.props?.label || ''}</div>;
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
) : (
<CloseOutlined />
)
) : (
<CloseOutlined />
);
return (
<div className={`${prefixCls}-slot-tag`}>
<span className={`${prefixCls}-slot-tag-label`}>{node.props?.label || ''}</span>
{!readOnly && allowClear && (
<span
className={`${prefixCls}-slot-tag-clear-icon`}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
removeTagSlot(node.key as string, e as unknown as EventType);
}}
Comment on lines +242 to +244
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Following the change in removeTagSlot, the onClick handler should be updated to not pass the event object. The event from onClick is a MouseEvent, which is incompatible with the EventType expected by onChange.

Suggested change
onClick={(e) => {
removeTagSlot(node.key as string, e as unknown as EventType);
}}
onClick={() => {
removeTagSlot(node.key as string);
}}

>
{clearIcon}
</span>
)}
</div>
);
}
case 'custom':
return node.customRender?.(
value,
Expand Down Expand Up @@ -318,6 +371,7 @@ const SlotTextArea = React.forwardRef<SlotTextAreaRef>((_, 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 {
Expand Down Expand Up @@ -468,6 +522,7 @@ const SlotTextArea = React.forwardRef<SlotTextAreaRef>((_, 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2374,7 +2374,35 @@ exports[`renders components/sender/demo/slot-filling.tsx extend context correctl
<div
class="ant-sender-slot-tag"
>
@ Chuck
<span
class="ant-sender-slot-tag-label"
>
@ Chuck
</span>
<span
class="ant-sender-slot-tag-clear-icon"
>
<span
aria-label="close"
class="anticon anticon-close"
role="img"
>
<svg
aria-hidden="true"
data-icon="close"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
</span>
</div>
</span>
, the date is
Expand Down
161 changes: 161 additions & 0 deletions packages/x/components/sender/__tests__/slot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GetRef<typeof Sender>>();
const { container, getByText } = render(
<Sender
ref={ref}
onChange={onChange}
slotConfig={[
{ type: 'text', value: 'A ' },
{ type: 'tag', key: 't1', props: { label: 'T1', value: 'v1', allowClear: true } },
{ type: 'text', value: ' B' },
]}
/>,
);
// 标签与清除按钮
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 = () => <span data-testid="custom-x">X</span>;
const { container, getByTestId } = render(
<Sender
onChange={onChange}
slotConfig={[
{
type: 'tag',
key: 't2',
props: { label: 'T2', value: 'v2', allowClear: { clearIcon: <CustomIcon /> } },
},
]}
/>,
);
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(
<Sender
slotConfig={[
{
type: 'tag',
key: 't3',
props: { label: 'T3', value: 'v3', allowClear: {} },
},
]}
/>,
);
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(
<Sender
readOnly
slotConfig={[
{
type: 'tag',
key: 't4',
props: { label: 'T4', value: 'v4', allowClear: true },
},
]}
/>,
);
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<GetRef<typeof Sender>>();
const { container, getByText } = render(
<Sender
ref={ref}
onChange={onChange}
slotConfig={[
{ type: 'text', value: 'A ' },
{ type: 'tag', key: 't1', props: { label: 'T1', value: 'v1', allowClear: true } },
{ type: 'text', value: ' B' },
]}
/>,
);

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 = () => <span data-testid="custom-x">X</span>;
const { container, getByTestId } = render(
<Sender
onChange={onChange}
slotConfig={[
{
type: 'tag',
key: 't2',
props: { label: 'T2', value: 'v2', allowClear: { clearIcon: <CustomIcon /> } },
},
]}
/>,
);

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(
<Sender
readOnly
slotConfig={[
{ type: 'tag', key: 't3', props: { label: 'T3', value: 'v3', allowClear: true } },
]}
/>,
);
expect(container.querySelector('.ant-sender-slot-tag-clear-icon')).toBeNull();
});

it('should not render clear when allowClear is false', () => {
const { container } = render(
<Sender
slotConfig={[
{ type: 'tag', key: 't4', props: { label: 'T4', value: 'v4', allowClear: false } },
]}
/>,
);
expect(container.querySelector('.ant-sender-slot-tag-clear-icon')).toBeNull();
});
});

describe('Edge Cases & Error Handling', () => {
it('should handle null/undefined slotConfig', () => {
const { rerender } = render(<Sender slotConfig={undefined} />);
Expand Down
2 changes: 1 addition & 1 deletion packages/x/components/sender/demo/slot-filling.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 5 additions & 4 deletions packages/x/components/sender/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions packages/x/components/sender/index.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 节点属性

Expand Down
1 change: 1 addition & 0 deletions packages/x/components/sender/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ interface SlotConfigTagType extends SlotConfigBaseType {
props?: {
label: React.ReactNode;
value?: string;
allowClear?: boolean | { clearIcon?: React.ReactNode };
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/x/components/sender/style/slot-textarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ const genSlotTextAreaStyle: GenerateStyle<SenderToken> = (token) => {
position: 'relative',
cursor: 'default',
},
[`${slotTagCls}-clear-icon`]: {
marginInlineStart: token.marginXXS,
fontSize: token.fontSize,
lineHeight: token.lineHeight,
cursor: 'pointer',
},
};
};

Expand Down
Loading