diff --git a/app/javascript/mastodon/components/account_header/buttons.tsx b/app/javascript/mastodon/components/account_header/buttons.tsx index ecc77769ba2..c66ae9b0892 100644 --- a/app/javascript/mastodon/components/account_header/buttons.tsx +++ b/app/javascript/mastodon/components/account_header/buttons.tsx @@ -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, - })} ) : ( { children: ReactNode; } +type AttributesToExclude = 'children' | 'title'; + type ButtonButtonProps = { as?: 'button' } & ButtonPropsBase<'button'> & - Omit, 'children'>; + Omit, AttributesToExclude>; type ButtonAnchorProps = { as: 'a' } & ButtonPropsBase<'a'> & - Omit, 'children'>; + Omit, AttributesToExclude>; type ButtonLinkProps = { as: 'link' } & ButtonPropsBase<'a'> & - Omit; + Omit; type BaseButtonProps = ButtonButtonProps | ButtonAnchorProps | ButtonLinkProps; @@ -107,47 +111,84 @@ const BaseButton: React.FC = ({ ); }; +interface ButtonTooltip { + type: 'label' | 'description'; + text: React.ReactNode; +} + export type ButtonProps = BaseButtonProps & { leadingIcon?: IconProp; trailingIcon?: IconProp; + tooltip?: ButtonTooltip; }; export const Button: React.FC = ({ children, leadingIcon, trailingIcon, + tooltip, ...props -}) => ( - - {leadingIcon && !props.loading && ( - - )} - {props.loading && } - {children} - {trailingIcon && ( - - )} - -); +}) => { + const buttonContent = ( + <> + {leadingIcon && !props.loading && ( + + )} + {props.loading && } + {children} + {trailingIcon && ( + + )} + + ); + + if (!tooltip?.text) { + return {buttonContent}; + } + return ( + + {({ getTooltipProps, tooltipId }) => ( + + {buttonContent} + + )} + + ); +}; export type IconButtonProps = BaseButtonProps & { icon: IconProp; + tooltipPlacement?: PopoverProps['placement']; }; export const IconButton: React.FC = ({ icon, className, + tooltipPlacement, children, ...props }) => ( - - {props.loading ? ( - - ) : ( - + + {({ getTooltipProps }) => ( + + {props.loading ? ( + + ) : ( + + )} + {children} + )} - {children} - + ); export const CaretIcon = ( diff --git a/app/javascript/mastodon/components/column_header/column_settings_menu.tsx b/app/javascript/mastodon/components/column_header/column_settings_menu.tsx index 6babe64de41..5203730a427 100644 --- a/app/javascript/mastodon/components/column_header/column_settings_menu.tsx +++ b/app/javascript/mastodon/components/column_header/column_settings_menu.tsx @@ -30,7 +30,7 @@ export const ColumnSettingsMenu: React.FC = ({ return ( - {labelPrefix} + {labelPrefix && <>{labelPrefix} } {label ?? ( = ({ size='sm' clipPadding variant='ghost' - title={reply.title} leadingIcon={reply.icon} onClick={reply.action} + tooltip={{ type: 'label', text: reply.title }} > {withCounters && reply.counter} @@ -112,7 +112,7 @@ export const StatusActionBar: React.FC = ({ 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 = ({ 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: ( + + ), + }} leadingIcon={boost.icon} > {children} diff --git a/app/javascript/mastodon/components/tooltip/index.tsx b/app/javascript/mastodon/components/tooltip/index.tsx new file mode 100644 index 00000000000..4c942174a28 --- /dev/null +++ b/app/javascript/mastodon/components/tooltip/index.tsx @@ -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 = >( + 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 = ({ + text, + placement = 'bottom', + offset = 4, + renderTextWhenClosed = false, + id = '', + className, + children, + ...otherProps +}) => { + const uniqueId = useId(); + const tooltipId = id || uniqueId; + + const [referenceElement, setReferenceElement] = useState( + null, + ); + const [isOpen, setIsOpen] = useState(false); + const openTimeout = useRef>(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( + (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( + >(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 && ( + + )} + + {({ props: popoverProps }) => ( + + {text} + + )} + + + ); +}; + +let shouldSkipOpenDelay = false; +let resetSkipEnterDelayTimeout: null | ReturnType = 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> = ({ + children, + ref, + ...otherProps +}) => { + const popoverRef = useRef(null); + + useLayoutEffect(() => { + const popover = popoverRef.current; + if (!popover) return; + + popover.showPopover(); + + return () => { + popover.hidePopover(); + }; + }, []); + + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/components/tooltip/styles.module.scss b/app/javascript/mastodon/components/tooltip/styles.module.scss new file mode 100644 index 00000000000..d81187f084d --- /dev/null +++ b/app/javascript/mastodon/components/tooltip/styles.module.scss @@ -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; +} diff --git a/app/javascript/mastodon/features/compose/redesign/header.tsx b/app/javascript/mastodon/features/compose/redesign/header.tsx index 5eb27639973..bf2ac4a03c2 100644 --- a/app/javascript/mastodon/features/compose/redesign/header.tsx +++ b/app/javascript/mastodon/features/compose/redesign/header.tsx @@ -92,6 +92,7 @@ export const ComposeFormHeader: React.FC<{ variant='ghost' icon={isMinimized ? ArrowsOutSimpleIcon : MinusIcon} onClick={onMinimize} + tooltipPlacement='top' > {isMinimized ? ( )} - + diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index 9c5fc43a7ff..facdf03c664 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -312,12 +312,17 @@ export const Notifications: React.FC<{ <> + } >