import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import {
arrow,
type ComputePositionReturn,
flip,
hide,
offset,
type Placement,
shift,
} from '@floating-ui/dom';
import type { CSSResult } from 'lit';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { HoverController, type HoverOptions } 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);
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
}
.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)',
},
};
// The padding for the autoShift and autoFlip middleware
// It's used to prevent the tooltip from overflowing the screen
const AUTO_SHIFT_PADDING = 12;
const AUTO_FLIP_PADDING = 12;
// 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
*
* `
* ```
*/
export class Tooltip extends LitElement {
static override styles = css`
:host {
display: none;
}
`;
private _hoverController!: HoverController;
private readonly _setUpHoverController = () => {
this._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 = {};
let tooltipStyles: StyleInfo = {};
return {
template: ({ positionSlot, updatePortal }) => {
positionSlot.subscribe(data => {
// The tooltip placement may change,
// so we need to update the arrow position
if (this.arrow) {
arrowStyles = updateArrowStyles(data);
} else {
arrowStyles = {};
}
if (this.autoHide) {
tooltipStyles.visibility = data.middlewareData.hide
?.referenceHidden
? 'hidden'
: '';
arrowStyles.visibility = tooltipStyles.visibility;
}
updatePortal();
});
const children = Array.from(this.childNodes).map(node =>
node.cloneNode(true)
);
return html`
${children}
`;
},
computePosition: portalRoot => ({
referenceElement: this.parentElement!,
placement: this.placement,
middleware: [
this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }),
this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }),
offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset),
arrow({
element: portalRoot.shadowRoot!.querySelector('.arrow')!,
}),
this.autoHide && hide({ strategy: 'referenceHidden' }),
],
autoUpdate: true,
}),
};
},
{
leaveDelay: 0,
// The tooltip is not interactive by default
safeBridge: false,
allowMultiple: true,
...this.hoverOptions,
}
);
const parent = this.parentElement;
if (!parent) {
console.error('Tooltip must have a parent element');
return;
}
// Wait for render
requestConnectedFrame(() => {
this._hoverController.setReference(parent);
}, this);
};
private _getStyles() {
return css`
${styles}
:host {
z-index: ${unsafeCSS(this.zIndex)};
opacity: 0;
// All the styles are applied to the portal element
${unsafeCSS(this.style.cssText)}
}
${this.allowInteractive
? css``
: css`
:host {
pointer-events: none;
}
`}
${this.tooltipStyle}
`;
}
override connectedCallback() {
super.connectedCallback();
this._setUpHoverController();
}
getPortal() {
return this._hoverController.portal;
}
/**
* Allow the tooltip to be interactive.
* eg. allow the user to select text in the tooltip.
*/
@property({ attribute: false })
accessor allowInteractive = false;
/**
* Show a triangle arrow pointing to the reference element.
*/
@property({ attribute: false })
accessor arrow = true;
/**
* 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 })
accessor autoFlip = true;
/**
* Hide the tooltip when the reference element is not in view.
*
* See https://floating-ui.com/docs/hide
*/
@property({ attribute: false })
accessor autoHide = false;
/**
* shifts the floating element to keep it in view.
* this prevents the floating element from
* overflowing along its axis of alignment,
* thereby preserving the side it’s placed on.
*
* See https://floating-ui.com/docs/shift
*/
@property({ attribute: false })
accessor autoShift = false;
@property({ attribute: false })
accessor hoverOptions: Partial = {};
/**
* Default is `4px`
*
* See https://floating-ui.com/docs/offset
*/
@property({ attribute: false })
accessor offset = 4;
@property({ attribute: 'tip-position' })
accessor placement: Placement = 'top';
@property({ attribute: false })
accessor tooltipStyle: CSSResult = css``;
@property({ attribute: false })
accessor zIndex: number | string = 'var(--affine-z-index-popover)';
}
declare global {
interface HTMLElementTagNameMap {
'affine-tooltip': Tooltip;
}
}