gristlabs_grist-core/app/client/ui/tooltips.ts
Dmitry S 86681de595 (core) Add overflowTooltip() tool, and use for long tables in widget picker, and long page names.
Summary:
Usage is simply to call `overflowTooltip()` with no arguments, as an argument
to an element whose text may overflow. On 'mouseenter', it'll check for
overflow and show the element's .textContent in a tooltip.

- Added for long table names in the widget picker (Add Page, Add Widget to Page).
- Added for long page names in the left-panel list of pages.

Test Plan: Added test cases for the new overflow tooltips

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3814
2023-03-13 21:47:35 -04:00

491 lines
14 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 {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<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`));
document.body.appendChild(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;
// Timer to open or close the tooltip, depending on whether tipControl is set.
let timer: ReturnType<typeof setTimeout>|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 => tipContentFunc({...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 (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('click', () => 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 column info icon that shows a tooltip with the specified `content` on click.
*/
export function columnInfoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) {
return cssColumnInfoTooltipButton(
icon('Info', dom.cls("info_toggle_icon")),
(elem) => {
setPopupToCreateDom(
elem,
(ctl) => {
return cssInfoTooltipPopup(
cssInfoTooltipPopupCloseButton(
icon('CrossSmall'),
dom.on('click', () => ctl.close()),
testId('column-info-tooltip-close'),
),
cssInfoTooltipPopupBody(
content,
{ style: 'white-space: pre-wrap;' },
testId('column-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('column-info-tooltip-popup'),
);
},
{ ...defaultMenuOptions, ...{ placement: 'bottom-end' }, ...menuOptions },
);
},
testId('column-info-tooltip'),
...domArgs,
);
}
const cssColumnInfoTooltipButton = styled('div', `
cursor: pointer;
--icon-color: ${theme.infoButtonFg};
border-radius: 50%;
display: inline-block;
margin-left: 5px;
line-height: 0px;
&:hover {
--icon-color: ${theme.infoButtonHoverFg};
}
&:active {
--icon-color: ${theme.infoButtonActiveFg};
}
`);
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 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;
`);