Improve keyboard accessibility of comment rate limit and error modals (#13536)

* add modal code to Forem namespace, use showModal in the rate limit modal generator

* add cypress tests for both modals

* styling tweaks

* tidy up

* make sure modal trap selector is unique to page, update Modal JSDoc

* add additional jest test for focusTrapSelector
This commit is contained in:
Suzanne Aitchison 2021-05-03 07:14:23 +01:00 committed by GitHub
parent c5598816ac
commit 4aebfb62f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 193 additions and 107 deletions

View file

@ -1,5 +1,5 @@
function showLoginModal() {
window.showModal({
window.Forem.showModal({
title: 'Log in to continue',
contentSelector: '#global-signup-modal',
overlay: true,

View file

@ -1,3 +1,12 @@
/**
* HTML ID for modal DOM node
*
* @private
* @constant modalId *
* @type {string}
*/
const modalId = 'user-alert-modal';
/**
* Displays a general purpose user alert modal with a title, body text, and confirmation button.
*
@ -10,8 +19,12 @@
* showUserAlertModal('Warning', 'You must wait', 'OK', '/faq/why-must-i-wait', 'Why must I wait?');
*/
function showUserAlertModal(title, text, confirm_text) {
let modalDiv = buildModalDiv(title, text, confirm_text);
toggleUserAlertModal();
buildModalDiv(text, confirm_text);
window.Forem.showModal({
title,
contentSelector: `#${modalId}`,
overlay: true,
});
}
/**
* Displays a user rate limit alert modal letting the user know what they did that exceeded a rate limit,
@ -36,60 +49,27 @@ function showRateLimitModal(action_text, next_action_text) {
);
}
/**
* HTML ID for modal DOM node
*
* @private
* @constant modalId *
* @type {string}
*/
const modalId = 'user-alert-modal';
/**
* HTML template for modal
*
* @private
* @function getModalHtml
*
* @param {string} title The title/heading text to be displayed
* @param {string} text The body text to be displayed
* @param {string} confirm_text Text of the confirmation button
*
* @returns {string} HTML for the modal
*/
const getModalHtml = (
title,
text,
confirm_text,
) => `<div id="${modalId}" data-testid="modal-container" class="crayons-modal crayons-modal--m hidden">
<div role="dialog" aria-modal="true" class="crayons-modal__box">
<div class="crayons-modal__box__header border-b-0 justify-end">
<button class="crayons-btn crayons-btn--ghost crayons-btn--icon" type="button"
onClick="toggleUserAlertModal();" aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon"
xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="714d29e78a3867c79b07f310e075e824">
<title id="714d29e78a3867c79b07f310e075e824">Close</title>
<path
d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z">
</path>
</svg>
</button>
</div>
<div class="crayons-modal__box__body pt-0 flex">
<div class="w-75">
<h2>
${title}
</h2>
<p class="color-base-70">
${text}
</p>
<button class="crayons-btn mt-4" type="button" onClick="toggleUserAlertModal();">
${confirm_text}
</button>
</div>
</div>
const getModalHtml = (text, confirm_text) => `
<div id="${modalId}" hidden>
<div class="flex flex-col">
<p class="color-base-70">
${text}
</p>
<button class="crayons-btn mt-4 ml-auto" type="button" onClick="window.Forem.closeModal()">
${confirm_text}
</button>
</div>
<div data-testid="modal-overlay" class="crayons-modal__overlay"></div>
</div>
`;
@ -108,36 +88,21 @@ function buildRateLimitText(action_text, next_action_text) {
return `Since you recently ${action_text}, youll need to wait a moment before ${next_action_text}.`;
}
/**
* Shows or hides the user alert modal by toggling it's 'hidden' class
*
* @private
* @function toggleUserAlertModal
*
*/
function toggleUserAlertModal() {
let modalDiv = document.getElementById(modalId);
if (modalDiv) {
modalDiv.classList.toggle('hidden');
}
}
/**
* Checks for the alert modal, and if it's not present builds and inserts it in the DOM
*
* @private
* @function buildModalDiv
*
* @param {string} title The title/heading text to be displayed
* @param {string} text The body text to be displayed
* @param {string} confirm_text Text of the confirmation button
*
* @returns {Element} DOM node of the inserted alert modal
*/
function buildModalDiv(title, text, confirm_text) {
function buildModalDiv(text, confirm_text) {
let modalDiv = document.getElementById(modalId);
if (!modalDiv) {
modalDiv = getModal(title, text, confirm_text);
modalDiv = getModal(text, confirm_text);
document.body.appendChild(modalDiv);
}
return modalDiv;
@ -149,14 +114,13 @@ function buildModalDiv(title, text, confirm_text) {
* @private
* @function getModal
*
* @param {string} title The title/heading text to be displayed
* @param {string} text The body text to be displayed
* @param {string} confirm_text Text of the confirmation button
*
* @returns {Element} DOM node of alert modal with formatted text
*/
function getModal(title, text, confirm_text) {
function getModal(text, confirm_text) {
let wrapper = document.createElement('div');
wrapper.innerHTML = getModalHtml(title, text, confirm_text);
return wrapper.firstChild;
wrapper.innerHTML = getModalHtml(text, confirm_text);
return wrapper;
}

View file

@ -33,19 +33,50 @@ const CloseIcon = () => (
</svg>
);
/**
* A modal component which can be presented with or without an overlay.
* The modal is presented within a focus trap for accessibility purposes - please note that the selector used for the focusTrap must be unique on the given page, otherwise focus may be trapped on the wrong element.
*
* @param {Object} props
* @param {Array} props.children The content to be displayed inside the Modal. Can be provided by composition (see example).
* @param {string} props.size The desired modal size ('s', 'm' or 'default')
* @param {string} props.className Optional additional classnames to apply to the modal container
* @param {string} props.title The title to be displayed in the modal heading. If provided, a title bar with a close button will be displayed.
* @param {boolean} props.overlay Whether or not to show a semi-opaque overlay behind the modal
* @param {Function} props.onClose Callback for any function to be executed on close button click or Escape
* @param {boolean} props.closeOnClickOutside Whether the modal should close if the user clicks outside of it
* @param {string} props.focusTrapSelector The CSS selector for where to trap the user's focus. This should be unique to the page in which the modal is presented.
*
* @example
* <Modal
overlay={true}
title="Example modal title"
onClose={cancelAction}
size="s"
focusTrapSelector="#window-modal"
closeOnClickOutside={false}
className=".additional-class-name"
>
<div>
<p>Some modal content</p>
</div>
</Modal>
*/
export const Modal = ({
children,
size = 'default',
className,
title,
overlay,
onClose,
overlay = true,
onClose = () => {},
closeOnClickOutside = false,
focusTrapSelector = '.crayons-modal',
}) => {
return (
<FocusTrap
onDeactivate={onClose}
clickOutsideDeactivates={closeOnClickOutside}
selector={focusTrapSelector}
>
<div
data-testid="modal-container"
@ -89,12 +120,6 @@ export const Modal = ({
Modal.displayName = 'Modal';
Modal.defaultProps = {
className: undefined,
overlay: true,
onClose: undefined,
};
Modal.propTypes = {
children: defaultChildrenPropTypes.isRequired,
className: PropTypes.string,
@ -102,4 +127,5 @@ Modal.propTypes = {
overlay: PropTypes.bool,
onClose: PropTypes.func,
size: PropTypes.oneOf(['default', 's', 'm']).isRequired,
focusTrapSelector: PropTypes.string,
};

View file

@ -14,7 +14,7 @@ it('should have no a11y violations', async () => {
expect(results).toHaveNoViolations();
});
it('should trap focus', async () => {
it('should trap focus inside the modal by default', async () => {
const { getByText, getByLabelText } = render(
<div>
<button>Outside modal button</button>
@ -34,6 +34,23 @@ it('should trap focus', async () => {
expect(closeButton).toHaveFocus();
});
it('should trap focus in the custom selector if provided in props', async () => {
const { getByText } = render(
<div>
<button>Outside modal button</button>
<Modal title="This is a modal title" focusTrapSelector="#trap-focus-here">
<button>Outside focus trap button</button>
<div id="trap-focus-here">
<button>Inside focus trap button</button>
</div>
</Modal>
</div>,
);
const buttonInsideFocusTrap = getByText('Inside focus trap button');
await waitFor(() => expect(buttonInsideFocusTrap).toHaveFocus());
});
it('should close when the close button is clicked', async () => {
const onClose = jest.fn();
const { getByLabelText } = render(

View file

@ -5,15 +5,25 @@ import {
initializeMemberMenu,
} from '../topNavigation/utilities';
// Unique ID applied to modals created using window.Forem.showModal
const WINDOW_MODAL_ID = 'window-modal';
// Namespace for functions which need to be accessed in plain JS initializers
window.Forem = {
preactImport: undefined,
getPreactImport() {
if (!this.preactImport) {
this.preactImport = import('preact');
}
return this.preactImport;
},
mentionAutoCompleteImports: undefined,
getMentionAutoCompleteImports() {
if (!this.mentionAutoCompleteImports) {
this.mentionAutoCompleteImports = [
import('@crayons/MentionAutocompleteTextArea'),
import('@utilities/search'),
import('preact'),
this.getPreactImport(),
];
}
@ -44,40 +54,58 @@ window.Forem = {
originalTextArea,
);
},
};
modalImports: undefined,
getModalImports() {
if (!this.modalImports) {
this.modalImports = [import('@crayons/Modal'), this.getPreactImport()];
}
return Promise.all(this.modalImports);
},
showModal: async ({
title,
contentSelector,
overlay = false,
size = 's',
}) => {
const [{ Modal }, { render, h }] = await window.Forem.getModalImports();
window.showModal = async ({
title,
contentSelector,
overlay = false,
size = 's',
}) => {
const [{ Modal }, { render, h }] = await Promise.all([
import('@crayons/Modal'),
import('preact'),
]);
// Guard against two modals being opened at once
let currentModalContainer = document.getElementById(WINDOW_MODAL_ID);
if (currentModalContainer) {
render(null, currentModalContainer);
} else {
currentModalContainer = document.createElement('div');
currentModalContainer.setAttribute('id', WINDOW_MODAL_ID);
document.body.appendChild(currentModalContainer);
}
const modalRoot = document.createElement('div');
document.body.appendChild(modalRoot);
render(
<Modal
overlay={overlay}
title={title}
onClose={() => {
render(null, modalRoot);
}}
size={size}
>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: document.querySelector(contentSelector).innerHTML,
render(
<Modal
overlay={overlay}
title={title}
onClose={() => {
render(null, currentModalContainer);
}}
/>
</Modal>,
modalRoot,
);
size={size}
focusTrapSelector={`#${WINDOW_MODAL_ID}`}
>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: document.querySelector(contentSelector).innerHTML,
}}
/>
</Modal>,
currentModalContainer,
);
},
closeModal: async () => {
const currentModalContainer = document.getElementById(WINDOW_MODAL_ID);
if (currentModalContainer) {
const { render } = await window.Forem.getPreactImport();
render(null, currentModalContainer);
}
},
};
function getPageEntries() {

View file

@ -449,4 +449,55 @@ describe('Comment on articles', () => {
});
});
});
it('should show rate limit modal', () => {
cy.intercept('POST', '/comments', { statusCode: 429, body: {} });
cy.findByRole('main').within(() => {
cy.findByRole('textbox', { name: /^Add a comment to the discussion$/i })
.focus() // Focus activates the Submit button and mini toolbar below a comment textbox
.type('this is a comment');
cy.findByRole('button', { name: /^Submit$/i }).click();
});
cy.findByTestId('modal-container').within(() => {
cy.findByRole('button', { name: /Close/ }).should('have.focus');
cy.findByRole('heading', { name: 'Wait a moment...' }).should('exist');
cy.findByText(
'Since you recently made a comment, youll need to wait a moment before making another comment.',
);
cy.findByRole('button', { name: 'Got it' }).click();
});
cy.findByTestId('modal-container').should('not.exist');
cy.findByRole('button', { name: /^Submit$/i }).should('have.focus');
});
it('should show error modal', () => {
cy.intercept('POST', '/comments', {
statusCode: 500,
body: { error: 'Test error' },
});
cy.findByRole('main').within(() => {
cy.findByRole('textbox', { name: /^Add a comment to the discussion$/i })
.focus() // Focus activates the Submit button and mini toolbar below a comment textbox
.type('this is a comment');
cy.findByRole('button', { name: /^Submit$/i }).click();
});
cy.findByTestId('modal-container').within(() => {
cy.findByRole('button', { name: /Close/ }).should('have.focus');
cy.findByRole('heading', { name: 'Error posting comment' }).should(
'exist',
);
cy.findByText(
'Your comment could not be posted due to an error: Test error',
);
cy.findByRole('button', { name: 'OK' }).click();
});
cy.findByTestId('modal-container').should('not.exist');
cy.findByRole('button', { name: /^Submit$/i }).should('have.focus');
});
});