gristlabs_grist-core/app/client/ui/tooltips.ts
George Gevoian de33c5a3c6 (core) Add tests and tooltips to new Add Column menu
Summary: Adds tooltips to the menu and tests for recently-added functionality.

Test Plan: Browser tests.

Reviewers: JakubSerafin

Reviewed By: JakubSerafin

Subscribers: JakubSerafin

Differential Revision: https://phab.getgrist.com/D4087
2023-10-24 11:53:26 -04:00

603 lines
18 KiB
TypeScript

/**
* 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, cssMenuItem, defaultMenuOptions, IPopupOptions, 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<string, ITooltipControl>();
/**
* 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<typeof setTimeout>|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'),
);
}
export interface InfoTooltipOptions {
/** Defaults to `click`. */
variant?: InfoTooltipVariant;
/** Only applicable to the `click` variant. */
popupOptions?: IPopupOptions;
}
export type InfoTooltipVariant = 'click' | 'hover';
/**
* Renders an info icon that shows a tooltip with the specified `content`.
*/
export function infoTooltip(
content: DomContents,
options: InfoTooltipOptions = {},
...domArgs: DomElementArg[]
) {
const {variant = 'click'} = options;
switch (variant) {
case 'click': {
const {popupOptions} = options;
return buildClickableInfoTooltip(content, popupOptions, domArgs);
}
case 'hover': {
return buildHoverableInfoTooltip(content, domArgs);
}
}
}
function buildClickableInfoTooltip(
content: DomContents,
popupOptions?: IPopupOptions,
...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'}, ...popupOptions},
);
},
testId('info-tooltip'),
...domArgs,
);
}
function buildHoverableInfoTooltip(content: DomContents, ...domArgs: DomElementArg[]) {
return cssInfoTooltipIcon('?',
hoverTooltip(() => cssInfoTooltipTransientPopup(
content,
cssTooltipCorner(testId('tooltip-origin')),
{tabIndex: '-1'},
testId('info-tooltip-popup'),
), {closeOnClick: false}),
testId('info-tooltip'),
...domArgs,
);
}
export interface WithInfoTooltipOptions {
/** Defaults to `click`. */
variant?: InfoTooltipVariant;
domArgs?: DomElementArg[];
iconDomArgs?: DomElementArg[];
/** Only applicable to the `click` variant. */
popupOptions?: IPopupOptions;
}
/**
* Wraps `domContent` with a info tooltip icon that displays the provided
* `tooltipContent` and returns the wrapped element.
*
* The tooltip button is displayed to the right of `domContents`, and displays
* a popup on click by default. The popup can be dismissed by clicking away from
* it; clicking the close button in the top-right corner; or pressing Enter or Escape.
*
* You may optionally specify `options.variant`, which controls whether the tooltip
* is shown on hover or on click.
*
* Arguments can be passed to both the top-level wrapped DOM element and the
* tooltip icon element with `options.domArgs` and `options.tooltipIconDomArgs`
* 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 {variant = 'click', domArgs, iconDomArgs, popupOptions} = options;
return cssDomWithTooltip(
domContents,
infoTooltip(tooltipContent, {variant, popupOptions}, iconDomArgs),
...(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 = () => cssInfoTooltipTransientPopup(
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 cssInfoTooltipTransientPopup = 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 cssInfoTooltipIcon = 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%;
user-select: none;
.${cssMenuItem.className}-sel & {
color: ${theme.menuItemSelectedFg};
border-color: ${theme.menuItemSelectedFg};
}
`);
const cssInfoTooltipButton = styled(cssInfoTooltipIcon, `
cursor: pointer;
&: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;
`);