From a9d6971c67a39f0918ed206fc7b2d1d6a150dd3e Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Wed, 16 Sep 2020 10:54:00 +0200 Subject: [PATCH] feat(overlays): abstract arrow logic from tooltip into ArrowMixin --- .changeset/nine-actors-occur.md | 6 + packages/overlays/README.md | 1 - packages/overlays/docs/20-index.md | 64 ++++++- packages/overlays/docs/demo-overlay-system.js | 1 - packages/overlays/index.js | 1 + packages/overlays/src/ArrowMixin.js | 162 ++++++++++++++++++ packages/overlays/src/OverlayController.js | 1 - packages/overlays/test/ArrowMixin.test.js | 106 ++++++++++++ packages/overlays/test/OverlayMixin.test.js | 1 - packages/overlays/types/ArrowMixinTypes.d.ts | 32 ++++ packages/tooltip/README.md | 2 +- packages/tooltip/src/LionTooltip.js | 138 ++------------- packages/tooltip/test/lion-tooltip.test.js | 10 -- 13 files changed, 384 insertions(+), 141 deletions(-) create mode 100644 .changeset/nine-actors-occur.md create mode 100644 packages/overlays/src/ArrowMixin.js create mode 100644 packages/overlays/test/ArrowMixin.test.js create mode 100644 packages/overlays/types/ArrowMixinTypes.d.ts diff --git a/.changeset/nine-actors-occur.md b/.changeset/nine-actors-occur.md new file mode 100644 index 000000000..6c410fd84 --- /dev/null +++ b/.changeset/nine-actors-occur.md @@ -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. diff --git a/packages/overlays/README.md b/packages/overlays/README.md index 7832cc2ce..5d71b2360 100644 --- a/packages/overlays/README.md +++ b/packages/overlays/README.md @@ -111,7 +111,6 @@ class MyOverlayComponent extends LitElement { render() { return html` -
diff --git a/packages/overlays/docs/20-index.md b/packages/overlays/docs/20-index.md index 79361bd61..c082dbae2 100644 --- a/packages/overlays/docs/20-index.md +++ b/packages/overlays/docs/20-index.md @@ -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` -
@@ -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 ``, `` and ``. +- `render`, the template needs to include a `` and `` 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` -
@@ -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` + + +
This is a tooltip with an arrow
+ + `; +}; +``` diff --git a/packages/overlays/docs/demo-overlay-system.js b/packages/overlays/docs/demo-overlay-system.js index 4bcbfa85f..c13b97298 100644 --- a/packages/overlays/docs/demo-overlay-system.js +++ b/packages/overlays/docs/demo-overlay-system.js @@ -40,7 +40,6 @@ class DemoOverlaySystem extends OverlayMixin(LitElement) { render() { return html` -
diff --git a/packages/overlays/index.js b/packages/overlays/index.js index a520b2394..4e93fcd88 100644 --- a/packages/overlays/index.js +++ b/packages/overlays/index.js @@ -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'; diff --git a/packages/overlays/src/ArrowMixin.js b/packages/overlays/src/ArrowMixin.js new file mode 100644 index 000000000..14e9239ce --- /dev/null +++ b/packages/overlays/src/ArrowMixin.js @@ -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` + +
+ +
${this._arrowTemplate()}
+
+ `; + } + + // eslint-disable-next-line class-methods-use-this + _arrowTemplate() { + return html` + + + + `; + } + + /** + * @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); diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js index 05861310b..5a4566e84 100644 --- a/packages/overlays/src/OverlayController.js +++ b/packages/overlays/src/OverlayController.js @@ -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; diff --git a/packages/overlays/test/ArrowMixin.test.js b/packages/overlays/test/ArrowMixin.test.js new file mode 100644 index 000000000..f7e557d38 --- /dev/null +++ b/packages/overlays/test/ArrowMixin.test.js @@ -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` + +
This is a tooltip
+ +
+ `)); + 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` + +
This is a tooltip
+ +
+ `)); + 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` + +
Hey there
+ +
+ `)); + + 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) + `, + ); + }); +}); diff --git a/packages/overlays/test/OverlayMixin.test.js b/packages/overlays/test/OverlayMixin.test.js index ca4955405..842b90e72 100644 --- a/packages/overlays/test/OverlayMixin.test.js +++ b/packages/overlays/test/OverlayMixin.test.js @@ -8,7 +8,6 @@ const tagString = defineCE( render() { return html` -
content of the overlay
diff --git a/packages/overlays/types/ArrowMixinTypes.d.ts b/packages/overlays/types/ArrowMixinTypes.d.ts new file mode 100644 index 000000000..c13f54c6c --- /dev/null +++ b/packages/overlays/types/ArrowMixinTypes.d.ts @@ -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; + + static styles: CSSResultArray; + + render(): TemplateResult; + _arrowTemplate(): TemplateResult; + _defineOverlayConfig(): OverlayConfig; + __setupRepositionCompletePromise(): void; + get _arrowNode(): Element | null; + __syncFromPopperState(data: Data): void; +} + +export declare function ArrowImplementation>( + superclass: T, +): T & Constructor & ArrowHost; + +export type ArrowMixin = typeof ArrowImplementation; diff --git a/packages/tooltip/README.md b/packages/tooltip/README.md index 251dfd79d..4bc63b7c3 100644 --- a/packages/tooltip/README.md +++ b/packages/tooltip/README.md @@ -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` diff --git a/packages/tooltip/src/LionTooltip.js b/packages/tooltip/src/LionTooltip.js index 5f5ff55ff..666482d20 100644 --- a/packages/tooltip/src/LionTooltip.js +++ b/packages/tooltip/src/LionTooltip.js @@ -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` - - -
- -
${this._arrowTemplate()}
-
- `; - } - - // eslint-disable-next-line class-methods-use-this - _arrowTemplate() { - return html` - - - - `; } // 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); diff --git a/packages/tooltip/test/lion-tooltip.test.js b/packages/tooltip/test/lion-tooltip.test.js index 6effd50ce..802c8c76a 100644 --- a/packages/tooltip/test/lion-tooltip.test.js +++ b/packages/tooltip/test/lion-tooltip.test.js @@ -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)', - // ); }); });