@advanced-rest-client/arc-overlay-mixin
Version:
arc-overlay-mixin is a port of iron-overlay-behavior that works with web components
423 lines (387 loc) • 11.4 kB
JavaScript
/**
@license
Copyright (c) 2015 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at
http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
part of the polymer project is also subject to an additional IP rights grant
found at http://polymer.github.io/PATENTS.txt
*/
import '../arc-overlay-backdrop.js';
/**
* @typedef {import('./ArcOverlayBackdrop').ArcOverlayBackdrop} ArcOverlayBackdrop
*/
/* eslint-disable class-methods-use-this */
/* eslint-disable no-plusplus */
/* eslint-disable no-continue */
/* eslint-disable no-param-reassign */
/* eslint-disable no-cond-assign */
/**
* The class was originally designed by Polymer team.
* @struct
* @constructor
*/
export class ArcOverlayManager {
constructor() {
/**
* Used to keep track of the opened overlays.
* @private {Array<HTMLElement>}
*/
this._overlays = [];
/**
* iframes have a default z-index of 100,
* so this default should be at least that.
* @private {number}
*/
this._minimumZ = 101;
/**
* Memoized backdrop element.
* @private {ArcOverlayBackdrop|null}
*/
this._backdropElement = null;
// Enable document-wide tap recognizer.
// NOTE: Use useCapture=true to avoid accidentally prevention of the closing
// of an overlay via event.stopPropagation(). The only way to prevent
// closing of an overlay should be through its APIs.
// NOTE: enable tap on <html> to workaround Polymer/polymer#4459
// Pass no-op function because MSEdge 15 doesn't handle null as 2nd argument
// https://github.com/Microsoft/ChakraCore/issues/3863
document.documentElement.addEventListener('click', () => {});
document.addEventListener('click', this._onCaptureClick.bind(this), true);
document.addEventListener('focus', this._onCaptureFocus.bind(this), true);
document.addEventListener('keydown', this._onCaptureKeyDown.bind(this), true);
}
/**
* The shared backdrop element.
* @return {!ArcOverlayBackdrop} backdropElement
*/
get backdropElement() {
if (!this._backdropElement) {
this._backdropElement = document.createElement('arc-overlay-backdrop');
}
return this._backdropElement;
}
/**
* The deepest active element.
* @return {!Element} activeElement the active element
*/
get deepActiveElement() {
let active = document.activeElement;
// document.activeElement can be null
// https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement
// In IE 11, it can also be an object when operating in iframes.
// In these cases, default it to document.body.
if (!active || active instanceof Element === false) {
active = document.body;
}
while (active.shadowRoot && active.shadowRoot.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
/**
* Brings the overlay at the specified index to the front.
* @param {number} i
* @private
*/
_bringOverlayAtIndexToFront(i) {
const overlay = this._overlays[i];
if (!overlay) {
return;
}
let lastI = this._overlays.length - 1;
const currentOverlay = this._overlays[lastI];
// Ensure always-on-top overlay stays on top.
if (currentOverlay &&
this._shouldBeBehindOverlay(overlay, currentOverlay)) {
lastI--;
}
// If already the top element, return.
if (i >= lastI) {
return;
}
// Update z-index to be on top.
const minimumZ = Math.max(this.currentOverlayZ(), this._minimumZ);
if (this._getZ(overlay) <= minimumZ) {
this._applyOverlayZ(overlay, minimumZ);
}
// Shift other overlays behind the new on top.
while (i < lastI) {
this._overlays[i] = this._overlays[i + 1];
i++;
}
this._overlays[lastI] = overlay;
}
/**
* Adds the overlay and updates its z-index if it's opened, or removes it if
* it's closed. Also updates the backdrop z-index.
* @param {HTMLElement} overlay
*/
addOrRemoveOverlay(overlay) {
// @ts-ignore
if (overlay.opened) {
this.addOverlay(overlay);
} else {
// @ts-ignore
this.removeOverlay(overlay);
}
}
/**
* Tracks overlays for z-index and focus management.
* Ensures the last added overlay with always-on-top remains on top.
* @param {HTMLElement} overlay
*/
addOverlay(overlay) {
const i = this._overlays.indexOf(overlay);
if (i >= 0) {
this._bringOverlayAtIndexToFront(i);
this.trackBackdrop();
return;
}
let insertionIndex = this._overlays.length;
const currentOverlay = this._overlays[insertionIndex - 1];
let minimumZ = Math.max(this._getZ(currentOverlay), this._minimumZ);
const newZ = this._getZ(overlay);
// Ensure always-on-top overlay stays on top.
if (currentOverlay &&
this._shouldBeBehindOverlay(overlay, currentOverlay)) {
// This bumps the z-index of +2.
this._applyOverlayZ(currentOverlay, minimumZ);
insertionIndex--;
// Update minimumZ to match previous overlay's z-index.
const previousOverlay = this._overlays[insertionIndex - 1];
minimumZ = Math.max(this._getZ(previousOverlay), this._minimumZ);
}
// Update z-index and insert overlay.
if (newZ <= minimumZ) {
this._applyOverlayZ(overlay, minimumZ);
}
this._overlays.splice(insertionIndex, 0, overlay);
this.trackBackdrop();
}
/**
* @param {HTMLElement} overlay
*/
removeOverlay(overlay) {
const i = this._overlays.indexOf(overlay);
if (i === -1) {
return;
}
this._overlays.splice(i, 1);
this.trackBackdrop();
}
/**
* Returns the current overlay.
* @return {HTMLElement|undefined}
*/
currentOverlay() {
const i = this._overlays.length - 1;
return this._overlays[i];
}
/**
* Returns the current overlay z-index.
* @return {number}
*/
currentOverlayZ() {
return this._getZ(this.currentOverlay());
}
/**
* Ensures that the minimum z-index of new overlays is at least `minimumZ`.
* This does not effect the z-index of any existing overlays.
* @param {number} minimumZ
*/
ensureMinimumZ(minimumZ) {
this._minimumZ = Math.max(this._minimumZ, minimumZ);
}
focusOverlay() {
const current = /** @type {?} */ (this.currentOverlay());
if (current) {
current._applyFocus();
}
}
/**
* Updates the backdrop z-index.
*/
trackBackdrop() {
const overlay = this._overlayWithBackdrop();
// Avoid creating the backdrop if there is no overlay with backdrop.
if (!overlay && !this._backdropElement) {
return;
}
this.backdropElement.style.zIndex = String(this._getZ(overlay) - 1);
this.backdropElement.opened = !!overlay;
// Property observers are not fired until element is attached
// in Polymer 2.x, so we ensure element is attached if needed.
// https://github.com/Polymer/polymer/issues/4526
this.backdropElement.prepare();
}
/**
* @return {!Array<!Element>}
*/
getBackdrops() {
const backdrops = [];
for (let i = 0; i < this._overlays.length; i++) {
if (this._overlays[i].withBackdrop) {
backdrops.push(this._overlays[i]);
}
}
return backdrops;
}
/**
* Returns the z-index for the backdrop.
* @return {number}
*/
backdropZ() {
return this._getZ(this._overlayWithBackdrop()) - 1;
}
/**
* Returns the top opened overlay that has a backdrop.
* @return {!HTMLElement|undefined}
* @private
*/
_overlayWithBackdrop() {
for (let i = this._overlays.length - 1; i >= 0; i--) {
if (this._overlays[i].withBackdrop) {
return this._overlays[i];
}
}
return undefined;
}
/**
* Calculates the minimum z-index for the overlay.
*
* @param {HTMLElement=} overlay
* @return {number}
* @private
*/
_getZ(overlay) {
let z = this._minimumZ;
if (overlay) {
const z1 = Number(overlay.style.zIndex || window.getComputedStyle(overlay).zIndex);
// Check if is a number
// Number.isNaN not supported in IE 10+
if (!Number.isNaN(z1)) {
z = z1;
}
}
return z;
}
/**
* @param {!HTMLElement} element
* @param {number|string} z
* @private
*/
_setZ(element, z) {
element.style.zIndex = String(z);
}
/**
* @param {!HTMLElement} overlay
* @param {number} aboveZ
* @private
*/
_applyOverlayZ(overlay, aboveZ) {
this._setZ(overlay, aboveZ + 2);
}
/**
* Returns the deepest overlay in the path.
* @param {!Array<!HTMLElement>=} path
* @return {!Element|undefined}
* @suppress {missingProperties}
* @private
*/
_overlayInPath(path) {
path = path || [];
for (let i = 0; i < path.length; i++) {
// @ts-ignore
if (path[i]._manager === this) {
return path[i];
}
}
return undefined;
}
/**
* Ensures the click event is delegated to the right overlay.
* @param {MouseEvent} e
* @private
*/
_onCaptureClick(e) {
let i = this._overlays.length - 1;
if (i === -1) {
return;
}
const cp = e.composedPath && e.composedPath();
// @ts-ignore
const path = /** @type {!Array<!EventTarget>} */ cp || e.path;
let overlay;
// Check if clicked outside of overlay.
while ((overlay = /** @type {?} */ (this._overlays[i])) &&
this._overlayInPath(path) !== overlay) {
if (this._clickIsInsideOverlay(e, overlay)) {
i--;
continue
}
overlay._onCaptureClick(e);
if (overlay.allowClickThrough) {
i--;
} else {
break;
}
}
}
_clickIsInsideOverlay(clickEvent, element) {
const { clientX, clientY } = clickEvent;
const { top, left, right, bottom} = element.getBoundingClientRect();
if (clientX < left || clientX > right || clientY < top || clientY > bottom) {
return false;
}
return true;
}
/**
* Ensures the focus event is delegated to the right overlay.
* @param {!Event} event
* @private
*/
_onCaptureFocus(event) {
const overlay = /** @type {?} */ (this.currentOverlay());
if (overlay) {
overlay._onCaptureFocus(event);
}
}
/**
* Ensures TAB and ESC keyboard events are delegated to the right overlay.
* @param {KeyboardEvent} e
* @private
*/
_onCaptureKeyDown(e) {
const overlay = /** @type {?} */ (this.currentOverlay());
if (overlay) {
if (this._keyboardEventMatchesKeys(e, 'Escape')) {
overlay._onCaptureEsc(e);
} else if (this._keyboardEventMatchesKeys(e, 'Tab')) {
overlay._onCaptureTab(e);
}
}
}
_keyboardEventMatchesKeys(e, key) {
if (e.key && e.key === key) {
return true;
}
if (e.detail && e.detail.key === key) {
return true;
}
return false;
}
/**
* Returns if the overlay1 should be behind overlay2.
* @param {!HTMLElement} overlay1
* @param {!HTMLElement} overlay2
* @return {boolean}
* @suppress {missingProperties}
* @private
*/
_shouldBeBehindOverlay(overlay1, overlay2) {
// @ts-ignore
return !overlay1.alwaysOnTop && overlay2.alwaysOnTop;
}
}