| // Copyright 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {LitElement, html, css} from 'lit-element'; |
| |
| /** |
| * `<chops-dialog>` displays a modal/dialog overlay. |
| * |
| * @customElement |
| */ |
| export class ChopsDialog extends LitElement { |
| /** @override */ |
| static get styles() { |
| return css` |
| :host { |
| position: fixed; |
| z-index: 9999; |
| left: 0; |
| top: 0; |
| width: 100%; |
| height: 100%; |
| overflow: auto; |
| background-color: rgba(0,0,0,0.4); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| :host(:not([opened])), [hidden] { |
| display: none; |
| visibility: hidden; |
| } |
| :host([closeOnOutsideClick]), |
| :host([closeOnOutsideClick]) .dialog::backdrop { |
| /* TODO(zhangtiff): Deprecate custom backdrop in favor of native |
| * browser backdrop. |
| */ |
| cursor: pointer; |
| } |
| .dialog { |
| background: none; |
| border: 0; |
| max-width: 90%; |
| } |
| .dialog-content { |
| /* This extra div is here because otherwise the browser can't |
| * differentiate between a click event that hits the dialog element or |
| * its backdrop pseudoelement. |
| */ |
| box-sizing: border-box; |
| background: var(--chops-white); |
| padding: 1em 16px; |
| cursor: default; |
| box-shadow: 0px 3px 20px 0px hsla(0, 0%, 0%, 0.4); |
| width: var(--chops-dialog-width); |
| max-width: var(--chops-dialog-max-width, 100%); |
| } |
| `; |
| } |
| |
| /** @override */ |
| render() { |
| return html` |
| <dialog class="dialog" role="dialog" @cancel=${this._cancelHandler}> |
| <div class="dialog-content"> |
| <slot></slot> |
| </div> |
| </dialog> |
| `; |
| } |
| |
| /** @override */ |
| static get properties() { |
| return { |
| /** |
| * Whether the dialog should currently be displayed or not. |
| */ |
| opened: { |
| type: Boolean, |
| reflect: true, |
| }, |
| /** |
| * A boolean that determines whether clicking outside of the dialog |
| * window should close it. |
| */ |
| closeOnOutsideClick: { |
| type: Boolean, |
| }, |
| /** |
| * A function fired when the element tries to change its own opened |
| * state. This is useful if you want the dialog state managed outside |
| * of the dialog instead of with internal state. (ie: with Redux) |
| */ |
| onOpenedChange: { |
| type: Object, |
| }, |
| /** |
| * When True, disables exiting keys and closing on outside clicks. |
| * Forces the user to interact with the dialog rather than just dismissing |
| * it. |
| */ |
| forced: { |
| type: Boolean, |
| }, |
| _boundKeydownHandler: { |
| type: Object, |
| }, |
| _previousFocusedElement: { |
| type: Object, |
| }, |
| }; |
| } |
| |
| /** @override */ |
| constructor() { |
| super(); |
| |
| this.opened = false; |
| this.closeOnOutsideClick = false; |
| this.forced = false; |
| this._boundKeydownHandler = this._keydownHandler.bind(this); |
| } |
| |
| /** @override */ |
| connectedCallback() { |
| super.connectedCallback(); |
| |
| this.addEventListener('click', (evt) => { |
| if (!this.opened || !this.closeOnOutsideClick || this.forced) return; |
| |
| const hasDialog = evt.composedPath().find( |
| (node) => { |
| return node.classList && node.classList.contains('dialog-content'); |
| } |
| ); |
| if (hasDialog) return; |
| |
| this.close(); |
| }); |
| |
| window.addEventListener('keydown', this._boundKeydownHandler, true); |
| } |
| |
| /** @override */ |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| window.removeEventListener('keydown', this._boundKeydownHandler, |
| true); |
| } |
| |
| /** @override */ |
| updated(changedProperties) { |
| if (changedProperties.has('opened')) { |
| this._openedChanged(this.opened); |
| } |
| } |
| |
| _keydownHandler(event) { |
| if (!this.opened) return; |
| if (event.key === 'Escape' && this.forced) { |
| // Stop users from using the Escape key in a forced dialog. |
| e.preventDefault(); |
| } |
| } |
| |
| /** |
| * Closes the dialog. |
| * May have its logic overridden by a custom onOpenChanged function. |
| */ |
| close() { |
| if (this.onOpenedChange) { |
| this.onOpenedChange(false); |
| } else { |
| this.opened = false; |
| } |
| } |
| |
| /** |
| * Opens the dialog. |
| * May have its logic overridden by a custom onOpenChanged function. |
| */ |
| open() { |
| if (this.onOpenedChange) { |
| this.onOpenedChange(true); |
| } else { |
| this.opened = true; |
| } |
| } |
| |
| /** |
| * Switches the dialog from open to closed or vice versa. |
| */ |
| toggle() { |
| this.opened = !this.opened; |
| } |
| |
| _cancelHandler(evt) { |
| if (!this.forced) { |
| this.close(); |
| } else { |
| evt.preventDefault(); |
| } |
| } |
| |
| _getActiveElement() { |
| // document.activeElement alone isn't sufficient to find the active |
| // element within shadow dom. |
| let active = document.activeElement || document.body; |
| let activeRoot = active.shadowRoot || active.root; |
| while (activeRoot && activeRoot.activeElement) { |
| active = activeRoot.activeElement; |
| activeRoot = active.shadowRoot || active.root; |
| } |
| return active; |
| } |
| |
| _openedChanged(opened) { |
| const dialog = this.shadowRoot.querySelector('dialog'); |
| if (opened) { |
| // For accessibility, we want to ensure we remember the element that was |
| // focused before this dialog opened. |
| this._previousFocusedElement = this._getActiveElement(); |
| |
| if (dialog.showModal) { |
| dialog.showModal(); |
| } else { |
| dialog.setAttribute('open', 'true'); |
| } |
| if (this._previousFocusedElement) { |
| this._previousFocusedElement.blur(); |
| } |
| } else { |
| if (dialog.close) { |
| dialog.close(); |
| } else { |
| dialog.setAttribute('open', undefined); |
| } |
| |
| if (this._previousFocusedElement) { |
| const element = this._previousFocusedElement; |
| requestAnimationFrame(() => { |
| // HACK. This is to prevent a possible accessibility bug where |
| // using a keypress to trigger a button that exits a modal causes |
| // the modal to immediately re-open because the button that |
| // originally opened the modal refocuses, and the keypress |
| // propagates. |
| element.focus(); |
| }); |
| } |
| } |
| } |
| } |
| |
| customElements.define('chops-dialog', ChopsDialog); |