mirror of
https://github.com/ant-design/ant-design.git
synced 2026-02-09 02:49:18 +08:00
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:
@@ -61,7 +61,7 @@ export interface DrawerProps
|
||||
destroyOnHidden?: boolean;
|
||||
mask?: MaskType;
|
||||
|
||||
focusable?: Omit<FocusableConfig, 'autoFocusButton'>;
|
||||
focusable?: FocusableConfig;
|
||||
}
|
||||
|
||||
const defaultPushState: PushState = { distance: 180 };
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 | <ExclamationCircleFilled /> | |
|
||||
|
||||
@@ -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 | <ExclamationCircleFilled /> | |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user