Read more at jamessimone.net, or return to the homepage





Lightning Web Components Composable Modal

Continuing on the LWC train from our talk on pagination comes this post on creating a reusable modal, or pop-up, as a Lightning Web Component. Modals, by themselves, have complicated requirements for both accessibility and UX; they must block-off the rest of the screen, for example. It's good practice for a modal to control the page's focus until it is closed. How can we build a composable modal, or one whose implementation is not tied to the existence of another LWC?

The answer, once more, lies with slots.

linkA Quick Aside On Web Components

Before we dive fully in, it's worth pointing out some quirks associated with the Shadow DOM, or the layer on top of the actual DOM (Document Object Model ... or the stuff that actually gets rendered on a web page) that the Web Components standard utilizes to encapsulate a component's consituents, be it styles, JavaScript, or markup. Technically speaking, a <template> based Web Component is self-contained. The HTML (markup), CSS (styles), and JavaScript (behavior) are not supposed to leak beyond the component. In this sense, the Web Component standard resembles backend objects a la Apex in more ways than one. This is why encapsulation is such a big part of the Web Component standard.

Injecting a Web Component (or Lightning Web Component) with additional markdown by way of <slot>s breaks this encapsulation. Now, the DOM associated with a web component is not just what's in that web component's markup — since it's also now responsible for rendering however many HTML nodes come from its slots when the web component is used in another component.

There has been an attempt by MDN (the creators of the Web Component framework) and Salesforce to differentiate between and keep separate the markup injected by way of slots versus the markup that's part of the component's <template>. For this reason, Salesforce includes in their documentation for composition the following tidbit:

The <slot></slot> element is part of a component’s shadow tree. To access elements in its shadow tree, a component calls this.template.querySelector() and this.template.querySelectorAll(). However, the DOM elements that are passed into the slot aren’t part of the component’s shadow tree. To access elements passed via slots, a component calls this.querySelector() and this.querySelectorAll().

That tidbit ended up yielding some interesting results when trying to enforce accessibility constraints within the modal.

The Lightning Design System features a whole page on modals, including some example markup:

The example Lightning Design System modal

I'm going to take this markup and run with it. There's one crucial piece of markup, in particular, that we'll need to wrap in a <template if:true> flag:

1link<template>

2link <section>

3link <!-- the rest of the modal content here -->

4link </section>

5link <template if:true="{isOpen}">

6link <div class="slds-backdrop slds-backdrop_open"></div>

7link </template>

8link</template>

That singular <div> at the bottom applies the styles necessary to gray out the remainder of the screen. We'll utilize an isOpen property on the LWC JavaScript controller to determine whether or not to show this. We can also make use of this singular flag to address some accessibility concerns presented in the Lightning Design System documentation:

When the modal is open, everything behind it has HTML attribute aria-hidden="true", so assistive technology won't read out the underlying page. The best way to do this is to give the modal and the page separate wrapper elements and toggle aria-hidden="true"/aria-hidden="false" on the main page's wrapper depending on whether or not the modal is open.

At first I took the documentation seriously and created a wrapper element; later I was able to handle all of the aria attributes correctly inside of the modal LWC alone. That said, we'll need to control not only the aria-hidden attributes, but also the CSS styles to show/hide the modal. Something like this will do:

1linkexport default class Modal extends LightningElement {

2link isOpen = false;

3link

4link // this has to be public so consumers of the modal can tell it to open!

5link @api

6link toggleModal() {

7link this.isOpen = !this.isOpen;

8link }

9link // the crucial CSS necessary to show/hide the modal

10link @api

11link get cssClass() {

12link const baseClass = "slds-modal ";

13link return (

14link baseClass +

15link (this.isOpen ? "slds-visible slds-fade-in-open" : "slds-hidden")

16link );

17link }

18link

19link // we have to use a separate property for this because you can't negate in markup

20link @api

21link get modalAriaHidden() {

22link return !this.isOpen;

23link }

24link}

That means our modal's baseline markup will look something like:

1link<template>

2link <section aria-hidden="{isOpen}" class="outerModalContent">

3link <slot name="body"></slot>

4link </section>

5link <section

6link aria-describedby="modal-content-id-1"

7link aria-hidden="{modalAriaHidden}"

8link aria-labelledby="modal-heading-01"

9link aria-modal="true"

10link class="{cssClass}"

11link role="dialog"

12link onclick="{toggleModal}"

13link >

14link <div class="slds-modal__container outerModalContent">

15link <div

16link class="innerModal"

17link onclick="{toggleModal}"

18link tabindex="0"

19link onfocus="{handleModalLostFocus}"

20link >

21link <template if:true="{modalHeader}">

22link <header class="slds-modal__header">

23link <h2 id="modal-heading-01" class="slds-modal__title slds-hyphenate">

24link {modalHeader}

25link </h2>

26link <template if:true="{modalTagline}">

27link <p class="slds-m-top_x-small">{modalTagline}</p>

28link </template>

29link </header>

30link </template>

31link <div

32link class="slds-modal__content slds-p-around_medium"

33link id="modal-content-id-1"

34link >

35link <slot name="modalContent"></slot>

36link </div>

37link <footer class="slds-modal__footer">

38link <button

39link class="slds-button slds-button_neutral focusable"

40link onclick="{closeModal}"

41link >

42link Cancel

43link </button>

44link <template if:true="{modalSaveHandler}">

45link <button

46link class="slds-button slds-button_brand focusable"

47link onclick="{modalSaveHandler}"

48link >

49link Save

50link </button>

51link </template>

52link </footer>

53link </div>

54link </div>

55link </section>

56link <template if:true="{isOpen}">

57link <div class="slds-backdrop slds-backdrop_open outerModalContent"></div>

58link </template>

59link</template>

So, what do we have?

linkHandling Clicks And Key Presses Properly For Modals

Of what's shown above, there are two complicated pieces to address:

It took quite a few iterations to get things working satisfactorily, and there's still a big caveat (which is why I went through the aside on the Shadow DOM, earlier). Closing the modal is complicated because if the element is not in focus properly when first opened, the ESC keypress won't be "heard", and thus the modal won't close. The modal also technically takes up more than the visible area shown in the example; technically, its bounds extend to the top and bottom of the page (this works in tandem with the aforementioned <div class="slds-backdrop slds-backdrop_open"> to effectively lock navigation while the modal is open). However, if clicks outside the modal are supposed to close it, but we're technically still clicking in the modal's list of DOM nodes ... that's going to represent an issue. Luckily, this one can be handled somewhat gracefully by appending the specific outerModalContent class to the "outer" sections of the modal.

Once again, there is a key snippet included in the docs (this time in the "Run Code When A Component Is Inserted Or Removed From The DOM" section) that gives us a clue as to how to proceed:

The connectedCallback() lifecycle hook fires when a component is inserted into the DOM. The disconnectedCallback() lifecycle hook fires when a component is removed from the DOM. The framework takes care of managing and cleaning up listeners for you as part of the component lifecycle. However, if you add a listener to anything else (like the window object, the document object, and so on), you’re responsible for removing the listener yourself.

Aha. The window object is available. But be warned — here be dragons:

1linkimport { api, LightningElement } from "lwc";

2link

3linkconst ESC_KEY_CODE = 27;

4linkconst ESC_KEY_STRING = "Escape";

5linkconst FOCUSABLE_ELEMENTS = ".focusable";

6linkconst OUTER_MODAL_CLASS = "outerModalContent";

7linkconst TAB_KEY_CODE = 9;

8linkconst TAB_KEY_STRING = "Tab";

9link

10linkexport default class Modal extends LightningElement {

11link isFirstRender = true;

12link isOpen = false;

13link

14link constructor() {

15link super();

16link this.template.addEventListener("click", (event) => {

17link const classList = [...event.target.classList];

18link if (classList.includes(OUTER_MODAL_CLASS)) {

19link this.toggleModal();

20link }

21link });

22link }

23link

24link renderedCallback() {

25link if (this.isFirstRender) {

26link this.isFirstRender = false;

27link

28link //the "once" option for `addEventListener` should auto-cleanup

29link window.addEventListener("keyup", (e) => this.handleKeyUp(e), {

30link once: true,

31link });

32link }

33link }

34link

35link @api modalHeader;

36link @api modalTagline;

37link @api modalSaveHandler;

38link

39link @api

40link toggleModal() {

41link this.isOpen = !this.isOpen;

42link if (this.isOpen) {

43link const focusableElems = this._getFocusableElements();

44link this._focusFirstTabbableElement(focusableElems);

45link }

46link }

47link

48link @api

49link get cssClass() {

50link const baseClass = "slds-modal " + OUTER_MODAL_CLASS + " ";

51link return (

52link baseClass +

53link (this.isOpen ? "slds-visible slds-fade-in-open" : "slds-hidden")

54link );

55link }

56link

57link @api

58link get modalAriaHidden() {

59link return !this.isOpen;

60link }

61link

62link closeModal(event) {

63link event.stopPropagation();

64link this.toggleModal();

65link }

66link

67link handleModalLostFocus() {

68link const focusableElems = this._getFocusableElements();

69link this._focusFirstTabbableElement(focusableElems);

70link }

71link

72link handleKeyUp(event) {

73link if (event.keyCode === ESC_KEY_CODE || event.code === ESC_KEY_STRING) {

74link this.toggleModal();

75link } else if (

76link event.keyCode === TAB_KEY_CODE ||

77link event.code === TAB_KEY_STRING

78link ) {

79link const focusableElems = this._getFocusableElements();

80link if (this._shouldRefocusToModal(focusableElems)) {

81link this._focusFirstTabbableElement(focusableElems);

82link }

83link }

84link }

85link

86link _shouldRefocusToModal(focusableElems) {

87link return focusableElems.indexOf(this.template.activeElement) === -1;

88link }

89link

90link _getFocusableElements() {

91link /*a not obvious distinct between slotted components

92link and the rest of the component's markup:

93link markup injected by slot appears with this.querySelector

94link or this.querySelectorAll; all other markup for a component

95link appears with this.template.querySelector/querySelectorAll.

96link unfortunately, at the present moment I cannot use the focusable

97link elements returned by this.querySelectorAll, because this.template.activeElement

98link is not set when markup injected via slot is focused. I have filed

99link an issue on the LWC github (https://github.com/salesforce/lwc/issues/1923)

100link and will fix the below lines once the issue has been resolved

101link

102link const potentialElems = [...this.querySelectorAll(FOCUSABLE_ELEMENTS)];

103link potentialElems.push(

104link ...this.template.querySelectorAll(FOCUSABLE_ELEMENTS)

105link ); */

106link

107link const potentialElems = [

108link ...this.template.querySelectorAll(FOCUSABLE_ELEMENTS),

109link ];

110link return potentialElems;

111link }

112link

113link _focusFirstTabbableElement(focusableElems) {

114link if (focusableElems.length > 0) {

115link focusableElems[0].focus();

116link }

117link }

118link}

The keyup listener ends up living on the window object, which is necessary to detect ESC presses if the modal is open but not focused.

In the example usage of the modal component on my Github, I show off what a consumer of the modal ends up looking like:

LWC example consumer component on Github

In the example, the modal_wrapper attempts to use the focusable CSS class to allow its date component to be focusable by the keyup listener. The ideal "tab order" for this component is:

  1. First tab selects the date-picker element
  2. Second tab selects the cancel button
  3. Third tab selects the save button

Unfortunately, this doesn't quite pan out (as mentioned in the commented out section above for this._getFocusableElements()). I am hopeful that the Github issue that I have filed with the LWC team will (eventually) be addressed, but at the moment, there's no good way to detect when an element injected by means of a <slot> has been focused. There is a workaround, of sorts, but it's not pretty:

1linkhandleKeyUp(event) {

2link //the rest of the method is omitted

3link else if (

4link event.keyCode === TAB_KEY_CODE ||

5link event.code === TAB_KEY_STRING

6link ) {

7link const focusableElems = this._getFocusableElements();

8link if (this._shouldRefocusToModal(focusableElems)) {

9link this._focusFirstTabbableElement(focusableElems);

10link }

11link }

12link}

13link

14link_shouldRefocusToModal(focusableElems) {

15link return (

16link focusableElems

17link .map(elem =>

18link elem.toString().replace('SecureElement', 'SecureObject')

19link )

20link .indexOf(document.activeElement.toString()) === -1

21link );

22link}

23link

24link_getFocusableElements() {

25link const potentialElems = [...this.querySelectorAll(FOCUSABLE_ELEMENTS)];

26link potentialElems.push(

27link ...this.template.querySelectorAll(FOCUSABLE_ELEMENTS)

28link );

29link

30link return potentialElems;

31link}

The gist of the workaround that has been posted on one of the associated Github issues doesn't apply here; the document's shadowRoot object isn't accessible by the component when the handler is invoked, and the activeElement on the document only has a toString method publicly available. Add the Lightning Locker Service into the equation, which makes comparing the activeElement on the document impossible against the HTMLNodeList returned by this_getFocusableElements, and this insane string comparison is the only option left on the table. While I might feel comfortable doing something like this in my own sandbox / scratch org, I wouldn't ever use it at production level (even if it works, which it does).

Unless a method is exposed via the same modal that this.querySelector/querySelectorAll works to access <slot> based markup that is focused, I'm happy with the component as-is, with the below tab order:

  1. First tab selects the cancel button
  2. Second tab selects the save button

linkExample Modal Implementation

The markup necessary for a consumer to add the modal to their own markup is quite minimal:

1link<template>

2link <c-modal

3link modal-header="Modal Header"

4link modal-tagline="Some tag line"

5link modal-save-handler="{modalSaveHandler}"

6link >

7link <p slot="body">This stuff can't be tabbed to when the modal is open</p>

8link <div

9link slot="modalContent"

10link class="modalContent slds-modal__content slds-p-around_medium"

11link >

12link <p>Did you know that "Gallia est omnis divisa in partes tres" ?</p>

13link <!-- not obvious, but "slds-form-element" applies -->

14link <!-- the styles necessary for this element to "pop out" of the modal -->

15link <!-- instead of adding scrolling to the inner container -->

16link

17link <lightning-input

18link class="slds-form-element slds-m-around_small focusable"

19link label="Some field that you have required to save a record"

20link type="date"

21link date-style="short"

22link required

23link ></lightning-input>

24link <p>Once you're done selecting the date, click "save" to proceed!</p>

25link </div>

26link </c-modal>

27link <button class="slds-m-left_small" onclick="{handleClick}">

28link Click me to open modal

29link </button>

30link</template>

And the example JavaScript controller:

1linkimport { LightningElement } from "lwc";

2linkimport { ShowToastEvent } from "lightning/platformShowToastEvent";

3link

4linkexport default class ModalWrapper extends LightningElement {

5link handleClick() {

6link this.template.querySelector("c-modal").toggleModal();

7link }

8link

9link //we have to use the fat arrow function here

10link //to retain "this" as the wrapper context

11link modalSaveHandler = (event) => {

12link //normally here you would do things like

13link //validate your inputs were correctly filled out

14link event.stopPropagation();

15link this.handleClick();

16link this.dispatchEvent(

17link new ShowToastEvent({

18link title: "Success",

19link variant: "success",

20link message: "Record successfully updated!",

21link })

22link );

23link };

24link}

If you were using Lightning Data Service or an Apex Controller to save a record in the modalSaveHandler, you would just need to make the function async and only display the toast (or an error) after awaiting the DML operation. Another thing to keep in mind is that if you weren't exposing the modal through a button, and instead were responding to a form element / input changing to some sentinel value, you wouldn't need handleClick to be a public method.

If you need to implement a pop-up modal into your own components, the source code from this post is available on my Github. Despite the issues in correctly focusing the inner contents of the modal (assuming you have elements you want to add that are in fact focusable), I like how clean the separation of concerns becomes:

This is a clean departure from the example modal that's part of the LWC-recipes on Github. Like the example pager I've also shared, I wrote this article to help people bridge the gap between the simple examples shown on Trailhead/Github and the practical, complicated edge-cases associated with actually using a component like this in production.

I hope you've enjoyed the latest in the Joys Of Apex. Writing about Lightning Web Components has proven to be extremely satisfying, and I may spend some time documenting the tests for a component like this next if there is enough interest. When I first started writing about LWC (in comparison to React), I had assumed that the usage of Jest was already very established within the SFDC community. Since then, I've had some feedback (and seen some questions online) that have made me realize people are still hungry to see testing examples. Either way, thanks for walking this road with me!

linkContributions

The original solution for determining when a click was outside the modal looked like this (some sections of the controller omitted for brevity's sake):

1linkexport default class Modal extends LightningElement {

2link isFirstRender = true;

3link modalDimensions = {

4link top: 0,

5link left: 0,

6link bottom: 0,

7link right: 0,

8link };

9link eventListeners = [

10link { name: "resize", listener: () => this._setModalSize() },

11link { name: "keyup", listener: (e) => this.handleKeyUp(e) },

12link ];

13link

14link renderedCallback() {

15link //always best to short-circuit when adding event listeners

16link if (this.isFirstRender) {

17link this.isFirstRender = false;

18link this._setModalSize();

19link for (let eventListener of this.eventListeners) {

20link window.addEventListener(eventListener.name, eventListener.listener);

21link }

22link }

23link }

24link

25link handleInnerModalClick(event) {

26link //stop the event from bubbling to the <section>

27link //otherwise any click, anywhere in the modal,

28link //will close it

29link event.stopPropagation();

30link

31link const isWithinInnerXBoundary =

32link event.clientX >= this.modalDimensions.left &&

33link event.clientX <= this.modalDimensions.right;

34link const isWithinInnerYBoundary =

35link event.clientY >= this.modalDimensions.top &&

36link event.clientY <= this.modalDimensions.bottom;

37link if (isWithinInnerXBoundary && isWithinInnerYBoundary) {

38link //do nothing, the click was properly within the modal bounds

39link return;

40link }

41link this.toggleModal();

42link }

43link

44link _setModalSize() {

45link //getBoundingClientRect() is one of those

46link //life-saving JS APIs you should know!

47link const innerModalDimensions = this.template

48link .querySelector(INNER_MODAL_CLASS)

49link .getBoundingClientRect();

50link this.modalDimensions { ... innerModalDimensions };

51link }

The original version of Lightning Web Components: Composable Modal can be read on my blog.

A Quick Aside On Web ComponentsModal BasicsHandling Clicks And Key Presses Properly For ModalsExample Modal ImplementationModal Wrap-UpContributions

Home Apex Logging Service Apex Object-Oriented Basics Batchable And Queueable Apex Building A Better Singleton Continuous Integration With SFDX Dependency Injection & Factory Pattern Enum Apex Class Gotchas Extendable Apis Future Methods, Callouts & Callbacks Idiomatic Salesforce Apex Introduction & Testing Philosophy Lazy Iterators Lightweight Trigger Handler LWC Composable Modal LWC Composable Pagination LWC Custom Lead Path Mocking DML React Versus Lightning Web Components Refactoring Tips & Tricks Repository Pattern setTimeout & Implementing Delays Sorting And Performance In Apex Test Driven Development Example Testing Custom Permissions Writing Performant Apex Tests



Read more tech articles