├── .circleci └── config.yml ├── .forceignore ├── .gitignore ├── .prettierrc ├── LICENSE ├── Readme.md ├── force-app └── main │ └── default │ └── lwc │ ├── modal │ ├── __tests__ │ │ └── modal.test.js │ ├── modal.css │ ├── modal.html │ ├── modal.js │ └── modal.js-meta.xml │ └── modal_wrapper │ ├── modal_wrapper.html │ ├── modal_wrapper.js │ └── modal_wrapper.js-meta.xml ├── lwc-modal-example.png ├── package.json └── sfdx-project.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:latest 6 | 7 | working_directory: ~/repo 8 | 9 | steps: 10 | - checkout 11 | - restore_cache: 12 | keys: 13 | - v1-dependencies-{{ checksum "package.json" }} 14 | - v1-dependencies- 15 | - run: yarn install 16 | - save_cache: 17 | paths: 18 | - node_modules 19 | key: v1-dependencies-{{ checksum "package.json" }} 20 | - run: yarn test 21 | -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # LWC configuration files 2 | **/jsconfig.json 3 | **/.eslintrc.json 4 | 5 | # LWC Jest 6 | **/__tests__/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Salesforce dev 2 | .sfdx/ 3 | .localdevserver/ 4 | 5 | # LWC VSCode autocomplete 6 | jsconfig.json 7 | 8 | # LWC Jest coverage reports 9 | coverage/ 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Dependency directories 19 | node_modules/ 20 | yarn.lock 21 | 22 | # Eslint cache 23 | .eslintcache 24 | 25 | # MacOS system files 26 | .DS_Store 27 | 28 | # Windows system files 29 | Thumbs.db 30 | ehthumbs.db 31 | [Dd]esktop.ini 32 | $RECYCLE.BIN/ 33 | 34 | # VS Code project settings 35 | .vscode/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "tabWidth": 4, 6 | "overrides": [ 7 | { 8 | "files": "**/lwc/**/*.html", 9 | "options": { 10 | "parser": "lwc" 11 | } 12 | }, 13 | { 14 | "files": "*.{cmp,page,component}", 15 | "options": { 16 | "parser": "html" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 James Simone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Lightning Web Component Modal 2 | 3 | An example of how to implement a composable modal LWC, which can be used in any project. To read the whole blog post about putting this together, please refer to [The Joys of Apex: Composable Modal post](https://www.jamessimone.net/blog/joys-of-apex/lwc-composable-modal/). The [next post](https://www.jamessimone.net/blog/joys-of-apex/lwc-modal-cleanup-and-testing/) also ties together several points related to `` based composition in LWC, as well as how to write good unit tests for your Lightning Web Components. 4 | 5 | ## Modal Usage 6 | 7 | Included in this repository is the `modal` as well as an example `modal_wrapper` component that hooks into the `modal`. The modal utilizes the following slots/properties to dynamically show content (properties are annotated typescript-style, where a question mark indicates that the property is optional) : 8 | 9 | - (property - `string?`) `modal-header` : use to display a title for your modal 10 | - (property - `string?`) `modal-tagline` : use to display a subtitle for your modal 11 | - (property - `function?`) `modalSaveHandler` can be passed to the modal to display a save button. Injecting this function into the modal _requires_ the use of a fat-arrow function, as demonstrated in the `modal_wrapper` example 12 | - (slot - name = "body") this slot is to be used for any and all LWC content that is to be "hidden" and marked inaccessible when the modal is open 13 | - (slot - name = "modalContent") this slot is used to inject markup into the body of the modal. In the `modal_wrapper` example below, you can see two standard text-based `lightning-input` components, as well as a date-type `lightning-input` being passed to the modal 14 | - (public method) `toggleModal` - the `modal_wrapper` example makes use of a button whose `onclick` method calls the modal to open it 15 | 16 | ## What Does It End Up Looking Like? 17 | 18 | This is what the `modal_wrapper` component looks like when added to a flexipage: 19 | 20 | ![Modal example](./lwc-modal-example.png) 21 | 22 | ## Accessibility 23 | 24 | As much as possible, this modal attempts to adhere to the guidelines set down for modals in the [Lightning Design System](https://www.lightningdesignsystem.com/components/modals/#Accessibility): 25 | 26 | > Modals, by definition, trap focus. This means that if a user presses Tab or Shift+Tab while their keyboard focus is in the modal, their focus should stay in and cycle through the modal’s content. Focus shouldn’t return to the underlying page until the user presses the Esc key, presses an explicit “Cancel” or “Close” button in the modal, or performs another action that closes the modal. 27 | 28 | Credit for properly trapping focus within the modal goes to [Suraj Pillai](https://github.com/surajp). The "issue" with LWC / Web Components in general at the moment is that the Shadow DOM is supposed to prevent a component from knowing anything about the DOM except what's present in the component - but slot-based composition, the ideal way to compose a modal that can be used by any component, isn't presently a first-class citizen for detecting focusable elements that have been injected by means of `` markup. I have [filed a Github issue with the LWC team](https://github.com/salesforce/lwc/issues/1923) trying to address this, but in the meantime, focus-trapping can still be achieved. 29 | 30 | Additionally, this modal meets all of the criteria for the [LDS's expected keyboard interactions](https://www.lightningdesignsystem.com/components/modals/#Expected-keyboard-interactions): 31 | 32 | > - Esc key closes the modal and moves focus to whatever triggered the modal to open 33 | > - Tab key at bottom of modal cycles focus back to first focusable element at top of modal 34 | > - Shift+Tab keys at top of modal cycle focus back to last focusable element at bottom of modal 35 | > - Enter key submits modal’s form data, if applicable 36 | > - Clicking outside of the modal closes the modal 37 | 38 | ## Closing 39 | 40 | In practice, the use of a modal is either to display optional information (in which case the `modalSaveHandler` function is unnecessary; you only need to give people options for how to close the modal), or to block progress until a form/required fields are filled out. Either way, this modal recipe gives you everything you need to succeed! 41 | 42 | ## Contributions 43 | 44 | - many thanks to reader and [SFXD Discord](https://discord.gg/xaM5cYq) frequenter **havana59er** for his contributions to the article. His investigation into assigning the `tabindex` property to different sections of the modal, additional `handleModalLostFocus` handler, and short-circuit feedback for `renderedCallback` were all excellent. I'm much obliged, and the modal is better off! 45 | - hats off to [Justin Lyon](https://github.com/jlyon87), another [SFXD Discord](https://discord.gg/xaM5cYq) frequenter and fellow LWC enthusiast for experimenting with his own modal. He managed to shave off one of the existing `window` event listeners by the use of explicit classes to determine when the modal should be closed. The post has been updated to reflect this; however, I leave the original solution below because I believe that `getBoundingClientRect()` is something you should know about when considering your options for examining the size of a contiguous DOM section! 46 | - the code has been cleaned up considerably by [SFXD Discord](https://discord.gg/xaM5cYq) legend, [Pseudobinary](https://github.com/surajp), aka'd as PSB. His focus-trapping solution is a huge success! 47 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/__tests__/modal.test.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'lwc'; 2 | 3 | import Modal from 'c/modal'; 4 | 5 | //just a little syntax sugar for testing 6 | function assertForTestConditions() { 7 | const resolvedPromise = Promise.resolve(); 8 | return resolvedPromise.then.apply(resolvedPromise, arguments); 9 | } 10 | 11 | describe('modal tests', () => { 12 | afterEach(() => { 13 | //the dom has to be reset after every test to prevent 14 | //the modal from preserving state 15 | while (document.body.firstChild) { 16 | document.body.removeChild(document.body.firstChild); 17 | } 18 | }); 19 | 20 | it('shows modal header elements when header is set', () => { 21 | const modal = createElement('c-modal', { 22 | is: Modal 23 | }); 24 | document.body.appendChild(modal); 25 | 26 | //initial DOM updates caused by the first render 27 | //including the negative case of something not being shown 28 | //can be immediately asserted for 29 | const headerElementBeforeHeaderSet = modal.shadowRoot.querySelector( 30 | '.slds-modal__header' 31 | ); 32 | expect(headerElementBeforeHeaderSet).toBeNull(); 33 | 34 | //setting an @api value on a LWC triggers a re-render, 35 | //but the re-render only completes in a resolved promise 36 | //you can gather variables to test the initial state 37 | //prior to returning the resolved promise to the test runner 38 | //but all updated state must be gathered / asserted for 39 | //within the promise 40 | modal.modalHeader = 'Some Header'; 41 | modal.modalTagline = 'Some tag line'; 42 | 43 | return assertForTestConditions(() => { 44 | const headerElementAfterHeaderSet = modal.shadowRoot.querySelector( 45 | '.slds-modal__header' 46 | ); 47 | expect(headerElementAfterHeaderSet).not.toBeNull(); 48 | expect( 49 | modal.shadowRoot.querySelector('.slds-modal__title').textContent 50 | ).toEqual('Some Header'); 51 | 52 | const modalTagline = modal.shadowRoot.querySelector( 53 | '.slds-m-top_x-small' 54 | ); 55 | expect(modalTagline.textContent).toEqual('Some tag line'); 56 | }); 57 | }); 58 | 59 | it('shows the modal with backdrop when toggled', () => { 60 | const modal = createElement('c-modal', { 61 | is: Modal 62 | }); 63 | document.body.appendChild(modal); 64 | 65 | const backdropBeforeToggle = modal.shadowRoot.querySelector( 66 | '.slds-backdrop_open' 67 | ); 68 | expect(backdropBeforeToggle).toBeNull(); 69 | expect(modal.modalAriaHidden).toBeTruthy(); 70 | 71 | modal.toggleModal(); 72 | 73 | return assertForTestConditions(() => { 74 | expect(modal.modalAriaHidden).toBeFalsy(); 75 | 76 | expect(modal.cssClass).toEqual( 77 | 'slds-modal slds-visible slds-fade-in-open' 78 | ); 79 | 80 | const backdropAfterOpen = modal.shadowRoot.querySelector( 81 | '.slds-backdrop_open' 82 | ); 83 | expect(backdropAfterOpen).toBeTruthy(); 84 | }); 85 | }); 86 | 87 | it('hides the modal when outer modal is clicked', () => { 88 | const modal = createElement('c-modal', { 89 | is: Modal 90 | }); 91 | document.body.appendChild(modal); 92 | 93 | modal.toggleModal(); 94 | 95 | return assertForTestConditions(() => { 96 | const anyOuterElement = modal.shadowRoot.querySelector( 97 | '.slds-modal' 98 | ); 99 | anyOuterElement.click(); 100 | expect(modal.cssClass).toEqual('slds-modal slds-hidden'); 101 | expect(modal.modalAriaHidden).toBeTruthy(); 102 | }); 103 | }); 104 | 105 | it('hides the modal when the esc key is pressed', () => { 106 | const modal = createElement('c-modal', { 107 | is: Modal 108 | }); 109 | document.body.appendChild(modal); 110 | 111 | modal.toggleModal(); 112 | 113 | return assertForTestConditions(() => { 114 | const event = new KeyboardEvent('keyup', { code: 'Escape' }); 115 | modal.shadowRoot 116 | .querySelector('section[role="dialog"]') 117 | .dispatchEvent(event); 118 | expect(modal.modalAriaHidden).toBeTruthy(); 119 | }); 120 | }); 121 | 122 | it('shows a save button when the modalSaveHandler is provided', () => { 123 | const modal = createElement('c-modal', { 124 | is: Modal 125 | }); 126 | document.body.appendChild(modal); 127 | 128 | let wasCalled = false; 129 | const modalSaveHandler = () => (wasCalled = true); 130 | modal.modalSaveHandler = modalSaveHandler; 131 | 132 | const saveSelector = `button[class="slds-button slds-button_brand save"]`; 133 | 134 | const saveButtonBefore = modal.shadowRoot.querySelector(saveSelector); 135 | 136 | return assertForTestConditions(() => { 137 | expect(wasCalled).toBeFalsy(); 138 | expect(saveButtonBefore).toBeNull(); 139 | 140 | const saveButtonAfter = modal.shadowRoot.querySelector( 141 | saveSelector 142 | ); 143 | expect(saveButtonAfter).toBeTruthy(); 144 | saveButtonAfter.click(); 145 | expect(wasCalled).toBeTruthy(); 146 | }); 147 | }); 148 | 149 | it('should focus the close button when no focusable markup is passed and header is present', () => { 150 | const modal = createElement('c-modal', { 151 | is: Modal 152 | }); 153 | modal.modalHeader = 'Some Value'; 154 | document.body.appendChild(modal); 155 | 156 | modal.toggleModal(); 157 | 158 | return assertForTestConditions(() => { 159 | const firstCloseButton = modal.shadowRoot.querySelector( 160 | 'button[title="Close"]' 161 | ); 162 | expect(firstCloseButton).toBeTruthy(); 163 | expect(modal.shadowRoot.activeElement).toBeTruthy(); 164 | 165 | expect(firstCloseButton).toEqual(modal.shadowRoot.activeElement); 166 | }); 167 | }); 168 | 169 | it('should focus the cancel button when no focusable markup is passed and no header is present', () => { 170 | const modal = createElement('c-modal', { 171 | is: Modal 172 | }); 173 | document.body.appendChild(modal); 174 | 175 | modal.toggleModal(); 176 | 177 | return assertForTestConditions(() => { 178 | const firstCloseButton = modal.shadowRoot.querySelector('button'); 179 | expect(firstCloseButton).toBeTruthy(); 180 | expect(firstCloseButton.textContent).toEqual('Cancel'); 181 | expect(modal.shadowRoot.activeElement).toBeTruthy(); 182 | 183 | expect(firstCloseButton).toEqual(modal.shadowRoot.activeElement); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.css: -------------------------------------------------------------------------------- 1 | .slds-modal__content { 2 | overflow: auto; 3 | max-height: 100vh; 4 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.html: -------------------------------------------------------------------------------- 1 | 61 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.js: -------------------------------------------------------------------------------- 1 | import { api, LightningElement } from 'lwc'; 2 | 3 | const ESC_KEY_CODE = 27; 4 | const ESC_KEY_STRING = 'Escape'; 5 | const TAB_KEY_CODE = 9; 6 | const TAB_KEY_STRING = 'Tab'; 7 | const LIGHTNING_INPUT_FIELD = 'LIGHTNING-INPUT-FIELD'; 8 | 9 | export default class Modal extends LightningElement { 10 | isFirstRender = true; 11 | isOpen = false; 12 | 13 | outsideClickListener = (e) => { 14 | e.stopPropagation(); 15 | if (!this.isOpen) { 16 | return; 17 | } 18 | this.toggleModal(); 19 | }; 20 | 21 | renderedCallback() { 22 | this.focusGained = false; 23 | if (this.isFirstRender) { 24 | this.isFirstRender = false; 25 | document.addEventListener('click', this.outsideClickListener); 26 | } 27 | } 28 | 29 | disconnectedCallback() { 30 | document.removeEventListener('click', this.outsideClickListener); 31 | } 32 | 33 | @api 34 | modalHeader; 35 | @api 36 | modalTagline; 37 | @api 38 | modalSaveHandler; 39 | 40 | @api 41 | toggleModal() { 42 | this.isOpen = !this.isOpen; 43 | if (this.isOpen) { 44 | this.focusFirstChild(); 45 | } 46 | } 47 | 48 | @api 49 | get cssClass() { 50 | const baseClasses = ['slds-modal']; 51 | baseClasses.push([this.isOpen ? 'slds-visible slds-fade-in-open' : 'slds-hidden']); 52 | return baseClasses.join(' '); 53 | } 54 | 55 | @api 56 | get modalAriaHidden() { 57 | return !this.isOpen; 58 | } 59 | 60 | closeModal(event) { 61 | event.stopPropagation(); 62 | this.toggleModal(); 63 | } 64 | 65 | innerClickHandler(event) { 66 | event.stopPropagation(); 67 | } 68 | 69 | handleKeyPress(event) { 70 | this.innerKeyUpHandler(event); 71 | } 72 | 73 | innerKeyUpHandler(event) { 74 | if (event.keyCode === ESC_KEY_CODE || event.code === ESC_KEY_STRING) { 75 | this.toggleModal(); 76 | } else if (event.keyCode === TAB_KEY_CODE || event.code === TAB_KEY_STRING) { 77 | const el = this.template.activeElement; 78 | let focusableElement; 79 | if (event.shiftKey && el && el.classList.contains('firstlink')) { 80 | // the save button is only shown 81 | // for modals with a saveHandler attached 82 | // fallback to the close button, otherwise 83 | focusableElement = this.modalSaveHandler 84 | ? this.template.querySelector('button.save') 85 | : this._getCloseButton(); 86 | } else if (el && el.classList.contains('lastLink')) { 87 | focusableElement = this._getCloseButton(); 88 | } 89 | if (focusableElement) { 90 | focusableElement.focus(); 91 | } 92 | } 93 | } 94 | 95 | _getCloseButton() { 96 | let closeButton = this.template.querySelector('button[title="Close"]'); 97 | if (!closeButton) { 98 | // if no header is present, the first button is 99 | // always the cancel button 100 | closeButton = this.template.querySelector('button'); 101 | } 102 | return closeButton; 103 | } 104 | 105 | _getSlotName(element) { 106 | let slotName = element.slot; 107 | while (!slotName && element.parentElement) { 108 | slotName = this._getSlotName(element.parentElement); 109 | } 110 | return slotName; 111 | } 112 | 113 | async focusFirstChild() { 114 | const children = [...this.querySelectorAll('*')]; 115 | for (let child of children) { 116 | let hasBeenFocused = false; 117 | if (this._getSlotName(child) === 'body') { 118 | continue; 119 | } 120 | await this.setFocus(child).then((res) => { 121 | hasBeenFocused = res; 122 | }); 123 | if (hasBeenFocused) { 124 | return; 125 | } 126 | } 127 | // if there is no focusable markup from slots 128 | // focus the first button 129 | const closeButton = this._getCloseButton(); 130 | if (closeButton) { 131 | closeButton.focus(); 132 | } 133 | } 134 | 135 | setFocus(el) { 136 | return new Promise((resolve) => { 137 | /** 138 | * don't ever try to trap focus on a disabled element that can't be interacted with ... 139 | * As well, there's been some regression with lightning-input-field components - 140 | * they don't properly pass the "focus" event downwards through the component hierarchy, which has the fun effect of: 141 | * - not triggering the promise to resolve (not what we wanted) 142 | * - triggering the validation for required fields (DEFINITELY not what we wanted) 143 | */ 144 | if (el.disabled || (el.tagName === LIGHTNING_INPUT_FIELD && el.required)) { 145 | return resolve(false); 146 | } else { 147 | const promiseListener = () => resolve(true); 148 | try { 149 | el.addEventListener('focus', promiseListener); 150 | el.focus && el.focus(); 151 | el.removeEventListener('focus', promiseListener); 152 | 153 | setTimeout(() => resolve(false), 0); 154 | } catch (ex) { 155 | return resolve(false); 156 | } 157 | } 158 | }); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54.0 4 | false 5 | 6 | 7 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal_wrapper/modal_wrapper.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal_wrapper/modal_wrapper.js: -------------------------------------------------------------------------------- 1 | import { LightningElement, api } from 'lwc'; 2 | import { ShowToastEvent } from 'lightning/platformShowToastEvent'; 3 | 4 | export default class ModalWrapper extends LightningElement { 5 | @api 6 | recordId; 7 | 8 | handleClick(event) { 9 | event.preventDefault(); 10 | event.stopPropagation(); 11 | this.template.querySelector('c-modal').toggleModal(); 12 | } 13 | 14 | //we have to use the fat arrow function here 15 | //to retain "this" as the wrapper context 16 | modalSaveHandler = (event) => { 17 | //normally here you would do things like 18 | //validate your inputs were correctly filled out 19 | event.stopPropagation(); 20 | this.handleClick(event); 21 | this.dispatchEvent( 22 | new ShowToastEvent({ 23 | title: 'Success', 24 | variant: 'success', 25 | message: 'Record successfully updated!' 26 | }) 27 | ); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal_wrapper/modal_wrapper.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54.0 4 | true 5 | 6 | lightning__AppPage 7 | lightning__RecordPage 8 | lightning__HomePage 9 | 10 | -------------------------------------------------------------------------------- /lwc-modal-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamessimone/lwc-modal/362e44a8421caf829d3f54f78417fc205ee4dcdc/lwc-modal-example.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lwc-modal", 3 | "version": "0.0.3", 4 | "description": "Lightning Web Components Modal recipe", 5 | "author": "James Simone ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/jamessimone/lwc-modal" 10 | }, 11 | "dependencies": { 12 | "@salesforce/sfdx-lwc-jest": "0.10.4" 13 | }, 14 | "scripts": { 15 | "test": "sfdx-lwc-jest" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sourceApiVersion": "51.0", 10 | "sfdcLoginUrl": "https://login.salesforce.com" 11 | } 12 | --------------------------------------------------------------------------------