(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:
George Gevoian 2022-10-19 16:06:05 -07:00
parent acc218398d
commit 4c662253a9
16 changed files with 435 additions and 134 deletions

View File

@ -1,7 +1,8 @@
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {docListHeader} from 'app/client/ui/DocMenuCss'; 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 {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders';
@ -79,10 +80,7 @@ export class DocumentUsage extends Disposable {
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE, maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB', unit: 'MB',
shouldHideLimits: maxValue === undefined, shouldHideLimits: maxValue === undefined,
tooltipContent: () => cssTooltipBody( tooltipContentFunc: GristTooltips.dataSize,
dom('div', 'The total size of all data in this document, excluding attachments.'),
dom('div', 'Updates every 5 minutes.'),
),
formatValue: (val) => { formatValue: (val) => {
// To display a nice, round number for `maximumValue`, we first convert // 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 // 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. // If true, limits will always be hidden, even if `maximumValue` is a positive number.
shouldHideLimits?: boolean; shouldHideLimits?: boolean;
// Shows an icon next to the metric name that displays a tooltip on hover. // Shows an icon next to the metric name that displays a tooltip on hover.
tooltipContent?(): DomContents; tooltipContentFunc?: TooltipContentFunc;
formatValue?(value: number): string; formatValue?(value: number): string;
} }
@ -279,11 +277,15 @@ interface MetricOptions {
* close `currentValue` is to hitting `maximumValue`. * close `currentValue` is to hitting `maximumValue`.
*/ */
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) { function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
const {name, tooltipContent} = options; const {name, tooltipContentFunc} = options;
return cssUsageMetric( return cssUsageMetric(
cssMetricName( cssMetricName(
cssOverflowableText(name, testId('name')), tooltipContentFunc
tooltipContent ? infoTooltip(tooltipContent()) : null, ? withInfoTooltip(
cssOverflowableText(name, testId('name')),
tooltipContentFunc()
)
: cssOverflowableText(name, testId('name')),
), ),
buildUsageProgressBar(options), buildUsageProgressBar(options),
...domArgs, ...domArgs,
@ -425,12 +427,6 @@ const cssSpinner = styled('div', `
margin-top: 32px; margin-top: 32px;
`); `);
const cssTooltipBody = styled('div', `
display: flex;
flex-direction: column;
gap: 8px;
`);
const cssLoadingDots = styled(loadingDots, ` const cssLoadingDots = styled(loadingDots, `
--dot-size: 8px; --dot-size: 8px;
`); `);

View File

@ -134,15 +134,26 @@ Command.prototype._run = function() {
return this._activeFunc.apply(null, arguments); 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. * Returns the text description for the command, including the keyboard shortcuts.
*/ */
Command.prototype.getDesc = function() { Command.prototype.getDesc = function() {
var desc = this.desc; var parts = [this.desc];
if (this.humanKeys.length) {
desc += " (" + this.humanKeys.join(", ") + ")"; var keysDesc = this.getKeysDesc();
} if (keysDesc) { parts.push(keysDesc); }
return desc;
return parts.join(' ');
}; };
/** /**

View File

@ -113,7 +113,9 @@ declare module "app/client/components/commands" {
public desc: string; public desc: string;
public humanKeys: string[]; public humanKeys: string[];
public keys: string[]; public keys: string[];
public run: () => any; public getDesc(): string;
public getKeysDesc(): string;
public run(): any;
} }
export type CommandsGroup = any; export type CommandsGroup = any;

View File

@ -2,7 +2,9 @@ import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec'; import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; 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 {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
import {textButton} from 'app/client/ui2018/buttons'; import {textButton} from 'app/client/ui2018/buttons';
import {testId, theme} from 'app/client/ui2018/cssVars'; import {testId, theme} from 'app/client/ui2018/cssVars';
@ -328,11 +330,14 @@ export function buildFormulaConfig(
dom.prop("disabled", disableOtherActions), dom.prop("disabled", disableOtherActions),
testId("field-set-formula") testId("field-set-formula")
)), )),
cssRow(textButton( cssRow(withInfoTooltip(
"Set trigger formula", textButton(
dom.on("click", setTrigger), "Set trigger formula",
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)), dom.on("click", setTrigger),
testId("field-set-trigger") dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
testId("field-set-trigger")
),
GristTooltips.setTriggerFormula(),
)), )),
cssRow(textButton( cssRow(textButton(
"Make into data column", "Make into data column",
@ -378,12 +383,15 @@ export function buildFormulaConfig(
// Else offer a way to convert to trigger formula. // Else offer a way to convert to trigger formula.
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [ dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
cssEmptySeparator(), cssEmptySeparator(),
cssRow(textButton( cssRow(withInfoTooltip(
"Set trigger formula", textButton(
dom.on("click", convertDataColumnToTriggerColumn), "Set trigger formula",
dom.prop("disabled", disableOtherActions), dom.on("click", convertDataColumnToTriggerColumn),
testId("field-set-trigger") dom.prop("disabled", disableOtherActions),
)) testId("field-set-trigger")
),
GristTooltips.setTriggerFormula()
)),
]) ])
]) ])
]); ]);

View 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,
),
};

View File

@ -3,6 +3,7 @@ import {AppModel} from 'app/client/models/AppModel';
import {ConnectState} from 'app/client/models/ConnectState'; import {ConnectState} from 'app/client/models/ConnectState';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {Expirable, IAppError, Notification, Notifier, NotifyAction, Progress} from 'app/client/models/NotifyModel'; 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 {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; 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), setPopupToCreateDom(elem, (ctl) => buildNotifyDropdown(ctl, notifier, appModel),
{...defaultMenuOptions, placement: 'bottom-end'}); {...defaultMenuOptions, placement: 'bottom-end'});
}, },
hoverTooltip('Notifications', {key: 'topBarBtnTooltip'}),
testId('menu-btn'), testId('menu-btn'),
); );
} }

View File

@ -1,6 +1,8 @@
import { reportError } from 'app/client/models/AppModel'; import { reportError } from 'app/client/models/AppModel';
import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel'; 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 { linkId, NoLink } from 'app/client/ui/selectBy';
import { withInfoTooltip } from 'app/client/ui/tooltips';
import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes'; import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes';
import { bigPrimaryButton } from "app/client/ui2018/buttons"; import { bigPrimaryButton } from "app/client/ui2018/buttons";
import { theme, vars } from "app/client/ui2018/cssVars"; import { theme, vars } from "app/client/ui2018/cssVars";
@ -342,11 +344,17 @@ export class PageWidgetSelect extends Disposable {
cssFooter( cssFooter(
cssFooterContent( cssFooterContent(
// If _selectByOptions exists and has more than then "NoLinkOption", show the selector. // If _selectByOptions exists and has more than then "NoLinkOption", show the selector.
dom.maybe((use) => this._selectByOptions && use(this._selectByOptions).length > 1, () => cssSelectBy( dom.maybe((use) => this._selectByOptions && use(this._selectByOptions).length > 1, () =>
cssSmallLabel('SELECT BY'), withInfoTooltip(
dom.update(cssSelect(this._value.link, this._selectByOptions!), cssSelectBy(
testId('selectby')) cssSmallLabel('SELECT BY'),
)), dom.update(cssSelect(this._value.link, this._selectByOptions!),
testId('selectby'))
),
GristTooltips.selectBy(),
{tooltipMenuOptions: {attach: null}},
)
),
dom('div', {style: 'flex-grow: 1'}), dom('div', {style: 'flex-grow: 1'}),
bigPrimaryButton( bigPrimaryButton(
// TODO: The button's label of the page widget picker should read 'Close' instead when // TODO: The button's label of the page widget picker should read 'Close' instead when

View File

@ -2,8 +2,10 @@ import {loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel'; import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel'; import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu'; import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
import {sendToDrive} from 'app/client/ui/sendToDrive'; import {sendToDrive} from 'app/client/ui/sendToDrive';
import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {primaryButton} from 'app/client/ui2018/buttons'; import {primaryButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; 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;` }, return cssHoverCircle({ style: `margin: 5px;` },
cssTopBarBtn('Share', dom.cls('tour-share-icon')), cssTopBarBtn('Share', dom.cls('tour-share-icon')),
menu(menuCreateFunc, {placement: 'bottom-end'}), menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
testId('tb-share'), testId('tb-share'),
); );
} else if (options.buttonAction) { } else if (options.buttonAction) {
@ -103,6 +106,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
cssShareCircle( cssShareCircle(
cssShareIcon('Share'), cssShareIcon('Share'),
menu(menuCreateFunc, {placement: 'bottom-end'}), menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
testId('tb-share'), testId('tb-share'),
), ),
); );
@ -115,6 +119,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
cssShareIcon('Share') cssShareIcon('Share')
), ),
menu(menuCreateFunc, {placement: 'bottom-end'}), menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
testId('tb-share'), testId('tb-share'),
); );
} }
@ -198,7 +203,13 @@ function menuWorkOnCopy(pageModel: DocPageModel) {
return [ return [
menuItem(makeUnsavedCopy, 'Work on a Copy', testId('work-on-copy')), 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}}
)
),
]; ];
} }

View File

@ -198,7 +198,13 @@ function addRevertViewAsUI() {
), ),
tooltipCloseButton(ctl), tooltipCloseButton(ctl),
), ),
{openOnClick: true} {
openOnClick: true,
closeOnClick: false,
openDelay: 100,
closeDelay: 400,
placement: 'top',
}
), ),
]; ];
} }

View File

@ -7,6 +7,7 @@ import {AccountWidget} from 'app/client/ui/AccountWidget';
import {buildNotifyMenuButton} from 'app/client/ui/NotifyUI'; import {buildNotifyMenuButton} from 'app/client/ui/NotifyUI';
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager'; import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
import {buildShareMenuButton} from 'app/client/ui/ShareMenu'; import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs'; import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
import {basicButton} from 'app/client/ui2018/buttons'; import {basicButton} from 'app/client/ui2018/buttons';
@ -80,11 +81,13 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
dom.maybe(pageModel.undoState, (state) => [ dom.maybe(pageModel.undoState, (state) => [
topBarUndoBtn('Undo', topBarUndoBtn('Undo',
dom.on('click', () => state.isUndoDisabled.get() || allCommands.undo.run()), dom.on('click', () => state.isUndoDisabled.get() || allCommands.undo.run()),
hoverTooltip('Undo', {key: 'topBarBtnTooltip'}),
cssHoverCircle.cls('-disabled', state.isUndoDisabled), cssHoverCircle.cls('-disabled', state.isUndoDisabled),
testId('undo') testId('undo')
), ),
topBarUndoBtn('Redo', topBarUndoBtn('Redo',
dom.on('click', () => state.isRedoDisabled.get() || allCommands.redo.run()), dom.on('click', () => state.isRedoDisabled.get() || allCommands.redo.run()),
hoverTooltip('Redo', {key: 'topBarBtnTooltip'}),
cssHoverCircle.cls('-disabled', state.isRedoDisabled), cssHoverCircle.cls('-disabled', state.isRedoDisabled),
testId('redo') testId('redo')
), ),

View File

@ -25,8 +25,9 @@ import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption,
Resource} from 'app/client/models/UserManagerModel'; Resource} from 'app/client/models/UserManagerModel';
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel'; import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
import {getResourceParent, ResourceType} 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 {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 {createUserImage} from 'app/client/ui/UserImage';
import {cssMemberBtn, cssMemberImage, cssMemberListItem, import {cssMemberBtn, cssMemberImage, cssMemberListItem,
cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem, cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem,
@ -166,14 +167,18 @@ function buildUserManagerModal(
testId('um-cancel') testId('um-cancel')
), ),
(model.resourceType === 'document' && model.gristDoc && !model.isPersonal (model.resourceType === 'document' && model.gristDoc && !model.isPersonal
? cssAccessLink({href: urlState().makeUrl({docPage: 'acl'})}, ? withInfoTooltip(
dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''), cssLink({href: urlState().makeUrl({docPage: 'acl'})},
'Open Access Rules', dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''),
dom.on('click', (ev) => { 'Open Access Rules',
ev.preventDefault(); dom.on('click', (ev) => {
return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'})); ev.preventDefault();
}), return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'}));
testId('um-open-access-rules') }),
testId('um-open-access-rules'),
),
GristTooltips.openAccessRules(),
{domArgs: [cssAccessLink.cls('')]},
) )
: null : null
), ),
@ -238,6 +243,7 @@ export class UserManager extends Disposable {
private _buildOptionsDom(): Element { private _buildOptionsDom(): Element {
const publicMember = this._model.publicMember; const publicMember = this._model.publicMember;
let tooltipControl: ITooltipControl | undefined;
return cssOptionRow( return cssOptionRow(
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may // TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
// be used to fill whitespace in org UserManager. // be used to fill whitespace in org UserManager.
@ -246,24 +252,32 @@ export class UserManager extends Disposable {
this._inheritRoleSelector() this._inheritRoleSelector()
), ),
publicMember ? dom('span', { style: `float: right;` }, publicMember ? dom('span', { style: `float: right;` },
cssSmallPublicMemberIcon('PublicFilled'),
dom('span', 'Public access: '), dom('span', 'Public access: '),
cssOptionBtn( cssOptionBtn(
menu(() => [ menu(() => {
menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)), tooltipControl?.close();
menuItem(() => publicMember.access.set(null), 'Off', return [
// Disable null access if anonymous access is inherited. menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)),
dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null), menuItem(() => publicMember.access.set(null), 'Off',
testId(`um-public-option`) // Disable null access if anonymous access is inherited.
), dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null),
// If the 'Off' setting is disabled, show an explanation. testId(`um-public-option`)
dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText( ),
`Public access inherited from ${getResourceParent(this._model.resourceType)}. ` + // If the 'Off' setting is disabled, show an explanation.
`To remove, set 'Inherit access' option to 'None'.`)) 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'), dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'),
cssCollapseIcon('Collapse'), cssCollapseIcon('Collapse'),
testId('um-public-access') testId('um-public-access')
) ),
hoverTooltip((ctl) => {
tooltipControl = ctl;
return 'Allow anyone with the link to open.';
}),
) : null ) : null
); );
} }
@ -674,6 +688,12 @@ const cssPublicMemberIcon = styled(icon, `
--icon-color: ${theme.accentIcon}; --icon-color: ${theme.accentIcon};
`); `);
const cssSmallPublicMemberIcon = styled(cssPublicMemberIcon, `
width: 16px;
height: 16px;
top: -2px;
`);
const cssPublicAccessIcon = styled(icon, ` const cssPublicAccessIcon = styled(icon, `
--icon-color: ${theme.accentIcon}; --icon-color: ${theme.accentIcon};
`); `);

View File

@ -17,8 +17,6 @@ import difference = require('lodash/difference');
const testId = makeTestId('test-section-menu-'); const testId = makeTestId('test-section-menu-');
const TOOLTIP_DELAY_OPEN = 750;
// Handler for [Save] button. // Handler for [Save] button.
async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> { async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {
await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([ 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'), testId('filter-icon'),
// Make green when there are some filters. If there are only sort options, leave grey. // Make green when there are some filters. If there are only sort options, leave grey.
cssFilterIconWrapper.cls('-any', anyFilter), cssFilterIconWrapper.cls('-any', anyFilter),
cssFilterIcon('Filter') cssFilterIcon('Filter'),
hoverTooltip('Sort and filter', {key: 'sortFilterBtnTooltip'}),
), ),
menu(ctl => [ menu(ctl => [
// Sorted by section. // Sorted by section.
@ -109,7 +108,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
cssSmallIconWrapper( cssSmallIconWrapper(
cssIcon('Tick'), cssSmallIconWrapper.cls('-green'), cssIcon('Tick'), cssSmallIconWrapper.cls('-green'),
dom.on('click', save), dom.on('click', save),
hoverTooltip(() => 'Save', {key: 'sortFilterButton', openDelay: TOOLTIP_DELAY_OPEN}), hoverTooltip('Save sort & filter settings', {key: 'sortFilterBtnTooltip'}),
testId('small-btn-save'), testId('small-btn-save'),
dom.hide(isReadonly), dom.hide(isReadonly),
), ),
@ -117,7 +116,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
cssSmallIconWrapper( cssSmallIconWrapper(
cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'), cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'),
dom.on('click', revert), dom.on('click', revert),
hoverTooltip(() => 'Revert', {key: 'sortFilterButton', openDelay: TOOLTIP_DELAY_OPEN}), hoverTooltip('Revert sort & filter settings', {key: 'sortFilterBtnTooltip'}),
testId('small-btn-revert'), testId('small-btn-revert'),
), ),
)), )),

View File

@ -6,42 +6,70 @@
*/ */
import {prepareForTransition} from 'app/client/ui/transitions'; import {prepareForTransition} from 'app/client/ui/transitions';
import {testId, theme} from 'app/client/ui2018/cssVars'; import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menuCssClass} from 'app/client/ui2018/menus';
import {dom, DomContents, DomElementArg, DomElementMethod, styled} from 'grainjs'; import {dom, DomContents, DomElementArg, DomElementMethod, styled} from 'grainjs';
import Popper from 'popper.js'; import Popper from 'popper.js';
import {cssMenu, defaultMenuOptions, IMenuOptions, setPopupToCreateDom} from 'popweasel';
export interface ITipOptions { 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; 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; key?: string;
} }
export interface ITransientTipOptions extends ITipOptions { 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; timeoutMs?: number;
} }
export interface IHoverTipOptions extends ITransientTipOptions { 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; 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; 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 * How soon after mouseleave to hide it.
// to the other. *
* 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; 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; 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 type ITooltipContentFunc = (ctl: ITooltipControl) => DomContents;
export interface ITooltipControl { export interface ITooltipControl {
@ -49,8 +77,10 @@ export interface ITooltipControl {
getDom(): HTMLElement; // The tooltip DOM. 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>(); const openTooltips = new Map<string, ITooltipControl>();
/** /**
@ -59,7 +89,7 @@ const openTooltips = new Map<string, ITooltipControl>();
*/ */
export function showTransientTooltip( export function showTransientTooltip(
refElem: Element, refElem: Element,
tipContent: DomContents | ITooltipContentFunc, tipContent: ITooltipContent,
options: ITransientTipOptions = {}) { options: ITransientTipOptions = {}) {
const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options); const ctl = showTooltip(refElem, typeof tipContent == 'function' ? tipContent : () => tipContent, options);
const origClose = ctl.close; const origClose = ctl.close;
@ -77,8 +107,9 @@ export function showTransientTooltip(
export function showTooltip( export function showTooltip(
refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {} refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {}
): ITooltipControl { ): ITooltipControl {
const placement: Popper.Placement = options.placement || 'top'; const placement: Popper.Placement = options.placement ?? 'top';
const key = options.key; const key = options.key;
const hasKey = key && openTooltips.has(key);
let closed = false; let closed = false;
// If we had a previous tooltip with the same key, clean it up. // 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. // If refElem is disposed we close the tooltip.
dom.onDisposeElem(refElem, close); dom.onDisposeElem(refElem, close);
// Fade in the content using transitions. // If we're not replacing the tooltip, fade in the content using transitions.
prepareForTransition(content, () => { content.style.opacity = '0'; }); if (!hasKey) {
content.style.opacity = ''; prepareForTransition(content, () => { content.style.opacity = '0'; });
content.style.opacity = '';
}
if (key) { openTooltips.set(key, ctl); } if (key) { openTooltips.set(key, ctl); }
return ctl; return ctl;
@ -119,17 +152,24 @@ export function showTooltip(
/** /**
* Render a tooltip on hover. Suitable for use during dom construction, e.g. * 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 { export function hoverTooltip(tipContent: ITooltipContent, options?: IHoverTipOptions): DomElementMethod {
return (elem) => setHoverTooltip(elem, tipContent, options); const defaultOptions: IHoverTipOptions = {placement: 'bottom'};
return (elem) => setHoverTooltip(elem, tipContent, {...defaultOptions, ...options});
} }
/** /**
* Attach a tooltip to the given element, to be rendered on hover. * Attach a tooltip to the given element, to be rendered on hover.
*/ */
export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFunc, options: IHoverTipOptions = {}) { export function setHoverTooltip(
const {openDelay = 100, timeoutMs, closeDelay = 400} = options; 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. // Controller for closing the tooltip, if one is open.
let tipControl: ITooltipControl|undefined; let tipControl: ITooltipControl|undefined;
@ -150,10 +190,10 @@ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFun
} }
function open() { function open() {
clearTimer(); 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(), 'mouseenter', clearTimer);
dom.onElem(tipControl.getDom(), 'mouseleave', scheduleCloseIfOpen); dom.onElem(tipControl.getDom(), 'mouseleave', scheduleCloseIfOpen);
dom.onDisposeElem(tipControl.getDom(), close); dom.onDisposeElem(tipControl.getDom(), () => close());
if (timeoutMs) { resetTimer(close, timeoutMs); } if (timeoutMs) { resetTimer(close, timeoutMs); }
} }
function close() { function close() {
@ -165,7 +205,9 @@ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFun
// We simulate hover effect by handling mouseenter/mouseleave. // We simulate hover effect by handling mouseenter/mouseleave.
dom.onElem(refElem, 'mouseenter', () => { dom.onElem(refElem, 'mouseenter', () => {
if (!tipControl && !timer) { 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) { } else if (tipControl) {
// Already shown, reset to newly-shown state. // Already shown, reset to newly-shown state.
clearTimer(); clearTimer();
@ -175,12 +217,15 @@ export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFun
dom.onElem(refElem, 'mouseleave', scheduleCloseIfOpen); dom.onElem(refElem, 'mouseleave', scheduleCloseIfOpen);
if (options.openOnClick) { if (openOnClick) {
// If request, re-open on click. // If requested, re-open on click.
dom.onElem(refElem, 'click', () => { close(); open(); }); 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); 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( function infoTooltip(content: DomContents, menuOptions?: IMenuOptions, ...domArgs: DomElementArg[]) {
iconName: IconName, return cssInfoTooltipButton('?',
tipContent: ITooltipContentFunc, (elem) => {
...domArgs: DomElementArg[] setPopupToCreateDom(
) { elem,
return cssIconTooltip(iconName, (ctl) => {
hoverTooltip(tipContent, { return cssInfoTooltipPopup(
openDelay: 0, cssInfoTooltipPopupCloseButton(
closeDelay: 0, icon('CrossSmall'),
openOnClick: true, 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, ...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[]) { export function withInfoTooltip(
return iconTooltip('Info', domContents: DomContents,
() => cssInfoTooltipBody(tipContent), tooltipContent: DomContents,
...domArgs, 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, ` const cssInfoTooltipButton = styled('div', `
height: 12px;
width: 12px;
background-color: ${theme.tooltipIcon};
flex-shrink: 0; 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', ` const cssInfoTooltipPopup = styled('div', `
text-align: left; display: flex;
flex-direction: column;
background-color: ${theme.popupBg};
max-width: 200px; 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;
`); `);

View File

@ -2,10 +2,10 @@
* Search icon that expands to a search bar and collapse on 'x' or blur. * Search icon that expands to a search bar and collapse on 'x' or blur.
* Takes a `SearchModel` that controls the search behavior. * 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 { reportError } from 'app/client/models/AppModel';
import { SearchModel } from 'app/client/models/SearchModel'; 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 { cssHoverCircle, cssTopBarBtn } from 'app/client/ui/TopBarCss';
import { labeledSquareCheckbox } from 'app/client/ui2018/checkbox'; import { labeledSquareCheckbox } from 'app/client/ui2018/checkbox';
import { mediaSmall, theme, vars } from 'app/client/ui2018/cssVars'; import { mediaSmall, theme, vars } from 'app/client/ui2018/cssVars';
@ -126,12 +126,6 @@ const cssShortcut = styled('span', `
color: ${theme.lightText}; color: ${theme.lightText};
`); `);
const searchArrowBtnTooltipOptions: IHoverTipOptions = {
key: 'searchArrowBtnTooltip',
openDelay: 500,
closeDelay: 100,
};
export function searchBar(model: SearchModel, testId: TestId = noTestId) { export function searchBar(model: SearchModel, testId: TestId = noTestId) {
let keepExpanded = false; let keepExpanded = false;
@ -178,6 +172,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
cssTopBarBtn('Search', cssTopBarBtn('Search',
testId('icon'), testId('icon'),
dom.on('click', focusAndSelect), dom.on('click', focusAndSelect),
hoverTooltip('Search', {key: 'topBarBtnTooltip'}),
) )
), ),
expandedSearch( expandedSearch(
@ -195,7 +190,13 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
// Prevent focus from being stolen from the input // Prevent focus from being stolen from the input
dom.on('mousedown', (event) => event.preventDefault()), dom.on('mousedown', (event) => event.preventDefault()),
dom.on('click', () => model.findNext()), dom.on('click', () => model.findNext()),
hoverTooltip(() => ['Find Next ', cssShortcut('(Enter, ⌘G)')], searchArrowBtnTooltipOptions), hoverTooltip(
[
'Find Next ',
cssShortcut(`(${['Enter', allCommands.findNext.humanKeys].join(', ')})`),
],
{key: 'searchArrowBtnTooltip'}
),
), ),
cssArrowBtn( cssArrowBtn(
icon('DropdownUp'), icon('DropdownUp'),
@ -203,7 +204,13 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) {
// Prevent focus from being stolen from the input // Prevent focus from being stolen from the input
dom.on('mousedown', (event) => event.preventDefault()), dom.on('mousedown', (event) => event.preventDefault()),
dom.on('click', () => model.findPrev()), dom.on('click', () => model.findPrev()),
hoverTooltip(() => ['Find Previous ', cssShortcut('(⌘⇧G)')], searchArrowBtnTooltipOptions), hoverTooltip(
[
'Find Previous ',
cssShortcut(allCommands.findPrev.getKeysDesc()),
],
{key: 'searchArrowBtnTooltip'}
),
) )
]; ];
}), }),

View File

@ -4,6 +4,8 @@ import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {RuleOwner} from 'app/client/models/RuleOwner'; import {RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles'; import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig'; 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 {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect'; import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
@ -67,11 +69,17 @@ export class ConditionalStyle extends Disposable {
return [ return [
cssRow( cssRow(
{ style: 'margin-top: 16px' }, { style: 'margin-top: 16px' },
textButton( withInfoTooltip(
'Add conditional style', textButton(
testId('add-conditional-style'), 'Add conditional style',
dom.on('click', () => this._ruleOwner.addEmptyRule()), testId('add-conditional-style'),
dom.prop('disabled', this._disabled) 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)) dom.hide(use => use(this._ruleOwner.hasRules))
), ),

View File

@ -61,7 +61,11 @@ export const MIN_URLID_PREFIX_LENGTH = 12;
export const commonUrls = { export const commonUrls = {
help: "https://support.getgrist.com", 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", 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", plans: "https://www.getgrist.com/pricing",
createTeamSite: "https://www.getgrist.com/create-team-site", createTeamSite: "https://www.getgrist.com/create-team-site",
sproutsProgram: "https://www.getgrist.com/sprouts-program", sproutsProgram: "https://www.getgrist.com/sprouts-program",