feat: Modal support focusable (#56500)

* feat: Modal support focusable config

* chore: doc & test

* chore: adjust logic

* test: add test case

* chore: update docs
This commit is contained in:
二货爱吃白萝卜
2026-01-06 14:34:30 +08:00
committed by GitHub
parent f7a216aaa9
commit 0cdfaf578a
10 changed files with 105 additions and 27 deletions

View File

@@ -61,7 +61,7 @@ export interface DrawerProps
destroyOnHidden?: boolean;
mask?: MaskType;
focusable?: Omit<FocusableConfig, 'autoFocusButton'>;
focusable?: FocusableConfig;
}
const defaultPushState: PushState = { distance: 180 };

View File

@@ -1,21 +1,26 @@
import { useMemo } from 'react';
export type OmitFocusType = 'autoFocusButton' | 'focusTriggerAfterClose' | 'focusTrap';
export type OmitFocusType = 'focusTriggerAfterClose' | 'focusTrap' | 'autoFocusButton';
export interface FocusableConfig {
autoFocusButton?: 'ok' | 'cancel' | false;
focusTriggerAfterClose?: boolean;
trap?: boolean;
}
export default function useFocusable(focusable?: FocusableConfig, defaultTrap?: boolean) {
export default function useFocusable(
focusable?: FocusableConfig,
defaultTrap?: boolean,
legacyFocusTriggerAfterClose?: FocusableConfig['focusTriggerAfterClose'],
) {
return useMemo(() => {
const ret = {
const ret: FocusableConfig = {
trap: defaultTrap ?? true,
focusTriggerAfterClose: true,
...focusable,
focusTriggerAfterClose: legacyFocusTriggerAfterClose ?? true,
};
return ret;
}, [focusable, defaultTrap]);
return {
...ret,
...focusable,
};
}, [focusable, defaultTrap, legacyFocusTriggerAfterClose]);
}

View File

@@ -63,6 +63,8 @@ export const ConfirmContent: React.FC<ConfirmDialogProps & { confirmPrefixCls: s
footer,
// Legacy for static function usage
locale: staticLocale,
autoFocusButton,
focusable,
...restProps
} = props;
@@ -102,7 +104,10 @@ export const ConfirmContent: React.FC<ConfirmDialogProps & { confirmPrefixCls: s
// 默认为 true保持向下兼容
const mergedOkCancel = okCancel ?? type === 'confirm';
const autoFocusButton = props.autoFocusButton === null ? false : props.autoFocusButton || 'ok';
const mergedAutoFocusButton = React.useMemo(() => {
const base = focusable?.autoFocusButton || autoFocusButton;
return base || base === null ? base : 'ok';
}, [autoFocusButton, focusable?.autoFocusButton]);
const [locale] = useLocale('Modal');
@@ -118,14 +123,14 @@ export const ConfirmContent: React.FC<ConfirmDialogProps & { confirmPrefixCls: s
const memoizedValue = React.useMemo<ModalContextProps>(() => {
return {
autoFocusButton,
autoFocusButton: mergedAutoFocusButton,
cancelTextLocale,
okTextLocale,
mergedOkCancel,
onClose,
...restProps,
};
}, [autoFocusButton, cancelTextLocale, okTextLocale, mergedOkCancel, onClose, restProps]);
}, [mergedAutoFocusButton, cancelTextLocale, okTextLocale, mergedOkCancel, onClose, restProps]);
// ====================== Footer Origin Node ======================
const footerOriginNode = (
@@ -200,8 +205,6 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = (props) => {
const width = props.width || 416;
const style = props.style || {};
// 默认为 false保持旧版默认行为
const maskClosable = props.maskClosable === undefined ? false : props.maskClosable;
const classString = clsx(
confirmPrefixCls,
@@ -210,6 +213,10 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = (props) => {
props.className,
);
// ========================== Mask ==========================
// 默认为 false保持旧版默认行为
const maskClosable = props.maskClosable === undefined ? false : props.maskClosable;
// ========================= zIndex =========================
const [, token] = useToken();

View File

@@ -20,6 +20,7 @@ import zIndexContext from '../_util/zindexContext';
import { ConfigContext } from '../config-provider';
import { useComponentConfig } from '../config-provider/context';
import useCSSVarCls from '../config-provider/hooks/useCSSVarCls';
import useFocusable from '../drawer/useFocusable';
import Skeleton from '../skeleton';
import { usePanelRef } from '../watermark/context';
import type { ModalClassNamesType, ModalProps, ModalStylesType, MousePosition } from './interface';
@@ -56,7 +57,6 @@ const Modal: React.FC<ModalProps> = (props) => {
wrapClassName,
centered,
getContainer,
focusTriggerAfterClose = true,
style,
width = 520,
footer,
@@ -77,6 +77,11 @@ const Modal: React.FC<ModalProps> = (props) => {
closable,
mask: modalMask,
modalRender,
// Focusable
focusTriggerAfterClose,
focusable,
...restProps
} = props;
@@ -96,7 +101,7 @@ const Modal: React.FC<ModalProps> = (props) => {
const { modal: modalContext } = React.useContext(ConfigContext);
const [closableAfterclose, onClose] = React.useMemo(() => {
const [closableAfterClose, onClose] = React.useMemo(() => {
if (typeof closable === 'boolean') {
return [undefined, undefined];
}
@@ -105,8 +110,13 @@ const Modal: React.FC<ModalProps> = (props) => {
const prefixCls = getPrefixCls('modal', customizePrefixCls);
const rootPrefixCls = getPrefixCls();
// ============================ Mask ============================
const [mergedMask, maskBlurClassName] = useMergedMask(modalMask, contextMask, prefixCls);
// ========================== Focusable =========================
const mergedFocusable = useFocusable(focusable, mergedMask, focusTriggerAfterClose);
// ============================ Open ============================
const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
if (confirmLoading) {
return;
@@ -166,7 +176,7 @@ const Modal: React.FC<ModalProps> = (props) => {
? {
disabled: closeBtnIsDisabled,
closeIcon: mergedCloseIcon,
afterClose: closableAfterclose,
afterClose: closableAfterClose,
...ariaProps,
}
: false;
@@ -188,7 +198,8 @@ const Modal: React.FC<ModalProps> = (props) => {
...props,
width,
panelRef,
focusTriggerAfterClose,
focusTriggerAfterClose: mergedFocusable.focusTriggerAfterClose,
focusable: mergedFocusable,
mask: mergedMask,
zIndex,
};
@@ -243,7 +254,6 @@ const Modal: React.FC<ModalProps> = (props) => {
onClose={handleCancel as any}
closable={mergedClosable}
closeIcon={mergedCloseIcon}
focusTriggerAfterClose={focusTriggerAfterClose}
transitionName={getTransitionName(rootPrefixCls, 'zoom', props.transitionName)}
maskTransitionName={getTransitionName(rootPrefixCls, 'fade', props.maskTransitionName)}
mask={mergedMask}
@@ -257,6 +267,9 @@ const Modal: React.FC<ModalProps> = (props) => {
panelRef={mergedPanelRef}
destroyOnHidden={destroyOnHidden ?? destroyOnClose}
modalRender={mergedModalRender}
// Focusable
focusTriggerAfterClose={mergedFocusable.focusTriggerAfterClose}
focusTrap={mergedFocusable.trap}
>
{loading ? (
<Skeleton

View File

@@ -401,4 +401,25 @@ describe('Modal', () => {
}
});
});
it('focusable default config should pass to classNames', () => {
const classNames = jest.fn(() => ({}));
render(
<Modal open getContainer={false} classNames={classNames}>
Here is content of Modal
</Modal>,
);
expect(classNames).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
focusable: {
trap: true,
focusTriggerAfterClose: true,
},
}),
}),
);
});
});

View File

@@ -5,8 +5,8 @@ import { warning } from '@rc-component/util';
import type { ModalFuncProps } from '..';
import Modal from '..';
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
import ConfigProvider, { defaultPrefixCls } from '../../config-provider';
import App from '../../app';
import ConfigProvider, { defaultPrefixCls } from '../../config-provider';
import type { GlobalConfigProps } from '../../config-provider';
import type { ModalFunc } from '../confirm';
import destroyFns from '../destroyFns';
@@ -978,6 +978,20 @@ describe('Modal.confirm triggers callbacks correctly', () => {
expect(document.querySelector('.ant-modal-root')).toBeFalsy();
});
it('focusable.autoFocusButton should working', async () => {
Modal.confirm({
title: 'Test',
content: 'Test content',
focusable: { autoFocusButton: 'cancel' },
});
await waitFakeTimer();
expect(document.activeElement).toBe(
document.querySelector('.ant-modal-confirm-btns .ant-btn-default'),
);
});
it('should support cancelButtonProps global config', () => {
const Confirm = () => {
const { modal } = App.useApp();

View File

@@ -59,9 +59,10 @@ Common props ref[Common props](/docs/react/common-props)
| confirmLoading | Whether to apply loading visual effect for OK button or not | boolean | false | |
| ~~destroyOnClose~~ | Whether to unmount child components on onClose | boolean | false | |
| destroyOnHidden | Whether to unmount child components on onClose | boolean | false | 5.25.0 |
| focusTriggerAfterClose | Whether need to focus trigger element after dialog is closed | boolean | true | 4.9.0 |
| ~~focusTriggerAfterClose~~ | Whether need to focus trigger element after dialog is closed. Please use `focusable.focusTriggerAfterClose` instead | boolean | true | 4.9.0 |
| footer | Footer content, set as `footer={null}` when you don't need default buttons | ReactNode \| (originNode: ReactNode, extra: { OkBtn: React.FC, CancelBtn: React.FC }) => ReactNode | (OK and Cancel buttons) | renderFunction: 5.9.0 |
| forceRender | Force render Modal | boolean | false | |
| 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 | |
@@ -103,7 +104,7 @@ The items listed above are all functions, expecting a settings object as paramet
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| afterClose | Specify a function that will be called when modal is closed completely | function | - | 4.9.0 |
| autoFocusButton | Specify which button to autofocus | null \| `ok` \| `cancel` | `ok` | |
| ~~autoFocusButton~~ | Specify which button to autofocus. Please use `focusable.autoFocusButton` instead | null \| `ok` \| `cancel` | `ok` | |
| cancelButtonProps | The cancel button props | [ButtonProps](/components/button/#api) | - | |
| cancelText | Text of the Cancel button with Modal.confirm | string | `Cancel` | |
| centered | Centered Modal | boolean | false | |
@@ -111,6 +112,7 @@ The items listed above are all functions, expecting a settings object as paramet
| closable | Whether a close (x) button is visible on top right of the confirm dialog or not | boolean \| [ClosableType](#closabletype) | false | - |
| closeIcon | Custom close icon | ReactNode | undefined | 4.9.0 |
| content | Content | ReactNode | - | |
| focusable.autoFocusButton | Specify which button to autofocus | null \| `ok` \| `cancel` | `ok` | 6.2.0 |
| footer | Footer content, set as `footer: null` when you don't need default buttons | ReactNode \| (originNode: ReactNode, extra: { OkBtn: React.FC, CancelBtn: React.FC }) => ReactNode | - | renderFunction: 5.9.0 |
| getContainer | Return the mount node for Modal | HTMLElement \| () => HTMLElement \| Selectors \| false | document.body | |
| icon | Custom icon | ReactNode | &lt;ExclamationCircleFilled /> | |

View File

@@ -60,9 +60,10 @@ demo:
| confirmLoading | 确定按钮 loading | boolean | false | |
| ~~destroyOnClose~~ | 关闭时销毁 Modal 里的子元素 | boolean | false | |
| destroyOnHidden | 关闭时销毁 Modal 里的子元素 | boolean | false | 5.25.0 |
| focusTriggerAfterClose | 对话框关闭后是否需要聚焦触发元素 | boolean | true | 4.9.0 |
| ~~focusTriggerAfterClose~~ | 对话框关闭后是否需要聚焦触发元素。请使用 `focusable.focusTriggerAfterClose` 替代 | boolean | true | 4.9.0 |
| footer | 底部内容,当不需要默认底部按钮时,可以设为 `footer={null}` | ReactNode \| (originNode: ReactNode, extra: { OkBtn: React.FC, CancelBtn: React.FC }) => ReactNode | (确定取消按钮) | renderFunction: 5.9.0 |
| forceRender | 强制渲染 Modal | boolean | false | |
| 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 | |
@@ -104,7 +105,7 @@ demo:
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| afterClose | Modal 完全关闭后的回调 | function | - | 4.9.0 |
| autoFocusButton | 指定自动获得焦点的按钮 | null \| `ok` \| `cancel` | `ok` | |
| ~~autoFocusButton~~ | 指定自动获得焦点的按钮。请使用 `focusable.autoFocusButton` 替代 | null \| `ok` \| `cancel` | `ok` | |
| cancelButtonProps | cancel 按钮 props | [ButtonProps](/components/button-cn#api) | - | |
| cancelText | 设置 Modal.confirm 取消按钮文字 | string | `取消` | |
| centered | 垂直居中展示 Modal | boolean | false | |
@@ -112,6 +113,7 @@ demo:
| closable | 是否显示右上角的关闭按钮 | boolean \| [ClosableType](#closabletype) | false | - |
| closeIcon | 自定义关闭图标 | ReactNode | undefined | 4.9.0 |
| content | 内容 | ReactNode | - | |
| focusable.autoFocusButton | 指定自动获得焦点的按钮 | null \| `ok` \| `cancel` | `ok` | 6.2.0 |
| footer | 底部内容,当不需要默认底部按钮时,可以设为 `footer: null` | ReactNode \| (originNode: ReactNode, extra: { OkBtn: React.FC, CancelBtn: React.FC }) => ReactNode | - | renderFunction: 5.9.0 |
| getContainer | 指定 Modal 挂载的 HTML 节点false 为挂载在当前 dom | HTMLElement \| () => HTMLElement \| Selectors \| false | document.body | |
| icon | 自定义图标 | ReactNode | &lt;ExclamationCircleFilled /> | |

View File

@@ -10,6 +10,7 @@ import type {
import type { Breakpoint } from '../_util/responsiveObserver';
import type { ButtonProps, LegacyButtonType } from '../button/Button';
import type { DirectionType } from '../config-provider';
import type { FocusableConfig, OmitFocusType } from '../drawer/useFocusable';
export type SemanticName = keyof ModalSemanticClassNames & keyof ModalSemanticStyles;
@@ -52,6 +53,7 @@ interface ModalCommonProps
| 'mask'
| 'classNames'
| 'styles'
| OmitFocusType
> {
footer?:
| React.ReactNode
@@ -121,13 +123,17 @@ export interface ModalProps extends ModalCommonProps {
prefixCls?: string;
closeIcon?: React.ReactNode;
modalRender?: (node: React.ReactNode) => React.ReactNode;
focusTriggerAfterClose?: boolean;
children?: React.ReactNode;
mousePosition?: MousePosition;
/**
* @since 5.18.0
*/
loading?: boolean;
// Focusable
/** @deprecated Please use `focusable.focusTriggerAfterClose` instead */
focusTriggerAfterClose?: boolean;
focusable?: FocusableConfig;
}
type getContainerFunc = () => HTMLElement;
@@ -162,7 +168,7 @@ export interface ModalFuncProps extends ModalCommonProps {
type?: 'info' | 'success' | 'error' | 'warn' | 'warning' | 'confirm';
keyboard?: boolean;
getContainer?: string | HTMLElement | getContainerFunc | false;
autoFocusButton?: null | 'ok' | 'cancel';
transitionName?: string;
maskTransitionName?: string;
direction?: DirectionType;
@@ -171,7 +177,15 @@ export interface ModalFuncProps extends ModalCommonProps {
closeIcon?: React.ReactNode;
footer?: ModalProps['footer'];
modalRender?: (node: React.ReactNode) => React.ReactNode;
// Focusable
/** @deprecated Please use `focusable.focusTriggerAfterClose` instead */
focusTriggerAfterClose?: boolean;
/** @deprecated Please use `focusable.autoFocusButton` instead */
autoFocusButton?: null | 'ok' | 'cancel';
focusable?: FocusableConfig & {
autoFocusButton?: null | 'ok' | 'cancel';
};
}
export interface ModalLocale {

View File

@@ -119,7 +119,7 @@
"@rc-component/checkbox": "~1.0.1",
"@rc-component/collapse": "~1.1.2",
"@rc-component/color-picker": "~3.0.3",
"@rc-component/dialog": "~1.7.0",
"@rc-component/dialog": "~1.8.0",
"@rc-component/drawer": "~1.4.0",
"@rc-component/dropdown": "~1.0.2",
"@rc-component/form": "~1.6.0",