* fix(overlay-controller): do not scroll to invoker if it is visible on screen When closing an overlay, srollIntoView forced the invoker element to be placed in the middle of the screen. This fix prevents the scroll if the invoker is already visible, but still scrolls it into view if needed. * chore: changeset
1323 lines
42 KiB
JavaScript
1323 lines
42 KiB
JavaScript
import { overlays } from './singleton.js';
|
|
import { containFocus } from './utils/contain-focus.js';
|
|
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
|
|
import { _adoptStyleUtils } from './utils/adopt-styles.js';
|
|
|
|
/**
|
|
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
|
|
* @typedef {import('@lion/ui/types/overlays.js').ViewportConfig} ViewportConfig
|
|
* @typedef {import('@popperjs/core').createPopper} Popper
|
|
* @typedef {import('@popperjs/core').Options} PopperOptions
|
|
* @typedef {import('@popperjs/core').Placement} Placement
|
|
* @typedef {{ createPopper: Popper }} PopperModule
|
|
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
|
|
*/
|
|
|
|
/**
|
|
* From:
|
|
* - wrappingDialogNodeL1: `<dialog role="none"/>`
|
|
* - contentWrapperNodeL2: `<div id="content-wrapper-node"/>`
|
|
* - contentNodeL3: `<div slot="my-content"/>`
|
|
* To:
|
|
* ```html
|
|
* <dialog role="none">
|
|
* <div id="content-wrapper-node">
|
|
* <!-- this was the (slot for) original content node -->
|
|
* <slot name="my-content"></slot>
|
|
* </div>
|
|
* </dialog>
|
|
* ```
|
|
*
|
|
* `<slot name="my-content">` belonging to `<div slot="content"/>` will be wrapped with wrappingDialogNodeL1 and contentWrapperNodeL2
|
|
* inside shadow dom. With the help of temp markers, `<slot name="my-content">`'s original position will be respected.
|
|
*
|
|
* @param {{ wrappingDialogNodeL1:HTMLDialogElement|HTMLDivElement; contentWrapperNodeL2:Element; contentNodeL3: Element }} opts
|
|
*/
|
|
function rearrangeNodes({ wrappingDialogNodeL1, contentWrapperNodeL2, contentNodeL3 }) {
|
|
if (!(contentWrapperNodeL2.isConnected || contentNodeL3.isConnected)) {
|
|
throw new Error(
|
|
'[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.',
|
|
);
|
|
}
|
|
|
|
let parentElement;
|
|
const tempMarker = document.createComment('tempMarker');
|
|
|
|
if (contentWrapperNodeL2.isConnected) {
|
|
// This is the case when contentWrapperNode (living in shadow dom, wrapping <slot name="my-content-outlet">) is already provided via controller.
|
|
parentElement = contentWrapperNodeL2.parentElement || contentWrapperNodeL2.getRootNode();
|
|
parentElement.insertBefore(tempMarker, contentWrapperNodeL2);
|
|
// Wrap...
|
|
wrappingDialogNodeL1.appendChild(contentWrapperNodeL2);
|
|
}
|
|
// if contentNodeL3.isConnected
|
|
else {
|
|
const contentIsProjected = contentNodeL3.assignedSlot;
|
|
if (contentIsProjected) {
|
|
parentElement =
|
|
contentNodeL3.assignedSlot.parentElement || contentNodeL3.assignedSlot.getRootNode();
|
|
parentElement.insertBefore(tempMarker, contentNodeL3.assignedSlot);
|
|
wrappingDialogNodeL1.appendChild(contentWrapperNodeL2);
|
|
// Important: we do not move around contentNodeL3, but the assigned slot
|
|
contentWrapperNodeL2.appendChild(contentNodeL3.assignedSlot);
|
|
} else {
|
|
parentElement = contentNodeL3.parentElement || contentNodeL3.getRootNode();
|
|
parentElement.insertBefore(tempMarker, contentNodeL3);
|
|
wrappingDialogNodeL1.appendChild(contentWrapperNodeL2);
|
|
contentWrapperNodeL2.appendChild(contentNodeL3);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* From:
|
|
* ```html
|
|
* #shadow-root:
|
|
* <div>
|
|
* <!-- tempMarker -->
|
|
* <slot name="x"/>
|
|
* </div>
|
|
* ```
|
|
*
|
|
* To:
|
|
* ```html
|
|
* #shadow-root:
|
|
* <div>
|
|
* <!-- tempMarker -->
|
|
* <dialog role="none">
|
|
* <div id="content-wrapper-node">
|
|
* <slot name="x"/>
|
|
* </div>
|
|
* </dialog>
|
|
* </div>
|
|
* ```
|
|
*/
|
|
parentElement.insertBefore(wrappingDialogNodeL1, tempMarker);
|
|
parentElement?.removeChild(tempMarker);
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<PopperModule>}
|
|
*/
|
|
async function preloadPopper() {
|
|
// @ts-ignore [external]: import complains about untyped module, but we typecast it ourselves
|
|
return /** @type {* & Promise<PopperModule>} */ (import('@popperjs/core/dist/esm/popper.js'));
|
|
}
|
|
|
|
/**
|
|
* OverlayController is the fundament for every single type of overlay. With the right
|
|
* configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers,
|
|
* bottom/top/left/right sheets etc.
|
|
*
|
|
*/
|
|
export class OverlayController extends EventTarget {
|
|
/**
|
|
* @constructor
|
|
* @param {OverlayConfig} config initial config. Will be remembered as shared config
|
|
* when `.updateConfig()` is called.
|
|
*/
|
|
constructor(config = {}, manager = overlays) {
|
|
super();
|
|
this.manager = manager;
|
|
/** @private */
|
|
this.__sharedConfig = config;
|
|
|
|
/** @private */
|
|
this.__activeElementRightBeforeHide = null;
|
|
|
|
/** @type {OverlayConfig} */
|
|
this.config = {};
|
|
|
|
/**
|
|
* @type {OverlayConfig}
|
|
* @protected
|
|
*/
|
|
this._defaultConfig = {
|
|
placementMode: undefined,
|
|
contentNode: config.contentNode,
|
|
contentWrapperNode: config.contentWrapperNode,
|
|
invokerNode: config.invokerNode,
|
|
backdropNode: config.backdropNode,
|
|
referenceNode: undefined,
|
|
elementToFocusAfterHide: config.invokerNode,
|
|
inheritsReferenceWidth: 'none',
|
|
hasBackdrop: false,
|
|
isBlocking: false,
|
|
preventsScroll: false,
|
|
trapsKeyboardFocus: false,
|
|
hidesOnEsc: false,
|
|
hidesOnOutsideEsc: false,
|
|
hidesOnOutsideClick: false,
|
|
isTooltip: false,
|
|
invokerRelation: 'description',
|
|
visibilityTriggerFunction: undefined,
|
|
handlesAccessibility: false,
|
|
popperConfig: {
|
|
placement: 'top',
|
|
strategy: 'fixed',
|
|
modifiers: [
|
|
{
|
|
name: 'preventOverflow',
|
|
enabled: true,
|
|
options: {
|
|
boundariesElement: 'viewport',
|
|
padding: 8, // viewport-margin for shifting/sliding
|
|
},
|
|
},
|
|
{
|
|
name: 'flip',
|
|
options: {
|
|
boundariesElement: 'viewport',
|
|
padding: 16, // viewport-margin for flipping
|
|
},
|
|
},
|
|
{
|
|
name: 'offset',
|
|
enabled: true,
|
|
options: {
|
|
offset: [0, 8], // horizontal and vertical margin (distance between popper and referenceElement)
|
|
},
|
|
},
|
|
{
|
|
name: 'arrow',
|
|
enabled: false,
|
|
},
|
|
],
|
|
},
|
|
viewportConfig: {
|
|
placement: 'center',
|
|
},
|
|
zIndex: 9999,
|
|
};
|
|
|
|
this.manager.add(this);
|
|
/** @protected */
|
|
this._contentId = `overlay-content--${Math.random().toString(36).slice(2, 10)}`;
|
|
/** @private */
|
|
this.__originalAttrs = new Map();
|
|
this.updateConfig(config);
|
|
/** @private */
|
|
this.__hasActiveTrapsKeyboardFocus = false;
|
|
/** @private */
|
|
this.__hasActiveBackdrop = true;
|
|
/** @private */
|
|
this.__escKeyHandler = this.__escKeyHandler.bind(this);
|
|
}
|
|
|
|
/**
|
|
* The invokerNode
|
|
* @type {HTMLElement | undefined}
|
|
*/
|
|
get invoker() {
|
|
return this.invokerNode;
|
|
}
|
|
|
|
/**
|
|
* The contentWrapperNode
|
|
* @type {HTMLDialogElement | HTMLDivElement}
|
|
*/
|
|
get content() {
|
|
return /** @type {HTMLDialogElement | HTMLDivElement} */ (this.__wrappingDialogNode);
|
|
}
|
|
|
|
/**
|
|
* Determines the connection point in DOM (body vs next to invoker).
|
|
* @type {'global' | 'local' | undefined}
|
|
*/
|
|
get placementMode() {
|
|
return this.config?.placementMode;
|
|
}
|
|
|
|
/**
|
|
* The interactive element (usually a button) invoking the dialog or tooltip
|
|
* @type {HTMLElement | undefined}
|
|
*/
|
|
get invokerNode() {
|
|
return this.config?.invokerNode;
|
|
}
|
|
|
|
/**
|
|
* The element that is used to position the overlay content relative to. Usually,
|
|
* this is the same element as invokerNode. Should only be provided when invokerNode should not
|
|
* be positioned against.
|
|
* @type {HTMLElement}
|
|
*/
|
|
get referenceNode() {
|
|
return /** @type {HTMLElement} */ (this.config?.referenceNode);
|
|
}
|
|
|
|
/**
|
|
* The most important element: the overlay itself
|
|
* @type {HTMLElement}
|
|
*/
|
|
get contentNode() {
|
|
return /** @type {HTMLElement} */ (this.config?.contentNode);
|
|
}
|
|
|
|
/**
|
|
* The wrapper element of contentNode, used to supply inline positioning styles. When a Popper
|
|
* arrow is needed, it acts as parent of the arrow node. Will be automatically created for global
|
|
* and non projected contentNodes. Required when used in shadow dom mode or when Popper arrow is
|
|
* supplied. Essential for allowing webcomponents to style their projected contentNodes
|
|
* @type {HTMLElement}
|
|
*/
|
|
get contentWrapperNode() {
|
|
return /** @type {HTMLElement} */ (
|
|
this.__contentWrapperNode || this.config?.contentWrapperNode
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The element that is placed behind the contentNode. When not provided and `hasBackdrop` is true,
|
|
* a backdropNode will be automatically created
|
|
* @type {HTMLElement}
|
|
*/
|
|
get backdropNode() {
|
|
return /** @type {HTMLElement} */ (this.__backdropNode || this.config?.backdropNode);
|
|
}
|
|
|
|
/**
|
|
* The element that should be called `.focus()` on after dialog closes
|
|
* @type {HTMLElement}
|
|
*/
|
|
get elementToFocusAfterHide() {
|
|
return /** @type {HTMLElement} */ (
|
|
this.__elementToFocusAfterHide || this.config?.elementToFocusAfterHide
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether it should have a backdrop (currently exclusive to globalOverlayController)
|
|
* @type {boolean}
|
|
*/
|
|
get hasBackdrop() {
|
|
return /** @type {boolean} */ (!!this.backdropNode || this.config?.hasBackdrop);
|
|
}
|
|
|
|
/**
|
|
* Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController)
|
|
* @type {boolean}
|
|
*/
|
|
get isBlocking() {
|
|
return /** @type {boolean} */ (this.config?.isBlocking);
|
|
}
|
|
|
|
/**
|
|
* Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController)
|
|
* @type {boolean}
|
|
*/
|
|
get preventsScroll() {
|
|
return /** @type {boolean} */ (this.config?.preventsScroll);
|
|
}
|
|
|
|
/**
|
|
* Rotates tab, implicitly set when 'isModal'
|
|
* @type {boolean}
|
|
*/
|
|
get trapsKeyboardFocus() {
|
|
return /** @type {boolean} */ (this.config?.trapsKeyboardFocus);
|
|
}
|
|
|
|
/**
|
|
* Hides the overlay when pressing [ esc ]
|
|
* @type {boolean}
|
|
*/
|
|
get hidesOnEsc() {
|
|
return /** @type {boolean} */ (this.config?.hidesOnEsc);
|
|
}
|
|
|
|
/**
|
|
* Hides the overlay when clicking next to it, exluding invoker
|
|
* @type {boolean}
|
|
*/
|
|
get hidesOnOutsideClick() {
|
|
return /** @type {boolean} */ (this.config?.hidesOnOutsideClick);
|
|
}
|
|
|
|
/**
|
|
* Hides the overlay when pressing esc, even when contentNode has no focus
|
|
* @type {boolean}
|
|
*/
|
|
get hidesOnOutsideEsc() {
|
|
return /** @type {boolean} */ (this.config?.hidesOnOutsideEsc);
|
|
}
|
|
|
|
/**
|
|
* Will align contentNode with referenceNode (invokerNode by default) for local overlays.
|
|
* Usually needed for dropdowns. 'max' will prevent contentNode from exceeding width of
|
|
* referenceNode, 'min' guarantees that contentNode will be at least as wide as referenceNode.
|
|
* 'full' will make sure that the invoker width always is the same.
|
|
* @type {'max' | 'full' | 'min' | 'none' | undefined }
|
|
*/
|
|
get inheritsReferenceWidth() {
|
|
return this.config?.inheritsReferenceWidth;
|
|
}
|
|
|
|
/**
|
|
* For non `isTooltip`:
|
|
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode
|
|
* - sets aria-controls on invokerNode
|
|
* - returns focus to invokerNode on hide
|
|
* - sets focus to overlay content(?)
|
|
*
|
|
* For `isTooltip`:
|
|
* - sets role="tooltip" and aria-labelledby/aria-describedby on the content
|
|
*
|
|
* @type {boolean}
|
|
*/
|
|
get handlesAccessibility() {
|
|
return /** @type {boolean} */ (this.config?.handlesAccessibility);
|
|
}
|
|
|
|
/**
|
|
* Has a totally different interaction- and accessibility pattern from all other overlays.
|
|
* Will behave as role="tooltip" element instead of a role="dialog" element
|
|
* @type {boolean}
|
|
*/
|
|
get isTooltip() {
|
|
return /** @type {boolean} */ (this.config?.isTooltip);
|
|
}
|
|
|
|
/**
|
|
* By default, the tooltip content is a 'description' for the invoker (uses aria-describedby).
|
|
* Setting this property to 'label' makes the content function as a label (via aria-labelledby)
|
|
* @type {'label' | 'description'| undefined}
|
|
*/
|
|
get invokerRelation() {
|
|
return this.config?.invokerRelation;
|
|
}
|
|
|
|
/**
|
|
* Popper configuration. Will be used when placementMode is 'local'
|
|
* @type {PopperOptions}
|
|
*/
|
|
get popperConfig() {
|
|
return /** @type {PopperOptions} */ (this.config?.popperConfig);
|
|
}
|
|
|
|
/**
|
|
* Viewport configuration. Will be used when placementMode is 'global'
|
|
* @type {ViewportConfig}
|
|
*/
|
|
get viewportConfig() {
|
|
return /** @type {ViewportConfig} */ (this.config?.viewportConfig);
|
|
}
|
|
|
|
get visibilityTriggerFunction() {
|
|
return /** @type {function} */ (this.config?.visibilityTriggerFunction);
|
|
}
|
|
|
|
/**
|
|
* @desc The element our local overlay will be positioned relative to.
|
|
* @type {HTMLElement | undefined}
|
|
* @protected
|
|
*/
|
|
get _referenceNode() {
|
|
return this.referenceNode || this.invokerNode;
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
*/
|
|
set elevation(value) {
|
|
// @ts-expect-error find out why config would/could be undfined
|
|
this.__wrappingDialogNode.style.zIndex = `${this.config.zIndex + value}`;
|
|
}
|
|
|
|
/**
|
|
* @type {number}
|
|
*/
|
|
get elevation() {
|
|
return Number(this.contentWrapperNode?.style.zIndex);
|
|
}
|
|
|
|
/**
|
|
* Allows to dynamically change the overlay configuration. Needed in case the
|
|
* presentation of the overlay changes depending on screen size.
|
|
* Note that this method is the only allowed way to update a configuration of an
|
|
* OverlayController instance.
|
|
* @param { OverlayConfig } cfgToAdd
|
|
*/
|
|
updateConfig(cfgToAdd) {
|
|
// Teardown all previous configs
|
|
this.teardown();
|
|
|
|
/**
|
|
* @type {OverlayConfig}
|
|
* @private
|
|
*/
|
|
this.__prevConfig = this.config;
|
|
|
|
/** @type {OverlayConfig} */
|
|
this.config = {
|
|
...this._defaultConfig, // our basic ingredients
|
|
...this.__sharedConfig, // the initial configured overlayController
|
|
...cfgToAdd, // the added config
|
|
popperConfig: {
|
|
...(this._defaultConfig.popperConfig || {}),
|
|
...(this.__sharedConfig.popperConfig || {}),
|
|
...(cfgToAdd.popperConfig || {}),
|
|
modifiers: [
|
|
...(this._defaultConfig.popperConfig?.modifiers || []),
|
|
...(this.__sharedConfig.popperConfig?.modifiers || []),
|
|
...(cfgToAdd.popperConfig?.modifiers || []),
|
|
],
|
|
},
|
|
};
|
|
|
|
/** @private */
|
|
this.__validateConfiguration(this.config);
|
|
/** @protected */
|
|
this._init();
|
|
/** @private */
|
|
this.__elementToFocusAfterHide = undefined;
|
|
}
|
|
|
|
/**
|
|
* @param {OverlayConfig} newConfig
|
|
* @private
|
|
*/
|
|
// eslint-disable-next-line class-methods-use-this
|
|
__validateConfiguration(newConfig) {
|
|
if (!newConfig.placementMode) {
|
|
throw new Error(
|
|
'[OverlayController] You need to provide a .placementMode ("global"|"local")',
|
|
);
|
|
}
|
|
if (!['global', 'local'].includes(newConfig.placementMode)) {
|
|
throw new Error(
|
|
`[OverlayController] "${newConfig.placementMode}" is not a valid .placementMode, use ("global"|"local")`,
|
|
);
|
|
}
|
|
if (!newConfig.contentNode) {
|
|
throw new Error('[OverlayController] You need to provide a .contentNode');
|
|
}
|
|
if (newConfig.isTooltip && !newConfig.handlesAccessibility) {
|
|
throw new Error(
|
|
'[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled',
|
|
);
|
|
}
|
|
// if (newConfig.popperConfig.modifiers.arrow && !newConfig.contentWrapperNode) {
|
|
// throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled');
|
|
// }
|
|
}
|
|
|
|
/**
|
|
* @protected
|
|
*/
|
|
_init() {
|
|
if (!this.__contentHasBeenInitialized) {
|
|
this.__initContentDomStructure();
|
|
this.__contentHasBeenInitialized = true;
|
|
}
|
|
|
|
// Reset all positioning styles (local, c.q. Popper) and classes (global)
|
|
this.contentWrapperNode.removeAttribute('style');
|
|
this.contentWrapperNode.removeAttribute('class');
|
|
|
|
if (this.placementMode === 'local') {
|
|
// Lazily load Popper as soon as the first local overlay is used...
|
|
if (!OverlayController.popperModule) {
|
|
OverlayController.popperModule = preloadPopper();
|
|
}
|
|
}
|
|
this.__handleOverlayStyles({ phase: 'init' });
|
|
this._handleFeatures({ phase: 'init' });
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @private
|
|
*/
|
|
__handleOverlayStyles({ phase }) {
|
|
const rootNode = /** @type {ShadowRoot} */ (this.contentWrapperNode?.getRootNode());
|
|
if (phase === 'init') {
|
|
_adoptStyleUtils.adoptStyle(rootNode, overlayShadowDomStyle);
|
|
} else if (phase === 'teardown') {
|
|
_adoptStyleUtils.adoptStyle(rootNode, overlayShadowDomStyle, { teardown: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Here we arrange our content node via:
|
|
* 1. HTMLDialogElement: the content will always be painted to the browser's top layer
|
|
* - no matter what context the contentNode lives in, the overlay will be painted correctly via the <dialog> element,
|
|
* even if 'overflow:hidden' or a css transform is applied in its parent hierarchy.
|
|
* - the dialog element will be unstyled, but will span the whole screen
|
|
* - a backdrop element will be a child of the dialog element, so it leverages the capabilities of the parent
|
|
* (filling the whole screen if wanted an always painted to top layer)
|
|
* 2. ContentWrapper: the content receives the right positioning styles in a clean/non conflicting way:
|
|
* - local positioning: receive inline (position) styling that can never conflict with the already existing computed styles
|
|
* - global positioning: receive flex (child) classes that position the content correctly relative to the viewport
|
|
*
|
|
* The resulting structure that will be created looks like this:
|
|
*
|
|
* ...
|
|
* <dialog role="none">
|
|
* <div id="optional-backdrop"></div>
|
|
* <div id="content-wrapper-node">
|
|
* <!-- this was the (slot for) original content node -->
|
|
* <slot name="content"></slot>
|
|
* </div>
|
|
* </dialog>
|
|
* ...
|
|
*
|
|
* @private
|
|
*/
|
|
__initContentDomStructure() {
|
|
const wrappingDialogElement = document.createElement(
|
|
this.config?._noDialogEl ? 'div' : 'dialog',
|
|
);
|
|
// We use a dialog for its visual capabilities: it renders to the top layer.
|
|
// A11y will depend on the type of overlay and is arranged on contentNode level.
|
|
// Also see: https://www.scottohara.me/blog/2019/03/05/open-dialog.html
|
|
//
|
|
// The role="dialog" is set on the contentNode (or another role), so role="none"
|
|
// is valid here, although AXE complains about this setup.
|
|
// For now we need to add `ignoredRules: ['aria-allowed-role']` in your AXE tests.
|
|
// see: https://lion-web.netlify.app/fundamentals/systems/overlays/rationale/#considerations
|
|
wrappingDialogElement.setAttribute('role', 'none');
|
|
wrappingDialogElement.setAttribute('data-overlay-outer-wrapper', '');
|
|
// N.B. position: fixed is needed to escape out of 'overflow: hidden'
|
|
// We give a high z-index for non-modal dialogs, so that we at least win from all siblings of our
|
|
// parent stacking context
|
|
// padding reset so we don't get a weird dialog visual square showing up
|
|
wrappingDialogElement.style.cssText = `display:none; z-index: ${this.config.zIndex}; padding: 0;`;
|
|
this.__wrappingDialogNode = wrappingDialogElement;
|
|
|
|
/**
|
|
* Based on the configuration of the developer, multiple scenarios are accounted for
|
|
* A. We already have a contentWrapperNode ()
|
|
*/
|
|
if (!this.config?.contentWrapperNode) {
|
|
this.__contentWrapperNode = document.createElement('div');
|
|
}
|
|
this.contentWrapperNode.setAttribute('data-id', 'content-wrapper');
|
|
|
|
rearrangeNodes({
|
|
wrappingDialogNodeL1: wrappingDialogElement,
|
|
contentWrapperNodeL2: this.contentWrapperNode,
|
|
contentNodeL3: this.contentNode,
|
|
});
|
|
// @ts-ignore
|
|
wrappingDialogElement.open = true;
|
|
|
|
this.__wrappingDialogNode.style.display = 'none';
|
|
this.contentWrapperNode.style.zIndex = '1';
|
|
|
|
if (getComputedStyle(this.contentNode).position === 'absolute') {
|
|
// Having a _contWrapperNode and a contentNode with 'position:absolute' results in
|
|
// computed height of 0...
|
|
this.contentNode.style.position = 'static';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display local overlays on top of elements with no z-index that appear later in the DOM
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleZIndex({ phase }) {
|
|
if (this.placementMode !== 'local') {
|
|
return;
|
|
}
|
|
|
|
if (phase === 'setup') {
|
|
const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex);
|
|
if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) {
|
|
this.contentNode.style.zIndex = '1';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @private
|
|
*/
|
|
__setupTeardownAccessibility({ phase }) {
|
|
if (phase === 'init') {
|
|
this.__storeOriginalAttrs(this.contentNode, ['role', 'id']);
|
|
|
|
if (this.invokerNode) {
|
|
this.__storeOriginalAttrs(this.invokerNode, [
|
|
'aria-expanded',
|
|
'aria-labelledby',
|
|
'aria-describedby',
|
|
]);
|
|
}
|
|
|
|
if (!this.contentNode.id) {
|
|
this.contentNode.setAttribute('id', this._contentId);
|
|
}
|
|
if (this.isTooltip) {
|
|
if (this.invokerNode) {
|
|
this.invokerNode.setAttribute(
|
|
this.invokerRelation === 'label' ? 'aria-labelledby' : 'aria-describedby',
|
|
this._contentId,
|
|
);
|
|
}
|
|
this.contentNode.setAttribute('role', 'tooltip');
|
|
} else {
|
|
if (this.invokerNode) {
|
|
this.invokerNode.setAttribute('aria-expanded', `${this.isShown}`);
|
|
}
|
|
if (!this.contentNode.getAttribute('role')) {
|
|
this.contentNode.setAttribute('role', 'dialog');
|
|
}
|
|
}
|
|
} else if (phase === 'teardown') {
|
|
this.__restoreOriginalAttrs();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} node
|
|
* @param {string[]} attrs
|
|
* @private
|
|
*/
|
|
__storeOriginalAttrs(node, attrs) {
|
|
const attrMap = {};
|
|
attrs.forEach(attrName => {
|
|
attrMap[attrName] = node.getAttribute(attrName);
|
|
});
|
|
this.__originalAttrs.set(node, attrMap);
|
|
}
|
|
|
|
/** @private */
|
|
__restoreOriginalAttrs() {
|
|
for (const [node, attrMap] of this.__originalAttrs) {
|
|
Object.entries(attrMap).forEach(([attrName, value]) => {
|
|
if (value !== null) {
|
|
node.setAttribute(attrName, value);
|
|
} else {
|
|
node.removeAttribute(attrName);
|
|
}
|
|
});
|
|
}
|
|
this.__originalAttrs.clear();
|
|
}
|
|
|
|
get isShown() {
|
|
return Boolean(this.__wrappingDialogNode?.style.display !== 'none');
|
|
}
|
|
|
|
/**
|
|
* @event before-show right before the overlay shows. Used for animations and switching overlays
|
|
* @event show right after the overlay is shown
|
|
* @param {HTMLElement} elementToFocusAfterHide
|
|
*/
|
|
async show(elementToFocusAfterHide = this.elementToFocusAfterHide) {
|
|
// Subsequent shows could happen, make sure we await it first.
|
|
// Otherwise it gets replaced before getting resolved, and places awaiting it will time out.
|
|
if (this._showComplete) {
|
|
await this._showComplete;
|
|
}
|
|
this._showComplete = new Promise(resolve => {
|
|
this._showResolve = resolve;
|
|
});
|
|
|
|
if (this.manager) {
|
|
this.manager.show(this);
|
|
}
|
|
|
|
if (this.isShown) {
|
|
/** @type {function} */
|
|
(this._showResolve)();
|
|
return;
|
|
}
|
|
|
|
const event = new CustomEvent('before-show', { cancelable: true });
|
|
this.dispatchEvent(event);
|
|
if (!event.defaultPrevented) {
|
|
if ('HTMLDialogElement' in window && this.__wrappingDialogNode instanceof HTMLDialogElement) {
|
|
this.__wrappingDialogNode.open = true;
|
|
}
|
|
// @ts-ignore
|
|
this.__wrappingDialogNode.style.display = '';
|
|
this._keepBodySize({ phase: 'before-show' });
|
|
await this._handleFeatures({ phase: 'show' });
|
|
this._keepBodySize({ phase: 'show' });
|
|
await this._handlePosition({ phase: 'show' });
|
|
this.__elementToFocusAfterHide = elementToFocusAfterHide;
|
|
this.dispatchEvent(new Event('show'));
|
|
await this._transitionShow({
|
|
backdropNode: this.backdropNode,
|
|
contentNode: this.contentNode,
|
|
});
|
|
}
|
|
/** @type {function} */
|
|
(this._showResolve)();
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
async _handlePosition({ phase }) {
|
|
if (this.placementMode === 'global') {
|
|
const placementClass = `overlays__overlay-container--${this.viewportConfig.placement}`;
|
|
|
|
if (phase === 'show') {
|
|
this.contentWrapperNode.classList.add('overlays__overlay-container');
|
|
this.contentWrapperNode.classList.add(placementClass);
|
|
this.contentNode.classList.add('overlays__overlay');
|
|
} else if (phase === 'hide') {
|
|
this.contentWrapperNode.classList.remove('overlays__overlay-container');
|
|
this.contentWrapperNode.classList.remove(placementClass);
|
|
this.contentNode.classList.remove('overlays__overlay');
|
|
}
|
|
} else if (this.placementMode === 'local' && phase === 'show') {
|
|
/**
|
|
* Popper is weird about properly positioning the popper element when it is recreated so
|
|
* we just recreate the popper instance to make it behave like it should.
|
|
* Probably related to this issue: https://github.com/FezVrasta/popper.js/issues/796
|
|
* calling just the .update() function on the popper instance sadly does not resolve this.
|
|
* This is however necessary for initial placement.
|
|
*/
|
|
await this.__createPopperInstance();
|
|
this._popper.forceUpdate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_keepBodySize({ phase }) {
|
|
switch (phase) {
|
|
case 'before-show':
|
|
this.__bodyClientWidth = document.body.clientWidth;
|
|
this.__bodyClientHeight = document.body.clientHeight;
|
|
this.__bodyMarginRightInline = document.body.style.marginRight;
|
|
this.__bodyMarginBottomInline = document.body.style.marginBottom;
|
|
break;
|
|
case 'show': {
|
|
if (window.getComputedStyle) {
|
|
const bodyStyle = window.getComputedStyle(document.body);
|
|
this.__bodyMarginRight = parseInt(bodyStyle.getPropertyValue('margin-right'), 10);
|
|
this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10);
|
|
} else {
|
|
this.__bodyMarginRight = 0;
|
|
this.__bodyMarginBottom = 0;
|
|
}
|
|
const scrollbarWidth =
|
|
document.body.clientWidth - /** @type {number} */ (this.__bodyClientWidth);
|
|
const scrollbarHeight =
|
|
document.body.clientHeight - /** @type {number} */ (this.__bodyClientHeight);
|
|
const newMarginRight = this.__bodyMarginRight + scrollbarWidth;
|
|
const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight;
|
|
// @ts-expect-error [external]: CSS not yet typed
|
|
if (window.CSS?.number && document.body.attributeStyleMap?.set) {
|
|
// @ts-expect-error [external]: types attributeStyleMap + CSS.px not available yet
|
|
document.body.attributeStyleMap.set('margin-right', CSS.px(newMarginRight));
|
|
// @ts-expect-error [external]: types attributeStyleMap + CSS.px not available yet
|
|
document.body.attributeStyleMap.set('margin-bottom', CSS.px(newMarginBottom));
|
|
} else {
|
|
document.body.style.marginRight = `${newMarginRight}px`;
|
|
document.body.style.marginBottom = `${newMarginBottom}px`;
|
|
}
|
|
break;
|
|
}
|
|
case 'hide':
|
|
document.body.style.marginRight = this.__bodyMarginRightInline || '';
|
|
document.body.style.marginBottom = this.__bodyMarginBottomInline || '';
|
|
break;
|
|
/* no default */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @event before-hide right before the overlay hides. Used for animations and switching overlays
|
|
* @event hide right after the overlay is hidden
|
|
*/
|
|
async hide() {
|
|
this._hideComplete = new Promise(resolve => {
|
|
this._hideResolve = resolve;
|
|
});
|
|
|
|
// save the current activeElement so we know if the user set focus to another element than the invoker of the dialog
|
|
// while the dialog was open.
|
|
// We need this in the _restoreFocus method to determine if we should focus this.elementToFocusAfterHide when the
|
|
// dialog is closed or keep focus on the element that the user deliberately gave focus
|
|
this.__activeElementRightBeforeHide = /** @type {ShadowRoot} */ (
|
|
this.contentNode.getRootNode()
|
|
).activeElement;
|
|
|
|
if (this.manager) {
|
|
this.manager.hide(this);
|
|
}
|
|
|
|
if (!this.isShown) {
|
|
/** @type {function} */ (this._hideResolve)();
|
|
return;
|
|
}
|
|
|
|
const event = new CustomEvent('before-hide', { cancelable: true });
|
|
this.dispatchEvent(event);
|
|
if (!event.defaultPrevented) {
|
|
await this._transitionHide({
|
|
backdropNode: this.backdropNode,
|
|
contentNode: this.contentNode,
|
|
});
|
|
|
|
if ('HTMLDialogElement' in window && this.__wrappingDialogNode instanceof HTMLDialogElement) {
|
|
this.__wrappingDialogNode.close();
|
|
}
|
|
|
|
// @ts-ignore
|
|
this.__wrappingDialogNode.style.display = 'none';
|
|
this._handleFeatures({ phase: 'hide' });
|
|
this._keepBodySize({ phase: 'hide' });
|
|
this.dispatchEvent(new Event('hide'));
|
|
this._restoreFocus();
|
|
}
|
|
/** @type {function} */ (this._hideResolve)();
|
|
}
|
|
|
|
/**
|
|
* Method to be overriden by subclassers
|
|
*
|
|
* @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} hideConfig
|
|
*/
|
|
// @ts-ignore
|
|
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
|
|
async transitionHide(hideConfig) {}
|
|
|
|
/**
|
|
* @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} hideConfig
|
|
* @protected
|
|
*/
|
|
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
|
|
async _transitionHide({ backdropNode, contentNode }) {
|
|
// `this.transitionHide` is a hook for our users
|
|
await this.transitionHide({ backdropNode, contentNode });
|
|
this._handlePosition({ phase: 'hide' });
|
|
if (!backdropNode) {
|
|
return;
|
|
}
|
|
backdropNode.classList.remove(`overlays__backdrop--animation-in`);
|
|
}
|
|
|
|
/**
|
|
* To be overridden by subclassers
|
|
*
|
|
* @param {{backdropNode:HTMLElement; contentNode:HTMLElement}} showConfig
|
|
*/
|
|
// @ts-ignore
|
|
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
|
|
async transitionShow(showConfig) {}
|
|
|
|
/**
|
|
* @param {{backdropNode:HTMLElement; contentNode:HTMLElement}} showConfig
|
|
*/
|
|
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
|
|
async _transitionShow(showConfig) {
|
|
// `this.transitionShow` is a hook for our users
|
|
await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode });
|
|
|
|
if (showConfig.backdropNode) {
|
|
showConfig.backdropNode.classList.add(`overlays__backdrop--animation-in`);
|
|
}
|
|
}
|
|
|
|
/** @protected */
|
|
_restoreFocus() {
|
|
// We only are allowed to move focus if we (still) 'own' the active element.
|
|
// Otherwise, we assume the 'outside world' has purposefully taken over
|
|
const weStillOwnActiveElement =
|
|
this.__activeElementRightBeforeHide instanceof HTMLElement &&
|
|
this.contentNode.contains(this.__activeElementRightBeforeHide);
|
|
|
|
if (!weStillOwnActiveElement) {
|
|
return;
|
|
}
|
|
|
|
if (this.elementToFocusAfterHide) {
|
|
this.elementToFocusAfterHide.focus();
|
|
this.elementToFocusAfterHide.scrollIntoView({ block: 'nearest' });
|
|
} else {
|
|
/** @type {HTMLElement} */ (this.__activeElementRightBeforeHide).blur();
|
|
}
|
|
}
|
|
|
|
async toggle() {
|
|
return this.isShown ? this.hide() : this.show();
|
|
}
|
|
|
|
/**
|
|
* All features are handled here.
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleFeatures({ phase }) {
|
|
this._handleZIndex({ phase });
|
|
|
|
if (this.preventsScroll) {
|
|
this._handlePreventsScroll({ phase });
|
|
}
|
|
if (this.isBlocking) {
|
|
this._handleBlocking({ phase });
|
|
}
|
|
if (this.hasBackdrop) {
|
|
this._handleBackdrop({ phase });
|
|
}
|
|
if (this.trapsKeyboardFocus) {
|
|
this._handleTrapsKeyboardFocus({ phase });
|
|
}
|
|
if (this.hidesOnEsc) {
|
|
this._handleHidesOnEsc({ phase });
|
|
}
|
|
if (this.hidesOnOutsideEsc) {
|
|
this._handleHidesOnOutsideEsc({ phase });
|
|
}
|
|
if (this.hidesOnOutsideClick) {
|
|
this._handleHidesOnOutsideClick({ phase });
|
|
}
|
|
if (this.handlesAccessibility) {
|
|
this._handleAccessibility({ phase });
|
|
}
|
|
if (this.inheritsReferenceWidth) {
|
|
this._handleInheritsReferenceWidth();
|
|
}
|
|
if (this.visibilityTriggerFunction) {
|
|
this._handleVisibilityTriggers({ phase });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
*/
|
|
_handleVisibilityTriggers({ phase }) {
|
|
if (typeof this.visibilityTriggerFunction === 'function') {
|
|
if (phase === 'init') {
|
|
this.__visibilityTriggerHandler = this.visibilityTriggerFunction({
|
|
phase,
|
|
controller: this,
|
|
});
|
|
}
|
|
if (this.__visibilityTriggerHandler[phase]) {
|
|
this.__visibilityTriggerHandler[phase]();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handlePreventsScroll({ phase }) {
|
|
switch (phase) {
|
|
case 'show':
|
|
this.manager.requestToPreventScroll();
|
|
break;
|
|
case 'hide':
|
|
this.manager.requestToEnableScroll();
|
|
break;
|
|
/* no default */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleBlocking({ phase }) {
|
|
switch (phase) {
|
|
case 'show':
|
|
this.manager.requestToShowOnly(this);
|
|
break;
|
|
case 'hide':
|
|
this.manager.retractRequestToShowOnly(this);
|
|
break;
|
|
/* no default */
|
|
}
|
|
}
|
|
|
|
get hasActiveBackdrop() {
|
|
return this.__hasActiveBackdrop;
|
|
}
|
|
|
|
/**
|
|
* Sets up backdrop on the given overlay. If there was a backdrop on another element
|
|
* it is removed. Otherwise this is the first time displaying a backdrop, so a animation-in
|
|
* animation is played.
|
|
* @param {{ animation?: boolean, phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleBackdrop({ phase }) {
|
|
// eslint-disable-next-line default-case
|
|
switch (phase) {
|
|
case 'init': {
|
|
if (!this.__backdropInitialized) {
|
|
if (!this.config?.backdropNode) {
|
|
this.__backdropNode = document.createElement('div');
|
|
// If backdropNode existed in config, styles are applied by implementing party
|
|
this.__backdropNode.classList.add(`overlays__backdrop`);
|
|
}
|
|
// @ts-ignore
|
|
this.__wrappingDialogNode.prepend(this.backdropNode);
|
|
this.__backdropInitialized = true;
|
|
}
|
|
break;
|
|
}
|
|
case 'show':
|
|
if (this.config.hasBackdrop) {
|
|
this.backdropNode.classList.add(`overlays__backdrop--visible`);
|
|
}
|
|
this.__hasActiveBackdrop = true;
|
|
break;
|
|
case 'hide':
|
|
case 'teardown':
|
|
this.backdropNode.classList.remove(`overlays__backdrop--visible`);
|
|
this.__hasActiveBackdrop = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
get hasActiveTrapsKeyboardFocus() {
|
|
return this.__hasActiveTrapsKeyboardFocus;
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleTrapsKeyboardFocus({ phase }) {
|
|
if (phase === 'show') {
|
|
// @ts-ignore
|
|
if ('showModal' in this.__wrappingDialogNode) {
|
|
// @ts-ignore
|
|
this.__wrappingDialogNode.close();
|
|
// @ts-ignore
|
|
this.__wrappingDialogNode.showModal();
|
|
}
|
|
// else {
|
|
this.enableTrapsKeyboardFocus();
|
|
// }
|
|
} else if (phase === 'hide' || phase === 'teardown') {
|
|
this.disableTrapsKeyboardFocus();
|
|
}
|
|
}
|
|
|
|
enableTrapsKeyboardFocus() {
|
|
if (this.__hasActiveTrapsKeyboardFocus) {
|
|
return;
|
|
}
|
|
if (this.manager) {
|
|
this.manager.disableTrapsKeyboardFocusForAll();
|
|
}
|
|
this._containFocusHandler = containFocus(this.contentNode);
|
|
this.__hasActiveTrapsKeyboardFocus = true;
|
|
if (this.manager) {
|
|
this.manager.informTrapsKeyboardFocusGotEnabled(this.placementMode);
|
|
}
|
|
}
|
|
|
|
disableTrapsKeyboardFocus({ findNewTrap = true } = {}) {
|
|
if (!this.__hasActiveTrapsKeyboardFocus) {
|
|
return;
|
|
}
|
|
if (this._containFocusHandler) {
|
|
this._containFocusHandler.disconnect();
|
|
this._containFocusHandler = undefined;
|
|
}
|
|
this.__hasActiveTrapsKeyboardFocus = false;
|
|
if (this.manager) {
|
|
this.manager.informTrapsKeyboardFocusGotDisabled({ disabledCtrl: this, findNewTrap });
|
|
}
|
|
}
|
|
|
|
/** @private */
|
|
__escKeyHandler(/** @type {KeyboardEvent} */ ev) {
|
|
return ev.key === 'Escape' && this.hide();
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleHidesOnEsc({ phase }) {
|
|
if (phase === 'show') {
|
|
this.contentNode.addEventListener('keyup', this.__escKeyHandler);
|
|
if (this.invokerNode) {
|
|
this.invokerNode.addEventListener('keyup', this.__escKeyHandler);
|
|
}
|
|
} else if (phase === 'hide') {
|
|
this.contentNode.removeEventListener('keyup', this.__escKeyHandler);
|
|
if (this.invokerNode) {
|
|
this.invokerNode.removeEventListener('keyup', this.__escKeyHandler);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleHidesOnOutsideEsc({ phase }) {
|
|
if (phase === 'show') {
|
|
this.__escKeyHandler = (/** @type {KeyboardEvent} */ ev) =>
|
|
ev.key === 'Escape' && this.hide();
|
|
document.addEventListener('keyup', this.__escKeyHandler);
|
|
} else if (phase === 'hide') {
|
|
document.removeEventListener('keyup', this.__escKeyHandler);
|
|
}
|
|
}
|
|
|
|
/** @protected */
|
|
_handleInheritsReferenceWidth() {
|
|
if (!this._referenceNode || this.placementMode === 'global') {
|
|
return;
|
|
}
|
|
const referenceWidth = `${this._referenceNode.getBoundingClientRect().width}px`;
|
|
switch (this.inheritsReferenceWidth) {
|
|
case 'max':
|
|
this.contentWrapperNode.style.maxWidth = referenceWidth;
|
|
break;
|
|
case 'full':
|
|
this.contentWrapperNode.style.width = referenceWidth;
|
|
break;
|
|
case 'min':
|
|
this.contentWrapperNode.style.minWidth = referenceWidth;
|
|
this.contentWrapperNode.style.width = 'auto';
|
|
break;
|
|
/* no default */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleHidesOnOutsideClick({ phase }) {
|
|
const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener';
|
|
|
|
if (phase === 'show') {
|
|
/**
|
|
* We listen to click (more specifically mouseup and mousedown) events
|
|
* in their capture phase (see our tests about 3rd parties stopping event propagation).
|
|
* We define an outside click as follows:
|
|
* - both mousedown and mouseup occur outside of content or invoker
|
|
*
|
|
* This means we have the following flow:
|
|
* [1]. (optional) mousedown is triggered on content/invoker
|
|
* [2]. mouseup is triggered on document (logic will be scheduled to step 4)
|
|
* [3]. (optional) mouseup is triggered on content/invoker
|
|
* [4]. mouseup logic is executed on document (its logic is inside a timeout and is thus
|
|
* executed after 3)
|
|
* [5]. Reset all helper variables that were considered in step [4]
|
|
*
|
|
*/
|
|
|
|
/** @type {boolean} */
|
|
let wasMouseDownInside = false;
|
|
/** @type {boolean} */
|
|
let wasMouseUpInside = false;
|
|
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
this.__onInsideMouseDown = () => {
|
|
// [1]. was mousedown inside content or invoker
|
|
wasMouseDownInside = true;
|
|
};
|
|
|
|
this.__onInsideMouseUp = () => {
|
|
// [3]. was mouseup inside content or invoker
|
|
wasMouseUpInside = true;
|
|
};
|
|
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
this.__onDocumentMouseUp = () => {
|
|
// [2]. The captured mouseup goes from top of the document to bottom. We add a timeout,
|
|
// so that [3] can be executed before [4]
|
|
setTimeout(() => {
|
|
// [4]. Keep open if step 1 (mousedown) or 3 (mouseup) was inside
|
|
if (!wasMouseDownInside && !wasMouseUpInside) {
|
|
this.hide();
|
|
}
|
|
// [5]. Reset...
|
|
wasMouseDownInside = false;
|
|
wasMouseUpInside = false;
|
|
});
|
|
};
|
|
}
|
|
|
|
this.contentWrapperNode[addOrRemoveListener](
|
|
'mousedown',
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
(this.__onInsideMouseDown),
|
|
true,
|
|
);
|
|
this.contentWrapperNode[addOrRemoveListener](
|
|
'mouseup',
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
(this.__onInsideMouseUp),
|
|
true,
|
|
);
|
|
if (this.invokerNode) {
|
|
// An invoker click (usually resulting in toggle) should be left to a different part of
|
|
// the code
|
|
this.invokerNode[addOrRemoveListener](
|
|
'mousedown',
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
(this.__onInsideMouseDown),
|
|
true,
|
|
);
|
|
this.invokerNode[addOrRemoveListener](
|
|
'mouseup',
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
(this.__onInsideMouseUp),
|
|
true,
|
|
);
|
|
}
|
|
document.documentElement[addOrRemoveListener](
|
|
'mouseup',
|
|
/** @type {EventListenerOrEventListenerObject} */
|
|
(this.__onDocumentMouseUp),
|
|
true,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {{ phase: OverlayPhase }} config
|
|
* @protected
|
|
*/
|
|
_handleAccessibility({ phase }) {
|
|
if (phase === 'init' || phase === 'teardown') {
|
|
this.__setupTeardownAccessibility({ phase });
|
|
}
|
|
if (this.invokerNode && !this.isTooltip) {
|
|
this.invokerNode.setAttribute('aria-expanded', `${phase === 'show'}`);
|
|
}
|
|
}
|
|
|
|
teardown() {
|
|
this.__handleOverlayStyles({ phase: 'teardown' });
|
|
this._handleFeatures({ phase: 'teardown' });
|
|
}
|
|
|
|
/** @private */
|
|
async __createPopperInstance() {
|
|
if (this._popper) {
|
|
this._popper.destroy();
|
|
this._popper = undefined;
|
|
}
|
|
|
|
if (OverlayController.popperModule !== undefined) {
|
|
const { createPopper } = await OverlayController.popperModule;
|
|
this._popper = createPopper(this._referenceNode, this.contentWrapperNode, {
|
|
...this.config?.popperConfig,
|
|
});
|
|
}
|
|
}
|
|
|
|
_hasDisabledInvoker() {
|
|
if (this.invokerNode) {
|
|
return (
|
|
/** @type {HTMLElement & { disabled: boolean }} */ (this.invokerNode).disabled ||
|
|
this.invokerNode.getAttribute('aria-disabled') === 'true'
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
/** @type {Promise<PopperModule> | undefined} */
|
|
OverlayController.popperModule = undefined;
|