feat(overlays): abstract arrow logic from tooltip into ArrowMixin

This commit is contained in:
Joren Broekema 2020-09-16 10:54:00 +02:00 committed by Thomas Allmer
parent de0e0d52b5
commit a9d6971c67
13 changed files with 384 additions and 141 deletions

View file

@ -0,0 +1,6 @@
---
'@lion/overlays': minor
'@lion/tooltip': minor
---
Abstracted the tooltip arrow related logic to a mixin, so it can be used in other overlays. Also created some demos to show this.

View file

@ -111,7 +111,6 @@ class MyOverlayComponent extends 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

@ -4,7 +4,9 @@
```js script
import { html } from 'lit-html';
import { render, LitElement } from '@lion/core';
import {
ArrowMixin,
OverlayMixin,
withBottomSheetConfig,
withDropdownConfig,
@ -94,8 +96,8 @@ or in your Web Component with `OverlayMixin`, make sure you override these metho
- Handle the tearing down of those event listeners
- 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.
- content slot for your user to provide the content that shows when the overlay is opened,
make sure to put it inside a div with id `overlay-content-node-wrapper` which is necessary for positioning logic to work properly.
```js
_defineOverlayConfig() {
@ -126,7 +128,6 @@ _teardownOpenCloseListeners() {
render() {
return html`
<slot name="invoker"></slot>
<slot name="_overlay-shadow-outlet"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
@ -356,7 +357,7 @@ Under the hood, the `OverlayMixin` will instantiate an OverlayController with th
By default, there are only a few `OverlayMixin` methods you need to override to create a working Web Component using an overlay:
- `render`, the template needs to include a `<slot name="content">`, `<slot name="invoker">` and `<slot name="_overlay-shadow-outlet">`.
- `render`, the template needs to include a `<slot name="invoker">` and `<slot name="content">` inside a div with id `overlay-content-node-wrapper` (for positioning).
- `_defineOverlayConfig`, in this protected method, return an object that contains the default configuration for your Web Component's overlay. See configuration section of OverlayController.
- `_setupOpenCloseListeners`, use this lifecycle hook to setup the open and close event listeners on your `_overlayInvokerNode`.
- `_teardownOpenCloseListeners`, use this lifecycle hook to ensure that the listeners are removed when the OverlayController is tearing down. For example when the Web Component is disconnected from the DOM.
@ -397,7 +398,6 @@ class MyOverlayWC 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>
@ -568,3 +568,57 @@ export const nestedOverlays = () => {
`;
};
```
## Local overlay with an arrow
To add an arrow to the localOverlay you can add `ArrowMixin` to your component.
And add the `arrowPopperConfig` to the `_defineOverlayConfig`.
```js preview-story
export const LocalWithArrow = () => {
class ArrowExample extends ArrowMixin(OverlayMixin(LitElement)) {
// Alternatively, set `this.config = { popperConfig: { placement: 'bottom' } }` on connectedCallback
_defineOverlayConfig() {
return {
...super._defineOverlayConfig(),
popperConfig: {
...super._defineOverlayConfig().popperConfig,
placement: 'bottom',
},
};
}
constructor() {
super();
this.__toggle = this.__toggle.bind(this);
}
__toggle() {
this.opened = !this.opened;
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.__toggle);
}
}
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.removeEventListener('click', this.__toggle);
}
}
}
if (!customElements.get('arrow-example')) {
customElements.define('arrow-example', ArrowExample);
}
return html`
<arrow-example>
<button slot="invoker">Click me to open the overlay!</button>
<div slot="content">This is a tooltip with an arrow<div>
</arrow-example>
`;
};
```

View file

@ -40,7 +40,6 @@ class DemoOverlaySystem 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

@ -3,6 +3,7 @@ export { overlays, setOverlays } from './src/overlays.js';
export { OverlaysManager } from './src/OverlaysManager.js';
export { OverlayController } from './src/OverlayController.js';
export { OverlayMixin } from './src/OverlayMixin.js';
export { ArrowMixin } from './src/ArrowMixin.js';
export { withBottomSheetConfig } from './src/configurations/withBottomSheetConfig.js';
export { withModalDialogConfig } from './src/configurations/withModalDialogConfig.js';

View file

@ -0,0 +1,162 @@
import { css, html, dedupeMixin } from '@lion/core';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('../types/ArrowMixinTypes').ArrowMixin} ArrowMixin
*
*/
/**
* @type {ArrowMixin}
*/
export const ArrowMixinImplementation = superclass =>
class ArrowMixin extends superclass {
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;
}
.arrow {
display: none;
position: absolute;
width: var(--tooltip-arrow-width);
height: var(--tooltip-arrow-height);
}
:host([has-arrow]) .arrow {
display: block;
}
.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);
}
`;
}
constructor() {
super();
this.hasArrow = true;
this.__setupRepositionCompletePromise();
}
render() {
return html`
<slot name="invoker"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
<div class="arrow" x-arrow>${this._arrowTemplate()}</div>
</div>
`;
}
// 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>
`;
}
/**
* @overridable method `_defineOverlay`
* @desc Overrides arrow and keepTogether modifier to be enabled,
* and adds onCreate and onUpdate hooks to sync from popper state
* @returns {OverlayConfig}
*/
// eslint-disable-next-line
_defineOverlayConfig() {
if (!this.hasArrow) {
return super._defineOverlayConfig();
}
return {
...super._defineOverlayConfig(),
popperConfig: {
...super._defineOverlayConfig()?.popperConfig,
placement: 'top',
modifiers: {
...super._defineOverlayConfig()?.popperConfig?.modifiers,
keepTogether: {
...super._defineOverlayConfig()?.popperConfig?.modifiers?.keepTogether,
enabled: true,
},
arrow: {
...super._defineOverlayConfig()?.popperConfig?.modifiers?.arrow,
enabled: true,
},
},
/** @param {import("popper.js").default.Data} data */
onCreate: data => {
this.__syncFromPopperState(data);
},
/** @param {import("popper.js").default.Data} data */
onUpdate: data => {
this.__syncFromPopperState(data);
},
},
};
}
__setupRepositionCompletePromise() {
this.repositionComplete = new Promise(resolve => {
this.__repositionCompleteResolver = resolve;
});
}
get _arrowNode() {
return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('[x-arrow]');
}
/**
* @param {import("popper.js").default.Data} data
*/
__syncFromPopperState(data) {
if (!data) {
return;
}
if (
this._arrowNode &&
data.placement !== /** @type {Element & {placement:string}} */ (this._arrowNode).placement
) {
/** @type {function} */ (this.__repositionCompleteResolver)(data.placement);
this.__setupRepositionCompletePromise();
}
}
};
export const ArrowMixin = dedupeMixin(ArrowMixinImplementation);

View file

@ -895,7 +895,6 @@ export class OverlayController extends EventTargetShim {
/** @type {HTMLElement} */
(this.backdropNode).classList.add('local-overlays__backdrop');
}
this.backdropNode.slot = '_overlay-shadow-outlet';
/** @type {HTMLElement} */
(this.contentNode.parentNode).insertBefore(this.backdropNode, this.contentNode);
break;

View file

@ -0,0 +1,106 @@
import { expect, fixture } from '@open-wc/testing';
import { LitElement, html } from '@lion/core';
import { ArrowMixin, OverlayMixin } from '../index.js';
describe('ArrowMixin', () => {
class ArrowTest extends ArrowMixin(OverlayMixin(LitElement)) {
/**
* @overridable method `_defineOverlay`
* @desc Overrides arrow and keepTogether modifier to be enabled,
* and adds onCreate and onUpdate hooks to sync from popper state
* @returns {import('../types/OverlayConfig').OverlayConfig}
*/
_defineOverlayConfig() {
return {
...super._defineOverlayConfig(),
placementMode: 'local',
popperConfig: {
...super._defineOverlayConfig().popperConfig,
placement: 'bottom',
},
};
}
constructor() {
super();
this.__toggle = this.__toggle.bind(this);
}
__toggle() {
this.opened = !this.opened;
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.__toggle);
}
}
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.removeEventListener('click', this.__toggle);
}
}
}
before(() => {
customElements.define('arrow-test', ArrowTest);
});
it('shows by default', async () => {
const el = /** @type {ArrowTest} */ (await fixture(html`
<arrow-test>
<div slot="content">This is a tooltip</div>
<button slot="invoker">Tooltip button</button>
</arrow-test>
`));
expect(el.hasAttribute('has-arrow')).to.be.true;
const arrowNode = /** @type {Element} */ (el._arrowNode);
expect(window.getComputedStyle(arrowNode).getPropertyValue('display')).to.equal('block');
});
it('hides the arrow when has-arrow is false', async () => {
const el = /** @type {ArrowTest} */ (await fixture(html`
<arrow-test>
<div slot="content">This is a tooltip</div>
<button slot="invoker">Tooltip button</button>
</arrow-test>
`));
el.hasArrow = false;
await el.updateComplete;
expect(el.hasAttribute('has-arrow')).to.be.false;
const arrowNode = /** @type {Element} */ (el._arrowNode);
expect(window.getComputedStyle(arrowNode).getPropertyValue('display')).to.equal('none');
});
it('makes sure positioning of the arrow is correct', async () => {
const el = /** @type {ArrowTest} */ (await fixture(html`
<arrow-test
.config="${/** @type {import('../types/OverlayConfig').OverlayConfig} */ ({
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>
</arrow-test>
`));
el.opened = true;
await el.repositionComplete;
expect(
getComputedStyle(/** @type {HTMLElement} */ (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)
`,
);
});
});

View file

@ -8,7 +8,6 @@ const tagString = defineCE(
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>

View file

@ -0,0 +1,32 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement, TemplateResult } from '@lion/core';
import { CSSResultArray } from 'lit-element';
import Data from 'popper.js';
import { OverlayConfig } from '../types/OverlayConfig';
export declare class ArrowHost {
static get properties(): {
hasArrow: {
type: BooleanConstructor;
reflect: boolean;
attribute: string;
};
};
hasArrow: boolean;
repositionComplete: Promise<void>;
static styles: CSSResultArray;
render(): TemplateResult;
_arrowTemplate(): TemplateResult;
_defineOverlayConfig(): OverlayConfig;
__setupRepositionCompletePromise(): void;
get _arrowNode(): Element | null;
__syncFromPopperState(data: Data): void;
}
export declare function ArrowImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<ArrowHost> & ArrowHost;
export type ArrowMixin = typeof ArrowImplementation;

View file

@ -180,7 +180,7 @@ Modifier explanations:
By default, the arrow is disabled for our tooltip. Via the `has-arrow` property it can be enabled.
> As a Subclasser, you can decide to turn the arrow on by default if this fits your Design System
> As a Subclasser, you can decide to turn the arrow on by default if this fits your Design System, by setting `this.hasArrow = true;` in the constructor.
```js preview-story
export const arrow = () => html`

View file

@ -1,21 +1,18 @@
import { css, html, LitElement } from '@lion/core';
import { OverlayMixin } from '@lion/overlays';
import { css, LitElement } from '@lion/core';
import { ArrowMixin, OverlayMixin } from '@lion/overlays';
/**
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('@lion/core').CSSResult} CSSResult
* @typedef {import('lit-element').CSSResultArray} CSSResultArray
*/
/**
* @customElement lion-tooltip
*/
export class LionTooltip extends OverlayMixin(LitElement) {
export class LionTooltip extends ArrowMixin(OverlayMixin(LitElement)) {
static get properties() {
return {
hasArrow: {
type: Boolean,
reflect: true,
attribute: 'has-arrow',
},
invokerRelation: {
type: String,
attribute: 'invoker-relation',
@ -24,56 +21,18 @@ export class LionTooltip extends OverlayMixin(LitElement) {
}
static get styles() {
return css`
:host {
--tooltip-arrow-width: 12px;
--tooltip-arrow-height: 8px;
display: inline-block;
}
return [
/** @type {CSSResult | CSSStyleSheet | CSSResultArray} */ (super.styles),
css`
:host {
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;
}
`;
:host([hidden]) {
display: none;
}
`,
];
}
constructor() {
@ -91,85 +50,22 @@ export class LionTooltip extends OverlayMixin(LitElement) {
this.invokerRelation = 'description';
this._mouseActive = false;
this._keyActive = false;
this.__setupRepositionCompletePromise();
}
render() {
return html`
<slot name="invoker"></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>
`;
}
// 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
_defineOverlayConfig() {
return /** @type {OverlayConfig} */ ({
...super._defineOverlayConfig(),
placementMode: 'local',
elementToFocusAfterHide: undefined,
hidesOnEsc: true,
hidesOnOutsideEsc: true,
popperConfig: {
placement: 'top', // default
modifiers: {
keepTogether: {
enabled: true,
},
arrow: {
enabled: this.hasArrow,
},
},
onCreate: data => {
this.__syncFromPopperState(data);
},
onUpdate: data => {
this.__syncFromPopperState(data);
},
},
handlesAccessibility: true,
isTooltip: true,
invokerRelation: this.invokerRelation,
});
}
__setupRepositionCompletePromise() {
this.repositionComplete = new Promise(resolve => {
this.__repositionCompleteResolver = resolve;
});
}
get _arrowNode() {
return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('[x-arrow]');
}
/**
* @param {import("popper.js").default.Data} data
*/
__syncFromPopperState(data) {
if (!data) {
return;
}
if (
this._arrowNode &&
data.placement !== /** @type {Element & {placement:string}} */ (this._arrowNode).placement
) {
/** @type {function} */ (this.__repositionCompleteResolver)(data.placement);
this.__setupRepositionCompletePromise();
}
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
this.__resetActive = this.__resetActive.bind(this);

View file

@ -185,11 +185,6 @@ describe('lion-tooltip', () => {
const initialPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.config.popperConfig.placement).to.equal('top');
// TODO: this fails in CI, we need to investigate why in CI
// the value of the transform is: translate3d(16px, -26px, 0px)'
// expect(el.querySelector('[slot=_overlay-shadow-outlet]').style.transform).to.equal(
// 'translate3d(15px, -26px, 0px)',
// );
el.config = {
popperConfig: {
@ -205,11 +200,6 @@ describe('lion-tooltip', () => {
expect(updatedPopperModifiers).to.deep.equal(initialPopperModifiers);
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.config.popperConfig.placement).to.equal('bottom');
// TODO: this fails in CI, we need to investigate why in CI
// the value of the transform is: translate3d(16px, 26px, 0px)'
// expect(el.querySelector('[slot=_overlay-shadow-outlet]').style.transform).to.equal(
// 'translate3d(15px, 26px, 0px)',
// );
});
});