UI: Improve modal a11y by setting role & using title as label (#45472)

* UI: Improve modal a11y by setting role & using title as label

* remove wrapping div for cutom title components

* Fix typo
This commit is contained in:
Giordano Ricci 2022-02-22 14:12:34 +00:00 committed by GitHub
parent c11cf7d470
commit 9307cc86f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 43 additions and 51 deletions

View File

@ -44,9 +44,6 @@ exports[`no enzyme tests`] = {
"packages/grafana-ui/src/components/Logs/LogRows.test.tsx:2288254498": [ "packages/grafana-ui/src/components/Logs/LogRows.test.tsx:2288254498": [
[3, 17, 13, "RegExp match", "2409514259"] [3, 17, 13, "RegExp match", "2409514259"]
], ],
"packages/grafana-ui/src/components/Modal/Modal.test.tsx:4235780832": [
[1, 17, 13, "RegExp match", "2409514259"]
],
"packages/grafana-ui/src/components/QueryField/QueryField.test.tsx:1906163280": [ "packages/grafana-ui/src/components/QueryField/QueryField.test.tsx:1906163280": [
[1, 19, 13, "RegExp match", "2409514259"] [1, 19, 13, "RegExp match", "2409514259"]
], ],

View File

@ -1,27 +1,24 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { render, screen } from '@testing-library/react';
describe('Modal', () => { describe('Modal', () => {
it('renders without error', () => {
mount(<Modal title={'Some Title'} isOpen={true} />);
});
it('renders nothing by default or when isOpen is false', () => { it('renders nothing by default or when isOpen is false', () => {
const wrapper = mount(<Modal title={'Some Title'} />); render(<Modal title="Some Title" />);
expect(wrapper.html()).toBe(null);
wrapper.setProps({ ...wrapper.props(), isOpen: false }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(wrapper.html()).toBe(null);
}); });
it('renders correct contents', () => { it('renders correct contents', () => {
const wrapper = mount( render(
<Modal title={'Some Title'} isOpen={true}> <Modal title="Some Title" isOpen>
<div id={'modal-content'}>Content</div> <div data-testid="modal-content">Content</div>
</Modal> </Modal>
); );
expect(wrapper.find('div#modal-content').length).toBe(1);
expect(wrapper.contains('Some Title')).toBeTruthy(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByLabelText('Some Title')).toBeInTheDocument();
expect(screen.getByTestId('modal-content')).toBeInTheDocument();
}); });
}); });

View File

@ -1,7 +1,9 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { FocusScope } from '@react-aria/focus'; import { FocusScope } from '@react-aria/focus';
import { OverlayContainer } from '@react-aria/overlays'; import { useDialog } from '@react-aria/dialog';
import React, { PropsWithChildren, useCallback, useEffect } from 'react';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import React, { PropsWithChildren, useRef } from 'react';
import { useTheme2 } from '../../themes'; import { useTheme2 } from '../../themes';
import { IconName } from '../../types'; import { IconName } from '../../types';
@ -39,33 +41,24 @@ export function Modal(props: PropsWithChildren<Props>) {
closeOnBackdropClick = true, closeOnBackdropClick = true,
className, className,
contentClassName, contentClassName,
onDismiss: propsOnDismiss, onDismiss,
onClickBackdrop, onClickBackdrop,
trapFocus = true, trapFocus = true,
} = props; } = props;
const theme = useTheme2(); const theme = useTheme2();
const styles = getModalStyles(theme); const styles = getModalStyles(theme);
const onDismiss = useCallback(() => {
if (propsOnDismiss) {
propsOnDismiss();
}
}, [propsOnDismiss]);
useEffect(() => { const ref = useRef<HTMLDivElement>(null);
const onEscKey = (ev: KeyboardEvent) => {
if (ev.key === 'Esc' || ev.key === 'Escape') { // Handle interacting outside the dialog and pressing
onDismiss(); // the Escape key to close the modal.
} const { overlayProps, underlayProps } = useOverlay(
}; { isKeyboardDismissDisabled: closeOnEscape, isOpen, onClose: onDismiss },
if (isOpen && closeOnEscape) { ref
document.addEventListener('keydown', onEscKey, false); );
} else {
document.removeEventListener('keydown', onEscKey, false); // Get props for the dialog and its title
} const { dialogProps, titleProps } = useDialog({}, ref);
return () => {
document.removeEventListener('keydown', onEscKey, false);
};
}, [closeOnEscape, isOpen, onDismiss]);
if (!isOpen) { if (!isOpen) {
return null; return null;
@ -78,16 +71,17 @@ export function Modal(props: PropsWithChildren<Props>) {
<div <div
className={styles.modalBackdrop} className={styles.modalBackdrop}
onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)} onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)}
{...underlayProps}
/> />
<FocusScope contain={trapFocus} autoFocus restoreFocus> <FocusScope contain={trapFocus} autoFocus restoreFocus>
{/* <div className={cx(styles.modal, className)} ref={ref} {...overlayProps} {...dialogProps}>
tabIndex=-1 is needed here to support highlighting text within the modal when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<div tabIndex={-1} className={cx(styles.modal, className)}>
<div className={headerClass}> <div className={headerClass}>
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} />} {typeof title === 'string' && <DefaultModalHeader {...props} title={title} id={titleProps.id} />}
{typeof title !== 'string' && title} {
// FIXME: custom title components won't get an accessible title.
// Do we really want to support them or shall we just limit this ModalTabsHeader?
typeof title !== 'string' && title
}
<div className={styles.modalHeaderClose}> <div className={styles.modalHeaderClose}>
<IconButton aria-label="Close dialogue" surface="header" name="times" size="xl" onClick={onDismiss} /> <IconButton aria-label="Close dialogue" surface="header" name="times" size="xl" onClick={onDismiss} />
</div> </div>
@ -130,11 +124,12 @@ function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode;
Modal.ButtonRow = ModalButtonRow; Modal.ButtonRow = ModalButtonRow;
interface DefaultModalHeaderProps { interface DefaultModalHeaderProps {
id?: string;
title: string; title: string;
icon?: IconName; icon?: IconName;
iconTooltip?: string; iconTooltip?: string;
} }
function DefaultModalHeader({ icon, iconTooltip, title }: DefaultModalHeaderProps): JSX.Element { function DefaultModalHeader({ icon, iconTooltip, title, id }: DefaultModalHeaderProps): JSX.Element {
return <ModalHeader icon={icon} iconTooltip={iconTooltip} title={title} />; return <ModalHeader icon={icon} iconTooltip={iconTooltip} title={title} id={id} />;
} }

View File

@ -5,6 +5,7 @@ import { useStyles2 } from '../../themes';
interface Props { interface Props {
title: string; title: string;
id?: string;
/** @deprecated */ /** @deprecated */
icon?: IconName; icon?: IconName;
/** @deprecated */ /** @deprecated */
@ -12,12 +13,14 @@ interface Props {
} }
/** @internal */ /** @internal */
export const ModalHeader: React.FC<Props> = ({ icon, iconTooltip, title, children }) => { export const ModalHeader: React.FC<Props> = ({ icon, iconTooltip, title, children, id }) => {
const styles = useStyles2(getModalStyles); const styles = useStyles2(getModalStyles);
return ( return (
<> <>
<h2 className={styles.modalHeaderTitle}>{title}</h2> <h2 className={styles.modalHeaderTitle} id={id}>
{title}
</h2>
{children} {children}
</> </>
); );