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 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>"
}
  • 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: 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;
    }
    
  • 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.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 = [];
        }
    }
    
  • URL: /components/raw/modal/index.js
  • Filesystem Path: components/modal/index.js
  • Size: 8.1 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 “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.