Skip to content

Instantly share code, notes, and snippets.

@megasmack
Last active May 24, 2022 18:56
Embed
What would you like to do?
LWC Focus-Trapping Modal

LWC Focus-Trapping Modal

Using SLDS classes, build a modal Lightning Web Component that has focus-trapping for accessiblity.

Notes: The shadowdom presents some challenges with creating focus-trapping within modals. Making creating a reusable component tricky. So instead we can create a modal utility file with all our methods to keep things as DRY as possible.

<template>
<div role="alertdialog"
tabindex="-1"
aria-labelledby="modal-heading"
aria-modal="true"
aria-describedby="modal-content"
class={modalClasses}>
<div class="slds-modal__container">
<header class="slds-modal__header">
<lightning-button-icon icon-name="utility:close"
title={closeLabel}
size="large"
variant="bare"
class="slds-modal__close slds-button_icon-inverse slds-medium-size"
onclick={handleModalClose}>
</lightning-button-icon>
<div id="modal-heading">
{headerTitle}
</div>
</header>
<div if:true={isModalOpen}
class="slds-modal__content slds-p-around_large"
id="modal-content">
<lightning-formatted-rich-text value={formattedMessage}>
</lightning-formatted-rich-text>
</div>
<footer class="slds-modal__footer">
<button class="slds-button slds-button_inverse"
onclick={handleModalClose}>
{closeLabel}
</button>
<button class="slds-button slds-button_brand"
onclick={handleModalAccept}>
{acceptLabel}
</button>
</footer>
</div>
</div>
<div class={modalBgClasses}></div>
</template>
import { LightningElement, api } from 'lwc';
import {
getModalClasses,
getModalBgClasses,
modalWindowKeyUp,
modalWindowKeyDown,
modalKeyDown,
modalCloseFocus
} from 'c/modalUtils';
// Replace these with Custom Label
const CloseLabel = 'Close';
const AcceptLabel = 'Accept';
const HeaderTitle = 'Modal Header';
const FormattedMessage = 'Some modal body content.';
export default class X7sAlertDialog extends LightningElement {
isModalOpen = false;
handleWindowKeyDownEvent;
handleWindowKeyUpEvent;
handleKeyDownEvent;
headerTitle = HeaderTitle;
formattedMessage = FormattedMessage;
closeLabel = CloseLabel;
acceptLabel = AcceptLabel;
connectedCallback() {
this.handleWindowKeyDownEvent = this.handleWindowKeyDown.bind(this);
this.handleWindowKeyUpEvent = this.handleWindowKeyUp.bind(this);
this.handleKeyDownEvent = this.handleKeyDown.bind(this);
window.addEventListener('keydown', this.handleWindowKeyDownEvent);
window.addEventListener('keyup', this.handleWindowKeyUpEvent);
this.addEventListener('keydown', this.handleKeyDownEvent);
}
renderedCallback() {
if (this.isModalOpen) {
modalCloseFocus(this.template);
}
}
disconnectedCallback() {
window.removeEventListener('keydown', this.handleWindowKeyDownEvent);
window.removeEventListener('keyup', this.handleWindowKeyUpEvent);
this.removeEventListener('keydown', this.handleKeyDownEvent);
}
get modalClasses() {
return getModalClasses(this.isModalOpen, 'medium');
}
get modalBgClasses() {
return getModalBgClasses(this.isModalOpen);
}
handleModalOpen() {
this.isModalOpen = true;
}
handleModalClose() {
this.isModalOpen = false;
}
handleWindowKeyUp(event) {
modalWindowKeyUp(event);
}
handleWindowKeyDown(event) {
modalWindowKeyDown(event);
}
handleKeyDown(event) {
modalKeyDown(event, this.template);
}
handleModalAccept() {
// Insert Accept action
this.handleModalClose();
}
}
<?xml version="1.0" encoding="UTF-8" ?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<isExposed>false</isExposed>
<masterLabel>Alert Dialog</masterLabel>
</LightningComponentBundle>
let isShiftKeyDown = false;
/**
* Toggle show/hide modal classes
* @param {boolean} showModal
* @param {undefined|'small'|'medium'|'large'}size
* @returns {string}
*/
export const getModalClasses = (showModal, size = undefined) => {
let classes = 'slds-modal';
classes += showModal ? ' slds-fade-in-open' : '';
classes += size === 'small' ? ' slds-modal_small' : '';
classes += size === 'medium' ? ' slds-modal_medium' : '';
classes += size === 'large' ? ' slds-modal_large' : '';
return classes;
};
/**
* Toggle show/hide backdrop modal classes
* @param showModal
* @returns {string}
*/
export const getModalBgClasses = (showModal) => {
const defaultClass = 'slds-backdrop';
return showModal ? `${defaultClass} slds-backdrop_open` : defaultClass;
};
export const modalWindowKeyUp = (event) => {
// Shift
if (event.key === 'Shift') {
isShiftKeyDown = false;
}
};
export const modalWindowKeyDown = (event) => {
// Shift
if (event.key === 'Shift') {
isShiftKeyDown = true;
}
};
export const modalKeyDown = (event, template) => {
/** @type {HTMLElement} */
const activeEl = template.activeElement;
/** @type {HTMLElement} */
const closeEl = template.querySelector('.slds-modal__close');
/** @type {HTMLElement[]} */
const focusEls = template.querySelectorAll(
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
);
/** @type {HTMLElement} */
const lastEl =
focusEls?.length > 0 ? focusEls[focusEls.length - 1] : closeEl;
// Escape
if (event.key === 'Escape') {
this.close();
}
// Tab
if (event.key === 'Tab' && activeEl) {
if (!isShiftKeyDown && activeEl === lastEl) {
event.preventDefault();
closeEl.focus();
} else if (isShiftKeyDown && activeEl === closeEl) {
event.preventDefault();
lastEl.focus();
}
}
};
/**
* Focus the Close Button
* @param template
*/
export const modalCloseFocus = (template) => {
/** @type {HTMLElement} */
const closeEl = template.querySelector('.slds-modal__close');
if (closeEl) {
closeEl.focus();
}
};
<?xml version="1.0" encoding="UTF-8" ?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<isExposed>false</isExposed>
</LightningComponentBundle>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment