feat(tooltip): simplified tooltip component by making arrow a template

Co-authored-by: Aymen Ben Amor <Aymen.Ben.Amor@ing.com>
This commit is contained in:
Thijs Louisse 2020-05-17 17:36:18 +02:00
parent 8a6bef8142
commit 73eb90ab96
7 changed files with 165 additions and 232 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
import { Story, Meta, html, object, withKnobs } from '@open-wc/demoing-storybook';
import { css } from '@lion/core';
import { tooltipDemoStyles } from './tooltipDemoStyles.js';
import { LionTooltipArrow } from '../src/LionTooltipArrow.js';
import { LionTooltip } from '../src/LionTooltip.js';
import '../lion-tooltip.js';
import '../lion-tooltip-arrow.js';
<Meta
title="Overlays/Tooltip"
parameters={{
@ -72,32 +70,28 @@ You can easily change the placement of the content node relative to the invoker.
${tooltipDemoStyles}
</style>
<div class="demo-box-placements">
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }} has-arrow>
<button slot="invoker">Top</button>
<div slot="content" class="demo-tooltip-content">Its top placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'right' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'right' } }} has-arrow>
<button slot="invoker">Right</button>
<div slot="content" class="demo-tooltip-content">Its right placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'bottom' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'bottom' } }} has-arrow>
<button slot="invoker">Bottom</button>
<div slot="content" class="demo-tooltip-content">Its bottom placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
<lion-tooltip .config=${{ popperConfig: { placement: 'left' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'left' } }} has-arrow>
<button slot="invoker">Left</button>
<div slot="content" class="demo-tooltip-content">Its left placement</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
</div>
`}
</Story>
```html
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }}>
<lion-tooltip .config=${{ popperConfig: { placement: 'top' } }} has-arrow>
<button slot="invoker">Top</button>
<div slot="content">Its top placement</div>
</lion-tooltip>
@ -178,44 +172,40 @@ Modifier explanations:
- flip: enables flipping behavior on the primary axis (e.g. if top placement, flipping to bottom if there is not enough space on the top). The padding property defines the margin with the boundariesElement, which is usually the viewport.
- offset: enables an offset between the content node and the invoker node. First argument is horizontal marign, second argument is vertical margin.
### Arrow
Popper also comes with an arrow modifier.
In our tooltip you can pass an arrow element (e.g. an SVG Element) through the `slot="arrow"`.
By default, the arrow is disabled for our tooltip. Via the `has-arrow` property it can be enabled.
We export a `lion-tooltip-arrow` that you can use by default for this.
> As a Subclasser, you can decide to turn the arrow on by default if this fits in your Design System
<Story name="Arrow">
{html`
<style>${tooltipDemoStyles}</style>
<lion-tooltip>
<lion-tooltip has-arrow>
<button slot="invoker" class="demo-tooltip-invoker">Hover me</button>
<div slot="content" class="demo-tooltip-content">This is a tooltip</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
`}
</Story>
```html
<lion-tooltip>
<lion-tooltip has-arrow>
<button slot="invoker">Hover me</button>
<div slot="content">This is a tooltip</div>
<lion-tooltip-arrow slot="arrow"></lion-tooltip-arrow>
</lion-tooltip>
```
#### Use a custom arrow
If you plan on passing your own arrow element, you can extend the `lion-tooltip-arrow`.
If you plan on providing a custom arrow, you can extend the `lion-tooltip`.
All you need to do is override the `render` method to pass your own SVG, and extend the styles to pass the proper dimensions of your arrow.
All you need to do is override the `_arrowTemplate` method to pass your own SVG, and extend the styles to pass the proper dimensions of your arrow.
The rest of the work is done by Popper.js (for positioning) and the `lion-tooltip-arrow` (arrow dimensions, rotation, etc.).
<Story name="Custom arrow">
{() => {
if (!customElements.get('custom-tooltip-arrow')) {
customElements.define('custom-tooltip-arrow', class extends LionTooltipArrow {
if (!customElements.get('custom-tooltip')) {
customElements.define('custom-tooltip', class extends LionTooltip {
static get styles() {
return [super.styles, css`
:host {
@ -224,7 +214,11 @@ The rest of the work is done by Popper.js (for positioning) and the `lion-toolti
}
`];
}
render() {
constructor() {
super();
this.hasArrow = true;
}
_arrowTemplate() {
return html`
<svg viewBox="0 0 20 8">
<path d="M 0,0 h 20 L 10,8 z"></path>
@ -235,11 +229,10 @@ The rest of the work is done by Popper.js (for positioning) and the `lion-toolti
}
return html`
<style>${tooltipDemoStyles}</style>
<lion-tooltip>
<custom-tooltip>
<button slot="invoker" class="demo-tooltip-invoker">Hover me</button>
<div slot="content" class="demo-tooltip-content">This is a tooltip</div>
<custom-tooltip-arrow slot="arrow"></custom-tooltip-arrow>
</lion-tooltip>
</custom-tooltip>
`;
}}
</Story>
@ -248,7 +241,7 @@ The rest of the work is done by Popper.js (for positioning) and the `lion-toolti
import { html, css } from '@lion/core';
import { LionTooltipArrow } from '@lion/tooltip';
class CustomTooltipArrow extends LionTooltipArrow {
class CustomTooltip extends LionTooltip {
static get styles() {
return [super.styles, css`
:host {
@ -257,8 +250,11 @@ class CustomTooltipArrow extends LionTooltipArrow {
}
`];
}
render() {
constructor() {
super();
this.hasArrow = true;
}
_arrowTemplate() {
return html`
<svg viewBox="0 0 20 8">
<path d="M 0,0 h 20 L 10,8 z"></path>
@ -266,27 +262,12 @@ class CustomTooltipArrow extends LionTooltipArrow {
`;
}
}
customElements.define('custom-tooltip-arrow', CustomTooltipArrow);
customElements.define('custom-tooltip', CustomTooltip);
```
```html
<lion-tooltip>
<custom-tooltip>
<button slot="invoker">Hover me</button>
<div slot="content">This is a tooltip</div>
<custom-tooltip-arrow slot="arrow"></custom-tooltip-arrow>
</lion-tooltip>
</custom-tooltip>
```
#### Rationale
**Why a Web Component for the Arrow?**
Our preferred API is to pass the arrow as a slot.
Popper.JS however, necessitates that the arrow element is **inside** the content node,
otherwise it cannot compute the positioning of the Popper element and the arrow element, so we move the arrow node inside the content node.
This means that we can no longer style the arrow through the `lion-tooltip` with the `::slotted` selector,
because the arrow element is no longer a direct child of `lion-tooltip`.
Additionally we now also need to sync the popper placement state to the arrow element, to style it properly.
For all this logic + style encapsulation, we decided a Web Component was the only reasonable solution for the arrow element.

View file

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

View file

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