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: ``
* - contentWrapperNodeL2: `
`
* - contentNodeL3: ``
* To:
* ```html
*
* ```
*
* `` belonging to `` will be wrapped with wrappingDialogNodeL1 and contentWrapperNodeL2
* inside shadow dom. With the help of temp markers, ``'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 ) 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:
*
*
*
*
* ```
*
* To:
* ```html
* #shadow-root:
*
* ```
*/
parentElement.insertBefore(wrappingDialogNodeL1, tempMarker);
parentElement?.removeChild(tempMarker);
}
/**
* @returns {Promise}
*/
async function preloadPopper() {
// @ts-ignore [external]: import complains about untyped module, but we typecast it ourselves
return /** @type {* & Promise} */ (import('@popperjs/core/dist/esm/popper.js'));
}
// @ts-expect-error [external]: CSS not yet typed
const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyleMap?.set;
/**
* 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