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() { render() {
return html` return html`
<slot name="invoker"></slot> <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

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

View file

@ -103,8 +103,10 @@ class MyOverlayComponent extends LitElement {
render() { render() {
return html` return html`
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></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_CONTAINER_CLASS = 'global-overlays__overlay-container';
const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay'; 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 { export class OverlayController {
/** /**
* @constructor * @constructor
@ -20,14 +80,17 @@ export class OverlayController {
this.__fakeExtendsEventTarget(); this.__fakeExtendsEventTarget();
this.manager = manager; this.manager = manager;
this.__sharedConfig = config; this.__sharedConfig = config;
/** @type {OverlayConfig} */
this._defaultConfig = { this._defaultConfig = {
placementMode: null, placementMode: null,
contentNode: config.contentNode, contentNode: config.contentNode,
contentWrapperNode: config.contentWrapperNode,
invokerNode: config.invokerNode, invokerNode: config.invokerNode,
backdropNode: config.backdropNode, backdropNode: config.backdropNode,
referenceNode: null, referenceNode: null,
elementToFocusAfterHide: config.invokerNode, elementToFocusAfterHide: config.invokerNode,
inheritsReferenceWidth: '', inheritsReferenceWidth: 'none',
hasBackdrop: false, hasBackdrop: false,
isBlocking: false, isBlocking: false,
preventsScroll: false, preventsScroll: false,
@ -69,12 +132,12 @@ export class OverlayController {
}; };
this.manager.add(this); this.manager.add(this);
this._contentNodeWrapper = document.createElement('div');
this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`; 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.updateConfig(config);
this.__hasActiveTrapsKeyboardFocus = false; this.__hasActiveTrapsKeyboardFocus = false;
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
} }
@ -84,31 +147,40 @@ export class OverlayController {
} }
get content() { get content() {
return this._contentNodeWrapper; return this._contentWrapperNode;
} }
/** /**
* @desc The element ._contentNodeWrapper will be appended to. * @desc Usually the parent node of contentWrapperNode that either exists locally or globally.
* If viewportConfig is configured, this will be OverlayManager.globalRootNode * When a responsive scenario is created (in which we switch from global to local or vice versa)
* If popperConfig is configured, this will be a sibling node of invokerNode * we need to know where we should reappend contentWrapperNode (or contentNode in case it's
* projected)
* @type {HTMLElement}
*/ */
get _renderTarget() { get _renderTarget() {
/** config [g1] */
if (this.placementMode === 'global') { if (this.placementMode === 'global') {
return this.manager.globalRootNode; return this.manager.globalRootNode;
} }
/** config [l2] or [l4] */
if (this.__isContentNodeProjected) {
return this.__originalContentParent.getRootNode().host;
}
/** config [l1] or [l3] */
return this.__originalContentParent; return this.__originalContentParent;
} }
/** /**
* @desc The element our local overlay will be positioned relative to. * @desc The element our local overlay will be positioned relative to.
* @type {HTMLElement}
*/ */
get _referenceNode() { get _referenceNode() {
return this.referenceNode || this.invokerNode; return this.referenceNode || this.invokerNode;
} }
set elevation(value) { set elevation(value) {
if (this._contentNodeWrapper) { if (this._contentWrapperNode) {
this._contentNodeWrapper.style.zIndex = value; this._contentWrapperNode.style.zIndex = value;
} }
if (this.backdropNode) { if (this.backdropNode) {
this.backdropNode.style.zIndex = value; this.backdropNode.style.zIndex = value;
@ -116,7 +188,7 @@ export class OverlayController {
} }
get elevation() { 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. * presentation of the overlay changes depending on screen size.
* Note that this method is the only allowed way to update a configuration of an * Note that this method is the only allowed way to update a configuration of an
* OverlayController instance. * OverlayController instance.
* @param {OverlayConfig} cfgToAdd * @param { OverlayConfig } cfgToAdd
*/ */
updateConfig(cfgToAdd) { updateConfig(cfgToAdd) {
// Teardown all previous configs // Teardown all previous configs
this._handleFeatures({ phase: 'teardown' }); 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.__prevConfig = this.config || {};
this.config = { this.config = {
@ -172,18 +240,23 @@ export class OverlayController {
if (!newConfig.contentNode) { if (!newConfig.contentNode) {
throw new Error('You need to provide a .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 }) { async _init({ cfgToAdd }) {
this.__initContentNodeWrapper(); this.__initcontentWrapperNode({ cfgToAdd });
this.__initConnectionTarget(); this.__initConnectionTarget();
if (this.handlesAccessibility) { if (this.handlesAccessibility) {
this.__initAccessibility({ cfgToAdd }); this.__initAccessibility({ cfgToAdd });
} }
if (this.placementMode === 'local') { if (this.placementMode === 'local') {
// Now, it is time to lazily load Popper if not done yet // Lazily load Popper if not done yet
// Do we really want to add display: inline or is this up to user?
if (!this.constructor.popperModule) { if (!this.constructor.popperModule) {
this.constructor.popperModule = preloadPopper(); this.constructor.popperModule = preloadPopper();
} }
@ -192,34 +265,56 @@ export class OverlayController {
} }
__initConnectionTarget() { __initConnectionTarget() {
// Now, add our node to the right place in dom (rendeTarget) // Now, add our node to the right place in dom (renderTarget)
if (this.contentNode !== this.__prevConfig.contentNode) { if (this._contentWrapperNode !== this.__prevConfig._contentWrapperNode) {
this._contentNodeWrapper.appendChild(this.contentNode); 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... * can lead to problems with event listeners...
*/ */
__initContentNodeWrapper() { __initcontentWrapperNode({ cfgToAdd }) {
Array.from(this._contentNodeWrapper.attributes).forEach(attrObj => { if (this.config.contentWrapperNode && this.placementMode === 'local') {
this._contentNodeWrapper.removeAttribute(attrObj.name); /** config [l2],[l3],[l4] */
}); this._contentWrapperNode = this.config.contentWrapperNode;
this._contentNodeWrapper.style.cssText = null; } else {
this._contentNodeWrapper.style.display = 'none'; /** 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._contentWrapperNode.style.cssText = null;
this._contentNodeWrapper.slot = '_overlay-shadow-outlet'; this._contentWrapperNode.style.display = 'none';
if (getComputedStyle(this.contentNode).position === 'absolute') { if (getComputedStyle(this.contentNode).position === 'absolute') {
// Having a _contWrapperNode and a contentNode with 'position:absolute' results in // Having a _contWrapperNode and a contentNode with 'position:absolute' results in
// computed height of 0... // computed height of 0...
this.contentNode.style.position = 'static'; 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') { if (phase === 'setup') {
const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex); const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex);
if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) { 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() { 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 }); const event = new CustomEvent('before-show', { cancelable: true });
this.dispatchEvent(event); this.dispatchEvent(event);
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
this._contentNodeWrapper.style.display = this.placementMode === 'local' ? 'inline-block' : ''; this._contentWrapperNode.style.display = '';
await this._handleFeatures({ phase: 'show' }); await this._handleFeatures({ phase: 'show' });
await this._handlePosition({ phase: 'show' }); await this._handlePosition({ phase: 'show' });
this.elementToFocusAfterHide = elementToFocusAfterHide; this.elementToFocusAfterHide = elementToFocusAfterHide;
@ -294,8 +389,8 @@ export class OverlayController {
if (this.placementMode === 'global') { if (this.placementMode === 'global') {
const addOrRemove = phase === 'show' ? 'add' : 'remove'; const addOrRemove = phase === 'show' ? 'add' : 'remove';
const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`; const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`;
this._contentNodeWrapper.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS); this._contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
this._contentNodeWrapper.classList[addOrRemove](placementClass); this._contentWrapperNode.classList[addOrRemove](placementClass);
this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS); this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS);
} else if (this.placementMode === 'local' && phase === 'show') { } else if (this.placementMode === 'local' && phase === 'show') {
/** /**
@ -327,7 +422,7 @@ export class OverlayController {
this.dispatchEvent(event); this.dispatchEvent(event);
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
// await this.transitionHide({ backdropNode: this.backdropNode, conentNode: this.contentNode }); // await this.transitionHide({ backdropNode: this.backdropNode, conentNode: this.contentNode });
this._contentNodeWrapper.style.display = 'none'; this._contentWrapperNode.style.display = 'none';
this._handleFeatures({ phase: 'hide' }); this._handleFeatures({ phase: 'hide' });
this.dispatchEvent(new Event('hide')); this.dispatchEvent(new Event('hide'));
this._restoreFocus(); this._restoreFocus();
@ -340,7 +435,7 @@ export class OverlayController {
_restoreFocus() { _restoreFocus() {
// We only are allowed to move focus if we (still) 'own' it. // We only are allowed to move focus if we (still) 'own' it.
// Otherwise we assume the 'outside world' has, purposefully, taken over // Otherwise we assume the 'outside world' has, purposefully, taken over
// if (this._contentNodeWrapper.activeElement) { // if (this._contentWrapperNode.activeElement) {
if (this.elementToFocusAfterHide) { if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus(); this.elementToFocusAfterHide.focus();
} }
@ -431,10 +526,7 @@ export class OverlayController {
this.backdropNode.classList.add('local-overlays__backdrop'); this.backdropNode.classList.add('local-overlays__backdrop');
} }
this.backdropNode.slot = '_overlay-shadow-outlet'; this.backdropNode.slot = '_overlay-shadow-outlet';
this._contentNodeWrapper.parentElement.insertBefore( this.contentNode.parentNode.insertBefore(this.backdropNode, this.contentNode);
this.backdropNode,
this._contentNodeWrapper,
);
break; break;
case 'show': case 'show':
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
@ -460,10 +552,9 @@ export class OverlayController {
case 'init': case 'init':
this.backdropNode = document.createElement('div'); this.backdropNode = document.createElement('div');
this.backdropNode.classList.add('global-overlays__backdrop'); this.backdropNode.classList.add('global-overlays__backdrop');
this.backdropNode.slot = '_overlay-shadow-outlet'; this._contentWrapperNode.parentElement.insertBefore(
this._contentNodeWrapper.parentElement.insertBefore(
this.backdropNode, this.backdropNode,
this._contentNodeWrapper, this._contentWrapperNode,
); );
break; break;
case 'show': case 'show':
@ -577,21 +668,21 @@ export class OverlayController {
} }
_handleInheritsReferenceWidth() { _handleInheritsReferenceWidth() {
if (!this._referenceNode) { if (!this._referenceNode || this.placementMode === 'global') {
return; return;
} }
const referenceWidth = `${this._referenceNode.clientWidth}px`; const referenceWidth = `${this._referenceNode.clientWidth}px`;
switch (this.inheritsReferenceWidth) { switch (this.inheritsReferenceWidth) {
case 'max': case 'max':
this._contentNodeWrapper.style.maxWidth = referenceWidth; this._contentWrapperNode.style.maxWidth = referenceWidth;
break; break;
case 'full': case 'full':
this._contentNodeWrapper.style.width = referenceWidth; this._contentWrapperNode.style.width = referenceWidth;
break; break;
case 'min': case 'min':
this._contentNodeWrapper.style.minWidth = referenceWidth; this._contentWrapperNode.style.minWidth = referenceWidth;
this._contentNodeWrapper.style.width = 'auto'; this._contentWrapperNode.style.width = 'auto';
break; break;
/* no default */ /* 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) { if (this.invokerNode) {
this.invokerNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true); this.invokerNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true);
} }
@ -634,9 +725,18 @@ export class OverlayController {
teardown() { teardown() {
this._handleFeatures({ phase: 'teardown' }); this._handleFeatures({ phase: 'teardown' });
// IE11 compatibility (does not support `Node.remove()`)
if (this._contentNodeWrapper && this._contentNodeWrapper.parentElement) { // Remove the content node wrapper from the global rootnode
this._contentNodeWrapper.parentElement.removeChild(this._contentNodeWrapper); 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; this._popper = null;
} }
const { default: Popper } = await this.constructor.popperModule; 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, ...this.config.popperConfig,
}); });
} }

View file

@ -3,7 +3,7 @@ import { OverlayController } from './OverlayController.js';
/** /**
* @type {Function()} * @type {Function()}
* @polymerMixin * @polymerMixinOverlayMixin
* @mixinFunction * @mixinFunction
*/ */
export const OverlayMixin = dedupeMixin( export const OverlayMixin = dedupeMixin(
@ -23,6 +23,10 @@ export const OverlayMixin = dedupeMixin(
super(); super();
this.opened = false; this.opened = false;
this.config = {}; this.config = {};
this._overlaySetupComplete = new Promise(resolve => {
this.__overlaySetupCompleteResolve = resolve;
});
} }
get config() { get config() {
@ -50,11 +54,12 @@ export const OverlayMixin = dedupeMixin(
* @returns {OverlayController} * @returns {OverlayController}
*/ */
// eslint-disable-next-line // eslint-disable-next-line
_defineOverlay({ contentNode, invokerNode, backdropNode }) { _defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
return new OverlayController({ return new OverlayController({
contentNode, contentNode,
invokerNode, invokerNode,
backdropNode, backdropNode,
contentWrapperNode,
...this._defineOverlayConfig(), // wc provided in the class as defaults ...this._defineOverlayConfig(), // wc provided in the class as defaults
...this.config, // user provided (e.g. in template) ...this.config, // user provided (e.g. in template)
popperConfig: { popperConfig: {
@ -132,11 +137,20 @@ export const OverlayMixin = dedupeMixin(
if (super.connectedCallback) { if (super.connectedCallback) {
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() { disconnectedCallback() {
@ -144,11 +158,26 @@ export const OverlayMixin = dedupeMixin(
super.disconnectedCallback(); 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) { if (this._overlayCtrl) {
this.__tornDown = true; // We need to prevent that we create a setup/teardown cycle during startup, where it
this.__overlayContentNodeWrapperBeforeTeardown = this._overlayContentNodeWrapper; // 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() { get _overlayInvokerNode() {
@ -160,22 +189,7 @@ export const OverlayMixin = dedupeMixin(
} }
get _overlayContentNode() { get _overlayContentNode() {
if (this._cachedOverlayContentNode) { 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 {
this._cachedOverlayContentNode = Array.from(this.children).find( this._cachedOverlayContentNode = Array.from(this.children).find(
child => child.slot === 'content', child => child.slot === 'content',
); );
@ -183,20 +197,14 @@ export const OverlayMixin = dedupeMixin(
return this._cachedOverlayContentNode; return this._cachedOverlayContentNode;
} }
get _overlayContentNodeWrapper() { get _overlayContentWrapperNode() {
return this._overlayContentNode.parentElement; return this.shadowRoot.querySelector('#overlay-content-node-wrapper');
} }
_setupOverlayCtrl() { _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({ this._overlayCtrl = this._defineOverlay({
contentNode: this._overlayContentNode, contentNode: this._overlayContentNode,
contentWrapperNode: this._overlayContentWrapperNode,
invokerNode: this._overlayInvokerNode, invokerNode: this._overlayInvokerNode,
backdropNode: this._overlayBackdropNode, backdropNode: this._overlayBackdropNode,
}); });
@ -204,17 +212,22 @@ export const OverlayMixin = dedupeMixin(
this.__setupSyncFromOverlayController(); this.__setupSyncFromOverlayController();
this._setupOpenCloseListeners(); this._setupOpenCloseListeners();
this.__overlaySetupCompleteResolve(); this.__overlaySetupCompleteResolve();
this.__isOverlaySetup = true;
} }
async _teardownOverlayCtrl() { _teardownOverlayCtrl() {
if (this._overlayCtrl) {
this.__teardownSyncFromOverlayController();
this._overlayCtrl.teardown();
}
await this.updateComplete;
this._teardownOpenCloseListeners(); 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) { async _setOpenedWithoutPropertyEffects(newOpened) {
this.__blockSyncToOverlayCtrl = true; this.__blockSyncToOverlayCtrl = true;
this.opened = newOpened; this.opened = newOpened;
@ -271,13 +284,5 @@ export const OverlayMixin = dedupeMixin(
this._overlayCtrl.hide(); 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 * @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 * 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) * 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) * (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) * opened (currently exclusive to globalOverlayController)
* @property {boolean} [trapsKeyboardFocus=false] - rotates tab, implicitly set when 'isModal' * @property {boolean} [trapsKeyboardFocus=false] rotates tab, implicitly set when 'isModal'
* @property {boolean} [hidesOnEsc=false] - hides the overlay when pressing [ esc ] * @property {boolean} [hidesOnEsc=false] hides the overlay when pressing [ esc ]
* @property {boolean} [hidesOnOutsideClick=false] - hides the overlay when clicking next to it, * @property {boolean} [hidesOnOutsideClick=false] hides the overlay when clicking next to it,
* exluding invoker. (currently exclusive to localOverlayController) * exluding invoker. (currently exclusive to localOverlayController)
* https://github.com/ing-bank/lion/pull/61 * https://github.com/ing-bank/lion/pull/61
* @property {HTMLElement} invokerNode * @property {'max'|'full'|'min'|'none'} [inheritsReferenceWidth='none'] will align contentNode
* @property {HTMLElement} contentNode * with referenceNode (invokerNode by default) for local overlays. Usually needed for dropdowns.
* @property {boolean} [isModal=false] - sets aria-modal and/or aria-hidden="true" on siblings * 'max' will prevent contentNode from exceeding width
* @property {boolean} [isGlobal=false] - determines the connection point in DOM (body vs next * of referenceNode, 'min' guarantees that contentNode will be at least as wide as referenceNode.
* to invoker). This is what other libraries often refer to as 'portal'. * 'full' will make sure that the invoker width always is the same.
* @property {boolean} [isTooltip=false] - has a totally different interaction- and accessibility pattern from all other overlays, so needed for internals. * @property {HTMLElement} invokerNode the interactive element (usually a button) invoking the
* @property {boolean} [handlesUserInteraction] - sets toggle on click, or hover when `isTooltip` * dialog or tooltip
* @property {boolean} [handlesAccessibility] - * @property {HTMLElement} [referenceNode] the element that is used to position the overlay content
* - For non `isTooltip`: * 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-expanded="true/false" and aria-haspopup="true" on invokerNode
* - sets aria-controls on invokerNode * - sets aria-controls on invokerNode
* - returns focus to invokerNode on hide * - returns focus to invokerNode on hide
* - sets focus to overlay content(?) * - sets focus to overlay content(?)
* - For `isTooltip`: *
* For `isTooltip`:
* - sets role="tooltip" and aria-labelledby/aria-describedby on the content * - 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 - Define configuration
- Handle setting up event listeners of toggling the opened state of your overlay - Handle setting up event listeners of toggling the opened state of your overlay
- Handle the tearing down of those event listeners - 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) - 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 - 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 ```js
_defineOverlayConfig() { _defineOverlayConfig() {
@ -157,8 +156,10 @@ _teardownOpenCloseListeners() {
render() { render() {
return html` return html`
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></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 Dropdown
</button> </button>
</div> </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> <button slot="invoker">Click me to open the overlay!</button>
<div slot="content" class="demo-overlay"> <div slot="content" class="demo-overlay">
Hello! You can close this notification here: Hello! You can close this notification here:
@ -327,7 +328,7 @@ Change config to:
Dropdown Dropdown
</button> </button>
</div> </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> <button slot="invoker">Click me to open the overlay!</button>
<div slot="content" class="demo-overlay"> <div slot="content" class="demo-overlay">
Hello! You can close this notification here: Hello! You can close this notification here:
@ -507,8 +508,10 @@ class MyOverlayWC extends OverlayMixin(LitElement) {
render() { render() {
return html` return html`
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></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> </div>
</demo-overlay-system> </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() { render() {
return html` return html`
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_overlay-shadow-outlet"></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> <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 sinon from 'sinon';
import { overlays } from '../src/overlays.js'; 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}`, () => { describe(`OverlayMixin${suffix}`, () => {
let el; let el;
@ -32,7 +38,7 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
expect(el._overlayCtrl.isShown).to.be.false; 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; expect(el.opened).to.be.false;
await el._overlayCtrl.show(); await el._overlayCtrl.show();
expect(el.opened).to.be.true; expect(el.opened).to.be.true;
@ -66,9 +72,11 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
`); `);
expect(spy).not.to.have.been.called; expect(spy).not.to.have.been.called;
await el._overlayCtrl.show(); await el._overlayCtrl.show();
await el.updateComplete;
expect(spy.callCount).to.equal(1); expect(spy.callCount).to.equal(1);
expect(el.opened).to.be.true; expect(el.opened).to.be.true;
await el._overlayCtrl.hide(); await el._overlayCtrl.hide();
await el.updateComplete;
expect(spy.callCount).to.equal(2); expect(spy.callCount).to.equal(2);
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
@ -149,17 +157,46 @@ export function runOverlayMixinSuite({ /* tagString, */ tag, suffix = '' }) {
}); });
describe(`OverlayMixin${suffix} nested`, () => { 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 () => { it('reconstructs the overlay when disconnected and reconnected to DOM (support for nested overlay nodes)', async () => {
const nestedEl = await fixture(html` const nestedEl = await fixture(html`
<${tag}> <${tag} id="nest">
<div slot="content">content of the nested overlay</div> <div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button> <button slot="invoker">invoker nested</button>
</${tag}> </${tag}>
`); `);
const mainEl = await fixture(html` const mainEl = await fixture(html`
<${tag}> <${tag} id="main">
<div slot="content"> <div slot="content" id="mainContent">
open nested overlay: open nested overlay:
${nestedEl} ${nestedEl}
</div> </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.. // 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 // Find the outlets that are not backdrop outlets
const outletsInGlobalRootNode = Array.from(overlays.globalRootNode.children).filter( const overlayContainerNodes = getGlobalOverlayNodes();
child => expect(overlayContainerNodes.length).to.equal(2);
child.slot === '_overlay-shadow-outlet' && const lastContentNodeInContainer = overlayContainerNodes[0];
!child.classList.contains('global-overlays__backdrop'), // Check that the last container is the nested one with the intended content
);
// 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
expect(lastContentNodeInContainer.firstElementChild.innerText).to.equal( expect(lastContentNodeInContainer.firstElementChild.innerText).to.equal(
'content of the nested overlay', 'content of the nested overlay',
); );
expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content'); expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content');
} else { } else {
const actualNestedOverlay = mainEl._overlayContentNode.firstElementChild; const contentNode = mainEl._overlayContentNode.querySelector('#nestedContent');
const outletNode = Array.from(actualNestedOverlay.children).find( expect(contentNode).to.not.be.null;
child => child.slot === '_overlay-shadow-outlet',
);
const contentNode = Array.from(outletNode.children).find(child => child.slot === 'content');
expect(contentNode).to.not.be.undefined;
expect(contentNode.innerText).to.equal('content of the nested overlay'); 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 */ /* eslint-disable no-new */
import '@lion/core/test-helpers/keyboardEventShimIE.js';
import { import {
expect,
html,
fixture,
aTimeout, aTimeout,
defineCE, defineCE,
expect,
fixture,
html,
nextFrame,
unsafeStatic, unsafeStatic,
nextFrame,
} from '@open-wc/testing'; } from '@open-wc/testing';
import { fixtureSync } from '@open-wc/testing-helpers'; import { fixtureSync } from '@open-wc/testing-helpers';
import '@lion/core/test-helpers/keyboardEventShimIE.js';
import sinon from 'sinon'; 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 { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js'; import { simulateTab } from '../src/utils/simulate-tab.js';
import { OverlayController } from '../src/OverlayController.js';
import { overlays } from '../src/overlays.js';
const withGlobalTestConfig = () => ({ const withGlobalTestConfig = () => ({
placementMode: 'global', placementMode: 'global',
contentNode: fixtureSync(html`<div>my content</div>`), contentNode: fixtureSync(html` <div>my content</div> `),
}); });
const withLocalTestConfig = () => ({ const withLocalTestConfig = () => ({
placementMode: 'local', placementMode: 'local',
contentNode: fixtureSync(html`<div>my content</div>`), contentNode: fixtureSync(html` <div>my content</div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div> <div role="button" style="width: 100px; height: 20px;">Invoker</div>
`), `),
@ -68,7 +68,7 @@ describe('OverlayController', () => {
} }
if (mode === 'inline') { if (mode === 'inline') {
contentNode = await fixture(html` contentNode = await fixture(html`
<div style="z-index:${zIndexVal};"> <div style="z-index: ${zIndexVal} ;">
I should be on top I should be on top
</div> </div>
`); `);
@ -134,7 +134,7 @@ describe('OverlayController', () => {
it.skip('creates local target next to sibling for placement mode "local"', async () => { it.skip('creates local target next to sibling for placement mode "local"', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
invokerNode: await fixture(html`<button>Invoker</button>`), invokerNode: await fixture(html` <button>Invoker</button> `),
}); });
expect(ctrl._renderTarget).to.be.undefined; expect(ctrl._renderTarget).to.be.undefined;
expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling); expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling);
@ -158,7 +158,7 @@ describe('OverlayController', () => {
// TODO: Add teardown feature tests // TODO: Add teardown feature tests
describe('Teardown', () => { 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({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
}); });
@ -185,6 +185,52 @@ describe('OverlayController', () => {
}); });
expect(ctrl.invokerNode).to.have.trimmed.text('invoke'); 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', () => { describe('Feature Configuration', () => {
@ -220,7 +266,7 @@ describe('OverlayController', () => {
}); });
await ctrl.show(); 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 input1 = ctrl.contentNode.querySelectorAll('input')[0];
const input2 = ctrl.contentNode.querySelectorAll('input')[1]; 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 () => { 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({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
@ -246,7 +292,7 @@ describe('OverlayController', () => {
await fixture(html` ${ctrl.content} `); await fixture(html` ${ctrl.content} `);
await ctrl.show(); await ctrl.show();
const elOutside = await fixture(html`<input />`); const elOutside = await fixture(html` <input /> `);
const input = ctrl.contentNode.querySelector('input'); const input = ctrl.contentNode.querySelector('input');
input.focus(); input.focus();
@ -451,7 +497,7 @@ describe('OverlayController', () => {
}); });
it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => { 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 contentNode = await fixture('<div>Content</div>');
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
@ -938,7 +984,7 @@ describe('OverlayController', () => {
it('reinitializes content', async () => { it('reinitializes content', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: await fixture(html`<div>content1</div>`), contentNode: await fixture(html` <div>content1</div> `),
}); });
await ctrl.show(); // Popper adds inline styles await ctrl.show(); // Popper adds inline styles
expect(ctrl.content.style.transform).not.to.be.undefined; expect(ctrl.content.style.transform).not.to.be.undefined;
@ -946,13 +992,13 @@ describe('OverlayController', () => {
ctrl.updateConfig({ ctrl.updateConfig({
placementMode: 'local', placementMode: 'local',
contentNode: await fixture(html`<div>content2</div>`), contentNode: await fixture(html` <div>content2</div> `),
}); });
expect(ctrl.contentNode.textContent).to.include('content2'); expect(ctrl.contentNode.textContent).to.include('content2');
}); });
it('respects the initial config provided to new OverlayController(initialConfig)', async () => { 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({ const ctrl = new OverlayController({
// This is the shared config // This is the shared config
@ -972,7 +1018,7 @@ describe('OverlayController', () => {
// Currently not working, enable again when we fix updateConfig // Currently not working, enable again when we fix updateConfig
it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => { 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({ const ctrl = new OverlayController({
// This is the shared config // This is the shared config
@ -983,13 +1029,13 @@ describe('OverlayController', () => {
ctrl.show(); ctrl.show();
expect( 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; expect(ctrl.isShown).to.be.true;
ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } }); ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } });
expect( expect(
ctrl._contentNodeWrapper.classList.contains( ctrl._contentWrapperNode.classList.contains(
'global-overlays__overlay-container--top-right', 'global-overlays__overlay-container--top-right',
), ),
); );
@ -1186,5 +1232,26 @@ describe('OverlayController', () => {
}); });
}).to.throw('You need to provide a .contentNode'); }).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 { 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 { runOverlayMixinSuite } from '../test-suites/OverlayMixin.suite.js';
import { OverlayMixin } from '../src/OverlayMixin.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); const tag = unsafeStatic(tagString);
runOverlayMixinSuite({ describe('OverlayMixin integrations', () => {
tagString, runOverlayMixinSuite({
tag, tagString,
tag,
});
}); });

View file

@ -1,11 +1,11 @@
import { expect, fixture, fixtureSync, html } from '@open-wc/testing'; import { expect, fixture, fixtureSync, html } from '@open-wc/testing';
import Popper from 'popper.js/dist/esm/popper.min.js'; import Popper from 'popper.js/dist/esm/popper.min.js';
import { OverlayController } from '../src/OverlayController.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 = () => ({ const withLocalTestConfig = () => ({
placementMode: 'local', placementMode: 'local',
contentNode: fixtureSync(html`<div>my content</div>`), contentNode: fixtureSync(html` <div>my content</div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div> <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 () => { it('uses top as the default placement', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`), contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <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 () => { it('positions to preferred place if placement is set and space is available', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`), contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <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 () => { it('positions to different place if placement is set and no space is available', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...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` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
content content
@ -115,7 +115,7 @@ describe('Local Positioning', () => {
it('allows the user to override default Popper modifiers', async () => { it('allows the user to override default Popper modifiers', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`), contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <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 () => { it('positions the Popper element correctly on show', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`), contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <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 () => { it.skip('updates placement properly even during hidden state', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`), contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <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 () => { it.skip('updates positioning correctly during shown state when config gets updated', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html`<div style="width: 80px; height: 20px;"></div>`), contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `),
invokerNode: fixtureSync(html` invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
Invoker Invoker

View file

@ -332,7 +332,10 @@ export class LionSelectRich extends ScopedElementsMixin(
return html` return html`
<div class="input-group__input"> <div class="input-group__input">
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="input"></slot> <slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="input"></slot>
</div>
</div> </div>
`; `;
} }
@ -366,8 +369,12 @@ export class LionSelectRich extends ScopedElementsMixin(
this.__hasInitialSelectedFormElement = true; 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); 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.__proxyChildModelValueChanged({ target: child });
this.resetInteractionState(); this.resetInteractionState();

View file

@ -37,7 +37,7 @@ describe('lion-select-rich', () => {
expect(el.formElements[0].name).to.equal('foo'); expect(el.formElements[0].name).to.equal('foo');
expect(el.formElements[1].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); el.appendChild(validChild);
expect(el.formElements[2].name).to.equal('foo'); expect(el.formElements[2].name).to.equal('foo');
@ -54,7 +54,7 @@ describe('lion-select-rich', () => {
`); `);
await nextFrame(); 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(() => { expect(() => {
el.addFormElement(invalidChild); el.addFormElement(invalidChild);
@ -100,17 +100,6 @@ describe('lion-select-rich', () => {
expect(el.formElements[2].checked).to.be.true; 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 () => { it(`has a fieldName based on the label`, async () => {
const el1 = await fixture( const el1 = await fixture(
html` html`
@ -774,13 +763,13 @@ describe('lion-select-rich', () => {
it('allows to override the type of overlay', async () => { it('allows to override the type of overlay', async () => {
const mySelectTagString = defineCE( const mySelectTagString = defineCE(
class MySelect extends LionSelectRich { class MySelect extends LionSelectRich {
_defineOverlay({ invokerNode, contentNode }) { _defineOverlay({ invokerNode, contentNode, contentWrapperNode }) {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
placementMode: 'global', placementMode: 'global',
contentNode, contentNode,
contentWrapperNode,
invokerNode, invokerNode,
}); });
this.addEventListener('switch', () => { this.addEventListener('switch', () => {
ctrl.updateConfig({ placementMode: 'local' }); ctrl.updateConfig({ placementMode: 'local' });
}); });
@ -802,7 +791,7 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</${mySelectTag}> </${mySelectTag}>
`); `);
await el.updateComplete;
expect(el._overlayCtrl.placementMode).to.equal('global'); expect(el._overlayCtrl.placementMode).to.equal('global');
el.dispatchEvent(new Event('switch')); el.dispatchEvent(new Event('switch'));
expect(el._overlayCtrl.placementMode).to.equal('local'); expect(el._overlayCtrl.placementMode).to.equal('local');
@ -812,7 +801,7 @@ describe('lion-select-rich', () => {
const invokerTagName = defineCE( const invokerTagName = defineCE(
class extends LionSelectInvoker { class extends LionSelectInvoker {
_noSelectionTemplate() { _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 { 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'; import { OverlayMixin } from '@lion/overlays';
export class LionTooltip extends OverlayMixin(LitElement) { 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() { constructor() {
super(); super();
this._mouseActive = false; this._mouseActive = false;
@ -12,27 +75,28 @@ export class LionTooltip extends OverlayMixin(LitElement) {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._overlayContentNode.setAttribute('role', 'tooltip'); 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() { render() {
return html` return html`
<slot name="invoker"></slot> <slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="arrow"></slot>
<slot name="_overlay-shadow-outlet"></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() { // eslint-disable-next-line class-methods-use-this
this.__arrowElement = Array.from(this.children).find(child => child.slot === 'arrow'); _arrowTemplate() {
if (!this.__arrowElement) { return html`
return; <svg viewBox="0 0 12 8">
} <path d="M 0,0 h 12 L 6,8 z"></path>
this.__arrowElement.setAttribute('x-arrow', true); </svg>
this._overlayContentNodeWrapper.appendChild(this.__arrowElement); `;
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
@ -49,7 +113,7 @@ export class LionTooltip extends OverlayMixin(LitElement) {
enabled: true, enabled: true,
}, },
arrow: { arrow: {
enabled: true, enabled: this.hasArrow,
}, },
}, },
onCreate: data => { onCreate: data => {
@ -68,12 +132,15 @@ export class LionTooltip extends OverlayMixin(LitElement) {
}); });
} }
get _arrowNode() {
return this.shadowRoot.querySelector('[x-arrow]');
}
__syncFromPopperState(data) { __syncFromPopperState(data) {
if (!data) { if (!data) {
return; return;
} }
if (this.__arrowElement && data.placement !== this.__arrowElement.placement) { if (this._arrowNode && data.placement !== this._arrowNode.placement) {
this.__arrowElement.placement = data.placement;
this.__repositionCompleteResolver(data.placement); this.__repositionCompleteResolver(data.placement);
this.__setupRepositionCompletePromise(); 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 { Story, Meta, html, object, withKnobs } from '@open-wc/demoing-storybook';
import { css } from '@lion/core'; import { css } from '@lion/core';
import { tooltipDemoStyles } from './tooltipDemoStyles.js'; import { tooltipDemoStyles } from './tooltipDemoStyles.js';
import { LionTooltipArrow } from '../src/LionTooltipArrow.js'; import { LionTooltip } from '../src/LionTooltip.js';
import '../lion-tooltip.js'; import '../lion-tooltip.js';
import '../lion-tooltip-arrow.js';
<Meta <Meta
title="Overlays/Tooltip" title="Overlays/Tooltip"
parameters={{ parameters={{
@ -72,32 +70,28 @@ You can easily change the placement of the content node relative to the invoker.
${tooltipDemoStyles} ${tooltipDemoStyles}
</style> </style>
<div class="demo-box-placements"> <div class="demo-box-placements">
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }}> <lion-tooltip .config=${{ popperConfig: { placement: 'top' } }} has-arrow>
<button slot="invoker">Top</button> <button slot="invoker">Top</button>
<div slot="content" class="demo-tooltip-content">Its top placement</div> <div slot="content" class="demo-tooltip-content">Its top placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip> </lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'right' } }}> <lion-tooltip .config=${{ popperConfig: { placement: 'right' } }} has-arrow>
<button slot="invoker">Right</button> <button slot="invoker">Right</button>
<div slot="content" class="demo-tooltip-content">Its right placement</div> <div slot="content" class="demo-tooltip-content">Its right placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip> </lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'bottom' } }}> <lion-tooltip .config=${{ popperConfig: { placement: 'bottom' } }} has-arrow>
<button slot="invoker">Bottom</button> <button slot="invoker">Bottom</button>
<div slot="content" class="demo-tooltip-content">Its bottom placement</div> <div slot="content" class="demo-tooltip-content">Its bottom placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip> </lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'left' } }}> <lion-tooltip .config=${{ popperConfig: { placement: 'left' } }} has-arrow>
<button slot="invoker">Left</button> <button slot="invoker">Left</button>
<div slot="content" class="demo-tooltip-content">Its left placement</div> <div slot="content" class="demo-tooltip-content">Its left placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip> </lion-tooltip>
</div> </div>
`} `}
</Story> </Story>
```html ```html
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }}> <lion-tooltip .config=${{ popperConfig: { placement: 'top' } }} has-arrow>
<button slot="invoker">Top</button> <button slot="invoker">Top</button>
<div slot="content">Its top placement</div> <div slot="content">Its top placement</div>
</lion-tooltip> </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. - 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. - offset: enables an offset between the content node and the invoker node. First argument is horizontal marign, second argument is vertical margin.
### Arrow ### Arrow
Popper also comes with an arrow modifier. By default, the arrow is disabled for our tooltip. Via the `has-arrow` property it can be enabled.
In our tooltip you can pass an arrow element (e.g. an SVG Element) through the `slot="arrow"`.
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"> <Story name="Arrow">
{html` {html`
<style>${tooltipDemoStyles}</style> <style>${tooltipDemoStyles}</style>
<lion-tooltip> <lion-tooltip has-arrow>
<button slot="invoker" class="demo-tooltip-invoker">Hover me</button> <button slot="invoker" class="demo-tooltip-invoker">Hover me</button>
<div slot="content" class="demo-tooltip-content">This is a tooltip</div> <div slot="content" class="demo-tooltip-content">This is a tooltip</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip> </lion-tooltip>
`} `}
</Story> </Story>
```html ```html
<lion-tooltip> <lion-tooltip has-arrow>
<button slot="invoker">Hover me</button> <button slot="invoker">Hover me</button>
<div slot="content">This is a tooltip</div> <div slot="content">This is a tooltip</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip> </lion-tooltip>
``` ```
#### Use a custom arrow #### 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.). 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"> <Story name="Custom arrow">
{() => { {() => {
if (!customElements.get('custom-tooltip-arrow')) { if (!customElements.get('custom-tooltip')) {
customElements.define('custom-tooltip-arrow', class extends LionTooltipArrow { customElements.define('custom-tooltip', class extends LionTooltip {
static get styles() { static get styles() {
return [super.styles, css` return [super.styles, css`
:host { :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` return html`
<svg viewBox="0 0 20 8"> <svg viewBox="0 0 20 8">
<path d="M 0,0 h 20 L 10,8 z"></path> <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` return html`
<style>${tooltipDemoStyles}</style> <style>${tooltipDemoStyles}</style>
<lion-tooltip> <custom-tooltip>
<button slot="invoker" class="demo-tooltip-invoker">Hover me</button> <button slot="invoker" class="demo-tooltip-invoker">Hover me</button>
<div slot="content" class="demo-tooltip-content">This is a tooltip</div> <div slot="content" class="demo-tooltip-content">This is a tooltip</div>
<custom-tooltip-arrow slot="arrow"></custom-tooltip-arrow> </custom-tooltip>
</lion-tooltip>
`; `;
}} }}
</Story> </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 { html, css } from '@lion/core';
import { LionTooltipArrow } from '@lion/tooltip'; import { LionTooltipArrow } from '@lion/tooltip';
class CustomTooltipArrow extends LionTooltipArrow { class CustomTooltip extends LionTooltip {
static get styles() { static get styles() {
return [super.styles, css` return [super.styles, css`
:host { :host {
@ -257,8 +250,11 @@ class CustomTooltipArrow extends LionTooltipArrow {
} }
`]; `];
} }
constructor() {
render() { super();
this.hasArrow = true;
}
_arrowTemplate() {
return html` return html`
<svg viewBox="0 0 20 8"> <svg viewBox="0 0 20 8">
<path d="M 0,0 h 20 L 10,8 z"></path> <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 ```html
<lion-tooltip> <custom-tooltip>
<button slot="invoker">Hover me</button> <button slot="invoker">Hover me</button>
<div slot="content">This is a tooltip</div> <div slot="content">This is a tooltip</div>
<custom-tooltip-arrow slot="arrow"></custom-tooltip-arrow> </custom-tooltip>
</lion-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', () => { 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` const el = await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
@ -32,7 +32,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(false); 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` const el = await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
@ -49,7 +49,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(true); 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` const el = await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
@ -67,7 +67,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(false); 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` const el = await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
@ -85,7 +85,7 @@ describe('lion-tooltip', () => {
expect(el._overlayCtrl.isShown).to.equal(true); 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` const el = await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content"> <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', () => { describe('Positioning', () => {
it('updates popper positioning correctly, without overriding other modifiers', async () => { it('updates popper positioning correctly, without overriding other modifiers', async () => {
const el = await fixture(html` const el = await fixture(html`