mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add info and hover tooltips
Summary: Adds tooltip buttons to various parts of the UI that either open a popup with information when clicked, or show a label on hover. Test Plan: Project tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3657
This commit is contained in:
@@ -2,7 +2,9 @@ import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
@@ -328,11 +330,14 @@ export function buildFormulaConfig(
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-formula")
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", setTrigger),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-trigger")
|
||||
cssRow(withInfoTooltip(
|
||||
textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", setTrigger),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-trigger")
|
||||
),
|
||||
GristTooltips.setTriggerFormula(),
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Make into data column",
|
||||
@@ -378,12 +383,15 @@ export function buildFormulaConfig(
|
||||
// Else offer a way to convert to trigger formula.
|
||||
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", convertDataColumnToTriggerColumn),
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-trigger")
|
||||
))
|
||||
cssRow(withInfoTooltip(
|
||||
textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", convertDataColumnToTriggerColumn),
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-trigger")
|
||||
),
|
||||
GristTooltips.setTriggerFormula()
|
||||
)),
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
82
app/client/ui/GristTooltips.ts
Normal file
82
app/client/ui/GristTooltips.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
||||
|
||||
const cssTooltipContent = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 8px;
|
||||
`);
|
||||
|
||||
type TooltipName =
|
||||
| 'dataSize'
|
||||
| 'setTriggerFormula'
|
||||
| 'selectBy'
|
||||
| 'workOnACopy'
|
||||
| 'openAccessRules'
|
||||
| 'addRowConditionalStyle'
|
||||
| 'addColumnConditionalStyle';
|
||||
|
||||
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
||||
|
||||
export const GristTooltips: Record<TooltipName, TooltipContentFunc> = {
|
||||
dataSize: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'The total size of all data in this document, excluding attachments.'),
|
||||
dom('div', 'Updates every 5 minutes.'),
|
||||
...args,
|
||||
),
|
||||
setTriggerFormula: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div',
|
||||
'Formulas that trigger in certain cases, and store the calculated value as data.'
|
||||
),
|
||||
dom('div',
|
||||
'Useful for storing the timestamp or author of a new record, data cleaning, and '
|
||||
+ 'more.'
|
||||
),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpTriggerFormulas, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
selectBy: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Link your new widget to an existing widget on this page.'),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
workOnACopy: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div',
|
||||
'Try out changes in a copy, then decide whether to replace the original with your edits.'
|
||||
),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpTryingOutChanges, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
openAccessRules: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div',
|
||||
'Access rules give you the power to create nuanced rules to determine who can '
|
||||
+ 'see or edit which parts of your document.'
|
||||
),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
addRowConditionalStyle: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Apply conditional formatting to rows based on formulas.'),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpConditionalFormatting, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
addColumnConditionalStyle: (...args: DomElementArg[]) => cssTooltipContent(
|
||||
dom('div', 'Apply conditional formatting to cells in this column when formula conditions are met.'),
|
||||
dom('div', 'Click on “Open row styles” to apply conditional formatting to rows.'),
|
||||
dom('div',
|
||||
cssLink({href: commonUrls.helpConditionalFormatting, target: '_blank'}, 'Learn more.'),
|
||||
),
|
||||
...args,
|
||||
),
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {AppModel} from 'app/client/models/AppModel';
|
||||
import {ConnectState} from 'app/client/models/ConnectState';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {Expirable, IAppError, Notification, Notifier, NotifyAction, Progress} from 'app/client/models/NotifyModel';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
@@ -126,6 +127,7 @@ export function buildNotifyMenuButton(notifier: Notifier, appModel: AppModel|nul
|
||||
setPopupToCreateDom(elem, (ctl) => buildNotifyDropdown(ctl, notifier, appModel),
|
||||
{...defaultMenuOptions, placement: 'bottom-end'});
|
||||
},
|
||||
hoverTooltip('Notifications', {key: 'topBarBtnTooltip'}),
|
||||
testId('menu-btn'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { reportError } from 'app/client/models/AppModel';
|
||||
import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
|
||||
import { GristTooltips } from 'app/client/ui/GristTooltips';
|
||||
import { linkId, NoLink } from 'app/client/ui/selectBy';
|
||||
import { withInfoTooltip } from 'app/client/ui/tooltips';
|
||||
import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes';
|
||||
import { bigPrimaryButton } from "app/client/ui2018/buttons";
|
||||
import { theme, vars } from "app/client/ui2018/cssVars";
|
||||
@@ -342,11 +344,17 @@ export class PageWidgetSelect extends Disposable {
|
||||
cssFooter(
|
||||
cssFooterContent(
|
||||
// If _selectByOptions exists and has more than then "NoLinkOption", show the selector.
|
||||
dom.maybe((use) => this._selectByOptions && use(this._selectByOptions).length > 1, () => cssSelectBy(
|
||||
cssSmallLabel('SELECT BY'),
|
||||
dom.update(cssSelect(this._value.link, this._selectByOptions!),
|
||||
testId('selectby'))
|
||||
)),
|
||||
dom.maybe((use) => this._selectByOptions && use(this._selectByOptions).length > 1, () =>
|
||||
withInfoTooltip(
|
||||
cssSelectBy(
|
||||
cssSmallLabel('SELECT BY'),
|
||||
dom.update(cssSelect(this._value.link, this._selectByOptions!),
|
||||
testId('selectby'))
|
||||
),
|
||||
GristTooltips.selectBy(),
|
||||
{tooltipMenuOptions: {attach: null}},
|
||||
)
|
||||
),
|
||||
dom('div', {style: 'flex-grow: 1'}),
|
||||
bigPrimaryButton(
|
||||
// TODO: The button's label of the page widget picker should read 'Close' instead when
|
||||
|
||||
@@ -2,8 +2,10 @@ import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||
import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
|
||||
import {sendToDrive} from 'app/client/ui/sendToDrive';
|
||||
import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
@@ -91,6 +93,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
|
||||
return cssHoverCircle({ style: `margin: 5px;` },
|
||||
cssTopBarBtn('Share', dom.cls('tour-share-icon')),
|
||||
menu(menuCreateFunc, {placement: 'bottom-end'}),
|
||||
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
|
||||
testId('tb-share'),
|
||||
);
|
||||
} else if (options.buttonAction) {
|
||||
@@ -103,6 +106,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
|
||||
cssShareCircle(
|
||||
cssShareIcon('Share'),
|
||||
menu(menuCreateFunc, {placement: 'bottom-end'}),
|
||||
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
|
||||
testId('tb-share'),
|
||||
),
|
||||
);
|
||||
@@ -115,6 +119,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
|
||||
cssShareIcon('Share')
|
||||
),
|
||||
menu(menuCreateFunc, {placement: 'bottom-end'}),
|
||||
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
|
||||
testId('tb-share'),
|
||||
);
|
||||
}
|
||||
@@ -198,7 +203,13 @@ function menuWorkOnCopy(pageModel: DocPageModel) {
|
||||
|
||||
return [
|
||||
menuItem(makeUnsavedCopy, 'Work on a Copy', testId('work-on-copy')),
|
||||
menuText('Edit without affecting the original'),
|
||||
menuText(
|
||||
withInfoTooltip(
|
||||
'Edit without affecting the original',
|
||||
GristTooltips.workOnACopy(),
|
||||
{tooltipMenuOptions: {attach: null}}
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,13 @@ function addRevertViewAsUI() {
|
||||
),
|
||||
tooltipCloseButton(ctl),
|
||||
),
|
||||
{openOnClick: true}
|
||||
{
|
||||
openOnClick: true,
|
||||
closeOnClick: false,
|
||||
openDelay: 100,
|
||||
closeDelay: 400,
|
||||
placement: 'top',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {AccountWidget} from 'app/client/ui/AccountWidget';
|
||||
import {buildNotifyMenuButton} from 'app/client/ui/NotifyUI';
|
||||
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
|
||||
import {basicButton} from 'app/client/ui2018/buttons';
|
||||
@@ -80,11 +81,13 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
|
||||
dom.maybe(pageModel.undoState, (state) => [
|
||||
topBarUndoBtn('Undo',
|
||||
dom.on('click', () => state.isUndoDisabled.get() || allCommands.undo.run()),
|
||||
hoverTooltip('Undo', {key: 'topBarBtnTooltip'}),
|
||||
cssHoverCircle.cls('-disabled', state.isUndoDisabled),
|
||||
testId('undo')
|
||||
),
|
||||
topBarUndoBtn('Redo',
|
||||
dom.on('click', () => state.isRedoDisabled.get() || allCommands.redo.run()),
|
||||
hoverTooltip('Redo', {key: 'topBarBtnTooltip'}),
|
||||
cssHoverCircle.cls('-disabled', state.isRedoDisabled),
|
||||
testId('redo')
|
||||
),
|
||||
|
||||
@@ -25,8 +25,9 @@ import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption,
|
||||
Resource} from 'app/client/models/UserManagerModel';
|
||||
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
|
||||
import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel';
|
||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {hoverTooltip, ITooltipControl, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
import {createUserImage} from 'app/client/ui/UserImage';
|
||||
import {cssMemberBtn, cssMemberImage, cssMemberListItem,
|
||||
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem,
|
||||
@@ -166,14 +167,18 @@ function buildUserManagerModal(
|
||||
testId('um-cancel')
|
||||
),
|
||||
(model.resourceType === 'document' && model.gristDoc && !model.isPersonal
|
||||
? cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})},
|
||||
dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''),
|
||||
'Open Access Rules',
|
||||
dom.on('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
|
||||
}),
|
||||
testId('um-open-access-rules')
|
||||
? withInfoTooltip(
|
||||
cssLink({href: urlState().makeUrl({docPage: 'acl'})},
|
||||
dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''),
|
||||
'Open Access Rules',
|
||||
dom.on('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
|
||||
}),
|
||||
testId('um-open-access-rules'),
|
||||
),
|
||||
GristTooltips.openAccessRules(),
|
||||
{domArgs: [cssAccessLink.cls('')]},
|
||||
)
|
||||
: null
|
||||
),
|
||||
@@ -238,6 +243,7 @@ export class UserManager extends Disposable {
|
||||
|
||||
private _buildOptionsDom(): Element {
|
||||
const publicMember = this._model.publicMember;
|
||||
let tooltipControl: ITooltipControl | undefined;
|
||||
return cssOptionRow(
|
||||
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
|
||||
// be used to fill whitespace in org UserManager.
|
||||
@@ -246,24 +252,32 @@ export class UserManager extends Disposable {
|
||||
this._inheritRoleSelector()
|
||||
),
|
||||
publicMember ? dom('span', { style: `float: right;` },
|
||||
cssSmallPublicMemberIcon('PublicFilled'),
|
||||
dom('span', 'Public access: '),
|
||||
cssOptionBtn(
|
||||
menu(() => [
|
||||
menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)),
|
||||
menuItem(() => publicMember.access.set(null), 'Off',
|
||||
// Disable null access if anonymous access is inherited.
|
||||
dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null),
|
||||
testId(`um-public-option`)
|
||||
),
|
||||
// If the 'Off' setting is disabled, show an explanation.
|
||||
dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText(
|
||||
`Public access inherited from ${getResourceParent(this._model.resourceType)}. ` +
|
||||
`To remove, set 'Inherit access' option to 'None'.`))
|
||||
]),
|
||||
menu(() => {
|
||||
tooltipControl?.close();
|
||||
return [
|
||||
menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)),
|
||||
menuItem(() => publicMember.access.set(null), 'Off',
|
||||
// Disable null access if anonymous access is inherited.
|
||||
dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null),
|
||||
testId(`um-public-option`)
|
||||
),
|
||||
// If the 'Off' setting is disabled, show an explanation.
|
||||
dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText(
|
||||
`Public access inherited from ${getResourceParent(this._model.resourceType)}. ` +
|
||||
`To remove, set 'Inherit access' option to 'None'.`))
|
||||
];
|
||||
}),
|
||||
dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'),
|
||||
cssCollapseIcon('Collapse'),
|
||||
testId('um-public-access')
|
||||
)
|
||||
),
|
||||
hoverTooltip((ctl) => {
|
||||
tooltipControl = ctl;
|
||||
return 'Allow anyone with the link to open.';
|
||||
}),
|
||||
) : null
|
||||
);
|
||||
}
|
||||
@@ -674,6 +688,12 @@ const cssPublicMemberIcon = styled(icon, `
|
||||
--icon-color: ${theme.accentIcon};
|
||||
`);
|
||||
|
||||
const cssSmallPublicMemberIcon = styled(cssPublicMemberIcon, `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: -2px;
|
||||
`);
|
||||
|
||||
const cssPublicAccessIcon = styled(icon, `
|
||||
--icon-color: ${theme.accentIcon};
|
||||
`);
|
||||
|
||||
@@ -17,8 +17,6 @@ import difference = require('lodash/difference');
|
||||
|
||||
const testId = makeTestId('test-section-menu-');
|
||||
|
||||
const TOOLTIP_DELAY_OPEN = 750;
|
||||
|
||||
// Handler for [Save] button.
|
||||
async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {
|
||||
await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([
|
||||
@@ -69,7 +67,8 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
testId('filter-icon'),
|
||||
// Make green when there are some filters. If there are only sort options, leave grey.
|
||||
cssFilterIconWrapper.cls('-any', anyFilter),
|
||||
cssFilterIcon('Filter')
|
||||
cssFilterIcon('Filter'),
|
||||
hoverTooltip('Sort and filter', {key: 'sortFilterBtnTooltip'}),
|
||||
),
|
||||
menu(ctl => [
|
||||
// Sorted by section.
|
||||
@@ -109,7 +108,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
cssSmallIconWrapper(
|
||||
cssIcon('Tick'), cssSmallIconWrapper.cls('-green'),
|
||||
dom.on('click', save),
|
||||
hoverTooltip(() => 'Save', {key: 'sortFilterButton', openDelay: TOOLTIP_DELAY_OPEN}),
|
||||
hoverTooltip('Save sort & filter settings', {key: 'sortFilterBtnTooltip'}),
|
||||
testId('small-btn-save'),
|
||||
dom.hide(isReadonly),
|
||||
),
|
||||
@@ -117,7 +116,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
cssSmallIconWrapper(
|
||||
cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'),
|
||||
dom.on('click', revert),
|
||||
hoverTooltip(() => 'Revert', {key: 'sortFilterButton', openDelay: TOOLTIP_DELAY_OPEN}),
|
||||
hoverTooltip('Revert sort & filter settings', {key: 'sortFilterBtnTooltip'}),
|
||||
testId('small-btn-revert'),
|
||||
),
|
||||
)),
|
||||
|
||||
@@ -6,42 +6,70 @@
|
||||
*/
|
||||
|
||||
import {prepareForTransition} from 'app/client/ui/transitions';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
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';
|
||||
|
||||
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
|
||||
/**
|
||||
* 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.
|
||||
/** When set, a tooltip will replace any previous tooltip with the same key. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface ITransientTipOptions extends ITipOptions {
|
||||
// When to remove the transient tooltip. Defaults to 2000ms.
|
||||
/** 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 100 ms.
|
||||
/** 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.
|
||||
/** 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.
|
||||
/**
|
||||
* 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.
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export type ITooltipContent = ITooltipContentFunc | DomContents;
|
||||
|
||||
export type ITooltipContentFunc = (ctl: ITooltipControl) => DomContents;
|
||||
|
||||
export interface ITooltipControl {
|
||||
@@ -49,8 +77,10 @@ export interface ITooltipControl {
|
||||
getDom(): HTMLElement; // The tooltip DOM.
|
||||
}
|
||||
|
||||
// Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows
|
||||
// removing the tooltip.
|
||||
/**
|
||||
* Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows removing
|
||||
* the tooltip.
|
||||
*/
|
||||
const openTooltips = new Map<string, ITooltipControl>();
|
||||
|
||||
/**
|
||||
@@ -59,7 +89,7 @@ const openTooltips = new Map<string, ITooltipControl>();
|
||||
*/
|
||||
export function showTransientTooltip(
|
||||
refElem: Element,
|
||||
tipContent: DomContents | ITooltipContentFunc,
|
||||
tipContent: ITooltipContent,
|
||||
options: ITransientTipOptions = {}) {
|
||||
const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options);
|
||||
const origClose = ctl.close;
|
||||
@@ -77,8 +107,9 @@ export function showTransientTooltip(
|
||||
export function showTooltip(
|
||||
refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {}
|
||||
): ITooltipControl {
|
||||
const placement: Popper.Placement = options.placement || 'top';
|
||||
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.
|
||||
@@ -109,9 +140,11 @@ export function showTooltip(
|
||||
// If refElem is disposed we close the tooltip.
|
||||
dom.onDisposeElem(refElem, close);
|
||||
|
||||
// Fade in the content using transitions.
|
||||
prepareForTransition(content, () => { content.style.opacity = '0'; });
|
||||
content.style.opacity = '';
|
||||
// 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;
|
||||
@@ -119,17 +152,24 @@ export function showTooltip(
|
||||
|
||||
/**
|
||||
* Render a tooltip on hover. Suitable for use during dom construction, e.g.
|
||||
* dom('div', 'Trigger', hoverTooltip(() => 'Hello!'))
|
||||
* dom('div', 'Trigger', hoverTooltip('Hello!')
|
||||
*/
|
||||
export function hoverTooltip(tipContent: ITooltipContentFunc, options?: IHoverTipOptions): DomElementMethod {
|
||||
return (elem) => setHoverTooltip(elem, tipContent, options);
|
||||
export function hoverTooltip(tipContent: ITooltipContent, options?: IHoverTipOptions): DomElementMethod {
|
||||
const defaultOptions: IHoverTipOptions = {placement: 'bottom'};
|
||||
return (elem) => setHoverTooltip(elem, tipContent, {...defaultOptions, ...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;
|
||||
export function setHoverTooltip(
|
||||
refElem: Element,
|
||||
tipContent: ITooltipContent,
|
||||
options: IHoverTipOptions = {}
|
||||
) {
|
||||
const {key, openDelay = 200, timeoutMs, closeDelay = 100, openOnClick, closeOnClick = true} = options;
|
||||
|
||||
const tipContentFunc = typeof tipContent === 'function' ? tipContent : () => tipContent;
|
||||
|
||||
// Controller for closing the tooltip, if one is open.
|
||||
let tipControl: ITooltipControl|undefined;
|
||||
@@ -150,10 +190,10 @@ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFun
|
||||
}
|
||||
function open() {
|
||||
clearTimer();
|
||||
tipControl = showTooltip(refElem, ctl => tipContent({...ctl, close}), options);
|
||||
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);
|
||||
dom.onDisposeElem(tipControl.getDom(), () => close());
|
||||
if (timeoutMs) { resetTimer(close, timeoutMs); }
|
||||
}
|
||||
function close() {
|
||||
@@ -165,7 +205,9 @@ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFun
|
||||
// We simulate hover effect by handling mouseenter/mouseleave.
|
||||
dom.onElem(refElem, 'mouseenter', () => {
|
||||
if (!tipControl && !timer) {
|
||||
resetTimer(open, openDelay);
|
||||
// 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();
|
||||
@@ -175,12 +217,15 @@ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFun
|
||||
|
||||
dom.onElem(refElem, 'mouseleave', scheduleCloseIfOpen);
|
||||
|
||||
if (options.openOnClick) {
|
||||
// If request, re-open on click.
|
||||
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
|
||||
// Close tooltip if refElem is disposed.
|
||||
dom.onDisposeElem(refElem, close);
|
||||
}
|
||||
|
||||
@@ -195,30 +240,77 @@ export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an icon that shows a tooltip with the specified `tipContent` on hover.
|
||||
* Renders an info icon that shows a tooltip with the specified `content` on click.
|
||||
*/
|
||||
export function iconTooltip(
|
||||
iconName: IconName,
|
||||
tipContent: ITooltipContentFunc,
|
||||
...domArgs: DomElementArg[]
|
||||
) {
|
||||
return cssIconTooltip(iconName,
|
||||
hoverTooltip(tipContent, {
|
||||
openDelay: 0,
|
||||
closeDelay: 0,
|
||||
openOnClick: true,
|
||||
}),
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an info icon that shows a tooltip with the specified `tipContent` on hover.
|
||||
* 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 infoTooltip(tipContent: DomContents, ...domArgs: DomElementArg[]) {
|
||||
return iconTooltip('Info',
|
||||
() => cssInfoTooltipBody(tipContent),
|
||||
...domArgs,
|
||||
export function withInfoTooltip(
|
||||
domContents: DomContents,
|
||||
tooltipContent: DomContents,
|
||||
options: WithInfoTooltipOptions = {},
|
||||
) {
|
||||
const {domArgs, tooltipButtonDomArgs, tooltipMenuOptions} = options;
|
||||
return cssDomWithTooltip(
|
||||
domContents,
|
||||
infoTooltip(tooltipContent, tooltipMenuOptions, tooltipButtonDomArgs),
|
||||
...(domArgs ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,14 +347,56 @@ const cssTooltipCloseButton = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssIconTooltip = styled(icon, `
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
background-color: ${theme.tooltipIcon};
|
||||
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 cssInfoTooltipBody = styled('div', `
|
||||
text-align: left;
|
||||
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;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user