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:
parent
c5598816ac
commit
4aebfb62f2
6 changed files with 193 additions and 107 deletions
|
|
@ -1,5 +1,5 @@
|
|||
function showLoginModal() {
|
||||
window.showModal({
|
||||
window.Forem.showModal({
|
||||
title: 'Log in to continue',
|
||||
contentSelector: '#global-signup-modal',
|
||||
overlay: true,
|
||||
|
|
|
|||
|
|
@ -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}, you’ll 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, you’ll 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue