Potato logoACE
Skip to main contentGitLab logo

Modal

Modal is a component that is overlaid on top of other site content and prevents users from interacting with content outside of it.

Modal conforms to the W3C WAI-ARIA authoring practices.

Set up

First import the styles into your main SASS file, replacing <path-to-node_modules> with the path to the node_modules directory relative to the file:

@import '<path-to-node_modules>/@potato/ace/components/modal/modal';

Alternatively ace.scss includes all ACE component SASS files, so if using multiple ACE components it can be imported instead:

@import '<path-to-node_modules>/@potato/ace/ace';

A CSS file is also provided for convenience and is located at <path-to-node_modules>/@potato/ace/components/modal/ace-modal.css.

Then import the class into your JavaScript entry point:

import '<path-to-node_modules>/@potato/ace/components/modal/modal';

For convenience the ES6 class is exported as Modal and the attribute names used by the class are exported as properties of ATTRS.

After the event DOMContentLoaded is fired on document an instance of Modal is instantiated within each <ace-modal> element and an ID ace-modal-<n> is added for any instance without one, where <n> is a unique integer. Once instantiation is complete a custom event ace-modal-ready is dispatched to window. See the Custom events section below for more details.

Visible Modals take up the full screen on mobile, and have a fixed width and height for all other devices as well as a backdrop that overlays site content outside the Modal, visually obscuring it. Modal uses any element on the page with attribute ace-modal-backdrop as the backdrop. If no element has this attribute then an <div> element will be appended to body and given this attribute.

Modal must have at least one button to hide it. Modal will look for a descendant with attribute ace-modal-hide-btn and if none are present it will add a <button> element as it's first child and will give it this attribute.

It is strongly recommended that Modal be provided with an accessible label using either aria-label or aria-labelledby.

Usage

Modals are hidden by default but can be initially shown on page load by adding the ace-modal-visible attribute to them, which is an observed attribute that can be added or removed to dynamically show or hide the Modal. When a Modal is shown the first focusable descendant is focused, and when hidden focus returns to the element that was focused before the Modal was shown, which in most cases is the trigger.

The attribute ace-modal-trigger-for must be added to elements that trigger the Modal with its value set to the Modal ID. For accessibility reasons it is recommended that only <button> elements are used for triggers. Modals can contain triggers for other Modals, which when clicked will hide the Modal they are in and show their target Modal. When a Modal becomes visible the attribute ace-modal-is-visible is added to body and the backdrop to pervent scrolling in the former and show the latter. Modals can be hidden by either clicking on any descendant with attribute ace-modal-hide-btn, clicking on the backdrop element or pressing Esc. When a Modal is hidden it still remains in the DOM with its content unchanged.

Visible Modals prevent users from interacting with content outside of it by either visually obscuring the content using the backdrop element or taking up the full screen of mobile devices, and by either making the content inert or by trapping keyboard focus within itself. Modal first attempts to use the HTML inert property, currently part of the HTML Living Standard specification. If the browser supports inert and the Modal is a direct child of body it will add the inert attribute to all of its siblings except the backdrop, thereby preventing users from interacting with them. For browsers that don't support inert or for Modals that are not children of body, a fallback focus trap technique is used. This method involves determining Modal's first and last interactable descendants by getting all its focusable descendants and filtering out elements that are disabled or hidden using CSS declarations display: none or visibility: hidden. Focus can then be moved to the first interactable descendant from the last when Tab is pressed, and to the last from the first when Shift + Tab are pressed. To allow for dynamically changing focusable descendants the focus trap listens for changes to the style, class and disabled attributes of all Modal's focusable descendants using a mutation observer and updates the first and last interactable descendants. An example use case for this is having a disabled form submission button as the last focusable descendant, which is enabled upon form validation thereby becoming the last interactable descendant due to the mutation observer, without which this button would be unfocusable. The first and last interactable descendants can also be manually updated by developers through a custom event. See the Custom events section below for more details.

Alert Modals

Modals can be used as alert dialogs that interrupt the user's workflow to communicate an important message and acquire a response. Examples include action confirmation prompts and error message confirmations. To create an alert Modal simply set its role attribute to alertdialog. This enables assistive technologies and browsers to distinguish alert dialogs from other dialogs so they have the option of giving alert dialogs special treatment, such as playing a system alert sound. Modals with role="alertdialog" will not be given role="dialog" during initialisation.

Styles

The following SASS is applied to Modal. The SASS variables use !default so can also be easily overridden by developers. SASS variables used that are not defined here are defined in <path-to-node_modules>/@potato/ace/common/constants.scss.

@import '../../common/constants';


// VARIABLES
$ace-modal-backdrop-bg-color: rgba(0, 0, 0, .5) !default;
$ace-modal-bg-color: #fff !default;
$ace-modal-padding: $ace-spacing-3 !default;
$ace-modal-switch-breakpoint: 768px !default;


// STYLES
ace-modal {
	background: $ace-modal-bg-color;
	overflow: auto;
	padding: $ace-modal-padding;
	position: fixed;
	z-index: $ace-modal-z-index;

	@media (max-width: #{$ace-modal-switch-breakpoint - 1px}) {
		height: 100vh;
		left: 0;
		top: 0;
		width: 100vw;
	}

	@media (min-width: #{$ace-modal-switch-breakpoint}) {
		left: 50%;
		top: 50%;
		transform: translate(-50%, -50%);
	}

	&:not([ace-modal-visible]) {
		// Using 'display: none' prevents VoiceOver from being able to focus within the modal
		visibility: hidden;
	}
}

// Placed on body and backdrop when a Modal is visible
[ace-modal-is-visible] {
	overflow: hidden;
	// prevent reflow due to scroll bar disappearing;
	padding-right: $ace-scrollbar-width;
}

[ace-modal-backdrop] {
	background: $ace-modal-backdrop-bg-color;
	bottom: 0;
	left: 0;
	position: fixed;
	right: 0;
	top: 0;
	z-index: $ace-modal-backdrop-z-index;

	&:not([ace-modal-is-visible]) {
		display: none;
	}
}

Custom events

Modal uses the following custom events, the names of which are available in its exported EVENTS object, similar to ATTRS, so they may be imported into other modules.

Dispatched events

The following events are dispatched to window by Modal.

Ready

ace-modal-ready

This event is dispatched when Modal finishes initialising. The event name is available as EVENTS.OUT.READY and its detail property is composed as follows:

'detail': {
	'id': // ID of Modal [string]
}

Visibility changed

ace-modal-visibility-changed

This event is dispatched when Modal finishes initialising. The event name is available as EVENTS.OUT.VISIBILITY_CHANGED and its detail property is composed as follows:

'detail': {
	'id': // ID of Modal [string]
	'visible': // Whether the Modal is visible or not [boolean]
}

Listened for event

Modal listens for the following event that should be dispatched to window.

Update focus trap

ace-disclosure-update-focus-trap

This event should be dispatched when an element is dynamically added to the Modal as its first or last focusable descendant and updates the focus trap accordingly. The event name is available as EVENTS.IN.UPDATE_FOCUS_TRAP and its detail property should be composed as follows:

'detail': {
	'id': // ID of target Modal [string]
}

Examples

Each example contains a live demo and the HTML code that produced it. The code shown may differ slightly to that rendered for the demo as some components may alter their HTML when they initialise.

Simple Modal

Example of a simple modal with two triggers that is shown on page load. The example also demonstrates how the focus trap and the ace-disclosure-update-focus-trap custom event work. After triggering the Modal, use Toggle disabled button button to toggle the disabled state of the disabled button, and notice that the mutation observer updates the focus trap. Next use the Add link to Modal and Remove link from Modal buttons to add and remove links and dispatch the custom event, and notice how the focus trap is again updated.

The JavaScript used by this example is shown below.

Modal heading

This modal was shown on page load because it had attribute ace-modal-visible when the page was loaded.

Potato logo
<button ace-modal-trigger-for="ace-visible-modal">Modal trigger 1</button>
<button ace-modal-trigger-for="ace-visible-modal">Modal trigger 2</button>

<ace-modal aria-label="Example Modal" id="ace-visible-modal" ace-modal-visible>
	<h3>Modal heading</h3>
	<p>This modal was shown on page load because it had attribute <code>ace-modal-visible</code> when the page was loaded.</p>
	<img src="/img/logo.svg" height="100px" alt="Potato logo"/>
	<button id="toggle-disabled-btn-btn">Toggle disabled button</button>
	<button id="add-link-btn">Add link to Modal</button>
	<button id="remove-link-btn">Remove link from Modal</button>
	<button id="disabled-btn" disabled>Disabled Button</button>
</ace-modal>
import {EVENTS} from '/ace/components/modal/modal.js';

document.addEventListener('DOMContentLoaded', () => {
	const MODAL_ID = 'ace-visible-modal';
	const modalEl = document.getElementById(MODAL_ID);
	const disabledBtn = document.getElementById('disabled-btn');

	modalEl.addEventListener('click', (e) => {
		const targetId = e.target.id;
		const toggleDisabledBtnBtnClicked = targetId === 'toggle-disabled-btn-btn';
		if (toggleDisabledBtnBtnClicked) {
			disabledBtn.disabled = !disabledBtn.disabled;
			return;
		}

		const addLinkBtnClicked = targetId === 'add-link-btn';
		if (addLinkBtnClicked) {
			const linkEl = document.createElement('a');
			linkEl.href = '#';
			linkEl.textContent = 'Dummy link';
			const pEl = document.createElement('p');
			pEl.appendChild(linkEl);
			modalEl.appendChild(pEl);
		}

		const removeLinkBtnClicked = targetId === 'remove-link-btn';
		if (removeLinkBtnClicked) {
			const linkEl = modalEl.querySelector('a');
			if (linkEl) {
				linkEl.remove();
			}
		}
		window.dispatchEvent(new CustomEvent(
			EVENTS.IN.UPDATE_FOCUS_TRAP,
			{'detail': {'id': MODAL_ID}},
		));
	});
});

Example of a Modal that has a trigger for another Modal and makes use of the ace-modal-visibility-changed custom event. When the second Modal's trigger in the first Modal is clicked, the first Modal is hidden and the second Modal shown. When the second Modal is closed and its ace-modal-visibility-changed custom event is dispatched, the first Modal is shown again.

The JavaScript used by this example is shown below.

Second Modal

Second Modal

Potato Spuddy with headphones and phone
<button ace-modal-trigger-for="ace-hidden-modal">
	Second Modal's trigger
</button>

<ace-modal aria-label="Example of Modal that shows another Modal" id="ace-hidden-modal">
	<button ace-modal-hide-modal-btn aria-label="Exit modal">&#x2715;</button>
	<h3>Second Modal</h3>
	<p>Second Modal</p>
	<img src="/img/phone-spuddy.png" height="100px" alt="Potato Spuddy with headphones and phone"/>
	<button ace-modal-trigger-for="ace-visible-modal">Show first modal</button>
</ace-modal>
import {ATTRS, EVENTS} from '/ace/components/modal/modal.js';

document.addEventListener('DOMContentLoaded', () => {
	const OTHER_MODAL_ID = 'ace-visible-modal';
	const modalEl = document.getElementById('ace-hidden-modal');
	let otherModalTriggerClicked;

	// If other Modal is shown using trigger in this Modal, show this Modal when other Modal is hidden
	const otherModalTrigger = modalEl.querySelector(`[ace-modal-trigger-for="${OTHER_MODAL_ID}"]`);
	otherModalTrigger.addEventListener('click', () => otherModalTriggerClicked = true);

	window.addEventListener(EVENTS.OUT.VISIBILITY_CHANGED, (e) => {
		if (!e.detail || e.detail.id !== OTHER_MODAL_ID || e.detail.visible || !otherModalTriggerClicked) {
			return;
		}
		otherModalTriggerClicked = false;
		modalEl.setAttribute(ATTRS.VISIBLE, '');
	});
});