mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
de33c5a3c6
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
603 lines
18 KiB
TypeScript
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;
|
|
`);
|