feat: Alert support aria-* in closable (#47446)

* feat: support aria-* in closeable

* feat: support aria-* in closeable

* feat: optimize code

* feat: optimize code

* feat: optimize code

* feat: optimize code
This commit is contained in:
kiner-tang 2024-02-18 18:08:02 +08:00 committed by GitHub
parent bb59275276
commit 9d3211757c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 231 additions and 17 deletions

View File

@ -18,9 +18,9 @@ export interface AlertProps {
/** Type of Alert styles, options:`success`, `info`, `warning`, `error` */
type?: 'success' | 'info' | 'warning' | 'error';
/** Whether Alert can be closed */
closable?: boolean;
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
/**
* @deprecated please use `closeIcon` instead.
* @deprecated please use `closable.closeIcon` instead.
* Close text to show
*/
closeText?: React.ReactNode;
@ -42,7 +42,10 @@ export interface AlertProps {
rootClassName?: string;
banner?: boolean;
icon?: React.ReactNode;
/** Custom closeIcon */
/**
* Custom closeIcon
* @deprecated please use `closable.closeIcon` instead.
*/
closeIcon?: React.ReactNode;
action?: React.ReactNode;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
@ -77,19 +80,26 @@ const IconNode: React.FC<IconNodeProps> = (props) => {
return React.createElement(iconType, { className: `${prefixCls}-icon` });
};
interface CloseIconProps {
type CloseIconProps = {
isClosable: boolean;
prefixCls: AlertProps['prefixCls'];
closeIcon: AlertProps['closeIcon'];
handleClose: AlertProps['onClose'];
}
ariaProps: React.AriaAttributes;
};
const CloseIconNode: React.FC<CloseIconProps> = (props) => {
const { isClosable, prefixCls, closeIcon, handleClose } = props;
const { isClosable, prefixCls, closeIcon, handleClose, ariaProps } = props;
const mergedCloseIcon =
closeIcon === true || closeIcon === undefined ? <CloseOutlined /> : closeIcon;
return isClosable ? (
<button type="button" onClick={handleClose} className={`${prefixCls}-close-icon`} tabIndex={0}>
<button
type="button"
onClick={handleClose}
className={`${prefixCls}-close-icon`}
tabIndex={0}
{...ariaProps}
>
{mergedCloseIcon}
</button>
) : null;
@ -120,7 +130,8 @@ const Alert: React.FC<AlertProps> = (props) => {
if (process.env.NODE_ENV !== 'production') {
const warning = devUseWarning('Alert');
warning.deprecated(!closeText, 'closeText', 'closeIcon');
warning.deprecated(!closeText, 'closeText', 'closable.closeIcon');
warning.deprecated(!closeIcon, 'closeIcon', 'closable.closeIcon');
}
const ref = React.useRef<HTMLDivElement>(null);
@ -144,6 +155,7 @@ const Alert: React.FC<AlertProps> = (props) => {
// closeable when closeText or closeIcon is assigned
const isClosable = React.useMemo<boolean>(() => {
if (typeof closable === 'object' && closable.closeIcon) return true;
if (closeText) {
return true;
}
@ -151,8 +163,12 @@ const Alert: React.FC<AlertProps> = (props) => {
return closable;
}
// should be true when closeIcon is 0 or ''
return closeIcon !== false && closeIcon !== null && closeIcon !== undefined;
}, [closeText, closeIcon, closable]);
if (closeIcon !== false && closeIcon !== null && closeIcon !== undefined) {
return true;
}
return !!alert?.closable;
}, [closeText, closeIcon, closable, alert?.closable]);
// banner mode defaults to Icon
const isShowIcon = banner && showIcon === undefined ? true : showIcon;
@ -175,6 +191,32 @@ const Alert: React.FC<AlertProps> = (props) => {
const restProps = pickAttrs(otherProps, { aria: true, data: true });
const mergedCloseIcon = React.useMemo(() => {
if (typeof closable === 'object' && closable.closeIcon) {
return closable.closeIcon;
}
if (closeText) {
return closeText;
}
if (closeIcon !== undefined) {
return closeIcon;
}
if (typeof alert?.closable === 'object' && alert?.closable?.closeIcon) {
return alert?.closable?.closeIcon;
}
return alert?.closeIcon;
}, [closeIcon, closable, closeText, alert?.closeIcon]);
const mergeAriaProps = React.useMemo(() => {
const merged = closable ?? alert?.closable;
if (typeof merged === 'object') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { closeIcon: _, ...ariaProps } = merged;
return ariaProps;
}
return {};
}, [closable, alert?.closable]);
return wrapCSSVar(
<CSSMotion
visible={!closed}
@ -212,8 +254,9 @@ const Alert: React.FC<AlertProps> = (props) => {
<CloseIconNode
isClosable={isClosable}
prefixCls={prefixCls}
closeIcon={closeText || (closeIcon ?? alert?.closeIcon)}
closeIcon={mergedCloseIcon}
handleClose={handleClose}
ariaProps={mergeAriaProps}
/>
</div>
)}

View File

@ -583,6 +583,57 @@ exports[`renders components/alert/demo/closable.tsx extend context correctly 1`]
</button>
</div>
</div>
<div
class="ant-space-item"
>
<div
class="ant-alert ant-alert-error ant-alert-with-description ant-alert-no-icon"
data-show="true"
role="alert"
>
<div
class="ant-alert-content"
>
<div
class="ant-alert-message"
>
Error Text
</div>
<div
class="ant-alert-description"
>
Error Description Error Description Error Description Error Description Error Description Error Description
</div>
</div>
<button
aria-label="close"
class="ant-alert-close-icon"
tabindex="0"
type="button"
>
<span
aria-label="close-square"
class="anticon anticon-close-square"
role="img"
>
<svg
aria-hidden="true"
data-icon="close-square"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M880 112c17.7 0 32 14.3 32 32v736c0 17.7-14.3 32-32 32H144c-17.7 0-32-14.3-32-32V144c0-17.7 14.3-32 32-32zM639.98 338.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
/>
</svg>
</span>
</button>
</div>
</div>
</div>
`;

View File

@ -573,6 +573,57 @@ exports[`renders components/alert/demo/closable.tsx correctly 1`] = `
</button>
</div>
</div>
<div
class="ant-space-item"
>
<div
class="ant-alert ant-alert-error ant-alert-with-description ant-alert-no-icon"
data-show="true"
role="alert"
>
<div
class="ant-alert-content"
>
<div
class="ant-alert-message"
>
Error Text
</div>
<div
class="ant-alert-description"
>
Error Description Error Description Error Description Error Description Error Description Error Description
</div>
</div>
<button
aria-label="close"
class="ant-alert-close-icon"
tabindex="0"
type="button"
>
<span
aria-label="close-square"
class="anticon anticon-close-square"
role="img"
>
<svg
aria-hidden="true"
data-icon="close-square"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M880 112c17.7 0 32 14.3 32 32v736c0 17.7-14.3 32-32 32H144c-17.7 0-32-14.3-32-32V144c0-17.7 14.3-32 32-32zM639.98 338.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
/>
</svg>
</span>
</button>
</div>
</div>
</div>
`;

View File

@ -154,6 +154,31 @@ describe('Alert', () => {
expect(container.querySelector('.ant-alert-close-icon')).toBeFalsy();
});
it('close button should be support aria-* by closable', () => {
const { container, rerender } = render(<Alert />);
expect(container.querySelector('*[aria-label]')).toBeFalsy();
rerender(<Alert closable={{ 'aria-label': 'Close' }} closeIcon="CloseIcon" />);
expect(container.querySelector('[aria-label="Close"]')).toBeTruthy();
rerender(<Alert closable={{ 'aria-label': 'Close' }} closeText="CloseText" />);
expect(container.querySelector('[aria-label="Close"]')).toBeTruthy();
rerender(<Alert closable={{ 'aria-label': 'Close', closeIcon: 'CloseIconProp' }} />);
expect(container.querySelector('[aria-label="Close"]')).toBeTruthy();
});
it('close button should be support custom icon by closable', () => {
const { container, rerender } = render(<Alert />);
expect(container.querySelector('.ant-alert-close-icon')).toBeFalsy();
rerender(<Alert closable={{ closeIcon: 'CloseBtn' }} />);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('CloseBtn');
rerender(<Alert closable={{ closeIcon: 'CloseBtn' }} closeIcon="CloseBtn2" />);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('CloseBtn');
rerender(<Alert closable={{ closeIcon: 'CloseBtn' }} closeText="CloseBtn3" />);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('CloseBtn');
rerender(<Alert closeText="CloseBtn2" />);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('CloseBtn2');
rerender(<Alert closeIcon="CloseBtn3" />);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('CloseBtn3');
});
it('should warning when using closeText', () => {
resetWarned();
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@ -161,7 +186,21 @@ describe('Alert', () => {
const { container } = render(<Alert closeText="close" />);
expect(warnSpy).toHaveBeenCalledWith(
`Warning: [antd: Alert] \`closeText\` is deprecated. Please use \`closeIcon\` instead.`,
`Warning: [antd: Alert] \`closeText\` is deprecated. Please use \`closable.closeIcon\` instead.`,
);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('close');
warnSpy.mockRestore();
});
it('should warning when using closeIcon', () => {
resetWarned();
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(<Alert closeIcon="close" />);
expect(warnSpy).toHaveBeenCalledWith(
`Warning: [antd: Alert] \`closeIcon\` is deprecated. Please use \`closable.closeIcon\` instead.`,
);
expect(container.querySelector('.ant-alert-close-icon')?.textContent).toBe('close');

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Alert, Space } from 'antd';
import { CloseSquareFilled } from '@ant-design/icons';
const onClose = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
console.log(e, 'I was closed.');
@ -20,6 +21,16 @@ const App: React.FC = () => (
closable
onClose={onClose}
/>
<Alert
message="Error Text"
description="Error Description Error Description Error Description Error Description Error Description Error Description"
type="error"
closable={{
'aria-label': 'close',
closeIcon: <CloseSquareFilled />,
}}
onClose={onClose}
/>
</Space>
);

View File

@ -42,7 +42,7 @@ Common props ref[Common props](/docs/react/common-props)
| action | The action of Alert | ReactNode | - | 4.9.0 |
| afterClose | Called when close animation is finished | () => void | - | |
| banner | Whether to show as banner | boolean | false | |
| closeIcon | Custom close icon, >=5.7.0: close button will be hidden when setting to `null` or `false` | ReactNode | `<CloseOutlined />` | |
| closable | The config of closable, >=5.15.0: support `aria-*` | boolean \| ({ closeIcon?: React.ReactNode } & React.AriaAttributes) | `false` | |
| description | Additional content of Alert | ReactNode | - | |
| icon | Custom icon, effective when `showIcon` is true | ReactNode | - | |
| message | Content of Alert | ReactNode | - | |

View File

@ -43,7 +43,7 @@ group:
| action | 自定义操作项 | ReactNode | - | 4.9.0 |
| afterClose | 关闭动画结束后触发的回调函数 | () => void | - | |
| banner | 是否用作顶部公告 | boolean | false | |
| closeIcon | 自定义关闭 Icon>=5.7.0: 设置为 `null``false` 时隐藏关闭按钮 | ReactNode | `<CloseOutlined />` | |
| closable | 可关闭配置,>=5.15.0: 支持 `aria-*` | boolean \| ({ closeIcon?: React.ReactNode } & React.AriaAttributes) | `false` | |
| description | 警告提示的辅助性文字介绍 | ReactNode | - | |
| icon | 自定义图标,`showIcon` 为 true 时有效 | ReactNode | - | |
| message | 警告提示内容 | ReactNode | - | |

View File

@ -708,18 +708,37 @@ describe('ConfigProvider support style and className props', () => {
});
it('Should Alert className works', () => {
const { container } = render(
const { container, rerender } = render(
<ConfigProvider
alert={{
className: 'test-class',
closeIcon: <span className="cp-test-icon">cp-test-icon</span>,
closable: { 'aria-label': 'close' },
}}
>
<Alert closable message="Test Message" />
<Alert message="Test Message" />
</ConfigProvider>,
);
expect(container.querySelector<HTMLDivElement>('.ant-alert')).toHaveClass('test-class');
expect(container.querySelector<HTMLSpanElement>('.ant-alert .cp-test-icon')).toBeTruthy();
expect(container.querySelectorAll('*[aria-label="close"]')).toBeTruthy();
rerender(
<ConfigProvider
alert={{
className: 'test-class',
closable: {
'aria-label': 'close',
closeIcon: <span className="cp-test-icon">cp-test-icon</span>,
},
}}
>
<Alert message="Test Message" />
</ConfigProvider>,
);
expect(container.querySelector<HTMLDivElement>('.ant-alert')).toHaveClass('test-class');
expect(container.querySelector<HTMLSpanElement>('.ant-alert .cp-test-icon')).toBeTruthy();
expect(container.querySelectorAll('*[aria-label="close"]')).toBeTruthy();
});
it('Should Alert style works', () => {

View File

@ -90,7 +90,7 @@ export type ModalConfig = ComponentStyleConfig &
export type TabsConfig = ComponentStyleConfig &
Pick<TabsProps, 'indicator' | 'indicatorSize' | 'moreIcon' | 'addIcon'>;
export type AlertConfig = ComponentStyleConfig & Pick<AlertProps, 'closeIcon'>;
export type AlertConfig = ComponentStyleConfig & Pick<AlertProps, 'closable' | 'closeIcon'>;
export type BadgeConfig = ComponentStyleConfig & Pick<BadgeProps, 'classNames' | 'styles'>;