From ccb266cd13bb819d676cf81cf205e56858d68295 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 15 Apr 2024 22:17:26 +1000 Subject: [PATCH] Animation features --- .../system/index.tsx | 2 +- docs/pages/experiments/tooltip.tsx | 67 +++++++++++++++++ .../mui-base/src/Tooltip/TooltipContent.tsx | 26 ++++--- .../Tooltip/{Tooltip.tsx => TooltipRoot.tsx} | 0 packages/mui-base/src/Tooltip/index.ts | 2 +- .../src/Tooltip/useTransitionStatus.ts | 40 +++++++++++ .../mui-base/src/useTooltip/useTooltip.ts | 71 ++++++++++++------- .../src/useTooltip/useTooltip.types.ts | 8 +++ 8 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 docs/pages/experiments/tooltip.tsx rename packages/mui-base/src/Tooltip/{Tooltip.tsx => TooltipRoot.tsx} (100%) create mode 100644 packages/mui-base/src/Tooltip/useTransitionStatus.ts diff --git a/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx index 0f23d24853..3ba3b5e7b8 100644 --- a/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx +++ b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx @@ -8,7 +8,7 @@ export default function UnstyledTooltipIntroduction() { Anchor - + Tooltip diff --git a/docs/pages/experiments/tooltip.tsx b/docs/pages/experiments/tooltip.tsx new file mode 100644 index 0000000000..dc6447f794 --- /dev/null +++ b/docs/pages/experiments/tooltip.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipIntroduction() { + return ( +
+ + + Anchor + + + Tooltip + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipContent = styled(Tooltip.Content)` + ${({ theme }) => ` + font-family: 'IBM Plex Sans', sans-serif; + background: ${theme.palette.mode === 'dark' ? 'white' : 'black'}; + color: ${theme.palette.mode === 'dark' ? 'black' : 'white'}; + padding: 4px 6px; + border-radius: 4px; + font-size: 95%; + cursor: default; + transition-property: opacity, transform; + transform-origin: var(--transform-origin); + + &[data-status='opened'], + &[data-status='closed'] { + transition-duration: 0.2s; + } + &[data-status='initial'], + &[data-status='closed'] { + opacity: 0; + transform: scale(0.9); + } + `} +`; + +export const AnchorButton = styled('button')` + border: none; + background: ${blue[600]}; + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 16px; + + &:focus-visible { + outline: 2px solid ${blue[400]}; + outline-offset: 2px; + } + + &:hover, + &[data-state='open'] { + background: ${blue[800]}; + } +`; diff --git a/packages/mui-base/src/Tooltip/TooltipContent.tsx b/packages/mui-base/src/Tooltip/TooltipContent.tsx index 571f4c302b..1125be1dca 100644 --- a/packages/mui-base/src/Tooltip/TooltipContent.tsx +++ b/packages/mui-base/src/Tooltip/TooltipContent.tsx @@ -4,7 +4,6 @@ import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import type { ContentProps } from './Tooltip.types'; import { resolveClassName } from '../utils/resolveClassName'; import { useTooltipContext } from './TooltipContext'; -import { useForkRef } from '../utils/useForkRef'; import { Portal } from '../Portal'; import { useTooltip } from '../useTooltip'; import { TooltipContentContext } from './TooltipContentContext'; @@ -77,15 +76,14 @@ const TooltipContent = React.forwardRef(function TooltipContent( setAnchorProps(getAnchorProps()); }, [setAnchorProps, getAnchorProps]); - const mergedRef = useForkRef(tooltip.setContentEl, forwardedRef); - const ownerState = React.useMemo( () => ({ open, side: tooltip.side, alignment: tooltip.alignment, + status: tooltip.status, }), - [open, tooltip.side, tooltip.alignment], + [open, tooltip.side, tooltip.alignment, tooltip.status], ); const contextValue = React.useMemo( @@ -97,21 +95,31 @@ const TooltipContent = React.forwardRef(function TooltipContent( [ownerState, tooltip.arrowRef, tooltip.floatingContext], ); - if (!keepMounted && !open) { + const shouldRender = keepMounted || tooltip.mounted; + if (!shouldRender) { return null; } - const contentProps = tooltip.getContentProps({ - ref: mergedRef, + const rootContentProps = tooltip.getContentProps(); + + // The content element needs to be a child of a wrapper floating element in order to avoid + // conflicts with CSS transitions and the positioning transform. + const contentProps = { + ref: forwardedRef, className: resolveClassName(className, ownerState), ['data-side' as string]: tooltip.side, ['data-alignment' as string]: tooltip.alignment, + ['data-status' as string]: tooltip.status, ...otherProps, - }); + }; return ( - {render(contentProps, ownerState)} + +
+ {render(contentProps, ownerState)} +
+
); }); diff --git a/packages/mui-base/src/Tooltip/Tooltip.tsx b/packages/mui-base/src/Tooltip/TooltipRoot.tsx similarity index 100% rename from packages/mui-base/src/Tooltip/Tooltip.tsx rename to packages/mui-base/src/Tooltip/TooltipRoot.tsx diff --git a/packages/mui-base/src/Tooltip/index.ts b/packages/mui-base/src/Tooltip/index.ts index c9446d55b2..07e0a40042 100644 --- a/packages/mui-base/src/Tooltip/index.ts +++ b/packages/mui-base/src/Tooltip/index.ts @@ -1,5 +1,5 @@ 'use client'; -export { TooltipRoot as Root } from './Tooltip'; +export { TooltipRoot as Root } from './TooltipRoot'; export { TooltipContent as Content } from './TooltipContent'; export { TooltipAnchorFragment as AnchorFragment } from './TooltipAnchorFragment'; export { TooltipArrow as Arrow } from './TooltipArrow'; diff --git a/packages/mui-base/src/Tooltip/useTransitionStatus.ts b/packages/mui-base/src/Tooltip/useTransitionStatus.ts new file mode 100644 index 0000000000..0c05a99ed5 --- /dev/null +++ b/packages/mui-base/src/Tooltip/useTransitionStatus.ts @@ -0,0 +1,40 @@ +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import * as React from 'react'; + +type Status = 'unmounted' | 'initial' | 'opened' | 'closed'; + +/** + * @ignore - internal hook. + */ +export function useTransitionStatus(trigger: boolean) { + const [status, setStatus] = React.useState('unmounted'); + const [mounted, setMounted] = React.useState(trigger); + + if (trigger && !mounted) { + setMounted(true); + } + + useEnhancedEffect(() => { + if (trigger) { + setStatus('initial'); + + const frame = requestAnimationFrame(() => { + setStatus('opened'); + }); + + return () => { + cancelAnimationFrame(frame); + }; + } + + setStatus('closed'); + + return undefined; + }, [trigger]); + + return { + mounted, + setMounted, + status, + }; +} diff --git a/packages/mui-base/src/useTooltip/useTooltip.ts b/packages/mui-base/src/useTooltip/useTooltip.ts index 82b46b37bf..61b521a408 100644 --- a/packages/mui-base/src/useTooltip/useTooltip.ts +++ b/packages/mui-base/src/useTooltip/useTooltip.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; import { autoUpdate, flip, @@ -22,6 +23,7 @@ import { import { getSide, getAlignment } from '@floating-ui/utils'; import type { UseTooltipParameters, UseTooltipReturnValue } from './useTooltip.types'; import { mergeReactProps } from '../utils/mergeReactProps'; +import { useTransitionStatus } from '../Tooltip/useTransitionStatus'; /** * The basic building block for creating custom tooltips. @@ -52,9 +54,21 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue hoverable = true, sticky = false, followCursorAxis = 'none', + arrowPadding = 5, } = params; - const arrowRef = React.useRef(null); + // Using a ref assumes that the arrow element is always present in the DOM for the lifetime of + // the tooltip. If this assumption ends up being false, we can switch to state to manage the + // arrow's presence. + const arrowRef = React.useRef(null); + + useEnhancedEffect(() => { + // `transform-origin` calculations rely on an arrow element existing. If it doesn't exist, we + // can create a fake node. + if (!arrowRef.current) { + arrowRef.current = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + } + }, []); const placement = alignment === 'center' ? side : (`${side}-${alignment}` as Placement); @@ -99,21 +113,17 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue }); }, }), - arrow({ element: arrowRef }), + arrow({ element: arrowRef, padding: arrowPadding }), hideWhenDetached && hide(), { name: 'transformOrigin', - fn({ elements }) { - const element = arrowRef.current; - - if (!element) { - return {}; - } - - const arrowX = context.middlewareData.arrow?.x ?? 0; - const arrowY = context.middlewareData.arrow?.y ?? 0; - const arrowWidth = element.clientWidth; - const arrowHeight = element.clientHeight; + fn({ elements, middlewareData, placement: renderedPlacement }) { + const currentRenderedSide = getSide(renderedPlacement); + const arrowEl = arrowRef.current; + const arrowX = middlewareData.arrow?.x ?? 0; + const arrowY = middlewareData.arrow?.y ?? 0; + const arrowWidth = arrowEl?.clientWidth ?? sideOffset; + const arrowHeight = arrowEl?.clientHeight ?? sideOffset; const transformX = arrowX + arrowWidth / 2; const transformY = arrowY + arrowHeight; @@ -122,7 +132,7 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue bottom: `${transformX}px ${-arrowHeight}px`, left: `calc(100% + ${arrowHeight}px) ${transformY}px`, right: `${-arrowHeight}px ${transformY}px`, - }[side]; + }[currentRenderedSide]; elements.floating.style.setProperty('--transform-origin', transformOrigin); @@ -160,14 +170,6 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue handleClose: hoverable ? safePolygon() : null, mouseOnly: true, move: false, - // We only want to show the tooltip when the user's cursor is at rest over the anchor. - // Two scenarios are possible when using a trackpad specifically: - // - They "tap", so their finger is lifted off the trackpad briefly after moving onto the anchor. - // This requires a longer delay to prevent the tooltip from briefly flickering, since it takes - // longer to dismiss the tooltip via the `referencePress` interaction. - // - They "click", so their finger is not lifted after moving onto the anchor. - // This can use a shorter delay, since there will not be a flicker around ~100ms. However, we - // are bound by the first constraint, so we need to use a longer ~200ms delay. restMs: delayType === 'rest' ? delay : undefined, }); const focus = useFocus(context); @@ -184,6 +186,8 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue clientPoint, ]); + const { mounted, setMounted, status } = useTransitionStatus(open); + const getAnchorProps: UseTooltipReturnValue['getAnchorProps'] = React.useCallback( (externalProps = {}) => mergeReactProps(externalProps, getReferenceProps()), [getReferenceProps], @@ -194,21 +198,38 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue mergeReactProps( externalProps, getFloatingProps({ + ['data-side' as string]: renderedSide, + ['data-alignment' as string]: renderedAlignment, + ['data-status' as string]: status, style: { ...floatingStyles, maxWidth: 'var(--available-width)', maxHeight: 'var(--available-height)', visibility: isHidden ? 'hidden' : undefined, - pointerEvents: followCursorAxis === 'both' ? 'none' : undefined, + pointerEvents: isHidden || followCursorAxis === 'both' ? 'none' : undefined, zIndex: 2147483647, // max z-index }, + onTransitionEnd() { + setMounted((prevMounted) => (prevMounted ? false : prevMounted)); + }, }), ), - [getFloatingProps, floatingStyles, isHidden, followCursorAxis], + [ + getFloatingProps, + floatingStyles, + isHidden, + followCursorAxis, + renderedSide, + renderedAlignment, + status, + setMounted, + ], ); return React.useMemo( () => ({ + status, + mounted, getAnchorProps, getContentProps, arrowRef, @@ -219,6 +240,8 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue alignment: renderedAlignment, }), [ + status, + mounted, getAnchorProps, getContentProps, refs.setReference, diff --git a/packages/mui-base/src/useTooltip/useTooltip.types.ts b/packages/mui-base/src/useTooltip/useTooltip.types.ts index 985ce2df9c..c4a738c00c 100644 --- a/packages/mui-base/src/useTooltip/useTooltip.types.ts +++ b/packages/mui-base/src/useTooltip/useTooltip.types.ts @@ -140,6 +140,14 @@ export interface UseTooltipReturnValue { * The rendered alignment of the tooltip element. */ alignment: 'start' | 'end' | 'center'; + /** + * Whether the tooltip is mounted, including CSS transitions or animations. + */ + mounted: boolean; + /** + * The status of the tooltip element when considering CSS transitions or animations. + */ + status: 'unmounted' | 'initial' | 'opened' | 'closed'; } export interface UseTooltipOpenStateReturnValue {