Redesign: Add tooltips to icon buttons (#40737)

This commit is contained in:
diondiondion
2026-09-29 14:48:51 +00:00
committed by GitHub
parent caf0908077
commit 0a8f15aa14
11 changed files with 441 additions and 39 deletions

View File

@@ -105,16 +105,13 @@ const AccountButtonsOther: FC<
icon={relationship.notifying ? BellSlashIcon : BellIcon}
active={relationship.notifying}
onClick={handleNotifyToggle}
title={intl.formatMessage(
>
{intl.formatMessage(
relationship.notifying
? messages.disableNotifications
: messages.enableNotifications,
{ name: account.username },
)}
>
{intl.formatMessage(messages.enableNotifications, {
name: account.username,
})}
</ToggleIconButton>
) : (
<LegacyIconButton

View File

@@ -11,6 +11,8 @@ import { CaretDownIcon } from '@phosphor-icons/react';
import { CircularProgress } from '../circular_progress';
import type { IconProp } from '../icon';
import { Icon } from '../icon';
import type { PopoverProps } from '../popover';
import { Tooltip } from '../tooltip';
import classes from './redesign.module.scss';
@@ -37,12 +39,14 @@ interface ButtonPropsBase<As extends 'a' | 'button'> {
children: ReactNode;
}
type AttributesToExclude = 'children' | 'title';
type ButtonButtonProps = { as?: 'button' } & ButtonPropsBase<'button'> &
Omit<React.ComponentPropsWithRef<'button'>, 'children'>;
Omit<React.ComponentPropsWithRef<'button'>, AttributesToExclude>;
type ButtonAnchorProps = { as: 'a' } & ButtonPropsBase<'a'> &
Omit<React.ComponentPropsWithRef<'a'>, 'children'>;
Omit<React.ComponentPropsWithRef<'a'>, AttributesToExclude>;
type ButtonLinkProps = { as: 'link' } & ButtonPropsBase<'a'> &
Omit<LinkProps, 'children'>;
Omit<LinkProps, AttributesToExclude>;
type BaseButtonProps = ButtonButtonProps | ButtonAnchorProps | ButtonLinkProps;
@@ -107,47 +111,84 @@ const BaseButton: React.FC<BaseButtonProps> = ({
);
};
interface ButtonTooltip {
type: 'label' | 'description';
text: React.ReactNode;
}
export type ButtonProps = BaseButtonProps & {
leadingIcon?: IconProp;
trailingIcon?: IconProp;
tooltip?: ButtonTooltip;
};
export const Button: React.FC<ButtonProps> = ({
children,
leadingIcon,
trailingIcon,
tooltip,
...props
}) => (
<BaseButton {...props}>
{leadingIcon && !props.loading && (
<Icon id='leading' icon={leadingIcon} className={classes.icon} />
)}
{props.loading && <LoadingIcon />}
{children}
{trailingIcon && (
<Icon id='trailing' icon={trailingIcon} className={classes.icon} />
)}
</BaseButton>
);
}) => {
const buttonContent = (
<>
{leadingIcon && !props.loading && (
<Icon id='leading' icon={leadingIcon} className={classes.icon} />
)}
{props.loading && <LoadingIcon />}
{children}
{trailingIcon && (
<Icon id='trailing' icon={trailingIcon} className={classes.icon} />
)}
</>
);
if (!tooltip?.text) {
return <BaseButton {...props}>{buttonContent}</BaseButton>;
}
return (
<Tooltip text={tooltip.text} renderTextWhenClosed>
{({ getTooltipProps, tooltipId }) => (
<BaseButton
{...getTooltipProps(props)}
aria-labelledby={tooltip.type === 'label' ? tooltipId : undefined}
aria-describedby={
tooltip.type === 'description' ? tooltipId : undefined
}
>
{buttonContent}
</BaseButton>
)}
</Tooltip>
);
};
export type IconButtonProps = BaseButtonProps & {
icon: IconProp;
tooltipPlacement?: PopoverProps['placement'];
};
export const IconButton: React.FC<IconButtonProps> = ({
icon,
className,
tooltipPlacement,
children,
...props
}) => (
<BaseButton {...props} className={classNames(className, classes.iconOnly)}>
{props.loading ? (
<LoadingIcon />
) : (
<Icon id='icon' icon={icon} className={classes.icon} />
<Tooltip text={children} placement={tooltipPlacement}>
{({ getTooltipProps }) => (
<BaseButton
{...getTooltipProps(props)}
className={classNames(className, classes.iconOnly)}
>
{props.loading ? (
<LoadingIcon />
) : (
<Icon id='icon' icon={icon} className={classes.icon} />
)}
<span className='sr-only'>{children}</span>
</BaseButton>
)}
<span className='sr-only'>{children}</span>
</BaseButton>
</Tooltip>
);
export const CaretIcon = (

View File

@@ -30,7 +30,7 @@ export const ColumnSettingsMenu: React.FC<ColumnSettingsMenuProps> = ({
return (
<Menu>
<MenuTrigger as={ColumnHeaderButton} icon={icon}>
{labelPrefix}
{labelPrefix && <>{labelPrefix} </>}
{label ?? (
<FormattedMessage
id='column.settings'

View File

@@ -97,9 +97,9 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
size='sm'
clipPadding
variant='ghost'
title={reply.title}
leadingIcon={reply.icon}
onClick={reply.action}
tooltip={{ type: 'label', text: reply.title }}
>
{withCounters && reply.counter}
</Button>
@@ -112,7 +112,7 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
size='sm'
variant='ghost'
active={like.active}
title={like.title}
tooltip={{ type: 'label', text: like.title }}
leadingIcon={like.icon}
onClick={like.action}
className={classNames(!onlyResponses && classes.actionsButtonGap)}
@@ -145,7 +145,6 @@ export const StatusActionBar: React.FC<StatusActionBarProps> = ({
size='sm'
variant='ghost'
active={bookmark.active}
title={bookmark.title}
icon={bookmark.icon}
onClick={bookmark.action}
>
@@ -180,7 +179,10 @@ const StatusReblogButton: React.FC<{
size='sm'
variant='ghost'
active={boost.active}
title={boost.title}
tooltip={{
type: 'label',
text: boost.title,
}}
leadingIcon={boost.icon}
disabled={boost.disabled}
onClick={boost.action}
@@ -197,7 +199,15 @@ const StatusReblogButton: React.FC<{
size='sm'
variant='ghost'
active={boost.active}
title={boost.title}
tooltip={{
type: 'label',
text: (
<FormattedMessage
id='status.reblog_or_quote'
defaultMessage='Boost or quote'
/>
),
}}
leadingIcon={boost.icon}
>
{children}

View File

@@ -0,0 +1,217 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import { mergeProps } from '@react-aria/utils';
import type { PopoverProps } from '@/mastodon/components/popover';
import { Popover } from '@/mastodon/components/popover';
import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs';
import classes from './styles.module.scss';
interface ForwardedReferenceProps {
ref: (element: HTMLElement | null) => void;
onMouseEnter: React.MouseEventHandler;
onMouseLeave: React.MouseEventHandler;
onFocus: React.KeyboardEventHandler;
onBlur: React.KeyboardEventHandler;
}
type GetTooltipProps = <T extends Record<string, unknown>>(
props?: T,
) => T & ForwardedReferenceProps;
interface TooltipProps extends Omit<
React.ComponentPropsWithoutRef<'div'>,
'children'
> {
text: React.ReactNode;
placement?: PopoverProps['placement'];
offset?: PopoverProps['offset'];
/**
* Enable this to render the tooltip text in a hidden element
* when the tooltip is closed, so that it can be referenced by
* ID to provide an accessible label or description.
*/
renderTextWhenClosed?: boolean;
children: (options: {
getTooltipProps: GetTooltipProps;
tooltipId: string;
}) => React.ReactNode;
}
/**
* A component for adding tooltips to interactive elements.
*/
export const Tooltip: React.FC<TooltipProps> = ({
text,
placement = 'bottom',
offset = 4,
renderTextWhenClosed = false,
id = '',
className,
children,
...otherProps
}) => {
const uniqueId = useId();
const tooltipId = id || uniqueId;
const [referenceElement, setReferenceElement] = useState<HTMLElement | null>(
null,
);
const [isOpen, setIsOpen] = useState(false);
const openTimeout = useRef<ReturnType<typeof setTimeout>>(null);
const clearOpenTimeout = useCallback(() => {
if (openTimeout.current !== null) {
clearTimeout(openTimeout.current);
openTimeout.current = null;
}
}, []);
const open = useCallback(() => {
if (shouldSkipOpenDelay) {
setIsOpen(true);
}
openTimeout.current = setTimeout(() => {
setIsOpen(true);
openTimeout.current = null;
}, 250);
}, []);
const close = useCallback(() => {
clearOpenTimeout();
if (isOpen) {
suppressEnterDelayTemporarily();
}
setIsOpen(false);
}, [clearOpenTimeout, isOpen]);
const handleFocus = useCallback<React.KeyboardEventHandler>(
(e) => {
if (
e.target instanceof HTMLElement &&
e.target.matches(':focus-visible')
) {
open();
}
},
[open],
);
useEffect(() => {
return () => {
clearOpenTimeout();
};
}, [clearOpenTimeout]);
/**
* Merges props from an outer component with those needed
* for the tooltip to avoid conflicts
*/
const getTooltipProps = useCallback(
<T extends Record<string, unknown>>(outerProps: T) => {
const referenceProps = {
ref: setReferenceElement,
onMouseEnter: open,
onMouseLeave: close,
onFocus: handleFocus,
onBlur: close,
} satisfies ForwardedReferenceProps;
return mergeProps(outerProps, referenceProps);
},
[close, handleFocus, open],
);
return (
<>
{
// eslint-disable-next-line react-hooks/refs
children({
getTooltipProps: getTooltipProps as GetTooltipProps,
tooltipId,
})
}
{!isOpen && renderTextWhenClosed && (
<span hidden id={tooltipId}>
{text}
</span>
)}
<Popover
isOpen={isOpen}
reference={referenceElement}
offset={offset}
placement={placement}
onClose={close}
>
{({ props: popoverProps }) => (
<TooltipElement
{...otherProps}
{...popoverProps}
id={tooltipId}
className={classNames(classes.tooltip, className)}
>
{text}
</TooltipElement>
)}
</Popover>
</>
);
};
let shouldSkipOpenDelay = false;
let resetSkipEnterDelayTimeout: null | ReturnType<typeof setTimeout> = null;
/**
* Globally disables the opening delay for any tooltips opened
* right after another one was closed, to make it easy to read
* the tooltips of grouped buttons, e.g. in a toolbar.
*/
function suppressEnterDelayTemporarily() {
shouldSkipOpenDelay = true;
if (resetSkipEnterDelayTimeout !== null) {
clearTimeout(resetSkipEnterDelayTimeout);
resetSkipEnterDelayTimeout = null;
}
resetSkipEnterDelayTimeout = setTimeout(() => {
shouldSkipOpenDelay = false;
resetSkipEnterDelayTimeout = null;
}, 100);
}
// This is a separate component to allow rendering it in the top layer
// as a native popover element.
const TooltipElement: React.FC<React.ComponentPropsWithRef<'span'>> = ({
children,
ref,
...otherProps
}) => {
const popoverRef = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const popover = popoverRef.current;
if (!popover) return;
popover.showPopover();
return () => {
popover.hidePopover();
};
}, []);
return (
<span {...otherProps} popover='manual' ref={useMergedRefs(ref, popoverRef)}>
{children}
</span>
);
};

View File

@@ -0,0 +1,10 @@
@use '@/styles/mastodon/mixins';
.tooltip {
display: block;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-3);
color: var(--color-text-inverted);
background-color: var(--color-bg-inverted);
@include mixins.type-meta;
}

View File

@@ -92,6 +92,7 @@ export const ComposeFormHeader: React.FC<{
variant='ghost'
icon={isMinimized ? ArrowsOutSimpleIcon : MinusIcon}
onClick={onMinimize}
tooltipPlacement='top'
>
{isMinimized ? (
<FormattedMessage
@@ -107,7 +108,13 @@ export const ComposeFormHeader: React.FC<{
</IconButton>
)}
<IconButton icon={XIcon} variant='ghost' size='sm' onClick={onClose}>
<IconButton
icon={XIcon}
variant='ghost'
size='sm'
onClick={onClose}
tooltipPlacement='top'
>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</IconButton>
</header>

View File

@@ -312,12 +312,17 @@ export const Notifications: React.FC<{
<>
<ColumnHeaderButton icon={GearIcon} onClick={openSettingsModal}>
<FormattedMessage
id='notifications.settings'
defaultMessage='Notification Settings'
id='notifications.open_settings'
defaultMessage='Open notification settings'
/>
</ColumnHeaderButton>
<ColumnSettingsMenu
labelPrefix={intl.formatMessage(messages.title)}
label={
<FormattedMessage
id='notifications.more_options'
defaultMessage='More options'
/>
}
>
<MenuItem
disabled={!canMarkAsRead}

View File

@@ -1184,6 +1184,8 @@
"notifications.hide_server_announcements": "Hide server announcements",
"notifications.mark_all_as_read": "Mark all as read",
"notifications.mark_as_read": "Mark every notification as read",
"notifications.more_options": "More options",
"notifications.open_settings": "Open notification settings",
"notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
"notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
"notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.",