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">
            <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="0">
            <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">
            <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="0">
            <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
    
    @import "../../shared/animation";
    @import "../../shared/media";
    @import "../../shared/style";
    @import "../../shared/theme";
    
    .cwf-modal {
        display: none;
    }
    
    .cwf-modal--open {
        display: block;
    }
    
    $modal__overlay--background-color: rgba(style__color(black), 0.75) !default;
    $modal__overlay--reduced-transparency--background-color: style__color(
        black
    ) !default;
    
    .cwf-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: #{$modal__overlay--background-color};
    
        @include media__reduced(transparency) {
            --cwf-modal__overlay--background-color: #{$modal__overlay--reduced-transparency--background-color};
        }
    
        @include media__reduced(transparency, no-preference) {
            --cwf-modal__overlay--background-color: #{$modal__overlay--background-color};
        }
    }
    
    .cwf-modal[aria-hidden="false"] .cwf-modal__overlay {
        @include animation__animation--fadeIn;
    }
    
    .cwf-modal[aria-hidden="true"] .cwf-modal__overlay {
        @include animation__animation--fadeOut;
    }
    
    $modal__dialog--background-color: style__color(white) !default;
    $modal__dialog--border-color: var(
        --cwf-modal__dialog--background-color
    ) !default;
    $modal__dialog--focus--outline: none !default;
    $modal__dialog--focus--border-color: style__color(black) !default;
    
    .cwf-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: #{$modal__dialog--background-color};
    
        &:focus {
            outline: $modal__dialog--focus--outline;
            border-color: $modal__dialog--focus--border-color;
        }
    }
    
    .cwf-modal[aria-hidden="false"] .cwf-modal__dialog {
        @include animation__animation--slideInUp;
    }
    
    .cwf-modal[aria-hidden="true"] .cwf-modal__dialog {
        @include animation__animation--slideOutDown;
    }
    
    .cwf-modal__title {
        display: block;
        font-size: 1.25rem;
        font-weight: bold;
        margin-bottom: 0.5rem;
    }
    
    $modal__close--background-color: style__color(black) !default;
    $modal__close--color: style__color(white) !default;
    $modal__close--active--background-color: style__color(black) !default;
    $modal__close--desktop--background-color: rgba(
        style__color(black),
        0.5
    ) !default;
    
    .cwf-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: #{$modal__close--background-color};
        --cwf-modal__close--color: #{$modal__close--color};
        --cwf-modal__close--active--background-color: #{$modal__close--active--background-color};
    
        &:hover,
        &:focus {
            background-color: var(--cwf-modal__close--active--background-color);
        }
    
        @include media__breakpoint {
            --cwf-modal__close--background-color: #{$modal__close--desktop--background-color};
        }
    }
    
    .cwf-modal__close span,
    .cwf-modal__times {
        pointer-events: none;
    }
    
    .cwf-modal__times {
        min-width: 1.125rem;
        width: 1.125rem;
        margin-left: 0.25rem;
    }
    
    .cwf-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';
    
    // Provide event driven functionality to the modals
    export class Modal extends Component {
        // When the class is instantiated
        constructor({
            modal = 'cwf-modal',
            open = 'cwf-modal--open',
            overlay = 'cwf-modal__overlay',
            close = 'cwf-modal__close',
            dialog = 'cwf-modal__dialog'
        } = {}) {
            super();
    
            // Store all selectors...
            this.selectors = {
                modal,
                overlay,
                close,
                dialog
            };
            // ... and classes
            this.classes = {
                open
            };
    
            // Attempt to grab all the modals from the page...
            this.modals = Array.from(
                document.querySelectorAll(`.${this.selectors.modal}`)
            );
            // ... and 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);
    
            // Register the toggle scroll lock function to this component
            this.toggleScrollLock = toggleScrollLock.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 }) {
            // If the click target is the overlay or the close button, clear the location hash
            if (target === this.overlay || target === this.close)
                return this.clearHash();
        }
    
        // Trap the focus within the modal
        trapFocus(event) {
            // Prevent the default behavior
            event.preventDefault();
    
            // Grab if the shift key was pressed,...
            const { shiftKey } = event,
                // ... the first and last indexes,...
                firstIndex = 0,
                lastIndex = this.focusable.length - 1,
                // ... and the index of the currently focused element
                focused = document.activeElement,
                focusedIndex = this.focusable.indexOf(focused);
    
            // Next, set the previous/next indexes
            let previous = focusedIndex - 1;
            let next = focusedIndex + 1;
    
            // If the previous index is less than zero, set it to the last index
            if (previous < 0) previous = lastIndex;
    
            // If the next index is more than the last index, set it to the first index
            if (next > lastIndex) next = firstIndex;
    
            // If shift key was pressed, focus the previous element...
            if (shiftKey) return this.focusable[previous].focus();
            // ... otherwise, focus the next element
            return this.focusable[next].focus();
        }
    
        // 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(event);
        }
    
        // Handle animation end events
        onAnimationEnd() {
            // Remove the modal's open class,...
            this.modal.classList.remove(this.classes.open);
            // ... remove this event handler,...
            this.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) {
                // ... close it,...
                this.modal.setAttribute('aria-hidden', true);
                // ... unbind from its click events,...
                this.modal.removeEventListener('click', this.onClick);
                // ... and bind to its animation end event
                this.modal.addEventListener('animationend', this.onAnimationEnd);
            }
    
            // Unitialize the modal's overlay, close button, and focusable elements
            this.overlay = null;
            this.close = null;
            this.dialog = null;
            this.focusable = null;
    
            // Unlock document scrolling
            this.toggleScrollLock();
    
            // Finally, unbind from the document's keydown and scroll events
            document.removeEventListener('keydown', this.onKeyDown);
        }
    
        // Open the modal
        openModal() {
            // Open the modal
            this.modal.setAttribute('aria-hidden', false);
            this.modal.classList.add(this.classes.open);
    
            // Globally set the overlay and close button
            this.overlay = this.modal.querySelector(`.${this.selectors.overlay}`);
            this.close = this.modal.querySelector(`.${this.selectors.close}`);
            this.dialog = this.modal.querySelector(`.${this.selectors.dialog}`);
    
            // Find and set all focusable elements from the modal
            const dialogFocusable = Array.from(
                this.dialog.querySelectorAll(
                    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
                )
            );
            this.focusable = [this.dialog, ...dialogFocusable, this.close];
    
            // Focus the dialog box
            this.focusable[0].focus();
    
            // Lock document scrolling
            this.toggleScrollLock();
    
            // Finnaly, bind to the modal's click events...
            this.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.modals.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();
        }
    
        // Initialize the modals
        initialize() {
            // If there are no modals on the page, do nothing
            if (!this.modals.length) return;
    
            // First, check to see if the current hash should open a modal...
            this.onHashChange();
            // ... and finally bind to the window's hash change event with the same method
            window.addEventListener('hashchange', this.onHashChange);
        }
    }
    
    export default Modal;
    
  • URL: /components/raw/modal/index.js
  • Filesystem Path: components/modal/index.js
  • Size: 7.3 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.