-
Notifications
You must be signed in to change notification settings - Fork 39
/
OverlayMixin.js
273 lines (254 loc) · 9.29 KB
/
OverlayMixin.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
import { deepContains, firstFocusableElement } from "../core/dom.js";
import { fragmentFrom, templateFrom } from "../core/htmlLiterals.js";
import ReactiveElement from "../core/ReactiveElement.js"; // eslint-disable-line no-unused-vars
import {
defaultState,
firstRender,
render,
rendered,
setState,
state,
template,
} from "./internal.js";
/** @type {any} */
const appendedToDocumentKey = Symbol("appendedToDocument");
/** @type {any} */
const defaultZIndexKey = Symbol("assignedZIndex");
/** @type {any} */
const restoreFocusToElementKey = Symbol("restoreFocusToElement");
/**
* Displays an opened element on top of other page elements.
*
* This mixin handles showing and hiding an overlay element. It, together with
* [OpenCloseMixin](OpenCloseMixin), form the core behavior for [Overlay](Overlay),
* which in turn forms the basis of Elix's overlay components.
*
* @module OverlayMixin
* @param {Constructor<ReactiveElement>} Base
*/
export default function OverlayMixin(Base) {
// The class prototype added by the mixin.
class Overlay extends Base {
// TODO: Document
get autoFocus() {
return this[state].autoFocus;
}
set autoFocus(autoFocus) {
this[setState]({ autoFocus });
}
// @ts-ignore
get [defaultState]() {
return Object.assign(super[defaultState] || {}, {
autoFocus: true,
persistent: false,
});
}
async open() {
if (!this[state].persistent && !this.isConnected) {
// Overlay isn't in document yet.
this[appendedToDocumentKey] = true;
document.body.append(this);
}
if (super.open) {
await super.open();
}
}
[render](/** @type {ChangedFlags} */ changed) {
if (super[render]) {
super[render](changed);
}
if (this[firstRender]) {
this.addEventListener("blur", (event) => {
// What has the focus now?
const newFocusedElement =
event.relatedTarget || document.activeElement;
/** @type {any} */
const node = this;
if (newFocusedElement instanceof HTMLElement) {
const focusInside = deepContains(node, newFocusedElement);
if (!focusInside) {
if (this.opened) {
// The user has most likely clicked on something in the background
// of a modeless overlay. Remember that element, and restore focus
// to it when the overlay finishes closing.
this[restoreFocusToElementKey] = newFocusedElement;
} else {
// A blur event fired, but the overlay closed itself before the blur
// event could be processed. In closing, we may have already
// restored the focus to the element that originally invoked the
// overlay. Since the user has clicked somewhere else to close the
// overlay, put the focus where they wanted it.
newFocusedElement.focus();
this[restoreFocusToElementKey] = null;
}
}
}
});
}
if (changed.effectPhase || changed.opened || changed.persistent) {
if (!this[state].persistent) {
// Temporary overlay
const closed =
typeof this.closeFinished === "undefined"
? this.closed
: this.closeFinished;
if (closed) {
if (this[defaultZIndexKey]) {
// Remove default z-index.
this.style.zIndex = "";
this[defaultZIndexKey] = null;
}
} else if (this[defaultZIndexKey]) {
this.style.zIndex = this[defaultZIndexKey];
} else {
if (!hasZIndex(this)) {
bringToFront(this);
}
}
}
}
}
[rendered](/** @type {ChangedFlags} */ changed) {
if (super[rendered]) {
super[rendered](changed);
}
if (this[firstRender]) {
// Perform one-time check to see if component needs a default z-index.
if (this[state].persistent && !hasZIndex(this)) {
bringToFront(this);
}
}
if (changed.opened) {
if (this[state].autoFocus) {
if (this[state].opened) {
// Opened
if (
!this[restoreFocusToElementKey] &&
document.activeElement !== document.body
) {
// Remember which element had the focus before we were opened.
this[restoreFocusToElementKey] = document.activeElement;
}
// Focus on the element itself (if it's focusable), or the first focusable
// element inside it.
// TODO: We'd prefer to require that overlays (like the Overlay base
// class) make use of delegatesFocus via DelegateFocusMixin, which would
// let us drop the need for this mixin here to do anything special with
// focus. However, an initial trial of this revealed an issue in
// MenuButton, where invoking the menu did not put the focus on the first
// menu item as expected. Needs more investigation.
const focusElement = firstFocusableElement(this);
if (focusElement) {
focusElement.focus();
}
} else {
// Closed
if (this[restoreFocusToElementKey]) {
// Restore focus to the element that had the focus before the overlay was
// opened.
this[restoreFocusToElementKey].focus();
this[restoreFocusToElementKey] = null;
}
}
}
}
// If we're finished closing an overlay that was automatically added to the
// document, remove it now. Note: we only do this when the component
// updates, not when it mounts, because we don't want an automatically-added
// element to be immediately removed during its connectedCallback.
if (
!this[firstRender] &&
!this[state].persistent &&
this.closeFinished &&
this[appendedToDocumentKey]
) {
this[appendedToDocumentKey] = false;
if (this.parentNode) {
this.parentNode.removeChild(this);
}
}
}
get [template]() {
const result = super[template] || templateFrom.html``;
// We'd like to just use the `hidden` attribute, but a side-effect of
// styling with the hidden attribute is that naive styling of the
// component from the outside (to change to display: flex, say) will
// override the display: none implied by hidden. To work around this
// problem, we use display: none when the overlay is closed.
result.content.append(fragmentFrom.html`
<style>
:host([closed]) {
display: none;
}
</style>
`);
return result;
}
}
return Overlay;
}
// Pick a default z-index, remember it, and apply it.
function bringToFront(element) {
const defaultZIndex = maxZIndexInUse() + 1;
element[defaultZIndexKey] = defaultZIndex;
element.style.zIndex = defaultZIndex.toString();
}
/**
* If the element has or inherits an explicit numeric z-index, return true.
* Otherwise, return false.
*
* @private
* @param {HTMLElement} element
* @returns {boolean}
*/
function hasZIndex(element) {
const computedZIndex = getComputedStyle(element).zIndex;
const explicitZIndex = element.style.zIndex;
const isExplicitZIndexNumeric = !isNaN(parseInt(explicitZIndex));
if (computedZIndex === "auto") {
return isExplicitZIndexNumeric;
}
if (computedZIndex === "0" && !isExplicitZIndexNumeric) {
// Might be on Safari, which reports a computed z-index of zero even in
// cases where no z-index has been inherited but the element creates a
// stacking context. Inspect the composed tree parent to infer whether the
// element is really inheriting a z-index.
const parent =
element.assignedSlot ||
(element instanceof ShadowRoot ? element.host : element.parentNode);
if (!(parent instanceof HTMLElement)) {
// Theoretical edge case, assume zero z-index is real.
return true;
}
if (!hasZIndex(parent)) {
// The parent doesn't have a numeric z-index, and the element itself
// doesn't have a numeric z-index, so the "0" value for the computed
// z-index is simulated, not a real assigned numeric z-index.
return false;
}
}
// Element has a non-zero numeric z-index.
return true;
}
/*
* Return the highest z-index currently in use in the document's light DOM.
*
* This calculation looks at all light DOM elements, so is theoretically
* expensive. That said, it only runs when an overlay is opening, and is only used
* if an overlay doesn't have a z-index already. In cases where performance is
* an issue, this calculation can be completely circumvented by manually
* applying a z-index to an overlay.
*/
function maxZIndexInUse() {
const elements = document.body.querySelectorAll("*");
const zIndices = Array.from(elements, (element) => {
const style = getComputedStyle(element);
let zIndex = 0;
if (style.position !== "static" && style.zIndex !== "auto") {
const parsed = style.zIndex ? parseInt(style.zIndex) : 0;
zIndex = !isNaN(parsed) ? parsed : 0;
}
return zIndex;
});
return Math.max(...zIndices);
}