`;
}
}
diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js
index 963a268f8..200abbe83 100644
--- a/packages/overlays/src/OverlayController.js
+++ b/packages/overlays/src/OverlayController.js
@@ -10,6 +10,66 @@ async function preloadPopper() {
const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container';
const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay';
+/**
+ * @desc 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.
+ *
+ * ### About contentNode, contentWrapperNode and renderTarget.
+ *
+ * #### contentNode
+ * Node containing actual overlay contents.
+ * It will not be touched by the OverlayController, it will only set attributes needed
+ * for accessibility.
+ *
+ * #### contentWrapperNode
+ * The 'positioning' element.
+ * For local overlays, this node will be provided to Popper and all
+ * inline positioning styles will be added here. It will also act as the container of an arrow
+ * element (the arrow needs to be a sibling of contentNode for Popper to work correctly).
+ * When projecting a contentNode from a shadowRoot, it is essential to have the wrapper in
+ * shadow dom, so that contentNode can be styled via `::slotted` from the shadow root.
+ * The Popper arrow can then be styled from that same shadow root as well.
+ * For global overlays, the contentWrapperNode will be appended to the globalRootNode structure.
+ *
+ * #### renderTarget
+ * Usually the parent node of contentWrapperNode that either exists locally or globally.
+ * When a responsive scenario is created (in which we switch from global to local or vice versa)
+ * we need to know where we should reappend contentWrapperNode (or contentNode in case it's projected)
+ *
+ * So a regular flow can be summarized as follows:
+ * 1. Application Developer spawns an OverlayController with a contentNode reference
+ * 2. OverlayController will create a contentWrapperNode around contentNode (or consumes when provided)
+ * 3. contentWrapperNode will be appended to the right renderTarget
+ *
+ * There are subtle differences depending on the following factors:
+ * - whether in global/local placement mode
+ * - whether contentNode projected
+ * - whether an arrow is provided
+ *
+ * This leads to the following possible combinations:
+ * - [l1]. local + no content projection + no arrow
+ * - [l2]. local + content projection + no arrow
+ * - [l3]. local + no content projection + arrow
+ * - [l4]. local + content projection + arrow
+ * - [g1]. global
+ *
+ * #### html structure for a content projected node
+ *
+ *
+ *
+ *
+ *
+ * Structure above depicts [l4]
+ * So in case of [l1] and [l3], the element would be a regular element
+ * In case of [l1] and [l2], there would be no arrow.
+ * Note that a contentWrapperNode should be provided for [l2], [l3] and [l4]
+ * In case of a global overlay ([g1]), it's enough to provide just the contentNode.
+ * In case of a local overlay or a responsive overlay switching from placementMode, one should
+ * always configure as if it was a local overlay.
+ *
+ */
+
export class OverlayController {
/**
* @constructor
@@ -20,14 +80,17 @@ export class OverlayController {
this.__fakeExtendsEventTarget();
this.manager = manager;
this.__sharedConfig = config;
+
+ /** @type {OverlayConfig} */
this._defaultConfig = {
placementMode: null,
contentNode: config.contentNode,
+ contentWrapperNode: config.contentWrapperNode,
invokerNode: config.invokerNode,
backdropNode: config.backdropNode,
referenceNode: null,
elementToFocusAfterHide: config.invokerNode,
- inheritsReferenceWidth: '',
+ inheritsReferenceWidth: 'none',
hasBackdrop: false,
isBlocking: false,
preventsScroll: false,
@@ -69,12 +132,12 @@ export class OverlayController {
};
this.manager.add(this);
-
- this._contentNodeWrapper = document.createElement('div');
this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`;
+ if (this._defaultConfig.contentNode) {
+ this.__isContentNodeProjected = Boolean(this._defaultConfig.contentNode.assignedSlot);
+ }
this.updateConfig(config);
-
this.__hasActiveTrapsKeyboardFocus = false;
this.__hasActiveBackdrop = true;
}
@@ -84,31 +147,40 @@ export class OverlayController {
}
get content() {
- return this._contentNodeWrapper;
+ return this._contentWrapperNode;
}
/**
- * @desc The element ._contentNodeWrapper will be appended to.
- * If viewportConfig is configured, this will be OverlayManager.globalRootNode
- * If popperConfig is configured, this will be a sibling node of invokerNode
+ * @desc Usually the parent node of contentWrapperNode that either exists locally or globally.
+ * When a responsive scenario is created (in which we switch from global to local or vice versa)
+ * we need to know where we should reappend contentWrapperNode (or contentNode in case it's
+ * projected)
+ * @type {HTMLElement}
*/
get _renderTarget() {
+ /** config [g1] */
if (this.placementMode === 'global') {
return this.manager.globalRootNode;
}
+ /** config [l2] or [l4] */
+ if (this.__isContentNodeProjected) {
+ return this.__originalContentParent.getRootNode().host;
+ }
+ /** config [l1] or [l3] */
return this.__originalContentParent;
}
/**
* @desc The element our local overlay will be positioned relative to.
+ * @type {HTMLElement}
*/
get _referenceNode() {
return this.referenceNode || this.invokerNode;
}
set elevation(value) {
- if (this._contentNodeWrapper) {
- this._contentNodeWrapper.style.zIndex = value;
+ if (this._contentWrapperNode) {
+ this._contentWrapperNode.style.zIndex = value;
}
if (this.backdropNode) {
this.backdropNode.style.zIndex = value;
@@ -116,7 +188,7 @@ export class OverlayController {
}
get elevation() {
- return this._contentNodeWrapper.zIndex;
+ return this._contentWrapperNode.zIndex;
}
/**
@@ -124,16 +196,12 @@ export class OverlayController {
* 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
+ * @param { OverlayConfig } cfgToAdd
*/
updateConfig(cfgToAdd) {
// Teardown all previous configs
this._handleFeatures({ phase: 'teardown' });
- if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) {
- // We need to keep track of the original local context.
- this.__originalContentParent = cfgToAdd.contentNode.parentElement;
- }
this.__prevConfig = this.config || {};
this.config = {
@@ -172,18 +240,23 @@ export class OverlayController {
if (!newConfig.contentNode) {
throw new Error('You need to provide a .contentNode');
}
+ if (this.__isContentNodeProjected && !newConfig.contentWrapperNode) {
+ throw new Error('You need to provide a .contentWrapperNode when .contentNode is projected');
+ }
+ // if (newConfig.popperConfig.modifiers.arrow && !newConfig.contentWrapperNode) {
+ // throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled');
+ // }
}
async _init({ cfgToAdd }) {
- this.__initContentNodeWrapper();
+ this.__initcontentWrapperNode({ cfgToAdd });
this.__initConnectionTarget();
if (this.handlesAccessibility) {
this.__initAccessibility({ cfgToAdd });
}
if (this.placementMode === 'local') {
- // Now, it is time to lazily load Popper if not done yet
- // Do we really want to add display: inline or is this up to user?
+ // Lazily load Popper if not done yet
if (!this.constructor.popperModule) {
this.constructor.popperModule = preloadPopper();
}
@@ -192,34 +265,56 @@ export class OverlayController {
}
__initConnectionTarget() {
- // Now, add our node to the right place in dom (rendeTarget)
- if (this.contentNode !== this.__prevConfig.contentNode) {
- this._contentNodeWrapper.appendChild(this.contentNode);
+ // Now, add our node to the right place in dom (renderTarget)
+ if (this._contentWrapperNode !== this.__prevConfig._contentWrapperNode) {
+ if (this.config.placementMode === 'global' || !this.__isContentNodeProjected) {
+ this._contentWrapperNode.appendChild(this.contentNode);
+ }
}
- if (this._renderTarget && this._renderTarget !== this._contentNodeWrapper.parentNode) {
- this._renderTarget.appendChild(this._contentNodeWrapper);
+
+ if (this.__isContentNodeProjected && this.placementMode === 'local') {
+ // We add the contentNode in its slot, so that it will be projected by contentWrapperNode
+ this._renderTarget.appendChild(this.contentNode);
+ } else {
+ const isInsideRenderTarget = this._renderTarget === this._contentWrapperNode.parentNode;
+ if (!isInsideRenderTarget) {
+ // contentWrapperNode becomes the direct (non projected) parent of contentNode
+ this._renderTarget.appendChild(this._contentWrapperNode);
+ }
}
}
/**
- * @desc Cleanup ._contentNodeWrapper. We do this, because creating a fresh wrapper
+ * @desc Cleanup ._contentWrapperNode. We do this, because creating a fresh wrapper
* can lead to problems with event listeners...
*/
- __initContentNodeWrapper() {
- Array.from(this._contentNodeWrapper.attributes).forEach(attrObj => {
- this._contentNodeWrapper.removeAttribute(attrObj.name);
- });
- this._contentNodeWrapper.style.cssText = null;
- this._contentNodeWrapper.style.display = 'none';
+ __initcontentWrapperNode({ cfgToAdd }) {
+ if (this.config.contentWrapperNode && this.placementMode === 'local') {
+ /** config [l2],[l3],[l4] */
+ this._contentWrapperNode = this.config.contentWrapperNode;
+ } else {
+ /** config [l1],[g1] */
+ this._contentWrapperNode = document.createElement('div');
+ }
- // Make sure that your shadow dom contains this outlet, when we are adding to light dom
- this._contentNodeWrapper.slot = '_overlay-shadow-outlet';
+ this._contentWrapperNode.style.cssText = null;
+ this._contentWrapperNode.style.display = 'none';
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';
}
+
+ if (this.__isContentNodeProjected && this._contentWrapperNode.isConnected) {
+ // We need to keep track of the original local context.
+ /** config [l2], [l4] */
+ this.__originalContentParent = this._contentWrapperNode.parentNode;
+ } else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) {
+ // We need to keep track of the original local context.
+ /** config [l1], [l3], [g1] */
+ this.__originalContentParent = this.contentNode.parentNode;
+ }
}
/**
@@ -233,7 +328,7 @@ export class OverlayController {
if (phase === 'setup') {
const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex);
if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) {
- this._contentNodeWrapper.style.zIndex = 1;
+ this._contentWrapperNode.style.zIndex = 1;
}
}
}
@@ -262,7 +357,7 @@ export class OverlayController {
}
get isShown() {
- return Boolean(this._contentNodeWrapper.style.display !== 'none');
+ return Boolean(this._contentWrapperNode.style.display !== 'none');
}
/**
@@ -282,7 +377,7 @@ export class OverlayController {
const event = new CustomEvent('before-show', { cancelable: true });
this.dispatchEvent(event);
if (!event.defaultPrevented) {
- this._contentNodeWrapper.style.display = this.placementMode === 'local' ? 'inline-block' : '';
+ this._contentWrapperNode.style.display = '';
await this._handleFeatures({ phase: 'show' });
await this._handlePosition({ phase: 'show' });
this.elementToFocusAfterHide = elementToFocusAfterHide;
@@ -294,8 +389,8 @@ export class OverlayController {
if (this.placementMode === 'global') {
const addOrRemove = phase === 'show' ? 'add' : 'remove';
const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`;
- this._contentNodeWrapper.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
- this._contentNodeWrapper.classList[addOrRemove](placementClass);
+ this._contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
+ this._contentWrapperNode.classList[addOrRemove](placementClass);
this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS);
} else if (this.placementMode === 'local' && phase === 'show') {
/**
@@ -327,7 +422,7 @@ export class OverlayController {
this.dispatchEvent(event);
if (!event.defaultPrevented) {
// await this.transitionHide({ backdropNode: this.backdropNode, conentNode: this.contentNode });
- this._contentNodeWrapper.style.display = 'none';
+ this._contentWrapperNode.style.display = 'none';
this._handleFeatures({ phase: 'hide' });
this.dispatchEvent(new Event('hide'));
this._restoreFocus();
@@ -340,7 +435,7 @@ export class OverlayController {
_restoreFocus() {
// We only are allowed to move focus if we (still) 'own' it.
// Otherwise we assume the 'outside world' has, purposefully, taken over
- // if (this._contentNodeWrapper.activeElement) {
+ // if (this._contentWrapperNode.activeElement) {
if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus();
}
@@ -431,10 +526,7 @@ export class OverlayController {
this.backdropNode.classList.add('local-overlays__backdrop');
}
this.backdropNode.slot = '_overlay-shadow-outlet';
- this._contentNodeWrapper.parentElement.insertBefore(
- this.backdropNode,
- this._contentNodeWrapper,
- );
+ this.contentNode.parentNode.insertBefore(this.backdropNode, this.contentNode);
break;
case 'show':
this.__hasActiveBackdrop = true;
@@ -460,10 +552,9 @@ export class OverlayController {
case 'init':
this.backdropNode = document.createElement('div');
this.backdropNode.classList.add('global-overlays__backdrop');
- this.backdropNode.slot = '_overlay-shadow-outlet';
- this._contentNodeWrapper.parentElement.insertBefore(
+ this._contentWrapperNode.parentElement.insertBefore(
this.backdropNode,
- this._contentNodeWrapper,
+ this._contentWrapperNode,
);
break;
case 'show':
@@ -577,21 +668,21 @@ export class OverlayController {
}
_handleInheritsReferenceWidth() {
- if (!this._referenceNode) {
+ if (!this._referenceNode || this.placementMode === 'global') {
return;
}
const referenceWidth = `${this._referenceNode.clientWidth}px`;
switch (this.inheritsReferenceWidth) {
case 'max':
- this._contentNodeWrapper.style.maxWidth = referenceWidth;
+ this._contentWrapperNode.style.maxWidth = referenceWidth;
break;
case 'full':
- this._contentNodeWrapper.style.width = referenceWidth;
+ this._contentWrapperNode.style.width = referenceWidth;
break;
case 'min':
- this._contentNodeWrapper.style.minWidth = referenceWidth;
- this._contentNodeWrapper.style.width = 'auto';
+ this._contentWrapperNode.style.minWidth = referenceWidth;
+ this._contentWrapperNode.style.width = 'auto';
break;
/* no default */
}
@@ -619,7 +710,7 @@ export class OverlayController {
};
}
- this._contentNodeWrapper[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true);
+ this._contentWrapperNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true);
if (this.invokerNode) {
this.invokerNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true);
}
@@ -634,9 +725,18 @@ export class OverlayController {
teardown() {
this._handleFeatures({ phase: 'teardown' });
- // IE11 compatibility (does not support `Node.remove()`)
- if (this._contentNodeWrapper && this._contentNodeWrapper.parentElement) {
- this._contentNodeWrapper.parentElement.removeChild(this._contentNodeWrapper);
+
+ // Remove the content node wrapper from the global rootnode
+ this._teardowncontentWrapperNode();
+ }
+
+ _teardowncontentWrapperNode() {
+ if (
+ this.placementMode === 'global' &&
+ this._contentWrapperNode &&
+ this._contentWrapperNode.parentNode
+ ) {
+ this._contentWrapperNode.parentNode.removeChild(this._contentWrapperNode);
}
}
@@ -646,7 +746,7 @@ export class OverlayController {
this._popper = null;
}
const { default: Popper } = await this.constructor.popperModule;
- this._popper = new Popper(this._referenceNode, this._contentNodeWrapper, {
+ this._popper = new Popper(this._referenceNode, this._contentWrapperNode, {
...this.config.popperConfig,
});
}
diff --git a/packages/overlays/src/OverlayMixin.js b/packages/overlays/src/OverlayMixin.js
index 163127b8f..c8cae95dd 100644
--- a/packages/overlays/src/OverlayMixin.js
+++ b/packages/overlays/src/OverlayMixin.js
@@ -3,7 +3,7 @@ import { OverlayController } from './OverlayController.js';
/**
* @type {Function()}
- * @polymerMixin
+ * @polymerMixinOverlayMixin
* @mixinFunction
*/
export const OverlayMixin = dedupeMixin(
@@ -23,6 +23,10 @@ export const OverlayMixin = dedupeMixin(
super();
this.opened = false;
this.config = {};
+
+ this._overlaySetupComplete = new Promise(resolve => {
+ this.__overlaySetupCompleteResolve = resolve;
+ });
}
get config() {
@@ -50,11 +54,12 @@ export const OverlayMixin = dedupeMixin(
* @returns {OverlayController}
*/
// eslint-disable-next-line
- _defineOverlay({ contentNode, invokerNode, backdropNode }) {
+ _defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
return new OverlayController({
contentNode,
invokerNode,
backdropNode,
+ contentWrapperNode,
...this._defineOverlayConfig(), // wc provided in the class as defaults
...this.config, // user provided (e.g. in template)
popperConfig: {
@@ -132,11 +137,20 @@ export const OverlayMixin = dedupeMixin(
if (super.connectedCallback) {
super.connectedCallback();
}
- this._overlaySetupComplete = new Promise(resolve => {
- this.__overlaySetupCompleteResolve = resolve;
+
+ // Wait for DOM to be ready before setting up the overlay, else extensions like rich select breaks
+ this.updateComplete.then(() => {
+ if (!this.__isOverlaySetup) {
+ this._setupOverlayCtrl();
+ }
});
- // Wait for DOM to be ready before setting up the overlay
- this.updateComplete.then(() => this._setupOverlayCtrl());
+
+ // When dom nodes are being moved around (meaning connected/disconnected are being fired
+ // repeatedly), we need to delay the teardown until we find a 'permanent disconnect'
+ if (this.__rejectOverlayDisconnectComplete) {
+ // makes sure _overlayDisconnectComplete never resolves: we don't want a teardown
+ this.__rejectOverlayDisconnectComplete();
+ }
}
disconnectedCallback() {
@@ -144,11 +158,26 @@ export const OverlayMixin = dedupeMixin(
super.disconnectedCallback();
}
+ this._overlayDisconnectComplete = new Promise((resolve, reject) => {
+ this.__resolveOverlayDisconnectComplete = resolve;
+ this.__rejectOverlayDisconnectComplete = reject;
+ });
+
+ setTimeout(() => {
+ // we start the teardown below
+ this.__resolveOverlayDisconnectComplete();
+ });
+
if (this._overlayCtrl) {
- this.__tornDown = true;
- this.__overlayContentNodeWrapperBeforeTeardown = this._overlayContentNodeWrapper;
+ // We need to prevent that we create a setup/teardown cycle during startup, where it
+ // is common that the overlay system moves around nodes. Therefore, we make the
+ // teardown async, so that it only happens when we are permanently disconnecting from dom
+ this._overlayDisconnectComplete
+ .then(() => {
+ this._teardownOverlayCtrl();
+ })
+ .catch(() => {});
}
- this._teardownOverlayCtrl();
}
get _overlayInvokerNode() {
@@ -160,22 +189,7 @@ export const OverlayMixin = dedupeMixin(
}
get _overlayContentNode() {
- if (this._cachedOverlayContentNode) {
- return this._cachedOverlayContentNode;
- }
-
- // (@jorenbroekema) This should shadow outlet in between the host and the content slot,
- // is a problem.
- // Should simply be Array.from(this.children).find(child => child.slot === 'content')
- // Issue: https://github.com/ing-bank/lion/issues/382
- const shadowOutlet = Array.from(this.children).find(
- child => child.slot === '_overlay-shadow-outlet',
- );
- if (shadowOutlet) {
- this._cachedOverlayContentNode = Array.from(shadowOutlet.children).find(
- child => child.slot === 'content',
- );
- } else {
+ if (!this._cachedOverlayContentNode) {
this._cachedOverlayContentNode = Array.from(this.children).find(
child => child.slot === 'content',
);
@@ -183,20 +197,14 @@ export const OverlayMixin = dedupeMixin(
return this._cachedOverlayContentNode;
}
- get _overlayContentNodeWrapper() {
- return this._overlayContentNode.parentElement;
+ get _overlayContentWrapperNode() {
+ return this.shadowRoot.querySelector('#overlay-content-node-wrapper');
}
_setupOverlayCtrl() {
- // When we reconnect, this is for recovering from disconnectedCallback --> teardown which removes the
- // the content node wrapper contents (which is necessary for global overlays to remove them from bottom of body)
- if (this.__tornDown) {
- this.__reappendContentNodeWrapperNodes();
- this.__tornDown = false;
- }
-
this._overlayCtrl = this._defineOverlay({
contentNode: this._overlayContentNode,
+ contentWrapperNode: this._overlayContentWrapperNode,
invokerNode: this._overlayInvokerNode,
backdropNode: this._overlayBackdropNode,
});
@@ -204,17 +212,22 @@ export const OverlayMixin = dedupeMixin(
this.__setupSyncFromOverlayController();
this._setupOpenCloseListeners();
this.__overlaySetupCompleteResolve();
+ this.__isOverlaySetup = true;
}
- async _teardownOverlayCtrl() {
- if (this._overlayCtrl) {
- this.__teardownSyncFromOverlayController();
- this._overlayCtrl.teardown();
- }
- await this.updateComplete;
+ _teardownOverlayCtrl() {
this._teardownOpenCloseListeners();
+ this.__teardownSyncFromOverlayController();
+ this._overlayCtrl.teardown();
+ this.__isOverlaySetup = false;
}
+ /**
+ * When the opened state is changed by an Application Developer,cthe OverlayController is
+ * requested to show/hide. It might happen that this request is not honoured
+ * (intercepted in before-hide for instance), so that we need to sync the controller state
+ * to this webcomponent again, preventing eternal loops.
+ */
async _setOpenedWithoutPropertyEffects(newOpened) {
this.__blockSyncToOverlayCtrl = true;
this.opened = newOpened;
@@ -271,13 +284,5 @@ export const OverlayMixin = dedupeMixin(
this._overlayCtrl.hide();
}
}
-
- // TODO: Simplify this logic of tearing down / reappending overlay content node wrapper
- // after we have moved this wrapper to ShadowDOM.
- __reappendContentNodeWrapperNodes() {
- Array.from(this.__overlayContentNodeWrapperBeforeTeardown.children).forEach(child => {
- this.appendChild(child);
- });
- }
},
);
diff --git a/packages/overlays/src/utils/typedef.js b/packages/overlays/src/utils/typedef.js
index b9072bf29..38a3a2990 100644
--- a/packages/overlays/src/utils/typedef.js
+++ b/packages/overlays/src/utils/typedef.js
@@ -1,32 +1,50 @@
/**
* @typedef {object} OverlayConfig
- * @property {HTMLElement} [elementToFocusAfterHide=document.body] - the element that should be
+ * @property {HTMLElement} [elementToFocusAfterHide=document.body] the element that should be
* called `.focus()` on after dialog closes
- * @property {boolean} [hasBackdrop=false] - whether it should have a backdrop (currently
+ * @property {boolean} [hasBackdrop=false] whether it should have a backdrop (currently
* exclusive to globalOverlayController)
- * @property {boolean} [isBlocking=false] - hides other overlays when mutiple are opened
+ * @property {boolean} [isBlocking=false] hides other overlays when mutiple are opened
* (currently exclusive to globalOverlayController)
- * @property {boolean} [preventsScroll=false] - prevents scrolling body content when overlay
+ * @property {boolean} [preventsScroll=false] prevents scrolling body content when overlay
* opened (currently exclusive to globalOverlayController)
- * @property {boolean} [trapsKeyboardFocus=false] - rotates tab, implicitly set when 'isModal'
- * @property {boolean} [hidesOnEsc=false] - hides the overlay when pressing [ esc ]
- * @property {boolean} [hidesOnOutsideClick=false] - hides the overlay when clicking next to it,
+ * @property {boolean} [trapsKeyboardFocus=false] rotates tab, implicitly set when 'isModal'
+ * @property {boolean} [hidesOnEsc=false] hides the overlay when pressing [ esc ]
+ * @property {boolean} [hidesOnOutsideClick=false] hides the overlay when clicking next to it,
* exluding invoker. (currently exclusive to localOverlayController)
* https://github.com/ing-bank/lion/pull/61
- * @property {HTMLElement} invokerNode
- * @property {HTMLElement} contentNode
- * @property {boolean} [isModal=false] - sets aria-modal and/or aria-hidden="true" on siblings
- * @property {boolean} [isGlobal=false] - determines the connection point in DOM (body vs next
- * to invoker). This is what other libraries often refer to as 'portal'.
- * @property {boolean} [isTooltip=false] - has a totally different interaction- and accessibility pattern from all other overlays, so needed for internals.
- * @property {boolean} [handlesUserInteraction] - sets toggle on click, or hover when `isTooltip`
- * @property {boolean} [handlesAccessibility] -
- * - For non `isTooltip`:
+ * @property {'max'|'full'|'min'|'none'} [inheritsReferenceWidth='none'] 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.
+ * @property {HTMLElement} invokerNode the interactive element (usually a button) invoking the
+ * dialog or tooltip
+ * @property {HTMLElement} [referenceNode] the element that is used to position the overlay content
+ * relative to. Usually, this is the same element as invokerNode. Should only be provided whne
+ * @property {HTMLElement} contentNode the most important element: the overlay itself.
+ * @property {HTMLElement} [contentWrapperNode] 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.
+ * @property {HTMLElement} [backdropNode] the element that is placed behin the contentNode. When
+ * not provided and `hasBackdrop` is true, a backdropNode will be automatically created
+ * @property {'global'|'local'} placementMode determines the connection point in DOM (body vs next
+ * to invoker).
+ * @property {boolean} [isTooltip=false] has a totally different interaction- and accessibility
+ * pattern from all other overlays. Will behave as role="tooltip" element instead of a role="dialog"
+ * element.
+ * @property {boolean} [handlesAccessibility]
+ * 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`:
+ *
+ * For `isTooltip`:
* - sets role="tooltip" and aria-labelledby/aria-describedby on the content
- * @property {PopperConfig} popperConfig
+ * @property {object} popperConfig popper configuration. Will be used when placementMode is 'local'
+ * @property {object} viewportConfig viewport configuration. Will be used when placementMode is
+ * 'global'
*/
diff --git a/packages/overlays/stories/20-index.stories.mdx b/packages/overlays/stories/20-index.stories.mdx
index 5ccba7414..2dff7aede 100644
--- a/packages/overlays/stories/20-index.stories.mdx
+++ b/packages/overlays/stories/20-index.stories.mdx
@@ -123,10 +123,9 @@ or in your Web Component with `OverlayMixin`, make sure you override these metho
- Define configuration
- Handle setting up event listeners of toggling the opened state of your overlay
- Handle the tearing down of those event listeners
-- Define a template which includes
+- Define a template which includes:
- invoker slot for your user to provide the invoker node (the element that invokes the overlay content)
- content slot for your user to provide the content that shows when the overlay is opened
- - _overlay-shadow-outlet, this slot is currently necessary under the hood for acting as a wrapper element for placement purposes, but is not something your end user should be concerned with, unless they are extending your component.
```js
_defineOverlayConfig() {
@@ -157,8 +156,10 @@ _teardownOpenCloseListeners() {
render() {
return html`
-
+
+
+
`;
}
```
@@ -296,7 +297,7 @@ Below is another demo where you can toggle between configurations using buttons.
Dropdown
-
+
Hello! You can close this notification here:
@@ -327,7 +328,7 @@ Change config to:
Dropdown
-
+
Hello! You can close this notification here:
@@ -507,8 +508,10 @@ class MyOverlayWC extends OverlayMixin(LitElement) {
render() {
return html`
-
+
+
+
`;
}
}
@@ -742,3 +745,38 @@ Another way to add custom backdrop is declaratively add an element with `slot="b
```
+
+
+### Nested Overlays
+
+Overlays can be nested, as the demo below shows.
+It's also possible to compose a nested construction by moving around dom nodes.
+
+
+
+ {html`
+
+
@@ -172,32 +209,57 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
// the node that was removed in the teardown but hasn't been garbage collected due to reference to it still existing..
// Find the outlets that are not backdrop outlets
- const outletsInGlobalRootNode = Array.from(overlays.globalRootNode.children).filter(
- child =>
- child.slot === '_overlay-shadow-outlet' &&
- !child.classList.contains('global-overlays__backdrop'),
- );
-
- // Check the last one, which is the most nested one
- const lastContentNodeInContainer =
- outletsInGlobalRootNode[outletsInGlobalRootNode.length - 1];
- expect(outletsInGlobalRootNode.length).to.equal(2);
-
- // Check that it indeed has the intended content
+ const overlayContainerNodes = getGlobalOverlayNodes();
+ expect(overlayContainerNodes.length).to.equal(2);
+ const lastContentNodeInContainer = overlayContainerNodes[0];
+ // Check that the last container is the nested one with the intended content
expect(lastContentNodeInContainer.firstElementChild.innerText).to.equal(
'content of the nested overlay',
);
expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content');
} else {
- const actualNestedOverlay = mainEl._overlayContentNode.firstElementChild;
- const outletNode = Array.from(actualNestedOverlay.children).find(
- child => child.slot === '_overlay-shadow-outlet',
- );
- const contentNode = Array.from(outletNode.children).find(child => child.slot === 'content');
-
- expect(contentNode).to.not.be.undefined;
+ const contentNode = mainEl._overlayContentNode.querySelector('#nestedContent');
+ expect(contentNode).to.not.be.null;
expect(contentNode.innerText).to.equal('content of the nested overlay');
}
});
+
+ it("doesn't tear down controller when dom nodes are being moved around", async () => {
+ const nestedEl = await fixture(html`
+ <${tag} id="nest">
+
`),
});
expect(ctrl.contentNode.textContent).to.include('content2');
});
it('respects the initial config provided to new OverlayController(initialConfig)', async () => {
- const contentNode = fixtureSync(html`
my content
`);
+ const contentNode = fixtureSync(html`
my content
`);
const ctrl = new OverlayController({
// This is the shared config
@@ -972,7 +1018,7 @@ describe('OverlayController', () => {
// Currently not working, enable again when we fix updateConfig
it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => {
- const contentNode = fixtureSync(html`
my content
`);
+ const contentNode = fixtureSync(html`
my content
`);
const ctrl = new OverlayController({
// This is the shared config
@@ -983,13 +1029,13 @@ describe('OverlayController', () => {
ctrl.show();
expect(
- ctrl._contentNodeWrapper.classList.contains('global-overlays__overlay-container--center'),
+ ctrl._contentWrapperNode.classList.contains('global-overlays__overlay-container--center'),
);
expect(ctrl.isShown).to.be.true;
ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } });
expect(
- ctrl._contentNodeWrapper.classList.contains(
+ ctrl._contentWrapperNode.classList.contains(
'global-overlays__overlay-container--top-right',
),
);
@@ -1186,5 +1232,26 @@ describe('OverlayController', () => {
});
}).to.throw('You need to provide a .contentNode');
});
+
+ it('throws if contentNodewrapper is not provided for projected contentNode', async () => {
+ const shadowHost = document.createElement('div');
+ shadowHost.attachShadow({ mode: 'open' });
+ shadowHost.shadowRoot.innerHTML = `
+
+
+
+
+ `;
+ const contentNode = document.createElement('div');
+ contentNode.slot = 'contentNode';
+ shadowHost.appendChild(contentNode);
+
+ expect(() => {
+ new OverlayController({
+ ...withLocalTestConfig(),
+ contentNode,
+ });
+ }).to.throw('You need to provide a .contentWrapperNode when .contentNode is projected');
+ });
});
});
diff --git a/packages/overlays/test/OverlayMixin.test.js b/packages/overlays/test/OverlayMixin.test.js
index 55e675cb2..ca4955405 100644
--- a/packages/overlays/test/OverlayMixin.test.js
+++ b/packages/overlays/test/OverlayMixin.test.js
@@ -1,12 +1,26 @@
import { defineCE, unsafeStatic } from '@open-wc/testing';
-import { LitElement } from '@lion/core';
+import { LitElement, html } from '@lion/core';
import { runOverlayMixinSuite } from '../test-suites/OverlayMixin.suite.js';
import { OverlayMixin } from '../src/OverlayMixin.js';
-const tagString = defineCE(class extends OverlayMixin(LitElement) {});
+const tagString = defineCE(
+ class extends OverlayMixin(LitElement) {
+ render() {
+ return html`
+
+
+
`),
@@ -54,7 +54,7 @@ describe('Local Positioning', () => {
it('uses top as the default placement', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
- contentNode: fixtureSync(html``),
+ contentNode: fixtureSync(html` `),
invokerNode: fixtureSync(html`
ctrl.show()}>
`),
@@ -71,7 +71,7 @@ describe('Local Positioning', () => {
it('positions to preferred place if placement is set and space is available', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
- contentNode: fixtureSync(html``),
+ contentNode: fixtureSync(html` `),
invokerNode: fixtureSync(html`
ctrl.show()}>
`),
@@ -92,7 +92,7 @@ describe('Local Positioning', () => {
it('positions to different place if placement is set and no space is available', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
- contentNode: fixtureSync(html`
invoker
`),
+ contentNode: fixtureSync(html`
invoker
`),
invokerNode: fixtureSync(html`
ctrl.show()}>
content
@@ -115,7 +115,7 @@ describe('Local Positioning', () => {
it('allows the user to override default Popper modifiers', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
- contentNode: fixtureSync(html``),
+ contentNode: fixtureSync(html` `),
invokerNode: fixtureSync(html`
ctrl.show()}>
`),
@@ -148,7 +148,7 @@ describe('Local Positioning', () => {
it('positions the Popper element correctly on show', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
- contentNode: fixtureSync(html``),
+ contentNode: fixtureSync(html` `),
invokerNode: fixtureSync(html`
@@ -178,44 +172,40 @@ Modifier explanations:
- flip: enables flipping behavior on the primary axis (e.g. if top placement, flipping to bottom if there is not enough space on the top). The padding property defines the margin with the boundariesElement, which is usually the viewport.
- offset: enables an offset between the content node and the invoker node. First argument is horizontal marign, second argument is vertical margin.
-
### Arrow
-Popper also comes with an arrow modifier.
-In our tooltip you can pass an arrow element (e.g. an SVG Element) through the `slot="arrow"`.
+By default, the arrow is disabled for our tooltip. Via the `has-arrow` property it can be enabled.
-We export a `lion-tooltip-arrow` that you can use by default for this.
+> As a Subclasser, you can decide to turn the arrow on by default if this fits in your Design System
{html`
-
+
This is a tooltip
-
`}
```html
-
+
This is a tooltip
-
```
#### Use a custom arrow
-If you plan on passing your own arrow element, you can extend the `lion-tooltip-arrow`.
+If you plan on providing a custom arrow, you can extend the `lion-tooltip`.
-All you need to do is override the `render` method to pass your own SVG, and extend the styles to pass the proper dimensions of your arrow.
+All you need to do is override the `_arrowTemplate` method to pass your own SVG, and extend the styles to pass the proper dimensions of your arrow.
The rest of the work is done by Popper.js (for positioning) and the `lion-tooltip-arrow` (arrow dimensions, rotation, etc.).
{() => {
- if (!customElements.get('custom-tooltip-arrow')) {
- customElements.define('custom-tooltip-arrow', class extends LionTooltipArrow {
+ if (!customElements.get('custom-tooltip')) {
+ customElements.define('custom-tooltip', class extends LionTooltip {
static get styles() {
return [super.styles, css`
:host {
@@ -224,7 +214,11 @@ The rest of the work is done by Popper.js (for positioning) and the `lion-toolti
}
`];
}
- render() {
+ constructor() {
+ super();
+ this.hasArrow = true;
+ }
+ _arrowTemplate() {
return html`
@@ -248,7 +241,7 @@ The rest of the work is done by Popper.js (for positioning) and the `lion-toolti
import { html, css } from '@lion/core';
import { LionTooltipArrow } from '@lion/tooltip';
-class CustomTooltipArrow extends LionTooltipArrow {
+class CustomTooltip extends LionTooltip {
static get styles() {
return [super.styles, css`
:host {
@@ -257,8 +250,11 @@ class CustomTooltipArrow extends LionTooltipArrow {
}
`];
}
-
- render() {
+ constructor() {
+ super();
+ this.hasArrow = true;
+ }
+ _arrowTemplate() {
return html`