Skip to content

Commit

Permalink
Animation features
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks committed Apr 16, 2024
1 parent 86fe45c commit ccb266c
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function UnstyledTooltipIntroduction() {
<Tooltip.AnchorFragment>
<AnchorButton>Anchor</AnchorButton>
</Tooltip.AnchorFragment>
<TooltipContent sideOffset={7}>
<TooltipContent sideOffset={7} arrowPadding={3}>
Tooltip
<TooltipArrow />
</TooltipContent>
Expand Down
67 changes: 67 additions & 0 deletions docs/pages/experiments/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ width: 700, margin: '0 auto', padding: 50 }}>
<Tooltip.Root>
<Tooltip.AnchorFragment>
<AnchorButton>Anchor</AnchorButton>
</Tooltip.AnchorFragment>
<TooltipContent sideOffset={7} arrowPadding={3}>
Tooltip
</TooltipContent>
</Tooltip.Root>
</div>
);
}

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]};
}
`;
26 changes: 17 additions & 9 deletions packages/mui-base/src/Tooltip/TooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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 (
<TooltipContentContext.Provider value={contextValue}>
<Portal container={container}>{render(contentProps, ownerState)}</Portal>
<Portal container={container}>
<div role="presentation" ref={tooltip.setContentEl} {...rootContentProps}>
{render(contentProps, ownerState)}
</div>
</Portal>
</TooltipContentContext.Provider>
);
});
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion packages/mui-base/src/Tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
40 changes: 40 additions & 0 deletions packages/mui-base/src/Tooltip/useTransitionStatus.ts
Original file line number Diff line number Diff line change
@@ -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<Status>('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,
};
}
71 changes: 47 additions & 24 deletions packages/mui-base/src/useTooltip/useTooltip.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
import {
autoUpdate,
flip,
Expand All @@ -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.
Expand Down Expand Up @@ -52,9 +54,21 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue
hoverable = true,
sticky = false,
followCursorAxis = 'none',
arrowPadding = 5,
} = params;

const arrowRef = React.useRef<SVGSVGElement>(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<SVGSVGElement | null>(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);

Expand Down Expand Up @@ -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;

Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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],
Expand All @@ -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,
Expand All @@ -219,6 +240,8 @@ export function useTooltip(params: UseTooltipParameters): UseTooltipReturnValue
alignment: renderedAlignment,
}),
[
status,
mounted,
getAnchorProps,
getContentProps,
refs.setReference,
Expand Down
8 changes: 8 additions & 0 deletions packages/mui-base/src/useTooltip/useTooltip.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit ccb266c

Please sign in to comment.