mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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:
		
							parent
							
								
									acc218398d
								
							
						
					
					
						commit
						4c662253a9
					
				@ -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;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
@ -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(' ');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								app/client/declarations.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								app/client/declarations.d.ts
									
									
									
									
										vendored
									
									
								
							@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
@ -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'}
 | 
			
		||||
            ),
 | 
			
		||||
          )
 | 
			
		||||
        ];
 | 
			
		||||
      }),
 | 
			
		||||
 | 
			
		||||
@ -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))
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user