<div id="example-modal" class="cwf-modal" aria-hidden="true">
<div class="cwf-modal__overlay" tabindex="-1">
<button class="cwf-modal__close" aria-label="Close modal" tabindex="-1">
<span>Close</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" class="cwf-modal__times" role="presentation">
<!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z" />
</svg> </button>
<div class="cwf-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="cwf-modal__title" tabindex="-1">
<span id="cwf-modal__title" class="cwf-modal__title">
Modal example
</span>
<div class="cwf-modal__content">
<p>
Any HTML can go in here. Lorem ipsum dolor sit amet, consectetur
<a href="https://www.vcu.edu/">Virginia Commonwealth University</a>
adipiscing elit. Nulla tristique quam in purus eleifend maximus. Nullam sed
magna eget leo consequat faucibus in vitae magna.
</p>
</div>
</div>
</div>
</div>
<div id="{{ id ?? 'cwf-modal' }}" class="cwf-modal" aria-hidden="true">
<div class="cwf-modal__overlay" tabindex="-1">
<button class="cwf-modal__close" aria-label="Close modal" tabindex="-1">
<span>Close</span>
{% include '../../shared/icons/times-solid.svg' with {
class: 'cwf-modal__times',
role: 'presentation'
} %}
</button>
<div class="cwf-modal__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="cwf-modal__title"
tabindex="-1">
<span id="cwf-modal__title" class="cwf-modal__title">
{{ title }}
</span>
<div class="cwf-modal__content">
{{ content }}
</div>
</div>
</div>
</div>
{
"id": "example-modal",
"title": "Modal example",
"content": "<p>\n Any HTML can go in here. Lorem ipsum dolor sit amet, consectetur\n <a href=\"https://www.vcu.edu/\">Virginia Commonwealth University</a>\n adipiscing elit. Nulla tristique quam in purus eleifend maximus. Nullam sed\n magna eget leo consequat faucibus in vitae magna.\n</p>"
}
// Modal component styles
@use "../../shared/animation";
@use "../../shared/media";
@use "../../shared/style";
@use "../../shared/theme";
// Selector prefix
$prefix: "cwf" !default;
.#{$prefix}-modal {
display: none;
}
.#{$prefix}-modal--open {
display: block;
}
$overlay__background-color: style.opacity("black", 75%) !default;
$overlay__background-color--reduced-transparency: style.color("black") !default;
.#{$prefix}-modal__overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
padding: calc(1rem - 4px);
background-color: var(--cwf-modal__overlay--background-color);
will-change: transform;
@include style.z-index("modal");
--cwf-modal__overlay--background-color: #{$overlay__background-color};
@include media.reduced(transparency) {
--cwf-modal__overlay--background-color: #{$overlay__background-color--reduced-transparency};
}
@include media.reduced(transparency, no-preference) {
--cwf-modal__overlay--background-color: #{$overlay__background-color};
}
}
.#{$prefix}-modal[aria-hidden="false"] .#{$prefix}-modal__overlay {
@include animation.animation--fadeIn;
}
.#{$prefix}-modal[aria-hidden="true"] .#{$prefix}-modal__overlay {
@include animation.animation--fadeOut;
}
$dialog__background-color: style.color("white") !default;
$dialog__border-color: var(--cwf-modal__dialog--background-color) !default;
$dialog__outline--focus: none !default;
$dialog__border-color--focus: style.color("black") !default;
.#{$prefix}-modal__dialog {
box-sizing: border-box;
max-width: 900px;
max-height: 100vh;
padding: 2rem;
border-radius: 0.5rem;
background-color: var(--cwf-modal__dialog--background-color);
border: 2px solid var(--cwf-modal__dialog--background-color);
overflow-y: auto;
will-change: transform;
--cwf-modal__dialog--background-color: #{$dialog__background-color};
&:focus {
outline: $dialog__outline--focus;
border-color: $dialog__border-color--focus;
}
}
.#{$prefix}-modal[aria-hidden="false"] .#{$prefix}-modal__dialog {
@include animation.animation--slideInUp;
}
.#{$prefix}-modal[aria-hidden="true"] .#{$prefix}-modal__dialog {
@include animation.animation--slideOutDown;
}
.#{$prefix}-modal__title {
display: block;
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
$close__background-color: style.color("black") !default;
$close__color: style.color("white") !default;
$close__background-color--active: style.color("black") !default;
$close__background-color--desktop: style.opacity("black", 50%) !default;
.#{$prefix}-modal__close {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-evenly;
min-width: 128px;
height: 64px;
padding: 0;
border: none;
background-color: var(--cwf-modal__close--background-color);
font-family: theme.font--sans-serif();
font-size: 1rem;
font-weight: 700;
color: var(--cwf-modal__close--color);
@include animation.transition(background-color);
--cwf-modal__close--background-color: #{$close__background-color};
--cwf-modal__close--color: #{$close__color};
--cwf-modal__close--active--background-color: #{$close__background-color--active};
&:hover,
&:focus {
background-color: var(--cwf-modal__close--active--background-color);
}
@include media.breakpoint {
--cwf-modal__close--background-color: #{$close__background-color--desktop};
}
}
.#{$prefix}-modal__close span,
.#{$prefix}-modal__times {
pointer-events: none;
}
.#{$prefix}-modal__times {
min-width: 1.125rem;
width: 1.125rem;
margin-left: 0.25rem;
}
.#{$prefix}-modal__content {
@include style.children;
}
// The default component class
import { Component } from '../../shared/component.js';
// Lock/unlock document scrolling
import { toggleScrollLock } from '../../shared/event.js';
// Traps the focus to a specifc set of elements
import { trap as trapFocus } from '../../shared/focus.js';
// Find focusable descendants and toggle an element's tab order
import {
descendants as getFocusableDescendants,
toggle as toggleTabOrder
} from '../../shared/focus.js';
// Provide event driven functionality to the modals
export class Modal extends Component {
// When the class is instantiated
constructor({
prefix = 'cwf',
modal = 'modal',
open = 'modal--open',
overlay = 'modal__overlay',
close = 'modal__close',
dialog = 'modal__dialog'
} = {}) {
super({
prefix,
classes: {
modal,
open,
overlay,
close,
dialog
}
});
// For each event listener, bind this to it
this.onClick = this.onClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onAnimationEnd = this.onAnimationEnd.bind(this);
this.onHashChange = this.onHashChange.bind(this);
this.setup = this.setup.bind(this);
// Register the toggle scroll lock...
this.toggleScrollLock = toggleScrollLock.bind(this);
// ... and trap focus functions to this component
this.trapFocus = trapFocus.bind(this);
}
// Clear the location hash
clearHash() {
// Remove the location hash...
window.location.hash = '';
// ... without jumping back to the top of the page...
document.scrollingElement.scrollTop = this.scrollTop;
document.scrollingElement.scrollLeft = this.scrollLeft;
// ... and if possible, remove the hash altogether
if ('replaceState' in history)
return history.replaceState(null, null, ' ');
}
// Handle click events
onClick(overlay, close, { target }) {
// If the overlay or close button were clicked, clear the location hash
if ([overlay, close].includes(target)) return this.clearHash();
}
// Handle key down events
onKeyDown(event) {
// Attempt to find a modal that's actively open...
const reference = this.references.find(
({ state }) => state.target && state.open
);
// ... and if none exist, do nothing else
if (!reference) return;
// Grab the key pressed
const { key } = event;
// If the escape key was pressed, clear the location hash
if (key === 'Escape') return this.clearHash();
// Otherwise, grab the dialog, focusables, and close button from the modal...
const { dialog, focusables, close } = reference;
// ... and if the tab key was pressed, trap the focus to them
if (key === 'Tab')
return this.trapFocus([dialog, ...focusables, close], event);
}
// Handle animation end events
onAnimationEnd({ currentTarget: modal }) {
// Remove this event handler...
modal.removeEventListener('animationend', this.onAnimationEnd);
// ... and the modal's open class
modal.classList.remove(this.classes.open);
}
// Close the modal if open
closeModal({ modal, state, overlay, dialog, focusables, close }) {
// Close the modal
state.open = false;
modal.setAttribute('aria-hidden', true);
// Next, remove all focusable elements from the tab order
toggleTabOrder(false, overlay, dialog, ...focusables, close);
// Finally, bind to its animation end event...
modal.addEventListener('animationend', this.onAnimationEnd);
// ... and unlock document scrolling
this.toggleScrollLock();
}
// Open the given modal
openModal({ modal, state, overlay, dialog, focusables, close }) {
// Open the modal
modal.setAttribute('aria-hidden', false);
modal.classList.add(this.classes.open);
state.open = true;
// Add all focusable elements to the tab order
toggleTabOrder(true, overlay, dialog, ...focusables, close);
// Finally, focus the dialog box...
dialog.focus();
// ... and lock document scrolling
this.toggleScrollLock();
}
// Toggle the given modal open or close
toggleModal(reference) {
// Grab the modal's state
const { state } = reference;
const { target, open } = state;
// If the modal is already in its correct state, do nothing else
if ((target && open) || (!target && !open)) return;
// Otherwise, open it if it's the target...
if (target) return this.openModal(reference);
// ... and close it if not
return this.closeModal(reference);
}
// Handle hash change events
onHashChange() {
// Attempt to grab the location hash
const locationHash = window.location.hash;
// Next, grab the element ID from the hash...
const id = locationHash.substring(1);
// ... and for each reference,...
this.references = this.references.map((reference) => {
// ... check if it's the target,...
const target = reference.modal.id === id;
// ... if it's already open,...
const open = reference.modal.classList.contains(this.classes.open);
// ... and update its state
reference.state = { target, open };
return reference;
});
// Finally, toggle each reference modal open or close
this.references.forEach(this.toggleModal.bind(this));
}
// Setup a modal reference
setup(reference) {
// Grab the modal
const modal = reference.modal || reference;
// Grab the given modal's overlay...
const overlay =
reference.overlay || modal.querySelector(this.selectors.overlay);
// ... and close button,...
const close =
reference.close || modal.querySelector(this.selectors.close);
// ... and use them for the modal's click events
const onClick =
reference.onClick || this.onClick.bind(this, overlay, close);
modal[this.listener]('click', onClick);
// Next, grab the modal's dialog box...
const dialog =
reference.dialog || modal.querySelector(this.selectors.dialog);
// ... and the dialog box's focusable descendants,...
const focusables =
reference.focusables || getFocusableDescendants(dialog);
// ... and for each focusable descendant, remove it from the tab order
focusables.forEach((focusable) => toggleTabOrder(!this.run, focusable));
// Finally, store a reference of the modal and its relevent elements
if (this.run)
this.references.push({
modal,
overlay,
close,
onClick,
dialog,
focusables
});
}
// Mount/unmount the modal functionality
mount(run) {
super.mount(run);
// Grab all the modals from the page...
const modals = this.references.length
? this.references
: Array.from(document.querySelectorAll(this.selectors.modal));
// ... and set them up
modals.forEach(this.setup);
// If no modal references exist, do nothing else
if (!this.references.length) return;
// If running, immediately toggle the modals based on the current hash
if (run) this.onHashChange();
// Bind/unbind to the window's hash change...
window[this.listener]('hashchange', this.onHashChange);
// ... and document's key down events
document[this.listener]('keydown', this.onKeyDown);
// Finally, if unmounting, reset the references
if (!run) this.references = [];
}
}
The modal component is a hidden dialog box that only opens when triggered.
The modal is comprised of 4 main elements:
div.cwf-modal
, encapsulates all modal pieces and indicates if the modal is visible or hidden using an aria-hidden
attribute.div.cwf-modal__overlay
, spans the entirety of the screen when the modal is opened, tints all content under it to make the dialog stand out, and adds or removes the modal from the tabindex using a tabindex
attribute.button.cwf-modal__close
, closes the modal and is always located at the top-right of the viewport.div.cwf-modal__dialog
, contains the content and is the central focal point of the modal.The modal will open when the location hash changes to the ID of the wrapping modal element. This is usually triggered by adding a link to the modal on the page, but may be tapped into using custom JS or other means.
Once the modal is open, it will automatically focus the dialog box. The focus will be trapped within the modal until closed, cycling through the dialog, its focusable elements, and the close button.
There are 3 ways to close the modal:
Modals are implemented in T4 as the “Modal” plugin, meaning its classes are .plugin-
prefixed instead of .cwf-
prefixed.
This plugin can be used within the global “Site-Feature”, “Site-Sidebar”, and “Site-Footer” sections to have it displayed globally within the feature, sidebar, and footer areas respectively.
In the “Injectors”* or “Name” field of the “Modal” plugin, the following injectors can be used:
id:{custom_id}
- Overrides the default, T4 ID of the modal with a custom ID.class:{custom_classes}
- Adds custom classes to the modal.style:{custom_styles}
- Adds custom styles to a style
attribute of the modal.before:{custom_html}
- Adds custom HTML before the modal.after:{custom_html}
- Adds custom HTML after the modal.* These features are only supported on T41.