/** * This module implements tooltips of two kinds: * - to be shown on hover, similar to the native "title" attribute (popweasel meant to provide * that, but its machinery isn't really needed). TODO these aren't yet implemented. * - to be shown briefly, as a transient notification next to some action element. */ import {prepareForTransition} from 'app/client/ui/transitions'; import {testId, theme} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {dom, DomContents, DomElementArg, DomElementMethod, styled} from 'grainjs'; import Popper from 'popper.js'; export interface ITipOptions { // Where to place the tooltip relative to the reference element. Defaults to 'top'. // See https://popper.js.org/docs/v1/#popperplacements--codeenumcode placement?: Popper.Placement; // When set, a tooltip will replace any previous tooltip with the same key. key?: string; } export interface ITransientTipOptions extends ITipOptions { // When to remove the transient tooltip. Defaults to 2000ms. timeoutMs?: number; } export interface IHoverTipOptions extends ITransientTipOptions { // How soon after mouseenter to show it. Defaults to 100 ms. openDelay?: number; // If set and non-zero, remove the tip automatically after this time. timeoutMs?: number; // How soon after mouseleave to hide it. Defaults to 400 ms. It also gives the pointer some time // to be outside of the trigger and the tooltip content if the user moves the pointer from one // to the other. closeDelay?: number; // Also show the tip on clicking the element it's attached to. openOnClick?: boolean; } export type ITooltipContentFunc = (ctl: ITooltipControl) => DomContents; export interface ITooltipControl { close(): void; getDom(): HTMLElement; // The tooltip DOM. } // Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows // removing the tooltip. const openTooltips = new Map(); /** * Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default). * See also ITipOptions. */ export function showTransientTooltip( refElem: Element, tipContent: DomContents | ITooltipContentFunc, options: ITransientTipOptions = {}) { const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options); const origClose = ctl.close; ctl.close = () => { clearTimeout(timer); origClose(); }; const timer = setTimeout(ctl.close, options.timeoutMs || 2000); return ctl; } /** * Show the return value of tipContent(ctl) in a tooltip next to refElem (on top of it, by default). * Returns ctl. In both places, ctl is an object with a close() method, which closes the tooltip. * See also ITipOptions. */ export function showTooltip( refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {} ): ITooltipControl { const placement: Popper.Placement = options.placement || 'top'; const key = options.key; let closed = false; // If we had a previous tooltip with the same key, clean it up. if (key) { openTooltips.get(key)?.close(); } // Cleanup involves destroying the Popper instance, removing the element, etc. function close() { if (closed) { return; } closed = true; popper.destroy(); dom.domDispose(content); content.remove(); if (key) { openTooltips.delete(key); } } const ctl: ITooltipControl = {close, getDom: () => content}; // Add the content element. const content = cssTooltip({role: 'tooltip'}, tipContent(ctl), testId(`tooltip`)); document.body.appendChild(content); // Create a popper for positioning the tooltip content relative to refElem. const popperOptions: Popper.PopperOptions = { modifiers: {preventOverflow: {boundariesElement: 'viewport'}}, placement, }; const popper = new Popper(refElem, content, popperOptions); // If refElem is disposed we close the tooltip. dom.onDisposeElem(refElem, close); // Fade in the content using transitions. prepareForTransition(content, () => { content.style.opacity = '0'; }); content.style.opacity = ''; if (key) { openTooltips.set(key, ctl); } return ctl; } /** * Render a tooltip on hover. Suitable for use during dom construction, e.g. * dom('div', 'Trigger', hoverTooltip(() => 'Hello!')) */ export function hoverTooltip(tipContent: ITooltipContentFunc, options?: IHoverTipOptions): DomElementMethod { return (elem) => setHoverTooltip(elem, tipContent, options); } /** * Attach a tooltip to the given element, to be rendered on hover. */ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFunc, options: IHoverTipOptions = {}) { const {openDelay = 100, timeoutMs, closeDelay = 400} = options; // Controller for closing the tooltip, if one is open. let tipControl: ITooltipControl|undefined; // Timer to open or close the tooltip, depending on whether tipControl is set. let timer: ReturnType|undefined; function clearTimer() { if (timer) { clearTimeout(timer); timer = undefined; } } function resetTimer(func: () => void, delay: number) { clearTimer(); timer = setTimeout(func, delay); } function scheduleCloseIfOpen() { clearTimer(); if (tipControl) { resetTimer(close, closeDelay); } } function open() { clearTimer(); tipControl = showTooltip(refElem, ctl => tipContent({...ctl, close}), options); dom.onElem(tipControl.getDom(), 'mouseenter', clearTimer); dom.onElem(tipControl.getDom(), 'mouseleave', scheduleCloseIfOpen); dom.onDisposeElem(tipControl.getDom(), close); if (timeoutMs) { resetTimer(close, timeoutMs); } } function close() { clearTimer(); tipControl?.close(); tipControl = undefined; } // We simulate hover effect by handling mouseenter/mouseleave. dom.onElem(refElem, 'mouseenter', () => { if (!tipControl && !timer) { resetTimer(open, openDelay); } else if (tipControl) { // Already shown, reset to newly-shown state. clearTimer(); if (timeoutMs) { resetTimer(close, timeoutMs); } } }); dom.onElem(refElem, 'mouseleave', scheduleCloseIfOpen); if (options.openOnClick) { // If request, re-open on click. dom.onElem(refElem, 'click', () => { close(); open(); }); } // close tooltip if refElem is disposed dom.onDisposeElem(refElem, close); } /** * Build a handy button for closing a tooltip. */ export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement { return cssTooltipCloseButton(icon('CrossSmall'), dom.on('click', () => ctl.close()), testId('tooltip-close'), ); } /** * Renders an icon that shows a tooltip with the specified `tipContent` on hover. */ export function iconTooltip( iconName: IconName, tipContent: ITooltipContentFunc, ...domArgs: DomElementArg[] ) { return cssIconTooltip(iconName, hoverTooltip(tipContent, { openDelay: 0, closeDelay: 0, openOnClick: true, }), ...domArgs, ); } /** * Renders an info icon that shows a tooltip with the specified `tipContent` on hover. */ export function infoTooltip(tipContent: DomContents, ...domArgs: DomElementArg[]) { return iconTooltip('Info', () => cssInfoTooltipBody(tipContent), ...domArgs, ); } const cssTooltip = styled('div', ` position: absolute; z-index: 5000; /* should be higher than a modal */ background-color: ${theme.tooltipBg}; border-radius: 3px; box-shadow: 0 0 2px rgba(0,0,0,0.5); text-align: center; color: ${theme.tooltipFg}; width: auto; font-family: sans-serif; font-size: 10pt; padding: 8px 16px; margin: 4px; transition: opacity 0.2s; `); const cssTooltipCloseButton = styled('div', ` cursor: pointer; user-select: none; width: 16px; height: 16px; line-height: 16px; text-align: center; margin: -4px -4px -4px 8px; --icon-color: ${theme.tooltipCloseButtonFg}; border-radius: 16px; &:hover { background-color: ${theme.tooltipCloseButtonHoverBg}; --icon-color: ${theme.tooltipCloseButtonHoverFg}; } `); const cssIconTooltip = styled(icon, ` height: 12px; width: 12px; background-color: ${theme.tooltipIcon}; flex-shrink: 0; `); const cssInfoTooltipBody = styled('div', ` text-align: left; max-width: 200px; `);