/** * 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, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {makeLinks} from 'app/client/ui2018/links'; import {menuCssClass} from 'app/client/ui2018/menus'; import {dom, DomContents, DomElementArg, DomElementMethod, styled} from 'grainjs'; import Popper from 'popper.js'; import {cssMenu, defaultMenuOptions, IMenuOptions, setPopupToCreateDom} from 'popweasel'; import merge = require('lodash/merge'); 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; /** * Optionally, popper modifiers (e.g. {offset: {offset: 8}}), * See https://popper.js.org/docs/v1/#modifiers. */ modifiers?: Popper.Modifiers; } 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 200 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 100 ms. * * A non-zero delay 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. * * Defaults to false. * * Should only be set to true if `closeOnClick` is false. */ openOnClick?: boolean; /** * Hide the tip on clicking the element it's attached to. * * Defaults to true. * * Should only be set to true if `openOnClick` is false. */ closeOnClick?: boolean; /** Whether to show the tooltip only when the ref element overflows horizontally. */ overflowOnly?: boolean; } export type ITooltipContent = ITooltipContentFunc | DomContents; 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: ITooltipContent, 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; const hasKey = key && openTooltips.has(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`)); // Prepending instead of appending allows better text selection, as this element is on top. document.body.prepend(content); // Create a popper for positioning the tooltip content relative to refElem. const popperOptions: Popper.PopperOptions = { modifiers: merge( { preventOverflow: {boundariesElement: 'viewport'} }, options.modifiers ), placement, }; const popper = new Popper(refElem, content, popperOptions); // If refElem is disposed we close the tooltip. dom.onDisposeElem(refElem, close); // If we're not replacing the tooltip, fade in the content using transitions. if (!hasKey) { 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: ITooltipContent, options?: IHoverTipOptions): DomElementMethod { const defaultOptions: IHoverTipOptions = {placement: 'bottom'}; return (elem) => setHoverTooltip(elem, tipContent, {...defaultOptions, ...options}); } /** * On hover, show the full text of this element when it overflows horizontally. It is intended * mainly for styled with "text-overflow: ellipsis". * E.g. dom('label', 'Long text...', overflowTooltip()). */ export function overflowTooltip(options?: IHoverTipOptions): DomElementMethod { const defaultOptions: IHoverTipOptions = { placement: 'bottom-start', overflowOnly: true, modifiers: {offset: {offset: '40, 0'}}, }; return (elem) => setHoverTooltip(elem, () => elem.textContent, {...defaultOptions, ...options}); } /** * Attach a tooltip to the given element, to be rendered on hover. */ export function setHoverTooltip( refElem: Element, tipContent: ITooltipContent, options: IHoverTipOptions = {} ) { const {key, openDelay = 200, timeoutMs, closeDelay = 100, openOnClick, closeOnClick = true, overflowOnly = false} = options; const tipContentFunc = typeof tipContent === 'function' ? tipContent : () => tipContent; // Controller for closing the tooltip, if one is open. let tipControl: ITooltipControl|undefined; // A marker, that the tooltip should be closed, but we are waiting for the mouseup event. const POSTPONED = Symbol(); // Timer to open or close the tooltip, depending on whether tipControl is set. let timer: ReturnType|undefined|typeof POSTPONED; // To allow user select text, we will monitor if the selection has started in the tooltip (by listening // to the mousedown event). If it has and mouse goes outside, we will mark that the tooltip should be closed. // When the selection is over (by listening to mouseup on window), a new close is scheduled with 1.4s, to allow // user to press Ctrl+C (but only if the marker - POSTPONED - is still set). let mouseGrabbed = false; function grabMouse(tip: Element) { mouseGrabbed = true; const listener = dom.onElem(window, 'mouseup', () => { mouseGrabbed = false; if (timer === POSTPONED) { scheduleCloseIfOpen(1400); } }); dom.autoDisposeElem(tip, listener); // Disable text selection in any other element except this one. This class sets user-select: none to all // elements except the tooltip. This helps to avoid accidental selection of text in other elements, once // the mouse leaves the tooltip. document.body.classList.add(cssDisableSelectOnAll.className); dom.onDisposeElem(tip, () => document.body.classList.remove(cssDisableSelectOnAll.className)); } function clearTimer() { if (timer !== POSTPONED) { clearTimeout(timer); } timer = undefined; } function resetTimer(func: () => void, delay: number|typeof POSTPONED) { clearTimer(); timer = delay === POSTPONED ? POSTPONED : setTimeout(func, delay); } function scheduleCloseIfOpen(timeout = closeDelay) { clearTimer(); if (tipControl) { resetTimer(close, mouseGrabbed ? POSTPONED : timeout); } } function open() { clearTimer(); tipControl = showTooltip(refElem, ctl => tipContentFunc({...ctl, close}), options); const tipDom = tipControl.getDom(); dom.onElem(tipDom, 'mouseenter', clearTimer); dom.onElem(tipDom, 'mouseleave', () => scheduleCloseIfOpen()); dom.onElem(tipDom, 'mousedown', grabMouse.bind(null, tipDom)); dom.onDisposeElem(tipDom, () => 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 (overflowOnly && (refElem as HTMLElement).offsetWidth >= refElem.scrollWidth) { return; } if (!tipControl && !timer) { // If we're replacing a tooltip, open without delay. const delay = key && openTooltips.has(key) ? 0 : openDelay; resetTimer(open, delay); } else if (tipControl) { // Already shown, reset to newly-shown state. clearTimer(); if (timeoutMs) { resetTimer(close, timeoutMs); } } }); dom.onElem(refElem, 'mouseleave', () => scheduleCloseIfOpen()); if (openOnClick) { // If requested, re-open on click. dom.onElem(refElem, 'click', () => { close(); open(); }); } else if (closeOnClick) { // If requested, close on click. dom.onElem(refElem, 'click', () => { close(); }); } // 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('mousedown', (ev) =>{ ev.stopPropagation(); ev.preventDefault(); ctl.close(); }), testId('tooltip-close'), ); } /** * Renders an info icon that shows a tooltip with the specified `content` on click. */ export function infoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) { return cssInfoTooltipButton('?', (elem) => { setPopupToCreateDom( elem, (ctl) => { return cssInfoTooltipPopup( cssInfoTooltipPopupCloseButton( icon('CrossSmall'), dom.on('click', () => ctl.close()), testId('info-tooltip-close'), ), cssInfoTooltipPopupBody( content, testId('info-tooltip-popup-body'), ), dom.cls(menuCssClass), dom.cls(cssMenu.className), dom.onKeyDown({ Enter: () => ctl.close(), Escape: () => ctl.close(), }), (popup) => { setTimeout(() => popup.focus(), 0); }, testId('info-tooltip-popup'), ); }, {...defaultMenuOptions, ...{placement: 'bottom-end'}, ...menuOptions}, ); }, testId('info-tooltip'), ...domArgs, ); } export interface WithInfoTooltipOptions { domArgs?: DomElementArg[]; tooltipButtonDomArgs?: DomElementArg[]; tooltipMenuOptions?: IMenuOptions; } /** * Wraps `domContent` with a info tooltip button that displays the provided * `tooltipContent` on click, and returns the wrapped element. * * The tooltip button is displayed to the right of `domContents`, and displays * a popup on click. The popup can be dismissed by clicking away from it, clicking * the close button in the top-right corner, or pressing Enter or Escape. * * Arguments can be passed to both the top-level wrapped DOM element and the * tooltip button element with `options.domArgs` and `options.tooltipButtonDomArgs` * respectively. * * Usage: * * withInfoTooltip( * dom('div', 'Hello World!'), * dom('p', 'This is some text to show inside the tooltip.'), * ) */ export function withInfoTooltip( domContents: DomContents, tooltipContent: DomContents, options: WithInfoTooltipOptions = {}, ) { const {domArgs, tooltipButtonDomArgs, tooltipMenuOptions} = options; return cssDomWithTooltip( domContents, infoTooltip(tooltipContent, tooltipMenuOptions, tooltipButtonDomArgs), ...(domArgs ?? []) ); } /** * Renders an description info icon that shows a tooltip with the specified `content` on click. */ export function descriptionInfoTooltip( content: string, testPrefix: string, ...domArgs: DomElementArg[]) { const body = makeLinks(content); const options = { closeDelay: 200, key: 'columnDescription', openOnClick: true, }; const builder = () => cssDescriptionInfoTooltip( body, // Used id test to find the origin of the tooltip regardless webdriver implementation (some of them start) cssTooltipCorner(testId('tooltip-origin')), testId(`${testPrefix}-info-tooltip-popup`), {tabIndex: '-1'} ); return cssDescriptionInfoTooltipButton( icon('Info', dom.cls("info_toggle_icon")), testId(`${testPrefix}-info-tooltip`), dom.on('mousedown', (e) => e.stopPropagation()), dom.on('click', (e) => e.stopPropagation()), hoverTooltip(builder, options), dom.cls("info_toggle_icon_wrapper"), ...domArgs, ); } const cssTooltipCorner = styled('div', ` position: absolute; width: 0; height: 0; top: 0; left: 0; visibility: hidden; `); const cssDescriptionInfoTooltip = styled('div', ` position: relative; white-space: pre-wrap; text-align: left; text-overflow: ellipsis; overflow: hidden; line-height: 1.4; max-width: min(500px, calc(100vw - 80px)); /* can't use 100%, 500px and 80px are picked by hand */ `); const cssDescriptionInfoTooltipButton = styled('div', ` cursor: pointer; --icon-color: ${theme.infoButtonFg}; border-radius: 50%; display: inline-block; padding-left: 5px; line-height: 0px; &:hover { --icon-color: ${theme.infoButtonHoverFg}; } &:active { --icon-color: ${theme.infoButtonActiveFg}; } `); const cssTooltip = styled('div', ` position: absolute; z-index: ${vars.tooltipZIndex}; /* 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; user-select: auto; `); const cssDisableSelectOnAll = styled('div', ` & *:not(.${cssTooltip.className}, .${cssTooltip.className} *) { user-select: none; } `); 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 cssInfoTooltipButton = styled('div', ` flex-shrink: 0; display: flex; align-items: center; justify-content: center; height: ${vars.largeFontSize}; width: ${vars.largeFontSize}; border: 1px solid ${theme.controlSecondaryFg}; color: ${theme.controlSecondaryFg}; border-radius: 50%; cursor: pointer; user-select: none; &:hover { border: 1px solid ${theme.controlSecondaryHoverFg}; color: ${theme.controlSecondaryHoverFg}; } `); const cssInfoTooltipPopup = styled('div', ` display: flex; flex-direction: column; background-color: ${theme.popupBg}; max-width: 200px; margin: 4px; padding: 0px; `); const cssInfoTooltipPopupBody = styled('div', ` color: ${theme.text}; text-align: left; padding: 0px 16px 16px 16px; `); const cssInfoTooltipPopupCloseButton = styled('div', ` flex-shrink: 0; align-self: flex-end; cursor: pointer; --icon-color: ${theme.controlSecondaryFg}; margin: 8px 8px 4px 0px; padding: 2px; border-radius: 4px; &:hover { background-color: ${theme.hover}; } `); const cssDomWithTooltip = styled('div', ` display: flex; align-items: center; column-gap: 8px; `);