import { assertExists } from '@blockeditor/global/utils'; import { arrow, type ComputePositionReturn, flip, offset, type Placement, } from '@floating-ui/dom'; import type { CSSResult } from 'lit'; import { css, html, LitElement, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { HoverController } from '../hover/index.js'; const styles = css` .affine-tooltip { box-sizing: border-box; max-width: 280px; min-height: 32px; font-family: var(--affine-font-family); font-size: var(--affine-font-sm); border-radius: 4px; padding: 6px 12px; color: var(--affine-white); background: var(--affine-tooltip); display: flex; justify-content: center; align-items: center; } .arrow { position: absolute; width: 0; height: 0; } `; // See http://apps.eky.hk/css-triangle-generator/ const TRIANGLE_HEIGHT = 6; const triangleMap = { top: { bottom: '-6px', borderStyle: 'solid', borderWidth: '6px 5px 0 5px', borderColor: 'var(--affine-tooltip) transparent transparent transparent', }, right: { left: '-6px', borderStyle: 'solid', borderWidth: '5px 6px 5px 0', borderColor: 'transparent var(--affine-tooltip) transparent transparent', }, bottom: { top: '-6px', borderStyle: 'solid', borderWidth: '0 5px 6px 5px', borderColor: 'transparent transparent var(--affine-tooltip) transparent', }, left: { right: '-6px', borderStyle: 'solid', borderWidth: '5px 0 5px 6px', borderColor: 'transparent transparent transparent var(--affine-tooltip)', }, }; // Ported from https://floating-ui.com/docs/tutorial#arrow-middleware const updateArrowStyles = ({ placement, middlewareData, }: ComputePositionReturn): StyleInfo => { const arrowX = middlewareData.arrow?.x; const arrowY = middlewareData.arrow?.y; const triangleStyles = triangleMap[placement.split('-')[0] as keyof typeof triangleMap]; return { left: arrowX != null ? `${arrowX}px` : '', top: arrowY != null ? `${arrowY}px` : '', ...triangleStyles, }; }; /** * @example * ```ts * // Simple usage * html` * Content * ` * // With placement * html` * * Content * * ` * * // With custom properties * html` * * Content * * ` * ``` */ @customElement('affine-tooltip') export class Tooltip extends LitElement { static override styles = css` :host { display: none; } `; @property({ attribute: 'tip-position' }) placement: Placement = 'top'; @property({ attribute: false }) zIndex: number | string = 'var(--affine-z-index-popover)'; @property({ attribute: false }) tooltipStyle: CSSResult = css``; /** * changes the placement of the floating element in order to keep it in view, * with the ability to flip to any placement. * * See https://floating-ui.com/docs/flip */ @property({ attribute: false }) autoFlip = true; /** * Show a triangle arrow pointing to the reference element. */ @property({ attribute: false }) arrow = true; /** * Default is `4px` * * See https://floating-ui.com/docs/offset */ @property({ attribute: false }) offset = 4; /** * Allow the tooltip to be interactive. * eg. allow the user to select text in the tooltip. */ @property({ attribute: false }) allowInteractive = false; private _hoverController = new HoverController( this, () => { // const parentElement = this.parentElement; // if ( // parentElement && // 'disabled' in parentElement && // parentElement.disabled // ) // return null; if (this.hidden) return null; let arrowStyles: StyleInfo = {}; return { template: ({ positionSlot, updatePortal }) => { positionSlot.on(data => { // The tooltip placement may change, // so we need to update the arrow position if (this.arrow) { arrowStyles = updateArrowStyles(data); } else { arrowStyles = {}; } updatePortal(); }); const slot = this.shadowRoot?.querySelector('slot'); if (!slot) throw new Error('slot not found in tooltip!'); // slot.addEventListener('slotchange', () => updatePortal, { // once: true, // }); const slottedChildren = slot .assignedNodes() .map(node => node.cloneNode(true)); return html`
`; }, computePosition: portalRoot => ({ referenceElement: this.parentElement!, placement: this.placement, middleware: [ this.autoFlip && flip({ padding: 12 }), offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset), arrow({ element: portalRoot.shadowRoot!.querySelector('.arrow')!, }), ], autoUpdate: true, }), }; }, { leaveDelay: 0, // The tooltip is not interactive by default safeBridge: false, allowMultiple: true, } ); override connectedCallback() { super.connectedCallback(); const parent = this.parentElement; assertExists(parent, 'Tooltip must have a parent element'); // Wait for render setTimeout(() => { this._hoverController.setReference(parent); }, 0); } getPortal() { return this._hoverController.portal; } private _getStyles() { return css` ${styles} :host { z-index: ${unsafeCSS(this.zIndex)}; ${ // All the styles are applied to the portal element unsafeCSS(this.style.cssText) } } ${this.allowInteractive ? css`` : css` :host { pointer-events: none; } `} ${this.tooltipStyle} `; } override render() { // The actual tooltip will render as a portal, and all content inside the slot will be treated as a template. // See https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots return html``; } } declare global { interface HTMLElementTagNameMap { 'affine-tooltip': Tooltip; } }