2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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';
|
2022-06-06 16:21:26 +00:00
|
|
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
|
|
|
import {IconName} from 'app/client/ui2018/IconList';
|
2021-03-08 21:08:13 +00:00
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2022-06-06 16:21:26 +00:00
|
|
|
import {dom, DomContents, DomElementArg, DomElementMethod, styled} from 'grainjs';
|
2020-10-02 15:10:00 +00:00
|
|
|
import Popper from 'popper.js';
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
export interface ITipOptions {
|
2020-10-02 15:10:00 +00:00
|
|
|
// 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;
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
// When set, a tooltip will replace any previous tooltip with the same key.
|
|
|
|
key?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface ITransientTipOptions extends ITipOptions {
|
2020-10-02 15:10:00 +00:00
|
|
|
// When to remove the transient tooltip. Defaults to 2000ms.
|
|
|
|
timeoutMs?: number;
|
2021-03-05 15:17:07 +00:00
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-08 21:08:13 +00:00
|
|
|
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;
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
export interface ITooltipControl {
|
|
|
|
close(): void;
|
2021-03-08 21:08:13 +00:00
|
|
|
getDom(): HTMLElement; // The tooltip DOM.
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
// Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows
|
|
|
|
// removing the tooltip.
|
|
|
|
const openTooltips = new Map<string, ITooltipControl>();
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
/**
|
|
|
|
* Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default).
|
|
|
|
* See also ITipOptions.
|
|
|
|
*/
|
2021-05-25 09:24:00 +00:00
|
|
|
export function showTransientTooltip(
|
|
|
|
refElem: Element,
|
|
|
|
tipContent: DomContents | ITooltipContentFunc,
|
|
|
|
options: ITransientTipOptions = {}) {
|
|
|
|
const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options);
|
2021-03-05 15:17:07 +00:00
|
|
|
const origClose = ctl.close;
|
|
|
|
ctl.close = () => { clearTimeout(timer); origClose(); };
|
|
|
|
|
|
|
|
const timer = setTimeout(ctl.close, options.timeoutMs || 2000);
|
2021-05-25 09:24:00 +00:00
|
|
|
return ctl;
|
2021-03-05 15:17:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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(
|
2021-03-08 21:08:13 +00:00
|
|
|
refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {}
|
2021-03-05 15:17:07 +00:00
|
|
|
): ITooltipControl {
|
2020-10-02 15:10:00 +00:00
|
|
|
const placement: Popper.Placement = options.placement || 'top';
|
|
|
|
const key = options.key;
|
2021-04-30 17:28:52 +00:00
|
|
|
let closed = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// If we had a previous tooltip with the same key, clean it up.
|
2021-03-05 15:17:07 +00:00
|
|
|
if (key) { openTooltips.get(key)?.close(); }
|
|
|
|
|
|
|
|
// Cleanup involves destroying the Popper instance, removing the element, etc.
|
|
|
|
function close() {
|
2021-04-30 17:28:52 +00:00
|
|
|
if (closed) { return; }
|
|
|
|
closed = true;
|
2021-03-05 15:17:07 +00:00
|
|
|
popper.destroy();
|
|
|
|
dom.domDispose(content);
|
|
|
|
content.remove();
|
|
|
|
if (key) { openTooltips.delete(key); }
|
|
|
|
}
|
2021-03-08 21:08:13 +00:00
|
|
|
const ctl: ITooltipControl = {close, getDom: () => content};
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// Add the content element.
|
2021-03-08 21:08:13 +00:00
|
|
|
const content = cssTooltip({role: 'tooltip'}, tipContent(ctl), testId(`tooltip`));
|
2020-10-02 15:10:00 +00:00
|
|
|
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);
|
|
|
|
|
2021-04-30 17:28:52 +00:00
|
|
|
// If refElem is disposed we close the tooltip.
|
|
|
|
dom.onDisposeElem(refElem, close);
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// Fade in the content using transitions.
|
|
|
|
prepareForTransition(content, () => { content.style.opacity = '0'; });
|
|
|
|
content.style.opacity = '';
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
if (key) { openTooltips.set(key, ctl); }
|
|
|
|
return ctl;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2021-03-08 21:08:13 +00:00
|
|
|
/**
|
|
|
|
* 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<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 => tipContent({...ctl, close}), options);
|
|
|
|
dom.onElem(tipControl.getDom(), 'mouseenter', clearTimer);
|
|
|
|
dom.onElem(tipControl.getDom(), 'mouseleave', scheduleCloseIfOpen);
|
2021-04-30 17:28:52 +00:00
|
|
|
dom.onDisposeElem(tipControl.getDom(), close);
|
2021-03-08 21:08:13 +00:00
|
|
|
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(); });
|
|
|
|
}
|
2021-05-04 07:39:17 +00:00
|
|
|
|
|
|
|
// close tooltip if refElem is disposed
|
|
|
|
dom.onDisposeElem(refElem, close);
|
2021-03-08 21:08:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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'),
|
|
|
|
);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-06-06 16:21:26 +00:00
|
|
|
/**
|
|
|
|
* 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,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const cssTooltip = styled('div', `
|
|
|
|
position: absolute;
|
|
|
|
z-index: 5000; /* should be higher than a modal */
|
2021-03-08 21:08:13 +00:00
|
|
|
background-color: rgba(0, 0, 0, 0.75);
|
2020-10-02 15:10:00 +00:00
|
|
|
border-radius: 3px;
|
|
|
|
box-shadow: 0 0 2px rgba(0,0,0,0.5);
|
|
|
|
text-align: center;
|
|
|
|
color: white;
|
|
|
|
width: auto;
|
|
|
|
font-family: sans-serif;
|
|
|
|
font-size: 10pt;
|
|
|
|
padding: 8px 16px;
|
|
|
|
margin: 4px;
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
`);
|
2021-03-08 21:08:13 +00:00
|
|
|
|
|
|
|
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: white;
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
background-color: white;
|
|
|
|
--icon-color: black;
|
|
|
|
}
|
|
|
|
`);
|
2022-06-06 16:21:26 +00:00
|
|
|
|
|
|
|
const cssIconTooltip = styled(icon, `
|
|
|
|
height: 12px;
|
|
|
|
width: 12px;
|
|
|
|
background-color: ${colors.slate};
|
|
|
|
flex-shrink: 0;
|
|
|
|
`);
|
|
|
|
|
|
|
|
const cssInfoTooltipBody = styled('div', `
|
|
|
|
text-align: left;
|
|
|
|
max-width: 200px;
|
|
|
|
`);
|