feat(component): shortcut style for tooltip (#7721)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/2e68337c-91f1-4ea7-8426-7fb33be02081.png)

- New `shortcut` prop for `<Tooltip />`
  - single key
      ```tsx
      <Tooltip shortcut="T" />
      ```
  - multiple
      ```tsx
      <Tooltip shortcut={["⌘",  "K"]} />
      ```
- Round tooltip's arrow
- Use new design system colors
- Replace some usage
  - App sidebar switch
  - Editor mode switch
  - New tab (new)
This commit is contained in:
CatsJuice 2024-08-05 02:57:24 +00:00
parent 3d855647c7
commit 249f3471c9
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
12 changed files with 211 additions and 43 deletions

View File

@ -64,7 +64,8 @@ export interface ButtonProps
suffixStyle?: CSSProperties;
tooltip?: TooltipProps['content'];
tooltipOptions?: Partial<Omit<TooltipProps, 'content'>>;
tooltipShortcut?: TooltipProps['shortcut'];
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
}
const IconSlot = ({
@ -113,6 +114,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
contentStyle,
tooltip,
tooltipShortcut,
tooltipOptions,
onClick,
@ -129,7 +131,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
);
return (
<Tooltip content={tooltip} {...tooltipOptions}>
<Tooltip content={tooltip} shortcut={tooltipShortcut} {...tooltipOptions}>
<button
{...otherProps}
ref={ref}

View File

@ -1,11 +1,46 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const tooltipContent = style({
backgroundColor: cssVar('tooltip'),
color: cssVar('white'),
backgroundColor: cssVarV2('tooltips/background'),
color: cssVarV2('tooltips/foreground'),
padding: '5px 12px',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
borderRadius: '4px',
maxWidth: '280px',
});
export const withShortcut = style({
display: 'flex',
gap: 10,
});
export const withShortcutContent = style({
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
});
export const shortcut = style({
display: 'flex',
alignItems: 'center',
gap: 2,
});
export const command = style({
background: cssVarV2('tooltips/secondaryBackground'),
fontSize: cssVar('fontXs'),
fontWeight: 400,
lineHeight: '20px',
height: 16,
minWidth: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
borderRadius: 4,
selectors: {
'&[data-length="1"]': {
width: 16,
},
},
});

View File

@ -1,6 +1,8 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { Button } from '../button';
import { RadioGroup } from '../radio';
import type { TooltipProps } from './index';
import Tooltip from './index';
@ -18,6 +20,77 @@ const Template: StoryFn<TooltipProps> = args => (
export const Default: StoryFn<TooltipProps> = Template.bind(undefined);
Default.args = {};
export const WithShortCut = () => {
const shortCuts = [
['Text', 'T'],
['Bold', ['⌘', 'B']],
['Quick Search', ['⌘', 'K']],
['Share', ['⌘', 'Shift', 'S']],
['Copy', ['$mod', '$shift', 'C']],
] as Array<[string, string | string[]]>;
return (
<div style={{ display: 'flex', gap: 4 }}>
{shortCuts.map(([name, shortcut]) => (
<Tooltip shortcut={shortcut} content={name} key={name}>
<Button>{name}</Button>
</Tooltip>
))}
</div>
);
};
export const CustomAlign = () => {
const [align, setAlign] = useState('center' as const);
const _ = undefined;
const positions = [
// [top, left, right, bottom, translateX, translateY]
[0, 0, _, _, _, _],
[0, '50%', _, _, '-50%', _],
[0, _, 0, _, _, _],
['50%', 0, _, _, _, '-50%'],
['50%', _, 0, _, _, '-50%'],
[_, 0, _, 0, _, _],
[_, '50%', _, 0, '-50%', _],
[_, _, 0, 0, _, _],
];
return (
<div>
<RadioGroup
items={['start', 'center', 'end']}
value={align}
onChange={setAlign}
/>
<div
style={{
width: '100%',
height: 200,
position: 'relative',
border: '1px solid rgba(100,100,100,0.2)',
marginTop: 40,
}}
>
{positions.map(pos => {
const key = pos.join('-');
const style = {
position: 'absolute',
top: pos[0],
left: pos[1],
right: pos[2],
bottom: pos[3],
transform: `translate(${pos[4] ?? 0}, ${pos[5] ?? 0})`,
} as const;
return (
<Tooltip align={align} content="This is a tooltip" key={key}>
<Button style={style}>Show tooltip</Button>
</Tooltip>
);
})}
</div>
</div>
);
};
export const WithCustomContent: StoryFn<TooltipProps> = args => (
<Tooltip
content={

View File

@ -4,15 +4,36 @@ import type {
TooltipProps as RootProps,
} from '@radix-ui/react-tooltip';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import type { ReactElement, ReactNode } from 'react';
import { type ReactElement, type ReactNode } from 'react';
import { getCommand } from '../../utils/keyboard-mapping';
import * as styles from './styles.css';
export interface TooltipProps {
// `children` can not be string, number or even undefined
children: ReactElement;
content?: ReactNode;
/**
* When shortcut is provided, will use a single line layout
*
* ```tsx
* <Tooltip shortcut="T" /> // [T]
* <Tooltip shortcut="⌘ + K" /> // [⌘ + K]
* <Tooltip shortcut={['⌘', 'K']} /> // [⌘] [K]
* <Tooltip shortcut={['$mod', 'K']} /> // [⌘] [K] or [Ctrl] [K]
* ```
*
* Mapping:
* | Shortcut | macOS | Windows |
* |----------|-------|---------|
* | `$mod` | `` | `Ctrl` |
* | `$alt` | `` | `Alt` |
* | `$shift` | `` | `Shift` |
*/
shortcut?: string | string[];
side?: TooltipContentProps['side'];
align?: TooltipContentProps['align'];
@ -21,11 +42,32 @@ export interface TooltipProps {
options?: Omit<TooltipContentProps, 'side' | 'align'>;
}
const TooltipShortcut = ({ shortcut }: { shortcut: string | string[] }) => {
const commands = (Array.isArray(shortcut) ? shortcut : [shortcut])
.map(cmd => cmd.trim())
.map(cmd => getCommand(cmd));
return (
<div className={styles.shortcut}>
{commands.map((cmd, index) => (
<div
key={`${index}-${cmd}`}
className={styles.command}
data-length={cmd.length}
>
{cmd}
</div>
))}
</div>
);
};
export const Tooltip = ({
children,
content,
side = 'top',
align = 'center',
shortcut,
options,
rootOptions,
portalOptions,
@ -34,6 +76,7 @@ export const Tooltip = ({
return children;
}
const { className, ...contentOptions } = options || {};
const { style: contentStyle, ...restContentOptions } = contentOptions;
return (
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root delayDuration={500} {...rootOptions}>
@ -45,15 +88,31 @@ export const Tooltip = ({
side={side}
align={align}
sideOffset={5}
style={{ zIndex: 'var(--affine-z-index-popover)' }}
{...contentOptions}
style={{ zIndex: cssVar('zIndexPopover'), ...contentStyle }}
{...restContentOptions}
>
{content}
<TooltipPrimitive.Arrow
height={6}
width={10}
fill="var(--affine-tooltip)"
/>
{shortcut ? (
<div className={styles.withShortcut}>
<div className={styles.withShortcutContent}>{content}</div>
<TooltipShortcut shortcut={shortcut} />
</div>
) : (
content
)}
<TooltipPrimitive.Arrow asChild>
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.11111 5.55C4.55556 6.15 5.44445 6.15 5.88889 5.55L10 0H0L4.11111 5.55Z"
fill={cssVarV2('tooltips/background')}
/>
</svg>
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>

View File

@ -0,0 +1,10 @@
import { isMacOS } from './platform';
const macOS = isMacOS();
export const getCommand = (cmd: '$mod' | '$shift' | '$alt' | string) => {
if (cmd === '$mod') return macOS ? '⌘' : 'Ctrl';
if (cmd === '$alt') return macOS ? '⌥' : 'Alt';
if (cmd === '$shift') return macOS ? '⇧' : 'Shift';
return cmd;
};

View File

@ -0,0 +1,4 @@
export function isMacOS() {
if (typeof navigator === 'undefined') return false;
return navigator.userAgent.indexOf('Mac') !== -1;
}

View File

@ -18,9 +18,6 @@ export const SidebarSwitch = ({
const tooltipContent = open
? t['com.affine.sidebarSwitch.collapse']()
: t['com.affine.sidebarSwitch.expand']();
// TODO(@CatsJuice): Tooltip shortcut style
const collapseKeyboardShortcuts =
environment.isBrowser && environment.isMacOs ? ' ⌘+/' : ' Ctrl+/';
return (
<div
@ -29,7 +26,8 @@ export const SidebarSwitch = ({
data-testid={`app-sidebar-arrow-button-${open ? 'collapse' : 'expand'}`}
>
<IconButton
tooltip={tooltipContent + ' ' + collapseKeyboardShortcuts}
tooltip={tooltipContent}
tooltipShortcut={['$mod', '/']}
tooltipOptions={{ side: open ? 'bottom' : 'right' }}
className={className}
size="24"

View File

@ -13,7 +13,7 @@ import { useCallback, useEffect } from 'react';
import type { DocCollection } from '../../../shared';
import { toast } from '../../../utils';
import { StyledEditorModeSwitch, StyledKeyboardItem } from './style';
import { StyledEditorModeSwitch } from './style';
import { EdgelessSwitchItem, PageSwitchItem } from './switch-items';
export type EditorModeSwitchProps = {
@ -24,17 +24,7 @@ export type EditorModeSwitchProps = {
isPublic?: boolean;
publicMode?: DocMode;
};
const TooltipContent = () => {
const t = useI18n();
return (
<>
{t['Switch']()}
<StyledKeyboardItem>
{!environment.isServer && environment.isMacOs ? '⌥ + S' : 'Alt + S'}
</StyledKeyboardItem>
</>
);
};
export const EditorModeSwitch = ({
style,
docCollection,
@ -106,7 +96,9 @@ export const EditorModeSwitch = ({
return (
<Tooltip
content={<TooltipContent />}
content={t['Switch']()}
shortcut={['$alt', 'S']}
side="bottom"
options={{
hidden: isPublic || trash,
}}

View File

@ -1,5 +1,6 @@
import { displayFlex, styled } from '@affine/component';
// TODO(@CatsJuice): refactor this component
export const StyledEditorModeSwitch = styled('div')<{
switchLeft: boolean;
showAlone?: boolean;
@ -59,14 +60,3 @@ export const StyledSwitchItem = styled('button')<{
},
};
});
export const StyledKeyboardItem = styled('span')(() => {
return {
marginLeft: '10px',
fontSize: 'var(--affine-font-xs)',
paddingLeft: '5px',
paddingRight: '5px',
backgroundColor: 'var(--affine-white-10)',
borderRadius: '4px',
};
});

View File

@ -16,6 +16,7 @@ import { WindowsAppControls } from '@affine/core/components/pure/header/windows-
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import { CloseIcon, PlusIcon, RightSidebarIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
@ -223,6 +224,7 @@ export const AppTabsHeader = ({
className?: string;
left?: ReactNode;
}) => {
const t = useI18n();
const sidebarWidth = useAtomValue(appSidebarWidthAtom);
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
const sidebarFloating = useAtomValue(appSidebarFloatingAtom);
@ -365,6 +367,8 @@ export const AppTabsHeader = ({
<IconButton
size={22.86}
onClick={onAddTab}
tooltip={t['com.affine.multi-tab.new-tab']()}
tooltipShortcut={['$mod', 'T']}
data-testid="add-tab-view-button"
style={{ width: 32, height: 32 }}
icon={<PlusIcon />}

View File

@ -36,7 +36,7 @@ export const tabs = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '0 8px',
paddingLeft: 8,
overflow: 'clip',
height: '100%',
selectors: {

View File

@ -1417,5 +1417,6 @@
"com.affine.ai.template-insert.failed": "Failed to insert template, please try again.",
"com.affine.selector-doc.search-placeholder": "Search docs...",
"com.affine.selector-collection.search.placeholder": "Search collections...",
"com.affine.selector-tag.search.placeholder": "Search tags..."
"com.affine.selector-tag.search.placeholder": "Search tags...",
"com.affine.multi-tab.new-tab": "New Tab"
}