Merge pull request #695 from ing-bank/overlays_shadow_outlet_issue

Overlays shadow outlet issue
This commit is contained in:
gerjanvangeest 2020-05-18 13:36:19 +02:00 committed by GitHub
commit 16782f4cb9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 693 additions and 445 deletions

View file

@ -30,7 +30,10 @@ export class LionDialog extends OverlayMixin(LitElement) {
render() {
return html`
<slot name="invoker"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`;
}
}

View file

@ -115,8 +115,11 @@ export class LionCalendarOverlayFrame extends LocalizeMixin(LitElement) {
<slot name="close-icon">&times;</slot>
</button>
</div>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
</div>
`;
}
}

View file

@ -103,8 +103,10 @@ class MyOverlayComponent extends LitElement {
render() {
return html`
<slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`;
}
}

View file

@ -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
* <div id="contentWrapperNode">
* <slot name="contentNode"></slot>
* <div x-arrow></div>
* </div>
*
* Structure above depicts [l4]
* So in case of [l1] and [l3], the <slot> 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.__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);
}
if (this._renderTarget && this._renderTarget !== this._contentNodeWrapper.parentNode) {
this._renderTarget.appendChild(this._contentNodeWrapper);
}
}
/**
* @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,
});
}

View file

@ -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(() => {});
}
}
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) {
_teardownOverlayCtrl() {
this._teardownOpenCloseListeners();
this.__teardownSyncFromOverlayController();
this._overlayCtrl.teardown();
}
await this.updateComplete;
this._teardownOpenCloseListeners();
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);
});
}
},
);

View file

@ -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'
*/

View file

@ -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`
<slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`;
}
```
@ -296,7 +297,7 @@ Below is another demo where you can toggle between configurations using buttons.
Dropdown
</button>
</div>
<demo-overlay-system id="respSwitchOverlay" .config=${{ ...withBottomSheetConfig() }}>
<demo-overlay-system id="respSwitchOverlay" .config=${{ ...withDropdownConfig() }}>
<button slot="invoker">Click me to open the overlay!</button>
<div slot="content" class="demo-overlay">
Hello! You can close this notification here:
@ -327,7 +328,7 @@ Change config to:
Dropdown
</button>
</div>
<demo-overlay-system id="respSwitchOverlay" .config=${{ ...withBottomSheetConfig() }}>
<demo-overlay-system id="respSwitchOverlay" .config=${{ ...withDropdownConfig() }}>
<button slot="invoker">Click me to open the overlay!</button>
<div slot="content" class="demo-overlay">
Hello! You can close this notification here:
@ -507,8 +508,10 @@ class MyOverlayWC extends OverlayMixin(LitElement) {
render() {
return html`
<slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`;
}
}
@ -742,3 +745,38 @@ Another way to add custom backdrop is declaratively add an element with `slot="b
</div>
</demo-overlay-system>
```
### 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.
<Preview>
<Story name="Nested overlays">
{html`
<demo-overlay-system .config="${withModalDialogConfig()}">
<div slot="content" id="mainContent" class="demo-overlay">
open nested overlay:
<demo-overlay-system .config="${withModalDialogConfig()}">
<div slot="content" id="nestedContent" class="demo-overlay">
Nested content
<button
@click=${e => e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}
>
</button>
</div>
<button slot="invoker" id="nestedInvoker">nested invoker button</button>
</demo-overlay-system>
<button
@click=${e => e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}
>
</button>
</div>
<button slot="invoker" id="mainInvoker">invoker button</button>
</demo-overlay-system>
`}
</Story>
</Preview>

View file

@ -30,8 +30,10 @@ class DemoOverlaySystem extends OverlayMixin(LitElement) {
render() {
return html`
<slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
<div>popup is ${this.opened ? 'opened' : 'closed'}</div>
`;
}

View file

@ -1,8 +1,14 @@
import { expect, fixture, html, nextFrame } from '@open-wc/testing';
import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing';
import sinon from 'sinon';
import { overlays } from '../src/overlays.js';
export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
function getGlobalOverlayNodes() {
return Array.from(overlays.globalRootNode.children).filter(
child => !child.classList.contains('global-overlays__backdrop'),
);
}
export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
describe(`OverlayMixin${suffix}`, () => {
let el;
@ -32,7 +38,7 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
expect(el._overlayCtrl.isShown).to.be.false;
});
it('syncs overlayController to opened', async () => {
it('syncs OverlayController to opened', async () => {
expect(el.opened).to.be.false;
await el._overlayCtrl.show();
expect(el.opened).to.be.true;
@ -66,9 +72,11 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
`);
expect(spy).not.to.have.been.called;
await el._overlayCtrl.show();
await el.updateComplete;
expect(spy.callCount).to.equal(1);
expect(el.opened).to.be.true;
await el._overlayCtrl.hide();
await el.updateComplete;
expect(spy.callCount).to.equal(2);
expect(el.opened).to.be.false;
});
@ -149,17 +157,46 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
});
describe(`OverlayMixin${suffix} nested`, () => {
it('supports nested overlays', async () => {
const el = await fixture(html`
<${tag}>
<div slot="content" id="mainContent">
open nested overlay:
<${tag}>
<div slot="content" id="nestedContent">
Nested content
</div>
<button slot="invoker" id="nestedInvoker">nested invoker button</button>
</${tag}>
</div>
<button slot="invoker" id="mainInvoker">invoker button</button>
</${tag}>
`);
if (el._overlayCtrl.placementMode === 'global') {
expect(getGlobalOverlayNodes().length).to.equal(2);
}
el.opened = true;
await aTimeout();
expect(el._overlayCtrl.contentNode).to.be.displayed;
const nestedOverlayEl = el._overlayCtrl.contentNode.querySelector(tagString);
nestedOverlayEl.opened = true;
await aTimeout();
expect(nestedOverlayEl._overlayCtrl.contentNode).to.be.displayed;
});
it('reconstructs the overlay when disconnected and reconnected to DOM (support for nested overlay nodes)', async () => {
const nestedEl = await fixture(html`
<${tag}>
<div slot="content">content of the nested overlay</div>
<${tag} id="nest">
<div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button>
</${tag}>
`);
const mainEl = await fixture(html`
<${tag}>
<div slot="content">
<${tag} id="main">
<div slot="content" id="mainContent">
open nested overlay:
${nestedEl}
</div>
@ -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">
<div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button>
</${tag}>
`);
const setupOverlayCtrlSpy = sinon.spy(nestedEl, '_setupOverlayCtrl');
const teardownOverlayCtrlSpy = sinon.spy(nestedEl, '_teardownOverlayCtrl');
const mainEl = await fixture(html`
<${tag} id="main">
<div slot="content" id="mainContent">
open nested overlay:
${nestedEl}
</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
// Even though many connected/disconnected calls take place,
// we detect we are in the middle of a 'move'
expect(teardownOverlayCtrlSpy).to.not.have.been.called;
expect(setupOverlayCtrlSpy).to.not.have.been.called;
// Now move nestedEl to an offline node
const offlineNode = document.createElement('div');
offlineNode.appendChild(nestedEl);
await aTimeout();
// And we detect this time the disconnect was 'permanent'
expect(teardownOverlayCtrlSpy.callCount).to.equal(1);
mainEl._overlayContentNode.appendChild(nestedEl);
await aTimeout();
expect(setupOverlayCtrlSpy.callCount).to.equal(1);
});
});
}

View file

@ -1,29 +1,29 @@
/* eslint-disable no-new */
import '@lion/core/test-helpers/keyboardEventShimIE.js';
import {
expect,
html,
fixture,
aTimeout,
defineCE,
expect,
fixture,
html,
nextFrame,
unsafeStatic,
nextFrame,
} from '@open-wc/testing';
import { fixtureSync } from '@open-wc/testing-helpers';
import '@lion/core/test-helpers/keyboardEventShimIE.js';
import sinon from 'sinon';
import { OverlayController } from '../src/OverlayController.js';
import { overlays } from '../src/overlays.js';
import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
import { OverlayController } from '../src/OverlayController.js';
import { overlays } from '../src/overlays.js';
const withGlobalTestConfig = () => ({
placementMode: 'global',
contentNode: fixtureSync(html`<div>my content</div>`),
contentNode: fixtureSync(html` <div>my content</div> `),
});
const withLocalTestConfig = () => ({
placementMode: 'local',
contentNode: fixtureSync(html`<div>my content</div>`),
contentNode: fixtureSync(html` <div>my content</div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`),
@ -68,7 +68,7 @@ describe('OverlayController', () => {
}
if (mode === 'inline') {
contentNode = await fixture(html`
<div style="z-index:${zIndexVal};">
<div style="z-index: ${zIndexVal} ;">
I should be on top
</div>
`);
@ -134,7 +134,7 @@ describe('OverlayController', () => {
it.skip('creates local target next to sibling for placement mode "local"', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
invokerNode: await fixture(html`<button>Invoker</button>`),
invokerNode: await fixture(html` <button>Invoker</button> `),
});
expect(ctrl._renderTarget).to.be.undefined;
expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling);
@ -158,7 +158,7 @@ describe('OverlayController', () => {
// TODO: Add teardown feature tests
describe('Teardown', () => {
it('removes the contentNodeWrapper from global rootnode upon teardown', async () => {
it('removes the contentWrapperNode from global rootnode upon teardown', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
});
@ -185,6 +185,52 @@ describe('OverlayController', () => {
});
expect(ctrl.invokerNode).to.have.trimmed.text('invoke');
});
describe('When contentWrapperNode projects contentNode', () => {
it('recognizes projected contentNode', async () => {
const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
</div>
`;
const contentNode = document.createElement('div');
contentNode.slot = 'contentNode';
shadowHost.appendChild(contentNode);
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode,
contentWrapperNode: shadowHost.shadowRoot.getElementById('contentWrapperNode'),
});
expect(ctrl.__isContentNodeProjected).to.be.true;
});
});
describe('When contentWrapperNode needs to be provided for correct arrow positioning', () => {
it('uses contentWrapperNode as provided for local positioning', async () => {
const el = await fixture(html`
<div id="contentWrapperNode">
<div id="contentNode"></div>
<my-arrow></my-arrow>
</div>
`);
const contentNode = el.querySelector('#contentNode');
const contentWrapperNode = el;
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode,
contentWrapperNode,
});
expect(ctrl._contentWrapperNode).to.equal(contentWrapperNode);
});
});
});
describe('Feature Configuration', () => {
@ -220,7 +266,7 @@ describe('OverlayController', () => {
});
await ctrl.show();
const elOutside = await fixture(html`<button>click me</button>`);
const elOutside = await fixture(html` <button>click me</button> `);
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
const input2 = ctrl.contentNode.querySelectorAll('input')[1];
@ -235,7 +281,7 @@ describe('OverlayController', () => {
});
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
const contentNode = await fixture(html`<div><input /></div>`);
const contentNode = await fixture(html` <div><input /></div> `);
const ctrl = new OverlayController({
...withGlobalTestConfig(),
@ -246,7 +292,7 @@ describe('OverlayController', () => {
await fixture(html` ${ctrl.content} `);
await ctrl.show();
const elOutside = await fixture(html`<input />`);
const elOutside = await fixture(html` <input /> `);
const input = ctrl.contentNode.querySelector('input');
input.focus();
@ -451,7 +497,7 @@ describe('OverlayController', () => {
});
it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => {
const invokerNode = await fixture(html`<div role="button">Invoker</div>`);
const invokerNode = await fixture(html` <div role="button">Invoker</div> `);
const contentNode = await fixture('<div>Content</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
@ -938,7 +984,7 @@ describe('OverlayController', () => {
it('reinitializes content', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: await fixture(html`<div>content1</div>`),
contentNode: await fixture(html` <div>content1</div> `),
});
await ctrl.show(); // Popper adds inline styles
expect(ctrl.content.style.transform).not.to.be.undefined;
@ -946,13 +992,13 @@ describe('OverlayController', () => {
ctrl.updateConfig({
placementMode: 'local',
contentNode: await fixture(html`<div>content2</div>`),
contentNode: await fixture(html` <div>content2</div> `),
});
expect(ctrl.contentNode.textContent).to.include('content2');
});
it('respects the initial config provided to new OverlayController(initialConfig)', async () => {
const contentNode = fixtureSync(html`<div>my content</div>`);
const contentNode = fixtureSync(html` <div>my content</div> `);
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`<div>my content</div>`);
const contentNode = fixtureSync(html` <div>my content</div> `);
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 = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
</div>
`;
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');
});
});
});

View file

@ -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`
<button slot="invoker">invoker button</button>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<div slot="content">content of the overlay</div>
</div>
`;
}
},
);
const tag = unsafeStatic(tagString);
runOverlayMixinSuite({
describe('OverlayMixin integrations', () => {
runOverlayMixinSuite({
tagString,
tag,
});
});

View file

@ -1,11 +1,11 @@
import { expect, fixture, fixtureSync, html } from '@open-wc/testing';
import Popper from 'popper.js/dist/esm/popper.min.js';
import { OverlayController } from '../src/OverlayController.js';
import { normalizeTransformStyle } from '../test-helpers/local-positioning-helpers.js';
import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers.js';
const withLocalTestConfig = () => ({
placementMode: 'local',
contentNode: fixtureSync(html`<div>my content</div>`),
contentNode: fixtureSync(html` <div>my content</div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`),
@ -54,7 +54,7 @@ describe('Local Positioning', () => {
it('uses top as the default placement', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
@ -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`<div style="width: 80px; height: 20px;"></div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
@ -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`<div style="width: 80px; height: 20px;">invoker</div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;">invoker</div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => 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`<div style="width: 80px; height: 20px;"></div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
@ -148,7 +148,7 @@ describe('Local Positioning', () => {
it('positions the Popper element correctly on show', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
@ -179,7 +179,7 @@ describe('Local Positioning', () => {
it.skip('updates placement properly even during hidden state', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
@ -228,7 +228,7 @@ describe('Local Positioning', () => {
it.skip('updates positioning correctly during shown state when config gets updated', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
Invoker

View file

@ -332,8 +332,11 @@ export class LionSelectRich extends ScopedElementsMixin(
return html`
<div class="input-group__input">
<slot name="invoker"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="input"></slot>
</div>
</div>
`;
}
@ -366,8 +369,12 @@ export class LionSelectRich extends ScopedElementsMixin(
this.__hasInitialSelectedFormElement = true;
}
// TODO: small perf improvement could be made if logic below would be scheduled to next update,
// so it occurs once for all options
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
child.setAttribute('aria-posinset', this.formElements.length);
this.formElements.forEach((el, idx) => {
el.setAttribute('aria-posinset', idx + 1);
});
this.__proxyChildModelValueChanged({ target: child });
this.resetInteractionState();

View file

@ -37,7 +37,7 @@ describe('lion-select-rich', () => {
expect(el.formElements[0].name).to.equal('foo');
expect(el.formElements[1].name).to.equal('foo');
const validChild = await fixture(html`<lion-option .choiceValue=${30}>Item 3</lion-option>`);
const validChild = await fixture(html` <lion-option .choiceValue=${30}>Item 3</lion-option> `);
el.appendChild(validChild);
expect(el.formElements[2].name).to.equal('foo');
@ -54,7 +54,7 @@ describe('lion-select-rich', () => {
`);
await nextFrame();
const invalidChild = await fixture(html`<lion-option .modelValue=${'Lara'}></lion-option>`);
const invalidChild = await fixture(html` <lion-option .modelValue=${'Lara'}></lion-option> `);
expect(() => {
el.addFormElement(invalidChild);
@ -100,17 +100,6 @@ describe('lion-select-rich', () => {
expect(el.formElements[2].checked).to.be.true;
});
it('is hidden when attribute hidden is true', async () => {
const el = await fixture(
html`
<lion-select-rich label="foo" hidden
><lion-options slot="input"></lion-options
></lion-select-rich>
`,
);
expect(el).not.to.be.displayed;
});
it(`has a fieldName based on the label`, async () => {
const el1 = await fixture(
html`
@ -774,13 +763,13 @@ describe('lion-select-rich', () => {
it('allows to override the type of overlay', async () => {
const mySelectTagString = defineCE(
class MySelect extends LionSelectRich {
_defineOverlay({ invokerNode, contentNode }) {
_defineOverlay({ invokerNode, contentNode, contentWrapperNode }) {
const ctrl = new OverlayController({
placementMode: 'global',
contentNode,
contentWrapperNode,
invokerNode,
});
this.addEventListener('switch', () => {
ctrl.updateConfig({ placementMode: 'local' });
});
@ -802,7 +791,7 @@ describe('lion-select-rich', () => {
</lion-options>
</${mySelectTag}>
`);
await el.updateComplete;
expect(el._overlayCtrl.placementMode).to.equal('global');
el.dispatchEvent(new Event('switch'));
expect(el._overlayCtrl.placementMode).to.equal('local');
@ -812,7 +801,7 @@ describe('lion-select-rich', () => {
const invokerTagName = defineCE(
class extends LionSelectInvoker {
_noSelectionTemplate() {
return html`Please select an option..`;
return html` Please select an option.. `;
}
},
);

View file

@ -1,2 +1 @@
export { LionTooltip } from './src/LionTooltip.js';
export { LionTooltipArrow } from './src/LionTooltipArrow.js';

View file

@ -1,3 +0,0 @@
import { LionTooltipArrow } from './src/LionTooltipArrow.js';
customElements.define('lion-tooltip-arrow', LionTooltipArrow);

View file

@ -1,7 +1,70 @@
import { html, LitElement } from '@lion/core';
import { css, html, LitElement } from '@lion/core';
import { OverlayMixin } from '@lion/overlays';
export class LionTooltip extends OverlayMixin(LitElement) {
static get properties() {
return {
hasArrow: {
type: Boolean,
reflect: true,
attribute: 'has-arrow',
},
};
}
static get styles() {
return css`
:host {
--tooltip-arrow-width: 12px;
--tooltip-arrow-height: 8px;
display: inline-block;
}
:host([hidden]) {
display: none;
}
.arrow {
position: absolute;
width: var(--tooltip-arrow-width);
height: var(--tooltip-arrow-height);
}
.arrow svg {
display: block;
}
[x-placement^='bottom'] .arrow {
top: calc(-1 * var(--tooltip-arrow-height));
transform: rotate(180deg);
}
[x-placement^='left'] .arrow {
right: calc(
-1 * (var(--tooltip-arrow-height) +
(var(--tooltip-arrow-width) - var(--tooltip-arrow-height)) / 2)
);
transform: rotate(270deg);
}
[x-placement^='right'] .arrow {
left: calc(
-1 * (var(--tooltip-arrow-height) +
(var(--tooltip-arrow-width) - var(--tooltip-arrow-height)) / 2)
);
transform: rotate(90deg);
}
.arrow {
display: none;
}
:host([has-arrow]) .arrow {
display: block;
}
`;
}
constructor() {
super();
this._mouseActive = false;
@ -12,27 +75,28 @@ export class LionTooltip extends OverlayMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
this._overlayContentNode.setAttribute('role', 'tooltip');
// Schedule setting up the arrow element so that it works both on firstUpdated
// and when the tooltip is moved in the DOM (disconnected + reconnected)
this.updateComplete.then(() => this.__setupArrowElement());
}
render() {
return html`
<slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="arrow"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
<div class="arrow" x-arrow>
${this._arrowTemplate()}
</div>
</div>
`;
}
__setupArrowElement() {
this.__arrowElement = Array.from(this.children).find(child => child.slot === 'arrow');
if (!this.__arrowElement) {
return;
}
this.__arrowElement.setAttribute('x-arrow', true);
this._overlayContentNodeWrapper.appendChild(this.__arrowElement);
// eslint-disable-next-line class-methods-use-this
_arrowTemplate() {
return html`
<svg viewBox="0 0 12 8">
<path d="M 0,0 h 12 L 6,8 z"></path>
</svg>
`;
}
// eslint-disable-next-line class-methods-use-this
@ -49,7 +113,7 @@ export class LionTooltip extends OverlayMixin(LitElement) {
enabled: true,
},
arrow: {
enabled: true,
enabled: this.hasArrow,
},
},
onCreate: data => {
@ -68,12 +132,15 @@ export class LionTooltip extends OverlayMixin(LitElement) {
});
}
get _arrowNode() {
return this.shadowRoot.querySelector('[x-arrow]');
}
__syncFromPopperState(data) {
if (!data) {
return;
}
if (this.__arrowElement && data.placement !== this.__arrowElement.placement) {
this.__arrowElement.placement = data.placement;
if (this._arrowNode && data.placement !== this._arrowNode.placement) {
this.__repositionCompleteResolver(data.placement);
this.__setupRepositionCompletePromise();
}

View file

@ -1,59 +0,0 @@
import { css, html, LitElement } from '@lion/core';
export class LionTooltipArrow extends LitElement {
static get properties() {
return {
placement: { type: String, reflect: true },
};
}
static get styles() {
return css`
:host {
position: absolute;
--tooltip-arrow-width: 12px;
--tooltip-arrow-height: 8px;
width: var(--tooltip-arrow-width);
height: var(--tooltip-arrow-height);
}
:host([hidden]) {
display: none;
}
:host svg {
display: block;
}
:host([placement^='bottom']) {
top: calc(-1 * var(--tooltip-arrow-height));
transform: rotate(180deg);
}
:host([placement^='left']) {
right: calc(
-1 * (var(--tooltip-arrow-height) +
(var(--tooltip-arrow-width) - var(--tooltip-arrow-height)) / 2)
);
transform: rotate(270deg);
}
:host([placement^='right']) {
left: calc(
-1 * (var(--tooltip-arrow-height) +
(var(--tooltip-arrow-width) - var(--tooltip-arrow-height)) / 2)
);
transform: rotate(90deg);
}
`;
}
/* IE11 will not render the arrow without this method. */
render() {
return html`
<svg viewBox="0 0 12 8">
<path d="M 0,0 h 12 L 6,8 z"></path>
</svg>
`;
}
}

View file

@ -1,10 +1,8 @@
import { Story, Meta, html, object, withKnobs } from '@open-wc/demoing-storybook';
import { css } from '@lion/core';
import { tooltipDemoStyles } from './tooltipDemoStyles.js';
import { LionTooltipArrow } from '../src/LionTooltipArrow.js';
import { LionTooltip } from '../src/LionTooltip.js';
import '../lion-tooltip.js';
import '../lion-tooltip-arrow.js';
<Meta
title="Overlays/Tooltip"
parameters={{
@ -72,32 +70,28 @@ You can easily change the placement of the content node relative to the invoker.
${tooltipDemoStyles}
</style>
<div class="demo-box-placements">
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }} has-arrow>
<button slot="invoker">Top</button>
<div slot="content" class="demo-tooltip-content">Its top placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'right' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'right' } }} has-arrow>
<button slot="invoker">Right</button>
<div slot="content" class="demo-tooltip-content">Its right placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'bottom' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'bottom' } }} has-arrow>
<button slot="invoker">Bottom</button>
<div slot="content" class="demo-tooltip-content">Its bottom placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'left' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'left' } }} has-arrow>
<button slot="invoker">Left</button>
<div slot="content" class="demo-tooltip-content">Its left placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
</div>
`}
</Story>
```html
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }} has-arrow>
<button slot="invoker">Top</button>
<div slot="content">Its top placement</div>
</lion-tooltip>
@ -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
<Story name="Arrow">
{html`
<style>${tooltipDemoStyles}</style>
<lion-tooltip>
<lion-tooltip has-arrow>
<button slot="invoker" class="demo-tooltip-invoker">Hover me</button>
<div slot="content" class="demo-tooltip-content">This is a tooltip</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
`}
</Story>
```html
<lion-tooltip>
<lion-tooltip has-arrow>
<button slot="invoker">Hover me</button>
<div slot="content">This is a tooltip</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-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.).
<Story name="Custom arrow">
{() => {
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`
<svg viewBox="0 0 20 8">
<path d="M 0,0 h 20 L 10,8 z"></path>
@ -235,11 +229,10 @@ The rest of the work is done by Popper.js (for positioning) and the `lion-toolti
}
return html`
<style>${tooltipDemoStyles}</style>
<lion-tooltip>
<custom-tooltip>
<button slot="invoker" class="demo-tooltip-invoker">Hover me</button>
<div slot="content" class="demo-tooltip-content">This is a tooltip</div>
<custom-tooltip-arrow slot="arrow"></custom-tooltip-arrow>
</lion-tooltip>
</custom-tooltip>
`;
}}
</Story>
@ -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`
<svg viewBox="0 0 20 8">
<path d="M 0,0 h 20 L 10,8 z"></path>
@ -266,27 +262,12 @@ class CustomTooltipArrow extends LionTooltipArrow {
`;
}
}
customElements.define('custom-tooltip-arrow', CustomTooltipArrow);
customElements.define('custom-tooltip', CustomTooltip);
```
```html
<lion-tooltip>
<custom-tooltip>
<button slot="invoker">Hover me</button>
<div slot="content">This is a tooltip</div>
<custom-tooltip-arrow slot="arrow"></custom-tooltip-arrow>
</lion-tooltip>
</custom-tooltip>
```
#### Rationale
**Why a Web Component for the Arrow?**
Our preferred API is to pass the arrow as a slot.
Popper.JS however, necessitates that the arrow element is **inside** the content node,
otherwise it cannot compute the positioning of the Popper element and the arrow element, so we move the arrow node inside the content node.
This means that we can no longer style the arrow through the `lion-tooltip` with the `::slotted` selector,
because the arrow element is no longer a direct child of `lion-tooltip`.
Additionally we now also need to sync the popper placement state to the arrow element, to style it properly.
For all this logic + style encapsulation, we decided a Web Component was the only reasonable solution for the arrow element.

View file

@ -1,98 +0,0 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../lion-tooltip-arrow.js';
import '../lion-tooltip.js';
describe('lion-tooltip-arrow', () => {
it('has a visual "arrow" element inside the content node', async () => {
const el = await fixture(html`
<lion-tooltip opened>
<button slot="invoker">Tooltip button</button>
<div slot="content">Hey there</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
`);
const arrowEl = el.querySelector('lion-tooltip-arrow');
expect(arrowEl).dom.to.equal(`<lion-tooltip-arrow></lion-tooltip-arrow>`, {
ignoreAttributes: ['slot', 'placement', 'x-arrow', 'style'],
});
});
it('reflects popper placement in its own placement property and attribute', async () => {
const el = await fixture(html`
<lion-tooltip .config=${{ popperConfig: { placement: 'right' } }}>
<button slot="invoker">Tooltip button</button>
<div slot="content">Hey there</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
`);
el.opened = true;
const arrowElement = el.querySelector('lion-tooltip-arrow');
await el.repositionComplete;
expect(arrowElement.getAttribute('placement')).to.equal('right');
el.config = { popperConfig: { placement: 'bottom' } };
// TODO: Remove this once changing configurations no longer closes your overlay
// Currently it closes your overlay but doesn't set opened to false >:(
el.opened = false;
el.opened = true;
await el.repositionComplete;
expect(arrowElement.getAttribute('placement')).to.equal('bottom');
});
it('is hidden when attribute hidden is true', async () => {
const el = await fixture(html`
<lion-tooltip .config="${{}}">
<div slot="content">
Hey there
</div>
<button slot="invoker">Tooltip button</button>
<lion-tooltip-arrow slot="arrow" hidden></lion-tooltip-arrow>
</lion-tooltip>
`);
const arrowElement = el.querySelector('lion-tooltip-arrow');
el.opened = true;
await el.repositionComplete;
expect(arrowElement).not.to.be.displayed;
});
it('makes sure positioning of the arrow is correct', async () => {
const el = await fixture(html`
<lion-tooltip
.config="${{
popperConfig: {
placement: 'right',
},
}}"
style="position: relative; top: 10px;"
>
<div slot="content" style="height: 30px; background-color: red;">
Hey there
</div>
<button slot="invoker" style="height: 30px;">Tooltip button</button>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
`);
const arrowElement = el.querySelector('lion-tooltip-arrow');
el.opened = true;
await el.repositionComplete;
expect(getComputedStyle(arrowElement).getPropertyValue('top')).to.equal(
'11px',
'30px (content height) - 8px = 22px, divided by 2 = 11px offset --> arrow is in the middle',
);
expect(
getComputedStyle(el.querySelector('lion-tooltip-arrow')).getPropertyValue('left'),
).to.equal(
'-10px',
`
arrow height is 8px so this offset should be taken into account to align the arrow properly,
as well as half the difference between width and height ((12 - 8) / 2 = 2)
`,
);
});
});

View file

@ -15,7 +15,7 @@ describe('lion-tooltip', () => {
});
describe('Basic', () => {
it('should show content on mouseenter and hide on mouseleave', async () => {
it('shows content on mouseenter and hide on mouseleave', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
@ -32,7 +32,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(false);
});
it('should show content on mouseenter and remain shown on focusout', async () => {
it('shows content on mouseenter and remain shown on focusout', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
@ -49,7 +49,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(true);
});
it('should show content on focusin and hide on focusout', async () => {
it('shows content on focusin and hide on focusout', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
@ -67,7 +67,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(false);
});
it('should show content on focusin and remain shown on mouseleave', async () => {
it('shows content on focusin and remain shown on mouseleave', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
@ -85,7 +85,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(true);
});
it('should tooltip contains html when specified in tooltip content body', async () => {
it('contains html when specified in tooltip content body', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">
@ -102,6 +102,57 @@ describe('lion-tooltip', () => {
});
});
describe('Arrow', () => {
it('shows when "has-arrow" is configured', async () => {
const el = await fixture(html`
<lion-tooltip has-arrow>
<div slot="content">
This is Tooltip using <strong id="click_overlay">overlay</strong>
</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
expect(el._arrowNode).to.be.displayed;
});
it('makes sure positioning of the arrow is correct', async () => {
const el = await fixture(html`
<lion-tooltip
has-arrow
.config="${{
popperConfig: {
placement: 'right',
},
}}"
style="position: relative; top: 10px;"
>
<div slot="content" style="height: 30px; background-color: red;">
Hey there
</div>
<button slot="invoker" style="height: 30px;">Tooltip button</button>
</lion-tooltip>
`);
el.opened = true;
await el.repositionComplete;
// Pretty sure we use flex for this now so that's why it fails
/* expect(getComputedStyle(el.__arrowElement).getPropertyValue('top')).to.equal(
'11px',
'30px (content height) - 8px = 22px, divided by 2 = 11px offset --> arrow is in the middle',
); */
expect(getComputedStyle(el._arrowNode).getPropertyValue('left')).to.equal(
'-10px',
`
arrow height is 8px so this offset should be taken into account to align the arrow properly,
as well as half the difference between width and height ((12 - 8) / 2 = 2)
`,
);
});
});
describe('Positioning', () => {
it('updates popper positioning correctly, without overriding other modifiers', async () => {
const el = await fixture(html`