mirror of
https://github.com/mastodon/mastodon.git
synced 2026-10-26 06:04:51 -05:00
Redesign: Add tooltips to icon buttons (#40737)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
217
app/javascript/mastodon/components/tooltip/index.tsx
Normal file
217
app/javascript/mastodon/components/tooltip/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user