Merge branch 'feature' into change-styles-classnames-ts-1

This commit is contained in:
叶枫
2026-02-06 09:15:23 +08:00
committed by GitHub
15 changed files with 208 additions and 28 deletions

View File

@@ -3,35 +3,52 @@ import { useMemo } from 'react';
export interface MaskConfig {
enabled?: boolean;
blur?: boolean;
closable?: boolean;
}
export type MaskType = MaskConfig | boolean;
const normalizeMaskConfig = (mask?: MaskType): MaskConfig => {
export const normalizeMaskConfig = (mask?: MaskType, maskClosable?: boolean): MaskConfig => {
let maskConfig: MaskConfig = {};
if (mask && typeof mask === 'object') {
return mask;
maskConfig = mask;
}
if (typeof mask === 'boolean') {
return {
maskConfig = {
enabled: mask,
blur: false,
};
}
return {};
if (maskConfig.closable === undefined && maskClosable !== undefined) {
maskConfig.closable = maskClosable;
}
return maskConfig;
};
export const useMergedMask = (
mask?: MaskType,
contextMask?: MaskType,
prefixCls?: string,
): [boolean, { [key: string]: string | undefined }] => {
maskClosable?: boolean,
): [
config: boolean,
maskBlurClassName: { [key: string]: string | undefined },
maskClosable: boolean,
] => {
return useMemo(() => {
const maskConfig = normalizeMaskConfig(mask);
const maskConfig = normalizeMaskConfig(mask, maskClosable);
const contextMaskConfig = normalizeMaskConfig(contextMask);
const mergedConfig: MaskConfig = { ...contextMaskConfig, ...maskConfig };
const mergedConfig: MaskConfig = {
blur: false,
...contextMaskConfig,
...maskConfig,
closable: maskConfig.closable ?? maskClosable ?? contextMaskConfig.closable ?? true,
};
const className = mergedConfig.blur ? `${prefixCls}-mask-blur` : undefined;
return [mergedConfig.enabled !== false, { mask: className }];
}, [mask, contextMask, prefixCls]);
return [mergedConfig.enabled !== false, { mask: className }, !!mergedConfig.closable];
}, [mask, contextMask, prefixCls, maskClosable]);
};

View File

@@ -59,6 +59,8 @@ export interface DrawerProps
* @since 5.25.0
*/
destroyOnHidden?: boolean;
/** @deprecated Please use `mask.closable` instead */
maskClosable?: boolean;
mask?: MaskType;
focusable?: FocusableConfig;
@@ -94,6 +96,7 @@ const Drawer: React.FC<DrawerProps> & {
focusable,
// Deprecated
maskClosable,
maskStyle,
drawerStyle,
contentWrapperStyle,
@@ -140,6 +143,7 @@ const Drawer: React.FC<DrawerProps> & {
['destroyInactivePanel', 'destroyOnHidden'],
['width', 'size'],
['height', 'size'],
['maskClosable', 'mask.closable'],
].forEach(([deprecatedName, newName]) => {
warning.deprecated(!(deprecatedName in props), deprecatedName, newName);
});
@@ -206,7 +210,12 @@ const Drawer: React.FC<DrawerProps> & {
const [zIndex, contextZIndex] = useZIndex('Drawer', rest.zIndex);
// ============================ Mask ============================
const [mergedMask, maskBlurClassName] = useMergedMask(drawerMask, contextMask, prefixCls);
const [mergedMask, maskBlurClassName, mergedMaskClosable] = useMergedMask(
drawerMask,
contextMask,
prefixCls,
maskClosable,
);
// ========================== Focusable =========================
const mergedFocusable = useFocusable(focusable, getContainer !== false && mergedMask);
@@ -218,6 +227,7 @@ const Drawer: React.FC<DrawerProps> & {
zIndex,
panelRef,
mask: mergedMask,
maskClosable: mergedMaskClosable,
defaultSize,
push,
focusable: mergedFocusable,
@@ -265,6 +275,7 @@ const Drawer: React.FC<DrawerProps> & {
}}
open={open}
mask={mergedMask}
maskClosable={mergedMaskClosable}
push={push}
size={drawerSize}
defaultSize={defaultSize}

View File

@@ -3,6 +3,7 @@ import React from 'react';
import type { DrawerProps } from '..';
import Drawer from '..';
import { act, fireEvent, render } from '../../../tests/utils';
import ConfigProvider from '../../config-provider';
const DrawerTest: React.FC<DrawerProps> = (props) => (
<Drawer open getContainer={false} {...props}>
@@ -81,6 +82,47 @@ describe('Drawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('mask.closable no trigger onClose', () => {
const onClose = jest.fn();
const { container } = render(<DrawerTest onClose={onClose} mask={{ closable: false }} />);
fireEvent.click(container.querySelector('.ant-drawer-mask')!);
expect(onClose).not.toHaveBeenCalled();
});
it("mask.closable no trigger onClose by ConfigProvider's drawer config", () => {
const onClose = jest.fn();
const { container } = render(
<ConfigProvider drawer={{ mask: { closable: false } }}>
<DrawerTest onClose={onClose} />
</ConfigProvider>,
);
fireEvent.click(container.querySelector('.ant-drawer-mask')!);
expect(onClose).not.toHaveBeenCalled();
});
it("mask.closable no trigger onClose when maskClosable is false and ConfigProvider's drawer config is true", () => {
const onClose = jest.fn();
const { container } = render(
<ConfigProvider drawer={{ mask: { closable: false } }}>
<DrawerTest onClose={onClose} maskClosable={false} />
</ConfigProvider>,
);
fireEvent.click(container.querySelector('.ant-drawer-mask')!);
expect(onClose).not.toHaveBeenCalled();
});
it("mask.closable trigger onClose when maskClosable is true and ConfigProvider's drawer config is false", () => {
const onClose = jest.fn();
const { container } = render(
<ConfigProvider drawer={{ mask: { closable: false } }}>
<DrawerTest onClose={onClose} maskClosable={true} />
</ConfigProvider>,
);
fireEvent.click(container.querySelector('.ant-drawer-mask')!);
expect(onClose).toHaveBeenCalled();
});
it('dom should be removed after close when destroyOnHidden is true', () => {
const { container, rerender } = render(<DrawerTest destroyOnHidden />);
expect(container.querySelector('.ant-drawer')).toBeTruthy();

View File

@@ -70,8 +70,8 @@ v5 uses `rootClassName` & `rootStyle` to configure the outermost element style,
| ~~height~~ | Placement is `top` or `bottom`, height of the Drawer dialog, please use `size` instead | string \| number | 378 | |
| keyboard | Whether support press esc to close | boolean | true | |
| loading | Show the Skeleton | boolean | false | 5.17.0 |
| mask | Mask effect | boolean \| `{ enabled?: boolean, blur?: boolean }` | true | |
| maskClosable | Clicking on the mask (area outside the Drawer) to close the Drawer or not | boolean | true | |
| mask | Mask effect | boolean \| `{ enabled?: boolean, blur?: boolean, closable?: boolean }` | true | mask.closable: 6.3.0 |
| ~~maskClosable~~ | Clicking on the mask (area outside the Drawer) to close the Drawer or not | boolean | true | |
| maxSize | Maximum size (width or height depending on `placement`) when resizable | number | - | 6.0.0 |
| open | Whether the Drawer dialog is visible or not | boolean | false | |
| placement | The placement of the Drawer | `top` \| `right` \| `bottom` \| `left` | `right` | |

View File

@@ -70,8 +70,8 @@ v5 使用 `rootClassName` 与 `rootStyle` 来配置最外层元素样式。原 v
| ~~height~~ | 高度,在 `placement``top``bottom` 时使用,请使用 `size` 替换 | string \| number | 378 | |
| keyboard | 是否支持键盘 esc 关闭 | boolean | true | |
| loading | 显示骨架屏 | boolean | false | 5.17.0 |
| mask | 遮罩效果 | boolean \| `{ enabled?: boolean, blur?: boolean }` | true | |
| maskClosable | 点击蒙层是否允许关闭 | boolean | true | |
| mask | 遮罩效果 | boolean \| `{ enabled?: boolean, blur?: boolean, closable?: boolean }` | true | mask.closable: 6.3.0 |
| ~~maskClosable~~ | 点击蒙层是否允许关闭 | boolean | true | |
| maxSize | 可拖拽的最大尺寸(宽度或高度,取决于 `placement` | number | - | 6.0.0 |
| open | Drawer 是否可见 | boolean | false | |
| placement | 抽屉的方向 | `top` \| `right` \| `bottom` \| `left` | `right` | |

View File

@@ -5,7 +5,7 @@ import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled';
import InfoCircleFilled from '@ant-design/icons/InfoCircleFilled';
import { clsx } from 'clsx';
import { CONTAINER_MAX_OFFSET } from '../_util/hooks';
import { CONTAINER_MAX_OFFSET, normalizeMaskConfig } from '../_util/hooks';
import { getTransitionName } from '../_util/motion';
import { devUseWarning } from '../_util/warning';
import type { ThemeConfig } from '../config-provider';
@@ -183,6 +183,8 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = (props) => {
onConfirm,
styles,
title,
mask,
maskClosable,
okButtonProps,
cancelButtonProps,
} = props;
@@ -215,7 +217,13 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = (props) => {
// ========================== Mask ==========================
// 默认为 false保持旧版默认行为
const maskClosable = props.maskClosable === undefined ? false : props.maskClosable;
const mergedMask = React.useMemo(() => {
const nextMaskConfig = normalizeMaskConfig(mask, maskClosable);
nextMaskConfig.closable ??= false;
return nextMaskConfig;
}, [mask, maskClosable]);
// ========================= zIndex =========================
const [, token] = useToken();
@@ -243,7 +251,7 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = (props) => {
footer={null}
transitionName={getTransitionName(rootPrefixCls || '', 'zoom', props.transitionName)}
maskTransitionName={getTransitionName(rootPrefixCls || '', 'fade', props.maskTransitionName)}
maskClosable={maskClosable}
mask={mergedMask}
style={style}
styles={{ body: bodyStyle, mask: maskStyle, ...styles }}
width={width}

View File

@@ -77,6 +77,7 @@ const Modal: React.FC<ModalProps> = (props) => {
closable,
mask: modalMask,
modalRender,
maskClosable,
// Focusable
focusTriggerAfterClose,
@@ -111,7 +112,12 @@ const Modal: React.FC<ModalProps> = (props) => {
const rootPrefixCls = getPrefixCls();
// ============================ Mask ============================
const [mergedMask, maskBlurClassName] = useMergedMask(modalMask, contextMask, prefixCls);
const [mergedMask, maskBlurClassName, mergeMaskClosable] = useMergedMask(
modalMask,
contextMask,
prefixCls,
maskClosable,
);
// ========================== Focusable =========================
const mergedFocusable = useFocusable(focusable, mergedMask, focusTriggerAfterClose);
@@ -139,6 +145,7 @@ const Modal: React.FC<ModalProps> = (props) => {
['destroyOnClose', 'destroyOnHidden'],
['autoFocusButton', 'focusable.autoFocusButton'],
['focusTriggerAfterClose', 'focusable.focusTriggerAfterClose'],
['maskClosable', 'mask.closable'],
].forEach(([deprecatedName, newName]) => {
warning.deprecated(!(deprecatedName in props), deprecatedName, newName);
});
@@ -203,6 +210,7 @@ const Modal: React.FC<ModalProps> = (props) => {
focusTriggerAfterClose: mergedFocusable.focusTriggerAfterClose,
focusable: mergedFocusable,
mask: mergedMask,
maskClosable: mergeMaskClosable,
zIndex,
};
@@ -259,6 +267,7 @@ const Modal: React.FC<ModalProps> = (props) => {
transitionName={getTransitionName(rootPrefixCls, 'zoom', props.transitionName)}
maskTransitionName={getTransitionName(rootPrefixCls, 'fade', props.maskTransitionName)}
mask={mergedMask}
maskClosable={mergeMaskClosable}
className={clsx(hashId, className, contextClassName)}
style={{ ...contextStyle, ...style, ...responsiveWidthVars }}
classNames={{

View File

@@ -257,6 +257,55 @@ describe('Modal', () => {
expect(document.querySelector('.ant-modal-footer .ant-btn-primary.ant-btn-sm')).toBeTruthy();
});
it('should not close when mask.closable is false from context', () => {
const onCancel = jest.fn();
render(
<ConfigProvider modal={{ mask: { closable: false } }}>
<Modal open onCancel={onCancel} />
</ConfigProvider>,
);
const maskElement = document.querySelector('.ant-modal-mask');
fireEvent.click(maskElement!);
expect(onCancel).not.toHaveBeenCalled();
});
it('should support maskClosable prop over mask.closable global config', async () => {
jest.useFakeTimers();
const Demo: React.FC<ModalProps> = ({ onCancel = () => {}, onOk = () => {}, ...restProps }) => {
const [open, setOpen] = React.useState<boolean>(false);
useEffect(() => {
setOpen(true);
}, []);
const handleCancel = (event: React.MouseEvent<HTMLButtonElement>) => {
setOpen(false);
onCancel(event);
};
return <Modal open={open} onCancel={handleCancel} onOk={onOk} {...restProps} />;
};
const onCancel = jest.fn();
const onOk = jest.fn();
render(
<ConfigProvider modal={{ mask: { closable: false } }}>
<Demo onCancel={onCancel} onOk={onOk} maskClosable />
</ConfigProvider>,
);
await act(async () => {
await waitFakeTimer(500);
});
const modalWrap = document.body.querySelectorAll('.ant-modal-wrap')[0];
fireEvent.click(modalWrap!);
await act(async () => {
await waitFakeTimer(500);
});
expect(onCancel).toHaveBeenCalled();
jest.useRealTimers();
});
it('should not close modal when confirmLoading is loading', async () => {
jest.useFakeTimers();

View File

@@ -190,6 +190,44 @@ describe('Modal.hook', () => {
expect(cancelCount).toEqual(2); // click modal wrapper, trigger onCancel
});
it('hooks modal should trigger onCancel with mask.closable', () => {
let cancelCount = 0;
const Demo = () => {
const [modal, contextHolder] = Modal.useModal();
const openBrokenModal = React.useCallback(() => {
modal.info({
okType: 'default',
mask: { closable: true },
okCancel: true,
onCancel: () => {
cancelCount += 1;
},
content: 'Hello!',
});
}, [modal]);
return (
<ConfigWarp>
{contextHolder}
<div className="open-hook-modal-btn" onClick={openBrokenModal}>
Test hook modal
</div>
</ConfigWarp>
);
};
const { container } = render(<Demo />);
fireEvent.click(container.querySelectorAll('.open-hook-modal-btn')[0]);
fireEvent.click(document.body.querySelectorAll('.ant-modal-confirm-btns .ant-btn')[0]);
expect(cancelCount).toEqual(1); // click cancel btn, trigger onCancel
fireEvent.click(container.querySelectorAll('.open-hook-modal-btn')[0]);
fireEvent.click(document.body.querySelectorAll('.ant-modal-wrap')[0]);
expect(cancelCount).toEqual(2); // click modal wrapper, trigger onCancel
});
it('update before render', () => {
const Demo = () => {
const [modal, contextHolder] = Modal.useModal();

View File

@@ -39,7 +39,7 @@ const Demo: React.FC = () => {
footer={null}
destroyOnHidden
onCancel={() => setIsModalOpen(false)}
maskClosable={false}
mask={{ closable: false }}
closable={false}
styles={{
container: {

View File

@@ -65,8 +65,8 @@ Common props ref[Common props](/docs/react/common-props)
| focusable | Configuration for focus management in the Modal | `{ trap?: boolean, focusTriggerAfterClose?: boolean }` | - | 6.2.0 |
| getContainer | The mounted node for Modal but still display at fullscreen | HTMLElement \| () => HTMLElement \| Selectors \| false | document.body | |
| keyboard | Whether support press esc to close | boolean | true | |
| mask | Mask effect | boolean \| `{enabled: boolean, blur: boolean}` | true | |
| maskClosable | Whether to close the modal dialog when the mask (area outside the modal) is clicked | boolean | true | |
| mask | Mask effect | boolean \| `{enabled?: boolean, blur?: boolean, closable?: boolean}` | true | mask.closable: 6.3.0 |
| ~~maskClosable~~ | Whether to close the modal dialog when the mask (area outside the modal) is clicked | boolean | true | |
| modalRender | Custom modal content render | (node: ReactNode) => ReactNode | - | 4.7.0 |
| okButtonProps | The ok button props | [ButtonProps](/components/button/#api) | - | |
| okText | Text of the OK button | ReactNode | `OK` | |

View File

@@ -66,7 +66,7 @@ demo:
| focusable | 对话框内焦点管理的配置 | `{ trap?: boolean, focusTriggerAfterClose?: boolean }` | - | 6.2.0 |
| getContainer | 指定 Modal 挂载的节点,但依旧为全屏展示,`false` 为挂载在当前位置 | HTMLElement \| () => HTMLElement \| Selectors \| false | document.body | |
| keyboard | 是否支持键盘 esc 关闭 | boolean | true | |
| mask | 遮罩效果 | boolean \| `{enabled: boolean, blur: boolean}` | true | |
| mask | 遮罩效果 | boolean \| `{enabled: boolean, blur: boolean, closable?: boolean}` | true | mask.closable: 6.3.0 |
| maskClosable | 点击蒙层是否允许关闭 | boolean | true | |
| modalRender | 自定义渲染对话框 | (node: ReactNode) => ReactNode | - | 4.7.0 |
| okButtonProps | ok 按钮 props | [ButtonProps](/components/button-cn#api) | - | |
@@ -118,8 +118,8 @@ demo:
| getContainer | 指定 Modal 挂载的 HTML 节点false 为挂载在当前 dom | HTMLElement \| () => HTMLElement \| Selectors \| false | document.body | |
| icon | 自定义图标 | ReactNode | &lt;ExclamationCircleFilled /> | |
| keyboard | 是否支持键盘 esc 关闭 | boolean | true | |
| mask | 遮罩效果 | boolean \| `{enabled: boolean, blur: boolean}` | true | |
| maskClosable | 点击蒙层是否允许关闭 | boolean | false | |
| mask | 遮罩效果 | boolean \| `{enabled?: boolean, blur?: boolean, closable?: boolean, closable?: true}` | true | |
| ~~maskClosable~~ | 点击蒙层是否允许关闭 | boolean | false | |
| okButtonProps | ok 按钮 props | [ButtonProps](/components/button-cn#api) | - | |
| okText | 确认按钮文字 | string | `确定` | |
| okType | 确认按钮类型 | string | `primary` | |

View File

@@ -92,7 +92,10 @@ export interface ModalProps extends ModalCommonProps {
okType?: LegacyButtonType;
/** Text of the Cancel button */
cancelText?: React.ReactNode;
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
/**
* @deprecated Please use `mask.closable` instead
* @description Whether to close the modal dialog when the mask (area outside the modal) is clicked
*/
maskClosable?: boolean;
/** Force render Modal */
forceRender?: boolean;
@@ -157,8 +160,9 @@ export interface ModalFuncProps extends ModalCommonProps {
okType?: LegacyButtonType;
cancelText?: React.ReactNode;
icon?: React.ReactNode;
mask?: MaskType;
/** @deprecated Please use `mask.closable` instead */
maskClosable?: boolean;
mask?: MaskType;
zIndex?: number;
okCancel?: boolean;
style?: React.CSSProperties;

View File

@@ -141,6 +141,8 @@ const genPictureCardStyle: GenerateStyle<UploadToken> = (token) => {
[`${listCls}${listCls}-picture-card, ${listCls}${listCls}-picture-circle`]: {
display: 'flex',
flexWrap: 'wrap',
height: uploadPictureCardSize,
'@supports not (gap: 1px)': {
'& > *': {
marginBlockEnd: token.marginXS,

View File

@@ -221,7 +221,7 @@
"adm-zip": "^0.5.16",
"ajv": "^8.17.1",
"ali-oss": "^6.23.0",
"antd-img-crop": "^4.27.0",
"antd-img-crop": "~4.27.0",
"antd-style": "^4.1.0",
"antd-token-previewer": "^3.0.0",
"axios": "^1.13.2",