@advanced-rest-client/arc-overlay-mixin
Version:
arc-overlay-mixin is a port of iron-overlay-behavior that works with web components
1,217 lines (1,112 loc) • 37.6 kB
JavaScript
import { dedupeMixin } from '@open-wc/dedupe-mixin';
import { ArcFitMixin } from '@advanced-rest-client/arc-fit-mixin';
import { ArcResizableMixin } from '@advanced-rest-client/arc-resizable-mixin';
import { ArcFocusableHelper } from '../arc-focusable-helper.js';
import { ArcOverlayManager } from '../arc-overlay-manager.js';
import { pushScrollLock, removeScrollLock } from './arc-scroll-manager.js';
/* eslint-disable class-methods-use-this */
/* eslint-disable no-unused-vars */
/* eslint-disable no-plusplus */
/* eslint-disable no-continue */
/* eslint-disable no-multi-assign */
/* eslint-disable no-param-reassign */
/**
* @param {typeof HTMLElement} base
*/
const mxFunction = base => {
class ArcOverlayMixinImpl extends ArcFitMixin(ArcResizableMixin(base)) {
static get properties() {
return {
/**
* True if the overlay is currently displayed.
*/
opened: { type: Boolean, reflect: true },
/**
* True if the overlay was canceled when it was last closed.
*/
__canceled: { type: Boolean, reflect: true, attribute: 'canceled' },
/**
* Set to true to display a backdrop behind the overlay. It traps the focus
* within the light DOM of the overlay.
*/
withBackdrop: { type: Boolean, reflect: true },
_oldWithBackdrop: { type: Boolean, attribute: 'with-backdrop' },
/**
* Set to true to disable auto-focusing the overlay or child nodes with
* the `autofocus` attribute` when the overlay is opened.
*/
noAutoFocus: { type: Boolean, reflect: true },
_oldNoAutoFocus: { type: Boolean, attribute: 'no-auto-focus' },
/**
* Set to true to disable canceling the overlay with the ESC key.
*/
noCancelOnEscKey: { type: Boolean, reflect: true },
_oldNoCancelOnEscKey: { type: Boolean, attribute: 'no-cancel-on-esc-key' },
/**
* Set to true to disable canceling the overlay by clicking outside it.
*/
noCancelOnOutsideClick: { type: Boolean, reflect: true },
_oldNoCancelOnOutsideClick: { type: Boolean, attribute: 'no-cancel-on-outside-click' },
/**
* Contains the reason(s) this overlay was last closed (see
* `overlay-closed`). `ArcOverlayMixin` provides the `canceled`
* reason; implementers of the behavior can provide other reasons in
* addition to `canceled`.
*/
closingReason: { type: Object },
/**
* Set to true to enable restoring of focus when overlay is closed.
*/
restoreFocusOnClose: { type: Boolean, reflect: true },
_oldRestoreFocusOnClose: { type: Boolean, attribute: 'restore-focus-on-close' },
/**
* Set to true to allow clicks to go through overlays.
* When the user clicks outside this overlay, the click may
* close the overlay below.
*/
allowClickThrough: { type: Boolean, reflect: true },
_oldAllowClickThrough: { type: Boolean, attribute: 'allow-click-through' },
/**
* Set to true to keep overlay always on top.
*/
alwaysOnTop: { type: Boolean, reflect: true },
_oldAlwaysOnTop: { type: Boolean, attribute: 'always-on-top' },
/**
* Determines which action to perform when scroll outside an opened overlay
* happens. Possible values: lock - blocks scrolling from happening, refit -
* computes the new position on the overlay cancel - causes the overlay to
* close
*/
scrollAction: { type: String, reflect: true },
_oldScrollAction: { type: String, attribute: 'scroll-action' },
/**
* Shortcut to access to the overlay manager.
* @private
*/
_manager: { type: Object },
/**
* The node being focused.
*/
_focusedChild: { type: Object }
};
}
/**
* True if the overlay is currently displayed.
*/
get opened() {
return this._opened;
}
set opened(value) {
const old = this._opened;
if (value === old) {
return;
}
this._opened = value;
// @ts-ignore
if (this.requestUpdate) {
// @ts-ignore
this.requestUpdate('opened', old);
}
this._openedChanged(value);
this.__updateScrollObservers(this._isAttached, value, this.scrollAction);
this.dispatchEvent(new CustomEvent('openedchange'));
this.dispatchEvent(new CustomEvent('opened-changed', {
detail: {
value
}
}));
}
/**
* True if the overlay was canceled when it was last closed.
*/
get canceled() {
return this.__canceled;
}
get _canceled() {
return this.__canceled;
}
set _canceled(value) {
if (value === this.__canceled) {
return;
}
this.__canceled = value;
this._canceledChanged();
}
/**
* Set to true to display a backdrop behind the overlay. It traps the focus
* within the light DOM of the overlay.
*/
get withBackdrop() {
return this._withBackdrop;
}
set withBackdrop(value) {
const old = this._withBackdrop;
if (value === old) {
return;
}
this._withBackdrop = value;
// @ts-ignore
if (this.requestUpdate) {
// @ts-ignore
this.requestUpdate('withBackdrop', old);
}
this._withBackdropChanged();
}
get _oldWithBackdrop() {
return this.withBackdrop;
}
set _oldWithBackdrop(value) {
this.withBackdrop = value;
}
get _oldNoAutoFocus() {
return this.noAutoFocus;
}
set _oldNoAutoFocus(value) {
this.noAutoFocus = value;
}
get _oldNoCancelOnEscKey() {
return this.noCancelOnEscKey;
}
set _oldNoCancelOnEscKey(value) {
this.noCancelOnEscKey = value;
}
get _oldNoCancelOnOutsideClick() {
return this.noCancelOnOutsideClick;
}
set _oldNoCancelOnOutsideClick(value) {
this.noCancelOnOutsideClick = value;
}
get _oldRestoreFocusOnClose() {
return this.restoreFocusOnClose;
}
set _oldRestoreFocusOnClose(value) {
this.restoreFocusOnClose = value;
}
get _oldAllowClickThrough() {
return this.allowClickThrough;
}
set _oldAllowClickThrough(value) {
this.allowClickThrough = value;
}
get _oldAlwaysOnTop() {
return this.alwaysOnTop;
}
set _oldAlwaysOnTop(value) {
this.alwaysOnTop = value;
}
get _oldScrollAction() {
return this.scrollAction;
}
set _oldScrollAction(value) {
this.scrollAction = value;
}
get 'scroll-action'() {
return this.scrollAction;
}
set 'scroll-action'(value) {
this.scrollAction = value;
}
get isAttached() {
return this._isAttached;
}
set isAttached(value) {
this._isAttached = value;
this.__updateScrollObservers(value, this._opened, this.scrollAction);
}
get scrollAction() {
return this._scrollAction;
}
set scrollAction(value) {
this._scrollAction = value;
this.__updateScrollObservers(this._isAttached, this._opened, value);
}
/**
* The backdrop element.
* @return {!Element}
*/
get backdropElement() {
return this._manager.backdropElement;
}
/**
* Returns the node to give focus to.
* @return {!HTMLElement}
*/
get _focusNode() {
return this._focusedChild || this.querySelector('[autofocus]') || this;
}
/**
* Array of nodes that can receive focus (overlay included), ordered by
* `tabindex`. This is used to retrieve which is the first and last focusable
* nodes in order to wrap the focus for overlays `with-backdrop`.
*
* If you know what is your content (specifically the first and last focusable
* children), you can override this method to return only `[firstFocusable,
* lastFocusable];`
* @return {!Array<!Node>}
* @protected
*/
get _focusableNodes() {
return ArcFocusableHelper.getTabbableNodes(this);
}
/**
* @return {EventListener} Previously registered handler for `opened-changed` event
*/
get onopenedchanged() {
return this['_onopened-changed'];
}
/**
* Registers a callback function for `opened-changed` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onopenedchanged(value) {
this._registerCallback('opened-changed', value);
}
/**
* @return {EventListener} Previously registered handler for `overlay-canceled` event
*/
get onoverlaycanceled() {
return this['_onoverlay-canceled'];
}
/**
* Registers a callback function for `overlay-canceled` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onoverlaycanceled(value) {
this._registerCallback('overlay-canceled', value);
}
/**
* @return {EventListener} Previously registered handler for `overlay-opened` event
*/
get onoverlayopened() {
return this['_onoverlay-opened'];
}
/**
* Registers a callback function for `overlay-opened` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onoverlayopened(value) {
this._registerCallback('overlay-opened', value);
}
/**
* @return {EventListener} Previously registered handler for `overlay-closed` event
*/
get onoverlayclosed() {
return this['_onoverlay-closed'];
}
/**
* Registers a callback function for `overlay-closed` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onoverlayclosed(value) {
this._registerCallback('overlay-closed', value);
}
/**
* @return {EventListener} Previously registered handler for `opened` event
*/
get onopened() {
// eslint-disable-next-line dot-notation
return this['_onopened'];
}
/**
* Registers a callback function for `opened` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onopened(value) {
this._registerCallback('opened', value);
}
/**
* @return {EventListener} Previously registered handler for `closed` event
*/
get onclosed() {
// eslint-disable-next-line dot-notation
return this['_onclosed'];
}
/**
* Registers a callback function for `closed` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onclosed(value) {
this._registerCallback('closed', value);
}
constructor() {
super();
this._opened = false;
this._canceled = false;
this.noAutoFocus = false;
this.noCancelOnEscKey = false;
this.noCancelOnOutsideClick = false;
this.restoreFocusOnClose = false;
this._manager = ArcOverlayManager;
// Used to skip calls to notifyResize and refit while the overlay is
// animating.
this.__isAnimating = false;
// with-backdrop needs tabindex to be set in order to trap the focus.
// If it is not set, IronOverlayBehavior will set it, and remove it if
// with-backdrop = false.
this.__shouldRemoveTabIndex = false;
// Used for wrapping the focus on TAB / Shift+TAB.
this.__firstFocusableNode = this.__lastFocusableNode = null;
// Used by to keep track of the RAF callbacks.
this.__rafs = {};
// Focused node before overlay gets opened. Can be restored on close.
this.__restoreFocusNode = null;
// Scroll info to be restored.
this.__scrollTop = this.__scrollLeft = null;
// Root nodes hosting the overlay, used to listen for scroll events on them.
this.__rootNodes = null;
this._onIronResize = this._onIronResize.bind(this);
this.__onCaptureScroll = this.__onCaptureScroll.bind(this);
this._boundSchedule = this._boundSchedule.bind(this);
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('iron-resize', this._onIronResize);
if (!this._elementReady) {
this._elementReady = true;
// @ts-ignore
if (this.updateComplete) {
// @ts-ignore
this.updateComplete.then(() => {
this._ensureSetup();
});
} else {
this._ensureSetup();
}
}
// Call _openedChanged here so that position can be computed correctly.
if (this.opened) {
this._openedChanged(this.opened);
}
this._setupSlotListeners();
this._ensureAria();
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('iron-resize', this._onIronResize);
this._removeSlotListeners();
Object.keys(this.__rafs).forEach((cb) => {
if (this.__rafs[cb] !== null) {
cancelAnimationFrame(this.__rafs[cb]);
}
});
this.__rafs = {};
this._manager.removeOverlay(this);
// We got detached while animating, ensure we show/hide the overlay
// and fire overlay-opened/closed event!
if (this.__isAnimating) {
if (this.opened) {
this._finishRenderOpened();
} else {
// Restore the focus if necessary.
this._applyFocus();
this._finishRenderClosed();
}
}
}
/**
* Registers an event handler for given type
* @param {string} eventType Event type (name)
* @param {EventListener} value The handler to register
*/
_registerCallback(eventType, value) {
const key = `_on${eventType}`;
if (this[key]) {
this.removeEventListener(eventType, this[key]);
}
if (typeof value !== 'function') {
this[key] = null;
return;
}
this[key] = value;
this.addEventListener(eventType, value);
}
_setupSlotListeners() {
const observer = new MutationObserver((mutations) => {
this._processMutations(mutations);
});
this._childrenObserver = observer;
this._childrenObserver.observe(this, { childList: true });
}
_removeSlotListeners() {
this._unlistenSlots(this.children);
this._childrenObserver.disconnect();
this._childrenObserver = null;
}
_processMutations(mutations) {
if (mutations) {
for (let i=0; i < mutations.length; i++) {
const mutation = mutations[i];
if (mutation.addedNodes) {
this._listenSlots(mutation.addedNodes);
}
if (mutation.removedNodes) {
this._unlistenSlots(mutation.removedNodes);
}
}
this._onNodesChange();
}
}
/**
* @param {!Array<!Node>|!NodeList|!HTMLCollection} nodeList Nodes that could change
* @return {void}
* @private
*/
_listenSlots(nodeList) {
for (let i=0; i < nodeList.length; i++) {
const n = /** @type Element */ (nodeList[i]);
if (n.localName === 'slot') {
n.addEventListener('slotchange', this._boundSchedule);
}
}
}
/**
* @param {!Array<!Node>|!NodeList|!HTMLCollection} nodeList Nodes that could change
* @return {void}
* @private
*/
_unlistenSlots(nodeList) {
for (let i=0; i < nodeList.length; i++) {
const n = /** @type Element */ (nodeList[i]);
if (n.localName === 'slot') {
n.removeEventListener('slotchange', this._boundSchedule);
}
}
}
_boundSchedule() {
setTimeout(() => {
this._onNodesChange();
});
}
/**
* Toggle the opened state of the overlay.
*/
toggle() {
this._canceled = false;
this.opened = !this.opened;
}
/**
* Open the overlay.
*/
open() {
this._canceled = false;
this.opened = true;
}
/**
* Close the overlay.
*/
close() {
this._canceled = false;
this.opened = false;
}
/**
* Cancels the overlay.
* @param {Event=} event The original event
*/
cancel(event) {
const detail = {
cancelable: true,
bubbles: true,
composed: true,
detail: event
};
// This is consistent with the web platform and the `cancel` event
// Note, don't set `oncancel` event registration as this is a standard property
let cancelEvent = new Event('cancel', { cancelable: true, bubbles: true });
this.dispatchEvent(cancelEvent);
if (cancelEvent.defaultPrevented) {
return;
}
// Deprecate the two
cancelEvent = new CustomEvent('overlay-canceled', detail);
this.dispatchEvent(cancelEvent);
if (cancelEvent.defaultPrevented) {
return;
}
cancelEvent = new CustomEvent('iron-overlay-canceled', detail);
this.dispatchEvent(cancelEvent);
if (cancelEvent.defaultPrevented) {
return;
}
this._canceled = true;
this.opened = false;
}
/**
* Invalidates the cached tabbable nodes. To be called when any of the
* focusable content changes (e.g. a button is disabled).
*/
invalidateTabbables() {
this.__firstFocusableNode = this.__lastFocusableNode = null;
}
_ensureSetup() {
if (this._overlaySetup) {
return;
}
this._overlaySetup = true;
this.style.outline = 'none';
this.style.display = 'none';
}
/**
* Called when `opened` changes.
* @param {boolean=} opened
* @protected
*/
_openedChanged(opened) {
this._ensureAria(opened);
// Defer any animation-related code on attached
// (_openedChanged gets called again on attached).
if (!this.isAttached) {
return;
}
this.__isAnimating = true;
// Deraf for non-blocking rendering.
this.__deraf('__openedChanged', this.__openedChanged);
}
_ensureAria(opened) {
if (opened === undefined) {
opened = this.opened;
}
if (opened) {
this.removeAttribute('aria-hidden');
} else {
this.setAttribute('aria-hidden', 'true');
}
}
_canceledChanged() {
this.closingReason = this.closingReason || {};
this.closingReason.canceled = this.canceled;
}
_withBackdropChanged() {
// If tabindex is already set, no need to override it.
if (this.withBackdrop && !this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '-1');
this.__shouldRemoveTabIndex = true;
} else if (this.__shouldRemoveTabIndex) {
this.removeAttribute('tabindex');
this.__shouldRemoveTabIndex = false;
}
if (this.opened && this.isAttached) {
this._manager.trackBackdrop();
}
}
/**
* tasks which must occur before opening; e.g. making the element visible.
* @protected
*/
_prepareRenderOpened() {
// Store focused node.
this.__restoreFocusNode = this._manager.deepActiveElement;
// Needed to calculate the size of the overlay so that transitions on its
// size will have the correct starting points.
this._preparePositioning();
this.refit();
this._finishPositioning();
// Safari will apply the focus to the autofocus element when displayed
// for the first time, so we make sure to return the focus where it was.
if (this.noAutoFocus && document.activeElement === this._focusNode) {
this._focusNode.blur();
this.__restoreFocusNode.focus();
}
}
/**
* Tasks which cause the overlay to actually open; typically play an
* animation.
* @protected
*/
_renderOpened() {
this._finishRenderOpened();
}
/**
* Tasks which cause the overlay to actually close; typically play an
* animation.
* @protected
*/
_renderClosed() {
this._finishRenderClosed();
}
/**
* Tasks to be performed at the end of open action. Will fire
* `overlay-opened`.
* @protected
*/
_finishRenderOpened() {
this.notifyResize();
this.__isAnimating = false;
const detail = {
bubbles: true,
composed: true
};
this.dispatchEvent(new CustomEvent('opened', detail));
// Deprecate the two
this.dispatchEvent(new CustomEvent('overlay-opened', detail));
this.dispatchEvent(new CustomEvent('iron-overlay-opened', detail));
}
/**
* Tasks to be performed at the end of close action. Will fire
* `overlay-closed`.
* @protected
*/
_finishRenderClosed() {
// Hide the overlay.
this.style.display = 'none';
// Reset z-index only at the end of the animation.
this.style.zIndex = '';
this.notifyResize();
this.__isAnimating = false;
const detail = {
bubbles: true,
composed: true,
detail: this.closingReason
};
this.dispatchEvent(new CustomEvent('closed', detail));
// Deprecate the two
this.dispatchEvent(new CustomEvent('overlay-closed', detail));
this.dispatchEvent(new CustomEvent('iron-overlay-closed', detail));
}
_preparePositioning() {
this.style.transition = this.style.webkitTransition = 'none';
this.style.transform = this.style.webkitTransform = 'none';
this.style.display = '';
}
_finishPositioning() {
// First, make it invisible & reactivate animations.
this.style.display = 'none';
// Force reflow before re-enabling animations so that they don't start.
// Set scrollTop to itself so that Closure Compiler doesn't remove this.
let { scrollTop } = this;
this.scrollTop = undefined;
this.scrollTop = scrollTop;
this.style.transition = this.style.webkitTransition = '';
this.style.transform = this.style.webkitTransform = '';
// Now that animations are enabled, make it visible again
this.style.display = '';
// Force reflow, so that following animations are properly started.
// Set scrollTop to itself so that Closure Compiler doesn't remove this.
scrollTop = this.scrollTop;
this.scrollTop = undefined;
this.scrollTop = scrollTop;
}
/**
* Applies focus according to the opened state.
* @protected
*/
_applyFocus() {
if (this.opened) {
if (!this.noAutoFocus) {
this._focusNode.focus();
}
} else {
// Restore focus.
if (this.restoreFocusOnClose && this.__restoreFocusNode) {
// If the activeElement is `<body>` or inside the overlay,
// we are allowed to restore the focus. In all the other
// cases focus might have been moved elsewhere by another
// component or by an user interaction (e.g. click on a
// button outside the overlay).
const activeElement = this._manager.deepActiveElement;
if (activeElement === document.body ||
this.shadowRoot.contains(activeElement) ||
this.contains(activeElement)) {
this.__restoreFocusNode.focus();
}
}
this.__restoreFocusNode = null;
this._focusNode.blur();
this._focusedChild = null;
}
}
/**
* Cancels (closes) the overlay. Call when click happens outside the overlay.
* @param {!Event} event
* @protected
*/
_onCaptureClick(event) {
if (!this.noCancelOnOutsideClick) {
this.cancel(event);
}
}
/**
* Keeps track of the focused child. If withBackdrop, traps focus within
* overlay.
* @param {!Event} event
* @protected
*/
_onCaptureFocus(event) {
if (!this.withBackdrop) {
return;
}
const cp = event.composedPath && event.composedPath();
// @ts-ignore
const path = cp || event.path;
if (path.indexOf(this) === -1) {
event.stopPropagation();
this._applyFocus();
} else {
// eslint-disable-next-line prefer-destructuring
this._focusedChild = path[0];
}
}
/**
* Handles the ESC key event and cancels (closes) the overlay.
* @param {!Event} event
* @protected
*/
_onCaptureEsc(event) {
if (!this.noCancelOnEscKey) {
this.cancel(event);
}
}
/**
* Handles TAB key events to track focus changes.
* Will wrap focus for overlays withBackdrop.
* @param {KeyboardEvent} event
* @protected
*/
_onCaptureTab(event) {
if (!this.withBackdrop) {
return;
}
this.__ensureFirstLastFocusables();
// TAB wraps from last to first focusable.
// Shift + TAB wraps from first to last focusable.
const shift = event.shiftKey;
const nodeToCheck =
shift ? this.__firstFocusableNode : this.__lastFocusableNode;
const nodeToSet =
shift ? this.__lastFocusableNode : this.__firstFocusableNode;
let shouldWrap = false;
if (nodeToCheck === nodeToSet) {
// If nodeToCheck is the same as nodeToSet, it means we have an overlay
// with 0 or 1 focusables; in either case we still need to trap the
// focus within the overlay.
shouldWrap = true;
} else {
// In dom=shadow, the manager will receive focus changes on the main
// root but not the ones within other shadow roots, so we can't rely on
// _focusedChild, but we should check the deepest active element.
const focusedNode = this._manager.deepActiveElement;
// If the active element is not the nodeToCheck but the overlay itself,
// it means the focus is about to go outside the overlay, hence we
// should prevent that (e.g. user opens the overlay and hit Shift+TAB).
shouldWrap = (focusedNode === nodeToCheck || focusedNode === this);
}
if (shouldWrap) {
// When the overlay contains the last focusable element of the document
// and it's already focused, pressing TAB would move the focus outside
// the document (e.g. to the browser search bar). Similarly, when the
// overlay contains the first focusable element of the document and it's
// already focused, pressing Shift+TAB would move the focus outside the
// document (e.g. to the browser search bar).
// In both cases, we would not receive a focus event, but only a blur.
// In order to achieve focus wrapping, we prevent this TAB event and
// force the focus. This will also prevent the focus to temporarily move
// outside the overlay, which might cause scrolling.
event.preventDefault();
this._focusedChild = nodeToSet;
this._applyFocus();
}
}
/**
* Refits if the overlay is opened and not animating.
* @protected
*/
_onIronResize() {
if (this.opened && !this.__isAnimating) {
this.__deraf('refit', this.refit);
}
}
/**
* Will call notifyResize if overlay is opened.
* Can be overridden in order to avoid multiple observers on the same node.
* @protected
*/
_onNodesChange() {
if (this.opened && !this.__isAnimating) {
// It might have added focusable nodes, so invalidate cached values.
this.invalidateTabbables();
this.notifyResize();
}
}
/**
* Updates the references to the first and last focusable nodes.
* @private
*/
__ensureFirstLastFocusables() {
const focusableNodes = this._focusableNodes;
// eslint-disable-next-line prefer-destructuring
this.__firstFocusableNode = focusableNodes[0];
this.__lastFocusableNode = focusableNodes[focusableNodes.length - 1];
}
/**
* Tasks executed when opened changes: prepare for the opening, move the
* focus, update the manager, render opened/closed.
* @private
*/
__openedChanged() {
if (this.opened) {
// Make overlay visible, then add it to the manager.
this._prepareRenderOpened();
this._manager.addOverlay(this);
// Move the focus to the child node with [autofocus].
this._applyFocus();
this._renderOpened();
} else {
// Remove overlay, then restore the focus before actually closing.
this._manager.removeOverlay(this);
this._applyFocus();
this._renderClosed();
}
}
/**
* Debounces the execution of a callback to the next animation frame.
* @param {!string} jobName
* @param {!Function} callback Always bound to `this`
* @private
*/
__deraf(jobName, callback) {
const rafs = this.__rafs;
if (rafs[jobName] !== null) {
cancelAnimationFrame(rafs[jobName]);
}
rafs[jobName] = requestAnimationFrame(() => {
rafs[jobName] = null;
// @ts-ignore
callback.call(this);
});
}
/**
* @param {boolean} isAttached
* @param {boolean} opened
* @param {string=} scrollAction
* @private
*/
__updateScrollObservers(isAttached, opened, scrollAction) {
if (!isAttached || !opened || !this.__isValidScrollAction(scrollAction)) {
removeScrollLock(this);
this.__removeScrollListeners();
} else {
if (scrollAction === 'lock') {
this.__saveScrollPosition();
pushScrollLock(this);
}
this.__addScrollListeners();
}
}
/**
* @private
*/
__addScrollListeners() {
if (!this.__rootNodes) {
this.__rootNodes = [];
// Listen for scroll events in all shadowRoots hosting this overlay only
// when in native ShadowDOM.
let node = this;
while (node) {
// @ts-ignore
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && node.host) {
this.__rootNodes.push(node);
}
// @ts-ignore
node = node.host || node.assignedSlot || node.parentNode;
}
this.__rootNodes.push(document);
}
this.__rootNodes.forEach((el) => {
el.addEventListener('scroll', this.__onCaptureScroll, {
capture: true,
passive: true,
});
});
}
/**
* @private
*/
__removeScrollListeners() {
if (this.__rootNodes) {
this.__rootNodes.forEach((el) => {
el.removeEventListener('scroll', this.__onCaptureScroll, {
capture: true,
passive: true,
});
});
}
if (!this.isAttached) {
this.__rootNodes = null;
}
}
/**
* @param {string=} scrollAction
* @return {boolean}
* @private
*/
__isValidScrollAction(scrollAction) {
return scrollAction === 'lock' || scrollAction === 'refit' ||
scrollAction === 'cancel';
}
__onCaptureScroll(event) {
if (this.__isAnimating) {
return;
}
// Check if scroll outside the overlay.
const cp = event.composedPath && event.composedPath();
const path = cp || event.path;
if (path.indexOf(this) >= 0) {
return;
}
switch (this.scrollAction) {
case 'lock':
// NOTE: scrolling might happen if a scroll event is not cancellable, or
// if user pressed keys that cause scrolling (they're not prevented in
// order not to break a11y features like navigate with arrow keys).
this.__restoreScrollPosition();
break;
case 'refit':
this.__deraf('refit', this.refit);
break;
case 'cancel':
this.cancel(event);
break;
default:
}
}
/**
* Memoizes the scroll position of the outside scrolling element.
* @private
*/
__saveScrollPosition() {
if (document.scrollingElement) {
this.__scrollTop = document.scrollingElement.scrollTop;
this.__scrollLeft = document.scrollingElement.scrollLeft;
} else {
// Since we don't know if is the body or html, get max.
this.__scrollTop =
Math.max(document.documentElement.scrollTop, document.body.scrollTop);
this.__scrollLeft = Math.max(
document.documentElement.scrollLeft, document.body.scrollLeft);
}
}
/**
* Resets the scroll position of the outside scrolling element.
* @private
*/
__restoreScrollPosition() {
if (document.scrollingElement) {
document.scrollingElement.scrollTop = this.__scrollTop;
document.scrollingElement.scrollLeft = this.__scrollLeft;
} else {
// Since we don't know if is the body or html, set both.
document.documentElement.scrollTop = document.body.scrollTop =
this.__scrollTop;
document.documentElement.scrollLeft = document.body.scrollLeft =
this.__scrollLeft;
}
}
/**
* Fired after the overlay opens.
* @event overlay-opened
*/
/**
* Fired after the overlay opens.
* @event iron-overlay-opened
* @deprecated Use `overlay-opened` event instead
*/
/**
* Fired when the overlay is canceled, but before it is closed.
* @event iron-overlay-canceled
* @param {Event} event The closing of the overlay can be prevented
* by calling `event.preventDefault()`. The `event.detail` is the original event
* that originated the canceling (e.g. ESC keyboard event or click event outside
* the overlay).
* @deprecated Use `overlay-canceled` event instead
*/
/**
* Fired when the overlay is canceled, but before it is closed.
* @event overlay-canceled
* @param {Event} event The closing of the overlay can be prevented
* by calling `event.preventDefault()`. The `event.detail` is the original event
* that originated the canceling (e.g. ESC keyboard event or click event outside
* the overlay).
*/
/**
* Fired after the overlay closes.
* @event overlay-closed
* @param {Event} event The `event.detail` is the `closingReason` property
* (contains `canceled`, whether the overlay was canceled).
*/
/**
* Fired after the overlay closes.
* @event iron-overlay-closed
* @param {Event} event The `event.detail` is the `closingReason` property
* (contains `canceled`, whether the overlay was canceled).
* @deprecated Use `overlay-closed` instead.
*/
}
return ArcOverlayMixinImpl;
}
/**
* This mixin is a port of [IronOverlayBehavior](https://github.com/PolymerElements/iron-overlay-behavior)
* that works with LitElement.
*
* Use `ArcOverlayMixin` to implement an element that can be hidden
* or shown, and displays on top of other content. It includes an optional
* backdrop, and can be used to implement a variety of UI controls including
* dialogs and drop downs. Multiple overlays may be displayed at once.
* See the [demo source
* code](https://github.com/advanced-rest-client/arc-overlay-mixin/blob/master/demo/simple-overlay.html)
* for an example.
*
* ### Closing and canceling
*
* An overlay may be hidden by closing or canceling. The difference between close
* and cancel is user intent. Closing generally implies that the user
* acknowledged the content on the overlay. By default, it will cancel whenever
* the user taps outside it or presses the escape key. This behavior is
* configurable with the `nocancelonesckey` and the
* `nocancelonoutsideclick` properties. `close()` should be called explicitly
* by the implementer when the user interacts with a control in the overlay
* element. When the dialog is canceled, the overlay fires an
* 'overlay-canceled' event. Call `preventDefault` on this event to prevent
* the overlay from closing.
*
* ### Positioning
*
* By default the element is sized and positioned to fit and centered inside the
* window. You can position and size it manually using CSS. See `ArcFitMixin`.
*
* ### Backdrop
*
* Set the `withbackdrop` attribute to display a backdrop behind the overlay.
* The backdrop is appended to `<body>` and is of type `<arc-overlay-backdrop>`.
* See its doc page for styling options.
* In addition, `withbackdrop` will wrap the focus within the content in the
* light DOM. Override the [`_focusableNodes`
* getter](#ArcOverlayMixin:property-_focusableNodes) to achieve a
* different behavior.
*
* ### Limitations
*
* The element is styled to appear on top of other content by setting its
* `z-index` property. You must ensure no element has a stacking context with a
* higher `z-index` than its parent stacking context. You should place this
* element as a child of `<body>` whenever possible.
*
*
* ## Usage
*
* ```javascript
* import { LitElement } from 'lit-element';
* import { ArcOverlayMixin } from '@advanced-rest-client/arc-overlay-mixin/arc-overlay-mixin.js';
*
* class ArcOverlayImpl extends ArcOverlayMixin(LitElement) {
* ...
* }
* ```
*
* @mixin
*/
export const ArcOverlayMixin = dedupeMixin(mxFunction);