blob: 0d40aa2a7ed22e221abf22f18a402328013a1ad0 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6
7/**
8 * `<chops-dialog>` displays a modal/dialog overlay.
9 *
10 * @customElement
11 */
12export class ChopsDialog extends LitElement {
13 /** @override */
14 static get styles() {
15 return css`
16 :host {
17 position: fixed;
18 z-index: 9999;
19 left: 0;
20 top: 0;
21 width: 100%;
22 height: 100%;
23 overflow: auto;
24 background-color: rgba(0,0,0,0.4);
25 display: flex;
26 align-items: center;
27 justify-content: center;
28 }
29 :host(:not([opened])), [hidden] {
30 display: none;
31 visibility: hidden;
32 }
33 :host([closeOnOutsideClick]),
34 :host([closeOnOutsideClick]) .dialog::backdrop {
35 /* TODO(zhangtiff): Deprecate custom backdrop in favor of native
36 * browser backdrop.
37 */
38 cursor: pointer;
39 }
40 .dialog {
41 background: none;
42 border: 0;
43 max-width: 90%;
44 }
45 .dialog-content {
46 /* This extra div is here because otherwise the browser can't
47 * differentiate between a click event that hits the dialog element or
48 * its backdrop pseudoelement.
49 */
50 box-sizing: border-box;
51 background: var(--chops-white);
52 padding: 1em 16px;
53 cursor: default;
54 box-shadow: 0px 3px 20px 0px hsla(0, 0%, 0%, 0.4);
55 width: var(--chops-dialog-width);
56 max-width: var(--chops-dialog-max-width, 100%);
57 }
58 `;
59 }
60
61 /** @override */
62 render() {
63 return html`
64 <dialog class="dialog" role="dialog" @cancel=${this._cancelHandler}>
65 <div class="dialog-content">
66 <slot></slot>
67 </div>
68 </dialog>
69 `;
70 }
71
72 /** @override */
73 static get properties() {
74 return {
75 /**
76 * Whether the dialog should currently be displayed or not.
77 */
78 opened: {
79 type: Boolean,
80 reflect: true,
81 },
82 /**
83 * A boolean that determines whether clicking outside of the dialog
84 * window should close it.
85 */
86 closeOnOutsideClick: {
87 type: Boolean,
88 },
89 /**
90 * A function fired when the element tries to change its own opened
91 * state. This is useful if you want the dialog state managed outside
92 * of the dialog instead of with internal state. (ie: with Redux)
93 */
94 onOpenedChange: {
95 type: Object,
96 },
97 /**
98 * When True, disables exiting keys and closing on outside clicks.
99 * Forces the user to interact with the dialog rather than just dismissing
100 * it.
101 */
102 forced: {
103 type: Boolean,
104 },
105 _boundKeydownHandler: {
106 type: Object,
107 },
108 _previousFocusedElement: {
109 type: Object,
110 },
111 };
112 }
113
114 /** @override */
115 constructor() {
116 super();
117
118 this.opened = false;
119 this.closeOnOutsideClick = false;
120 this.forced = false;
121 this._boundKeydownHandler = this._keydownHandler.bind(this);
122 }
123
124 /** @override */
125 connectedCallback() {
126 super.connectedCallback();
127
128 this.addEventListener('click', (evt) => {
129 if (!this.opened || !this.closeOnOutsideClick || this.forced) return;
130
131 const hasDialog = evt.composedPath().find(
132 (node) => {
133 return node.classList && node.classList.contains('dialog-content');
134 }
135 );
136 if (hasDialog) return;
137
138 this.close();
139 });
140
141 window.addEventListener('keydown', this._boundKeydownHandler, true);
142 }
143
144 /** @override */
145 disconnectedCallback() {
146 super.disconnectedCallback();
147 window.removeEventListener('keydown', this._boundKeydownHandler,
148 true);
149 }
150
151 /** @override */
152 updated(changedProperties) {
153 if (changedProperties.has('opened')) {
154 this._openedChanged(this.opened);
155 }
156 }
157
158 _keydownHandler(event) {
159 if (!this.opened) return;
160 if (event.key === 'Escape' && this.forced) {
161 // Stop users from using the Escape key in a forced dialog.
162 e.preventDefault();
163 }
164 }
165
166 /**
167 * Closes the dialog.
168 * May have its logic overridden by a custom onOpenChanged function.
169 */
170 close() {
171 if (this.onOpenedChange) {
172 this.onOpenedChange(false);
173 } else {
174 this.opened = false;
175 }
176 }
177
178 /**
179 * Opens the dialog.
180 * May have its logic overridden by a custom onOpenChanged function.
181 */
182 open() {
183 if (this.onOpenedChange) {
184 this.onOpenedChange(true);
185 } else {
186 this.opened = true;
187 }
188 }
189
190 /**
191 * Switches the dialog from open to closed or vice versa.
192 */
193 toggle() {
194 this.opened = !this.opened;
195 }
196
197 _cancelHandler(evt) {
198 if (!this.forced) {
199 this.close();
200 } else {
201 evt.preventDefault();
202 }
203 }
204
205 _getActiveElement() {
206 // document.activeElement alone isn't sufficient to find the active
207 // element within shadow dom.
208 let active = document.activeElement || document.body;
209 let activeRoot = active.shadowRoot || active.root;
210 while (activeRoot && activeRoot.activeElement) {
211 active = activeRoot.activeElement;
212 activeRoot = active.shadowRoot || active.root;
213 }
214 return active;
215 }
216
217 _openedChanged(opened) {
218 const dialog = this.shadowRoot.querySelector('dialog');
219 if (opened) {
220 // For accessibility, we want to ensure we remember the element that was
221 // focused before this dialog opened.
222 this._previousFocusedElement = this._getActiveElement();
223
224 if (dialog.showModal) {
225 dialog.showModal();
226 } else {
227 dialog.setAttribute('open', 'true');
228 }
229 if (this._previousFocusedElement) {
230 this._previousFocusedElement.blur();
231 }
232 } else {
233 if (dialog.close) {
234 dialog.close();
235 } else {
236 dialog.setAttribute('open', undefined);
237 }
238
239 if (this._previousFocusedElement) {
240 const element = this._previousFocusedElement;
241 requestAnimationFrame(() => {
242 // HACK. This is to prevent a possible accessibility bug where
243 // using a keypress to trigger a button that exits a modal causes
244 // the modal to immediately re-open because the button that
245 // originally opened the modal refocuses, and the keypress
246 // propagates.
247 element.focus();
248 });
249 }
250 }
251 }
252}
253
254customElements.define('chops-dialog', ChopsDialog);