Modal

<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 aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11 cwf-modal__times" role="presentation" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512">
                <path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z" />
            </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>"
}
  • Content:
    // 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: rgba(style.color(black), 0.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: rgba(style.color(black), 0.5) !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;
    }
    
  • URL: /components/raw/modal/_index.scss
  • Filesystem Path: components/modal/_index.scss
  • Size: 3.9 KB
  • Content:
    // The default component class
    import { Component } from '../../shared/component';
    
    // Lock/unlock document scrolling
    import { toggleScrollLock } from '../../shared/event';
    
    // Traps the focus to a specifc set of elements
    import { trap as trapFocus } from '../../shared/focus';
    
    // Find focusable descendants and toggle an element's tab order
    import {
        descendants as getFocusableDescendants,
        toggle as toggleTabOrder
    } from '../../shared/focus';
    
    // 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
                }
            });
    
            // Initialize the currently active modal
            this.modal = null;
    
            // 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.store = this.store.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({ target }) {
            // Grab the modal overlay and close button...
            const { overlay, close } = this.modal;
            // ... and if either are the click target, clear the location hash
            if ([overlay, close].includes(target)) return this.clearHash();
        }
    
        // Handle key down events
        onKeyDown(event) {
            // Grab the key pressed
            const { key } = event;
    
            // If the escape key was pressed, clear the location hash
            if (key === 'Escape') return this.clearHash();
    
            // If the tab key was pressed, trap the focus within the modal
            if (key === 'Tab')
                return this.trapFocus(
                    [this.modal.dialog, ...this.modal.focusables, this.modal.close],
                    event
                );
        }
    
        // Handle animation end events
        onAnimationEnd() {
            // Grab the modal element,...
            const { modal } = this.modal;
            // ... remove its open class,...
            modal.classList.remove(this.classes.open);
            // ... remove this event handler,...
            modal.removeEventListener('animationend', this.onAnimationEnd);
            // ... and uninitialize the modal
            this.modal = null;
        }
    
        // Close the modal if open
        closeModal() {
            // If the modal is globally set,...
            if (this.modal) {
                // ... grab the modal element,...
                const { modal, overlay, dialog, focusables, close } = this.modal;
                // ... and close it
                modal.setAttribute('aria-hidden', true);
    
                // Next, remove all focusable elements from the tab order
                toggleTabOrder(false, overlay, dialog, ...focusables, close);
    
                // Finally, unbind the modal from its click events,...
                modal.removeEventListener('click', this.onClick);
                // ... and bind to its animation end event
                modal.addEventListener('animationend', this.onAnimationEnd);
            }
    
            // Unlock document scrolling
            this.toggleScrollLock();
    
            // Finally, unbind from the document's keydown and scroll events
            document.removeEventListener('keydown', this.onKeyDown);
        }
    
        // Open the modal
        openModal() {
            // Grab the modal and its dialog box
            const { modal, overlay, dialog, focusables, close } = this.modal;
    
            // Open the modal
            modal.setAttribute('aria-hidden', false);
            modal.classList.add(this.classes.open);
    
            // Add all focusable elements to the tab order
            toggleTabOrder(true, overlay, dialog, ...focusables, close);
    
            // Focus the dialog box
            dialog.focus();
    
            // Lock document scrolling
            this.toggleScrollLock();
    
            // Finnaly, bind to the modal's click events...
            modal.addEventListener('click', this.onClick);
            // ... and the document's keydown and scroll events
            document.addEventListener('keydown', this.onKeyDown);
        }
    
        // Handle hash change events
        onHashChange() {
            // Attempt to grab the location hash...
            const locationHash = window.location.hash;
            // ... and if there is none, close the modal if open
            if (!location.hash && this.modal) return this.closeModal();
    
            // Otherwise, remove the '#' from the location hash and see if there's a matching modal for it...
            const hash = locationHash.substr(1),
                modal = this.references.find(({ modal }) => modal.id === hash);
            // ... and if not,...
            if (!modal) {
                // .. if there's an active modal, close it,...
                if (this.modal) return this.closeModal();
                // ... otherwise, do nothing
                return;
            }
    
            // Otherwise, globally set the modal...
            this.modal = modal;
            // ... and open it
            return this.openModal();
        }
    
        // Store a modal reference
        store(modal) {
            // Grab the given modal's overlay,...
            const overlay = modal.querySelector(this.selectors.overlay);
            // ... close button,...
            const close = modal.querySelector(this.selectors.close);
            // ... dialog box,...
            const dialog = modal.querySelector(this.selectors.dialog);
            // ... and the dialog box's focusable descendants
            const focusables = getFocusableDescendants(dialog);
    
            // For each focusable descendant, remove it from the tab order
            focusables.forEach((focusable) => toggleTabOrder(false, focusable));
    
            // Finally, store a reference of the modal and its relevent elements
            this.references.push({
                modal,
                overlay,
                close,
                dialog,
                focusables
            });
        }
    
        // Mount/unmount the modal functionality
        mount(run) {
            super.mount(run);
    
            // If no modals already exist,...
            if (!this.references.length) {
                // ... attempt to grab all the modals from the page...
                const modals = Array.from(
                    document.querySelectorAll(this.selectors.modal)
                );
                // ... and store references to all modals and relevant elements
                modals.forEach(this.store);
            }
    
            // If no modal references exist, do nothing else
            if (!this.references.length) return;
    
            // Finally, check to see if the current hash should open a modal (if mounting)...
            if (run) this.onHashChange();
            // ... and finally bind to the window's hash change event with the same method
            window[this.listener]('hashchange', this.onHashChange);
        }
    }
    
    export default Modal;
    
  • URL: /components/raw/modal/index.js
  • Filesystem Path: components/modal/index.js
  • Size: 7.7 KB

Modal

The modal component is a hidden dialog box that only opens when triggered.

Markup

The modal is comprised of 4 main elements:

  1. The wrapping element, div.cwf-modal, encapsulates all modal pieces and indicates if the modal is visible or hidden using an aria-hidden attribute.
  2. The overlay element, 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.
  3. The close button, button.cwf-modal__close, closes the modal and is always located at the top-right of the viewport.
  4. The dialog box, div.cwf-modal__dialog, contains the content and is the central focal point of the modal.

Javascript

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:

  1. Clicking the close button.
  2. Clicking anywhere on the overlay outside of the dialog.
  3. Pressing the Escape key.

T4 implementation

Modals are implemented in T4 as the “Modal” plugin, meaning its classes are .plugin- prefixed instead of .cwf- prefixed.

Areas

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.

Injectors

In the “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.
View graphic versionView graphic versionView graphic version