diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts index 03fc1fd8..97453f64 100644 --- a/app/client/components/DocumentUsage.ts +++ b/app/client/components/DocumentUsage.ts @@ -1,7 +1,8 @@ import {DocPageModel} from 'app/client/models/DocPageModel'; import {urlState} from 'app/client/models/gristUrlState'; import {docListHeader} from 'app/client/ui/DocMenuCss'; -import {infoTooltip} from 'app/client/ui/tooltips'; +import {GristTooltips, TooltipContentFunc} from 'app/client/ui/GristTooltips'; +import {withInfoTooltip} from 'app/client/ui/tooltips'; import {mediaXSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders'; @@ -79,10 +80,7 @@ export class DocumentUsage extends Disposable { maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE, unit: 'MB', shouldHideLimits: maxValue === undefined, - tooltipContent: () => cssTooltipBody( - dom('div', 'The total size of all data in this document, excluding attachments.'), - dom('div', 'Updates every 5 minutes.'), - ), + tooltipContentFunc: GristTooltips.dataSize, formatValue: (val) => { // To display a nice, round number for `maximumValue`, we first convert // to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't @@ -269,7 +267,7 @@ interface MetricOptions { // If true, limits will always be hidden, even if `maximumValue` is a positive number. shouldHideLimits?: boolean; // Shows an icon next to the metric name that displays a tooltip on hover. - tooltipContent?(): DomContents; + tooltipContentFunc?: TooltipContentFunc; formatValue?(value: number): string; } @@ -279,11 +277,15 @@ interface MetricOptions { * close `currentValue` is to hitting `maximumValue`. */ function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) { - const {name, tooltipContent} = options; + const {name, tooltipContentFunc} = options; return cssUsageMetric( cssMetricName( - cssOverflowableText(name, testId('name')), - tooltipContent ? infoTooltip(tooltipContent()) : null, + tooltipContentFunc + ? withInfoTooltip( + cssOverflowableText(name, testId('name')), + tooltipContentFunc() + ) + : cssOverflowableText(name, testId('name')), ), buildUsageProgressBar(options), ...domArgs, @@ -425,12 +427,6 @@ const cssSpinner = styled('div', ` margin-top: 32px; `); -const cssTooltipBody = styled('div', ` - display: flex; - flex-direction: column; - gap: 8px; -`); - const cssLoadingDots = styled(loadingDots, ` --dot-size: 8px; `); diff --git a/app/client/components/commands.js b/app/client/components/commands.js index 3be23819..e846894b 100644 --- a/app/client/components/commands.js +++ b/app/client/components/commands.js @@ -134,15 +134,26 @@ Command.prototype._run = function() { return this._activeFunc.apply(null, arguments); }; +/** + * Returns a comma-separated string of all keyboard shortcuts, or `null` if no + * shortcuts exist. + */ + Command.prototype.getKeysDesc = function() { + if (this.humanKeys.length === 0) { return null; } + + return `(${this.humanKeys.join(', ')})`; +}; + /** * Returns the text description for the command, including the keyboard shortcuts. */ Command.prototype.getDesc = function() { - var desc = this.desc; - if (this.humanKeys.length) { - desc += " (" + this.humanKeys.join(", ") + ")"; - } - return desc; + var parts = [this.desc]; + + var keysDesc = this.getKeysDesc(); + if (keysDesc) { parts.push(keysDesc); } + + return parts.join(' '); }; /** diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 680d18a3..f9c4aa02 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -113,7 +113,9 @@ declare module "app/client/components/commands" { public desc: string; public humanKeys: string[]; public keys: string[]; - public run: () => any; + public getDesc(): string; + public getKeysDesc(): string; + public run(): any; } export type CommandsGroup = any; diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index b334e399..a4d365eb 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -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() + )), ]) ]) ]); diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts new file mode 100644 index 00000000..52924169 --- /dev/null +++ b/app/client/ui/GristTooltips.ts @@ -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 = { + 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, + ), +}; diff --git a/app/client/ui/NotifyUI.ts b/app/client/ui/NotifyUI.ts index eacca12f..8d23dd51 100644 --- a/app/client/ui/NotifyUI.ts +++ b/app/client/ui/NotifyUI.ts @@ -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'), ); } diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index adaaf6d0..20858698 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -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 diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index f040126d..f1b0de18 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -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}} + ) + ), ]; } diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index f27c01a4..1a14fe0f 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -198,7 +198,13 @@ function addRevertViewAsUI() { ), tooltipCloseButton(ctl), ), - {openOnClick: true} + { + openOnClick: true, + closeOnClick: false, + openDelay: 100, + closeDelay: 400, + placement: 'top', + } ), ]; } diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 55eb5467..98550084 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -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') ), diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index dae49890..90ab396c 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -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}; `); diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index 9f275f90..1836bfa1 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -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 { 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'), ), )), diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index 0bc5d36d..68d26655 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -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(); /** @@ -59,7 +89,7 @@ const openTooltips = new Map(); */ 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; `); diff --git a/app/client/ui2018/search.ts b/app/client/ui2018/search.ts index dfa6186f..1919f281 100644 --- a/app/client/ui2018/search.ts +++ b/app/client/ui2018/search.ts @@ -2,10 +2,10 @@ * Search icon that expands to a search bar and collapse on 'x' or blur. * Takes a `SearchModel` that controls the search behavior. */ -import { createGroup } from 'app/client/components/commands'; +import { allCommands, createGroup } from 'app/client/components/commands'; import { reportError } from 'app/client/models/AppModel'; import { SearchModel } from 'app/client/models/SearchModel'; -import { hoverTooltip, IHoverTipOptions } from 'app/client/ui/tooltips'; +import { hoverTooltip } from 'app/client/ui/tooltips'; import { cssHoverCircle, cssTopBarBtn } from 'app/client/ui/TopBarCss'; import { labeledSquareCheckbox } from 'app/client/ui2018/checkbox'; import { mediaSmall, theme, vars } from 'app/client/ui2018/cssVars'; @@ -126,12 +126,6 @@ const cssShortcut = styled('span', ` color: ${theme.lightText}; `); -const searchArrowBtnTooltipOptions: IHoverTipOptions = { - key: 'searchArrowBtnTooltip', - openDelay: 500, - closeDelay: 100, -}; - export function searchBar(model: SearchModel, testId: TestId = noTestId) { let keepExpanded = false; @@ -178,6 +172,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) { cssTopBarBtn('Search', testId('icon'), dom.on('click', focusAndSelect), + hoverTooltip('Search', {key: 'topBarBtnTooltip'}), ) ), expandedSearch( @@ -195,7 +190,13 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) { // Prevent focus from being stolen from the input dom.on('mousedown', (event) => event.preventDefault()), dom.on('click', () => model.findNext()), - hoverTooltip(() => ['Find Next ', cssShortcut('(Enter, ⌘G)')], searchArrowBtnTooltipOptions), + hoverTooltip( + [ + 'Find Next ', + cssShortcut(`(${['Enter', allCommands.findNext.humanKeys].join(', ')})`), + ], + {key: 'searchArrowBtnTooltip'} + ), ), cssArrowBtn( icon('DropdownUp'), @@ -203,7 +204,13 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) { // Prevent focus from being stolen from the input dom.on('mousedown', (event) => event.preventDefault()), dom.on('click', () => model.findPrev()), - hoverTooltip(() => ['Find Previous ', cssShortcut('(⌘⇧G)')], searchArrowBtnTooltipOptions), + hoverTooltip( + [ + 'Find Previous ', + cssShortcut(allCommands.findPrev.getKeysDesc()), + ], + {key: 'searchArrowBtnTooltip'} + ), ) ]; }), diff --git a/app/client/widgets/ConditionalStyle.ts b/app/client/widgets/ConditionalStyle.ts index 21caca3f..66666f6d 100644 --- a/app/client/widgets/ConditionalStyle.ts +++ b/app/client/widgets/ConditionalStyle.ts @@ -4,6 +4,8 @@ import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {RuleOwner} from 'app/client/models/RuleOwner'; import {Style} from 'app/client/models/Styles'; import {cssFieldFormula} from 'app/client/ui/FieldConfig'; +import {GristTooltips} from 'app/client/ui/GristTooltips'; +import {withInfoTooltip} from 'app/client/ui/tooltips'; import {textButton} from 'app/client/ui2018/buttons'; import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect'; import {theme, vars} from 'app/client/ui2018/cssVars'; @@ -67,11 +69,17 @@ export class ConditionalStyle extends Disposable { return [ cssRow( { style: 'margin-top: 16px' }, - textButton( - 'Add conditional style', - testId('add-conditional-style'), - dom.on('click', () => this._ruleOwner.addEmptyRule()), - dom.prop('disabled', this._disabled) + withInfoTooltip( + textButton( + 'Add conditional style', + testId('add-conditional-style'), + dom.on('click', () => this._ruleOwner.addEmptyRule()), + dom.prop('disabled', this._disabled), + ), + (this._label === 'Row Style' + ? GristTooltips.addRowConditionalStyle() + : GristTooltips.addColumnConditionalStyle() + ), ), dom.hide(use => use(this._ruleOwner.hasRules)) ), diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index a8f0ba0b..3e4ff508 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -61,7 +61,11 @@ export const MIN_URLID_PREFIX_LENGTH = 12; export const commonUrls = { help: "https://support.getgrist.com", + helpAccessRules: "https://support.getgrist.com/access-rules", + helpConditionalFormatting: "https://support.getgrist.com/conditional-formatting", helpLinkingWidgets: "https://support.getgrist.com/linking-widgets", + helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas", + helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes", plans: "https://www.getgrist.com/pricing", createTeamSite: "https://www.getgrist.com/create-team-site", sproutsProgram: "https://www.getgrist.com/sprouts-program",