diff --git a/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.js b/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.js new file mode 100644 index 000000000..c9fd9eb9f --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.js @@ -0,0 +1,60 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipDelayGroup() { + return ( +
+ + + Anchor A + + Tooltip A + + + + Anchor B + + Tooltip B + + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.tsx b/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.tsx new file mode 100644 index 000000000..c9fd9eb9f --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipDelayGroup() { + return ( +
+ + + Anchor A + + Tooltip A + + + + Anchor B + + Tooltip B + + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.tsx.preview b/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.tsx.preview new file mode 100644 index 000000000..79ad0d2f2 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipDelayGroup.tsx.preview @@ -0,0 +1,14 @@ + + + Anchor A + + Tooltip A + + + + Anchor B + + Tooltip B + + + \ No newline at end of file diff --git a/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.js b/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.js new file mode 100644 index 000000000..12a3d7d98 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.js @@ -0,0 +1,52 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipFollowCursor() { + return ( +
+ + Anchor + + Tooltip + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.tsx b/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.tsx new file mode 100644 index 000000000..12a3d7d98 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipFollowCursor() { + return ( +
+ + Anchor + + Tooltip + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.tsx.preview b/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.tsx.preview new file mode 100644 index 000000000..c95cdc12a --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipFollowCursor.tsx.preview @@ -0,0 +1,6 @@ + + Anchor + + Tooltip + + \ No newline at end of file diff --git a/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.js b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.js new file mode 100644 index 000000000..fd19295c8 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.js @@ -0,0 +1,110 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipIntroduction() { + return ( +
+ + + B + + + Bold + + + + + + I + + + Italic + + + + + + U + + + Underline + + + + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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]}; + } + + &[aria-label='bold'] { + font-weight: bold; + } + + &[aria-label='italic'] { + font-style: italic; + } + + &[aria-label='underline'] { + text-decoration: underline; + } +`; + +export const TooltipArrow = styled(Tooltip.Arrow)` + ${({ theme }) => ` + width: 10px; + height: 10px; + transform: rotate(45deg); + background: ${theme.palette.mode === 'dark' ? 'white' : 'black'}; + z-index: -1; + + &[data-side='top'] { + bottom: -5px; + } + &[data-side='right'] { + left: -5px; + } + &[data-side='bottom'] { + top: -5px; + } + &[data-side='left'] { + right: -5px; + } + `} +`; diff --git a/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx new file mode 100644 index 000000000..fd19295c8 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipIntroduction() { + return ( +
+ + + B + + + Bold + + + + + + I + + + Italic + + + + + + U + + + Underline + + + + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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]}; + } + + &[aria-label='bold'] { + font-weight: bold; + } + + &[aria-label='italic'] { + font-style: italic; + } + + &[aria-label='underline'] { + text-decoration: underline; + } +`; + +export const TooltipArrow = styled(Tooltip.Arrow)` + ${({ theme }) => ` + width: 10px; + height: 10px; + transform: rotate(45deg); + background: ${theme.palette.mode === 'dark' ? 'white' : 'black'}; + z-index: -1; + + &[data-side='top'] { + bottom: -5px; + } + &[data-side='right'] { + left: -5px; + } + &[data-side='bottom'] { + top: -5px; + } + &[data-side='left'] { + right: -5px; + } + `} +`; diff --git a/docs/data/base/components/tooltip/UnstyledTooltipTransition.js b/docs/data/base/components/tooltip/UnstyledTooltipTransition.js new file mode 100644 index 000000000..0c15e8a10 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipTransition.js @@ -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 UnstyledTooltipTransition() { + return ( +
+ + Anchor + + Tooltip + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + transition-duration: 0.2s; + opacity: 0; + transform: scale(0.9); + transform-origin: var(--transform-origin); + + &[data-state='open'] { + opacity: 1; + transform: scale(1); + } + + &[data-entering] { + opacity: 0; + transform: scale(0.9); + } + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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/docs/data/base/components/tooltip/UnstyledTooltipTransition.tsx b/docs/data/base/components/tooltip/UnstyledTooltipTransition.tsx new file mode 100644 index 000000000..0c15e8a10 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipTransition.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 UnstyledTooltipTransition() { + return ( +
+ + Anchor + + Tooltip + + +
+ ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + ${({ theme }) => ` + 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; + transition-duration: 0.2s; + opacity: 0; + transform: scale(0.9); + transform-origin: var(--transform-origin); + + &[data-state='open'] { + opacity: 1; + transform: scale(1); + } + + &[data-entering] { + opacity: 0; + transform: scale(0.9); + } + `} +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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/docs/data/base/components/tooltip/UnstyledTooltipTransition.tsx.preview b/docs/data/base/components/tooltip/UnstyledTooltipTransition.tsx.preview new file mode 100644 index 000000000..ae1604e87 --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipTransition.tsx.preview @@ -0,0 +1,6 @@ + + Anchor + + Tooltip + + \ No newline at end of file diff --git a/docs/data/base/components/tooltip/tooltip.md b/docs/data/base/components/tooltip/tooltip.md index 02ea58050..946c9b210 100644 --- a/docs/data/base/components/tooltip/tooltip.md +++ b/docs/data/base/components/tooltip/tooltip.md @@ -1,14 +1,401 @@ --- productId: base-ui title: React Tooltip component +components: TooltipProvider, TooltipRoot, TooltipTrigger, TooltipPositioner, TooltipPopup, TooltipArrow +hooks: useTooltipRoot, useTooltipPositioner githubLabel: 'component: tooltip' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ --- -# Tooltip 🚧 +# Tooltip -

Tooltips display informative text when users hover over, focus on, or tap an element.

+

Tooltips are visual-only floating elements that display information about a trigger element when a user hovers or focuses it.

-:::warning -The Base UI Tooltip component isn't available yet, but you can upvote [this GitHub issue](https://github.com/mui/base-ui/issues/32) to see it arrive sooner. -::: +{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +{{"component": "modules/components/ComponentPageTabs.js"}} + +## Introduction + +{{"demo": "UnstyledTooltipIntroduction", "defaultCodeOpen": false, "bg": "gradient"}} + +## Installation + +Base UI components are all available as a single package. + + + +```bash npm +npm install @base_ui/react +``` + +```bash yarn +yarn add @base_ui/react +``` + +```bash pnpm +pnpm add @base_ui/react +``` + + + +Once you have the package installed, import the component. + +```ts +import * as Tooltip from '@base_ui/react/Tooltip'; +``` + +## Anatomy + +Tooltip is implemented using a collection of related components: + +- `` wraps around `` or a group of ``s. +- `` is a top-level component that wraps the other components. +- `` renders the trigger element. +- `` renders the tooltip's positioning element. +- `` renders the tooltip popup itself. +- `` renders an optional pointing arrow, placed inside the popup. + +```tsx + + + + + + + + + + +``` + +## Provider + +`Tooltip.Provider` provides a shared delay for tooltips so that once a tooltip is shown, the rest of the tooltips in the group don't wait for the delay before showing. You can wrap this globally, or around an individual group of tooltips anywhere in your React tree (or both). + +```tsx + + + +``` + +{{"demo": "UnstyledTooltipDelayGroup.js"}} + +## Accessibility + +Tooltips are only for sighted users with access to a pointer with hover capability or keyboard focus. This means you must supply an accessible name via `aria-label` to trigger elements that don't contain text content, such as an icon button. + +```jsx + + + + + + Edit + + +``` + +Your `aria-label` and tooltip content should closely match or be identical so that screen reader users and sighted users receive the same information. + +Tooltips should ideally also be secondary in nature, because touch users cannot see them. They are most useful as progressive enhancement in high-density desktop applications that have many icon buttons where visual labels are impractical to use. + +## Placement + +By default, the tooltip is placed on the top side of its trigger, the default anchor. To change this, use the `side` prop: + +```jsx + + + + Tooltip + + +``` + +You can also change the alignment of the tooltip in relation to its anchor. By default, it is centered, but it can be aligned to an edge of the anchor using the `alignment` prop: + +```jsx + + Tooltip + +``` + +Due to collision detection, the tooltip may change its placement to avoid overflow. Therefore, your explicitly specified `side` and `alignment` props act as "ideal", or preferred, values. + +To access the true rendered values, which may change as the result of a collision, the content element receives data attributes: + +```jsx +// Rendered HTML (simplified) +
+
+ Tooltip +
+
+``` + +This allows you to conditionally style the tooltip based on its rendered side or alignment. + +## Offset + +The `sideOffset` prop creates a gap between the anchor and tooltip popup, while `alignmentOffset` slides the tooltip popup from its alignment, acting logically for `start` and `end` alignments. + +```jsx + +``` + +## Delay + +To change how long the tooltip waits until it opens or closes, use the `delay` and `closeDelay` props, which represent how long the tooltip waits after the cursor rests on the trigger to open, or moves away from the trigger to close, in milliseconds: + +```jsx + +``` + +The delay type can be changed from `"rest"` (user's cursor is static over the trigger for the given timeout in milliseconds) to `"hover"` (the user's cursor has entered the trigger): + +```jsx + +``` + +## Controlled + +To control the tooltip with external state, use the `open` and `onOpenChange` props: + +```jsx +function App() { + const [open, setOpen] = React.useState(false); + return ( + + {/* Subcomponents */} + + ); +} +``` + +## Arrow + +To add an arrow (caret or triangle) inside the tooltip content that points toward the center of the anchor element, use the `Tooltip.Arrow` component: + +```js + + + Tooltip + + + +``` + +It automatically positions a wrapper element that can be styled or contain a custom SVG shape. + +## Cursor following + +The tooltip can follow the cursor on both axes or one axis using the `followCursorAxis` prop on `Tooltip.Root`. Possible values are: `none` (default), `both`, `x`, or `y`. + +{{"demo": "UnstyledTooltipFollowCursor.js"}} + +## Anchoring + +By default, the `Trigger` acts as the anchor, but this can be changed to another element. + +- A DOM element (stored in React state): + +```jsx + +``` + +- A React ref: + +```jsx + +``` + +- A virtual element object, consisting of a `getBoundingClientRect` method and an optional `contextElement` property: + +```jsx + DOMRect, + // `contextElement` is an optional but recommended property when `getBoundingClientRect` is + // derived from a real element, to ensure collision detection and position updates work as + // expected in certain DOM trees. + contextElement: domNode, + }} +> +``` + +## Styling + +The `Tooltip.Positioner` element receives the following CSS variables, which can be used by `Tooltip.Popup`: + +- `--anchor-width`: Specifies the width of the anchor element. You can use this to match the width of the tooltip with its anchor. +- `--anchor-height`: Specifies the height of the anchor element. You can use this to match the height of the tooltip with its anchor. +- `--available-width`: Specifies the available width of the popup element before it overflows the viewport. +- `--available-height`: Specifies the available height of the popup element before it overflows the viewport. +- `--transform-origin`: Specifies the origin of the popup element that represents the point of the anchor element's center. When animating scale, this allows it to correctly emanate from the center of the anchor. + +By default, `maxWidth` and `maxHeight` are already specified on the positioner using `--available-{width,height}` to prevent the tooltip from being too big to fit on the screen. + +## Animations + +The tooltip can animate when opening or closing with either: + +- CSS transitions +- CSS animations +- JavaScript animations + +### CSS transitions + +Here is an example of how to apply a symmetric scale and fade transition with the default conditionally-rendered behavior: + +```jsx +Tooltip +``` + +```css +.TooltipPopup { + transform-origin: var(--transform-origin); + transition-property: opacity, transform; + transition-duration: 0.2s; + /* Represents the final styles once exited */ + opacity: 0; + transform: scale(0.9); +} + +/* Represents the final styles once entered */ +.TooltipPopup[data-state='open'] { + opacity: 1; + transform: scale(1); +} + +/* Represents the initial styles when entering */ +.TooltipPopup[data-entering] { + opacity: 0; + transform: scale(0.9); +} +``` + +Styles need to be applied in three states: + +- The exiting styles, placed on the base element class +- The open styles, placed on the base element class with `[data-state="open"]` +- The entering styles, placed on the base element class with `[data-entering]` + +{{"demo": "UnstyledTooltipTransition.js", "defaultCodeOpen": false}} + +In newer browsers, there is a feature called `@starting-style` which allows transitions to occur on open for conditionally-mounted components: + +```css +/* Base UI API - Polyfill */ +.TooltipPopup[data-entering] { + opacity: 0; + transform: scale(0.9); +} + +/* Official Browser API - no Firefox support as of May 2024 */ +@starting-style { + .TooltipPopup[data-state='open'] { + opacity: 0; + transform: scale(0.9); + } +} +``` + +### CSS animations + +CSS animations can also be used, requiring only two separate declarations: + +```css +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.9); + } +} + +@keyframes scale-out { + to { + opacity: 0; + transform: scale(0.9); + } +} + +.TooltipPopup { + animation: scale-in 0.2s forwards; +} + +.TooltipPopup[data-exiting] { + animation: scale-out 0.2s forwards; +} +``` + +### JavaScript animations + +The `keepMounted` prop lets an external library control the mounting, for example `framer-motion`'s `AnimatePresence` component. + +```js +function App() { + const [open, setOpen] = useState(false); + return ( + + Trigger + + {open && ( + + + } + > + Tooltip + + + )} + + + ); +} +``` + +### Animation states + +Four states are available as data attributes to animate the popup, which enables full control depending on whether the popup is being animated with CSS transitions or animations, JavaScript, or is using the `keepMounted` prop. + +- `[data-state="open"]` - `open` state is `true`. +- `[data-state="closed"]` - `open` state is `false`. Can still be mounted to the DOM if closing. +- `[data-entering]` - the popup was just inserted to the DOM. The attribute is removed 1 animation frame later. Enables "starting styles" upon insertion for conditional rendering. +- `[data-exiting]` - the popup is in the process of being removed from the DOM, but is still mounted. + +### Instant animation + +Animations can be removed under certain conditions using the `data-instant` attribute on `Tooltip.Popup`. This attribute can be used unconditionally, but it also has different values for granular checks: + +- `data-instant="delay"` indicates the tooltip is grouped and instantly opened with no delay. +- `data-instant="focus"` indicates it was triggered by keyboard focus. +- `data-instant="dismiss"` indicates it was dismissed by pressing the `esc` key. + +In most of these cases, you'll want to remove any animations: + +```css +.TooltipPopup[data-instant] { + transition-duration: 0s; +} +``` + +## Overriding default components + +Use the `render` prop to override the rendered elements with your own components. + +```jsx +// Element shorthand +} /> +``` + +```jsx +// Function + } /> +``` diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts index 845f90a38..9f049a2af 100644 --- a/docs/data/base/pages.ts +++ b/docs/data/base/pages.ts @@ -35,14 +35,14 @@ const pages: readonly MuiPage[] = [ // { pathname: '/base-ui/react-toggle-button-group', title: 'Toggle Button Group', planned: true }, ], }, - // { - // pathname: '/base-ui/components/data-display', - // subheader: 'data-display', - // children: [ - // { pathname: '/base-ui/react-badge', title: 'Badge' }, - // { pathname: '/base-ui/react-tooltip', title: 'Tooltip', planned: true }, - // ], - // }, + { + pathname: '/base-ui/components/data-display', + subheader: 'data-display', + children: [ + // { pathname: '/base-ui/react-badge', title: 'Badge' }, + { pathname: '/base-ui/react-tooltip', title: 'Tooltip' }, + ], + }, // { // pathname: '/base-ui/components/feedback', // subheader: 'feedback', diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js index 40324afae..17e5e1f9e 100644 --- a/docs/data/base/pagesApi.js +++ b/docs/data/base/pagesApi.js @@ -107,6 +107,30 @@ module.exports = [ pathname: '/base-ui/react-textarea-autosize/components-api/#textarea-autosize', title: 'TextareaAutosize', }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-arrow', + title: 'TooltipArrow', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-popup', + title: 'TooltipPopup', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-positioner', + title: 'TooltipPositioner', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-provider', + title: 'TooltipProvider', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-root', + title: 'TooltipRoot', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-trigger', + title: 'TooltipTrigger', + }, { pathname: '/base-ui/react-autocomplete/hooks-api/#use-autocomplete', title: 'useAutocomplete', @@ -161,6 +185,14 @@ module.exports = [ { pathname: '/base-ui/react-tabs/hooks-api/#use-tab-panel', title: 'useTabPanel' }, { pathname: '/base-ui/react-tabs/hooks-api/#use-tabs-list', title: 'useTabsList' }, { pathname: '/base-ui/react-tabs/hooks-api/#use-tabs-root', title: 'useTabsRoot' }, + { + pathname: '/base-ui/react-tooltip/hooks-api/#use-tooltip-positioner', + title: 'useTooltipPositioner', + }, + { + pathname: '/base-ui/react-tooltip/hooks-api/#use-tooltip-root', + title: 'useTooltipRoot', + }, { pathname: '/base-ui/react-transitions/hooks-api/#use-transition-state-manager', title: 'useTransitionStateManager', diff --git a/docs/package.json b/docs/package.json index b66d8f1f4..e4b8e382b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -88,6 +88,7 @@ "@types/stylis": "^4.2.5", "chai": "^4.4.1", "cross-fetch": "^4.0.0", + "framer-motion": "^11.2.5", "marked": "^12.0.2", "playwright": "^1.43.1", "prettier": "^3.2.5", diff --git a/docs/pages/base-ui/api/tooltip-arrow.json b/docs/pages/base-ui/api/tooltip-arrow.json new file mode 100644 index 000000000..61b36f53e --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-arrow.json @@ -0,0 +1,20 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "hideWhenUncentered": { "type": { "name": "bool" }, "default": "false" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "TooltipArrow", + "imports": [ + "import * as Tooltip from '@base_ui/react/Tooltip';\nconst TooltipArrow = Tooltip.Arrow;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "TooltipArrow", + "forwardsRefTo": "Element", + "filename": "/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-popup.json b/docs/pages/base-ui/api/tooltip-popup.json new file mode 100644 index 000000000..df09d08b1 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-popup.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "TooltipPopup", + "imports": [ + "import * as Tooltip from '@base_ui/react/Tooltip';\nconst TooltipPopup = Tooltip.Popup;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "TooltipPopup", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Tooltip/Popup/TooltipPopup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-positioner.json b/docs/pages/base-ui/api/tooltip-positioner.json new file mode 100644 index 000000000..08dd615d3 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-positioner.json @@ -0,0 +1,64 @@ +{ + "props": { + "alignment": { + "type": { + "name": "enum", + "description": "'center'
| 'end'
| 'start'" + }, + "default": "'center'" + }, + "alignmentOffset": { "type": { "name": "number" }, "default": "0" }, + "anchor": { + "type": { + "name": "union", + "description": "HTML element
| object
| func" + } + }, + "arrowPadding": { "type": { "name": "number" }, "default": "5" }, + "className": { "type": { "name": "union", "description": "func
| string" } }, + "collisionBoundary": { + "type": { + "name": "union", + "description": "HTML element
| Array<HTML element>
| string
| { height?: number, width?: number, x?: number, y?: number }" + }, + "default": "'clippingAncestors'" + }, + "collisionPadding": { + "type": { + "name": "union", + "description": "number
| { bottom?: number, left?: number, right?: number, top?: number }" + }, + "default": "5" + }, + "container": { "type": { "name": "union", "description": "HTML element
| func" } }, + "hideWhenDetached": { "type": { "name": "bool" }, "default": "false" }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "positionStrategy": { + "type": { "name": "enum", "description": "'absolute'
| 'fixed'" }, + "default": "'absolute'" + }, + "render": { "type": { "name": "union", "description": "element
| func" } }, + "side": { + "type": { + "name": "enum", + "description": "'bottom'
| 'left'
| 'right'
| 'top'" + }, + "default": "'top'" + }, + "sideOffset": { "type": { "name": "number" }, "default": "0" }, + "sticky": { "type": { "name": "bool" }, "default": "false" } + }, + "name": "TooltipPositioner", + "imports": [ + "import * as Tooltip from '@base_ui/react/Tooltip';\nconst TooltipPositioner = Tooltip.Positioner;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "TooltipPositioner", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-provider.json b/docs/pages/base-ui/api/tooltip-provider.json new file mode 100644 index 000000000..570269af7 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-provider.json @@ -0,0 +1,19 @@ +{ + "props": { + "closeDelay": { "type": { "name": "number" }, "default": "0" }, + "delay": { "type": { "name": "number" }, "default": "0" }, + "timeout": { "type": { "name": "number" }, "default": "400" } + }, + "name": "TooltipProvider", + "imports": [ + "import * as Tooltip from '@base_ui/react/Tooltip';\nconst TooltipProvider = Tooltip.Provider;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": null, + "muiName": "TooltipProvider", + "filename": "/packages/mui-base/src/Tooltip/Provider/TooltipProvider.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-root.json b/docs/pages/base-ui/api/tooltip-root.json new file mode 100644 index 000000000..7054c783e --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-root.json @@ -0,0 +1,34 @@ +{ + "props": { + "animated": { "type": { "name": "bool" }, "default": "true" }, + "closeDelay": { "type": { "name": "number" }, "default": "0" }, + "defaultOpen": { "type": { "name": "bool" } }, + "delay": { "type": { "name": "number" }, "default": "300" }, + "delayType": { + "type": { "name": "enum", "description": "'hover'
| 'rest'" }, + "default": "'rest'" + }, + "followCursorAxis": { + "type": { + "name": "enum", + "description": "'both'
| 'none'
| 'x'
| 'y'" + }, + "default": "'none'" + }, + "hoverable": { "type": { "name": "bool" }, "default": "true" }, + "onOpenChange": { "type": { "name": "func" } }, + "open": { "type": { "name": "bool" } } + }, + "name": "TooltipRoot", + "imports": [ + "import * as Tooltip from '@base_ui/react/Tooltip';\nconst TooltipRoot = Tooltip.Root;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": null, + "muiName": "TooltipRoot", + "filename": "/packages/mui-base/src/Tooltip/Root/TooltipRoot.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-trigger.json b/docs/pages/base-ui/api/tooltip-trigger.json new file mode 100644 index 000000000..bdb850291 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-trigger.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "TooltipTrigger", + "imports": [ + "import * as Tooltip from '@base_ui/react/Tooltip';\nconst TooltipTrigger = Tooltip.Trigger;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "TooltipTrigger", + "forwardsRefTo": "HTMLButtonElement", + "filename": "/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/use-tooltip-positioner.json b/docs/pages/base-ui/api/use-tooltip-positioner.json new file mode 100644 index 000000000..aab8fc2f8 --- /dev/null +++ b/docs/pages/base-ui/api/use-tooltip-positioner.json @@ -0,0 +1,117 @@ +{ + "parameters": { + "getRootPositionerProps": { + "type": { + "name": "(externalProps?: GenericHTMLProps) => GenericHTMLProps", + "description": "(externalProps?: GenericHTMLProps) => GenericHTMLProps" + }, + "required": true + }, + "alignment": { + "type": { + "name": "'start' | 'end' | 'center'", + "description": "'start' | 'end' | 'center'" + }, + "default": "'center'" + }, + "alignmentOffset": { "type": { "name": "number", "description": "number" }, "default": "0" }, + "anchor": { + "type": { + "name": "Element | null | VirtualElement | React.MutableRefObject<Element | null> | (() => Element | VirtualElement | null)", + "description": "Element | null | VirtualElement | React.MutableRefObject<Element | null> | (() => Element | VirtualElement | null)" + } + }, + "arrowPadding": { "type": { "name": "number", "description": "number" }, "default": "5" }, + "collisionBoundary": { + "type": { "name": "Boundary", "description": "Boundary" }, + "default": "'clippingAncestors'" + }, + "collisionPadding": { "type": { "name": "Padding", "description": "Padding" }, "default": "5" }, + "followCursorAxis": { + "type": { + "name": "'none' | 'x' | 'y' | 'both'", + "description": "'none' | 'x' | 'y' | 'both'" + }, + "default": "'none'" + }, + "hideWhenDetached": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false" + }, + "instant": { "type": { "name": "boolean", "description": "boolean" } }, + "keepMounted": { "type": { "name": "boolean", "description": "boolean" }, "default": "false" }, + "mounted": { "type": { "name": "boolean", "description": "boolean" } }, + "open": { "type": { "name": "boolean", "description": "boolean" } }, + "positionStrategy": { + "type": { + "name": "'absolute' | 'fixed'", + "description": "'absolute' | 'fixed'" + }, + "default": "'absolute'" + }, + "rootContext": { + "type": { "name": "FloatingRootContext", "description": "FloatingRootContext" } + }, + "setMounted": { + "type": { + "name": "React.Dispatch<React.SetStateAction<boolean>>", + "description": "React.Dispatch<React.SetStateAction<boolean>>" + } + }, + "side": { + "type": { + "name": "'top' | 'right' | 'bottom' | 'left'", + "description": "'top' | 'right' | 'bottom' | 'left'" + }, + "default": "'top'" + }, + "sideOffset": { "type": { "name": "number", "description": "number" }, "default": "0" }, + "sticky": { "type": { "name": "boolean", "description": "boolean" }, "default": "false" } + }, + "returnValue": { + "alignment": { + "type": { + "name": "'start' | 'end' | 'center'", + "description": "'start' | 'end' | 'center'" + }, + "required": true + }, + "arrowRef": { + "type": { + "name": "React.MutableRefObject<Element | null>", + "description": "React.MutableRefObject<Element | null>" + }, + "required": true + }, + "arrowUncentered": { + "type": { "name": "boolean", "description": "boolean" }, + "required": true + }, + "getArrowProps": { + "type": { + "name": "(externalProps?: GenericHTMLProps) => GenericHTMLProps", + "description": "(externalProps?: GenericHTMLProps) => GenericHTMLProps" + }, + "required": true + }, + "getPositionerProps": { + "type": { + "name": "(externalProps?: GenericHTMLProps) => GenericHTMLProps", + "description": "(externalProps?: GenericHTMLProps) => GenericHTMLProps" + }, + "required": true + }, + "mounted": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "side": { + "type": { + "name": "'top' | 'right' | 'bottom' | 'left'", + "description": "'top' | 'right' | 'bottom' | 'left'" + }, + "required": true + } + }, + "name": "useTooltipPositioner", + "filename": "/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.ts", + "imports": ["import { useTooltipPositioner } from '@base_ui/react/Tooltip';"], + "demos": "" +} diff --git a/docs/pages/base-ui/api/use-tooltip-root.json b/docs/pages/base-ui/api/use-tooltip-root.json new file mode 100644 index 000000000..67b04edb3 --- /dev/null +++ b/docs/pages/base-ui/api/use-tooltip-root.json @@ -0,0 +1,90 @@ +{ + "parameters": { + "animated": { "type": { "name": "boolean", "description": "boolean" }, "default": "true" }, + "closeDelay": { "type": { "name": "number", "description": "number" }, "default": "0" }, + "defaultOpen": { "type": { "name": "boolean", "description": "boolean" } }, + "delay": { "type": { "name": "number", "description": "number" }, "default": "300" }, + "delayType": { + "type": { + "name": "'rest' | 'hover'", + "description": "'rest' | 'hover'" + }, + "default": "'rest'" + }, + "followCursorAxis": { + "type": { + "name": "'none' | 'x' | 'y' | 'both'", + "description": "'none' | 'x' | 'y' | 'both'" + }, + "default": "'none'" + }, + "hoverable": { "type": { "name": "boolean", "description": "boolean" }, "default": "true" }, + "keepMounted": { "type": { "name": "boolean", "description": "boolean" }, "default": "false" }, + "onOpenChange": { + "type": { + "name": "(isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void", + "description": "(isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void" + } + }, + "open": { "type": { "name": "boolean", "description": "boolean" } }, + "popupElement": { + "type": { "name": "HTMLElement | null", "description": "HTMLElement | null" }, + "default": "null" + }, + "triggerElement": { + "type": { "name": "Element | null", "description": "Element | null" }, + "default": "null" + } + }, + "returnValue": { + "getRootPositionerProps": { + "type": { + "name": "UseInteractionsReturn['getFloatingProps']", + "description": "UseInteractionsReturn['getFloatingProps']" + }, + "required": true + }, + "getTriggerProps": { + "type": { + "name": "UseInteractionsReturn['getReferenceProps']", + "description": "UseInteractionsReturn['getReferenceProps']" + }, + "required": true + }, + "instantType": { + "type": { + "name": "'delay' | 'dismiss' | 'focus' | undefined", + "description": "'delay' | 'dismiss' | 'focus' | undefined" + }, + "required": true + }, + "mounted": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "open": { "type": { "name": "boolean", "description": "boolean" }, "required": true }, + "rootContext": { + "type": { "name": "FloatingRootContext", "description": "FloatingRootContext" }, + "required": true + }, + "setMounted": { + "type": { + "name": "React.Dispatch<React.SetStateAction<boolean>>", + "description": "React.Dispatch<React.SetStateAction<boolean>>" + }, + "required": true + }, + "setOpen": { + "type": { + "name": "(value: boolean, event?: Event, reason?: OpenChangeReason) => void", + "description": "(value: boolean, event?: Event, reason?: OpenChangeReason) => void" + }, + "required": true + }, + "transitionStatus": { + "type": { "name": "TransitionStatus", "description": "TransitionStatus" }, + "required": true + } + }, + "name": "useTooltipRoot", + "filename": "/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts", + "imports": ["import { useTooltipRoot } from '@base_ui/react/Tooltip';"], + "demos": "" +} diff --git a/docs/pages/base-ui/react-tooltip/[docsTab]/index.js b/docs/pages/base-ui/react-tooltip/[docsTab]/index.js new file mode 100644 index 000000000..dd8615efa --- /dev/null +++ b/docs/pages/base-ui/react-tooltip/[docsTab]/index.js @@ -0,0 +1,116 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2'; +import AppFrame from 'docs/src/modules/components/AppFrame'; +import * as pageProps from 'docs-base/data/base/components/tooltip/tooltip.md?@mui/markdown'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import TooltipArrowApiJsonPageContent from '../../api/tooltip-arrow.json'; +import TooltipPopupApiJsonPageContent from '../../api/tooltip-popup.json'; +import TooltipPositionerApiJsonPageContent from '../../api/tooltip-positioner.json'; +import TooltipProviderApiJsonPageContent from '../../api/tooltip-provider.json'; +import TooltipRootApiJsonPageContent from '../../api/tooltip-root.json'; +import TooltipTriggerApiJsonPageContent from '../../api/tooltip-trigger.json'; +import useTooltipPositionerApiJsonPageContent from '../../api/use-tooltip-positioner.json'; +import useTooltipRootApiJsonPageContent from '../../api/use-tooltip-root.json'; + +export default function Page(props) { + const { userLanguage, ...other } = props; + return ; +} + +Page.getLayout = (page) => { + return {page}; +}; + +export const getStaticPaths = () => { + return { + paths: [{ params: { docsTab: 'components-api' } }, { params: { docsTab: 'hooks-api' } }], + fallback: false, // can also be true or 'blocking' + }; +}; + +export const getStaticProps = () => { + const TooltipArrowApiReq = require.context( + 'docs-base/translations/api-docs/tooltip-arrow', + false, + /\.\/tooltip-arrow.*.json$/, + ); + const TooltipArrowApiDescriptions = mapApiPageTranslations(TooltipArrowApiReq); + + const TooltipPopupApiReq = require.context( + 'docs-base/translations/api-docs/tooltip-popup', + false, + /\.\/tooltip-popup.*.json$/, + ); + const TooltipPopupApiDescriptions = mapApiPageTranslations(TooltipPopupApiReq); + + const TooltipPositionerApiReq = require.context( + 'docs-base/translations/api-docs/tooltip-positioner', + false, + /\.\/tooltip-positioner.*.json$/, + ); + const TooltipPositionerApiDescriptions = mapApiPageTranslations(TooltipPositionerApiReq); + + const TooltipProviderApiReq = require.context( + 'docs-base/translations/api-docs/tooltip-provider', + false, + /\.\/tooltip-provider.*.json$/, + ); + const TooltipProviderApiDescriptions = mapApiPageTranslations(TooltipProviderApiReq); + + const TooltipRootApiReq = require.context( + 'docs-base/translations/api-docs/tooltip-root', + false, + /\.\/tooltip-root.*.json$/, + ); + const TooltipRootApiDescriptions = mapApiPageTranslations(TooltipRootApiReq); + + const TooltipTriggerApiReq = require.context( + 'docs-base/translations/api-docs/tooltip-trigger', + false, + /\.\/tooltip-trigger.*.json$/, + ); + const TooltipTriggerApiDescriptions = mapApiPageTranslations(TooltipTriggerApiReq); + + const useTooltipPositionerApiReq = require.context( + 'docs-base/translations/api-docs/use-tooltip-positioner', + false, + /\.\/use-tooltip-positioner.*.json$/, + ); + const useTooltipPositionerApiDescriptions = mapApiPageTranslations(useTooltipPositionerApiReq); + + const useTooltipRootApiReq = require.context( + 'docs-base/translations/api-docs/use-tooltip-root', + false, + /\.\/use-tooltip-root.*.json$/, + ); + const useTooltipRootApiDescriptions = mapApiPageTranslations(useTooltipRootApiReq); + + return { + props: { + componentsApiDescriptions: { + TooltipArrow: TooltipArrowApiDescriptions, + TooltipPopup: TooltipPopupApiDescriptions, + TooltipPositioner: TooltipPositionerApiDescriptions, + TooltipProvider: TooltipProviderApiDescriptions, + TooltipRoot: TooltipRootApiDescriptions, + TooltipTrigger: TooltipTriggerApiDescriptions, + }, + componentsApiPageContents: { + TooltipArrow: TooltipArrowApiJsonPageContent, + TooltipPopup: TooltipPopupApiJsonPageContent, + TooltipPositioner: TooltipPositionerApiJsonPageContent, + TooltipProvider: TooltipProviderApiJsonPageContent, + TooltipRoot: TooltipRootApiJsonPageContent, + TooltipTrigger: TooltipTriggerApiJsonPageContent, + }, + hooksApiDescriptions: { + useTooltipPositioner: useTooltipPositionerApiDescriptions, + useTooltipRoot: useTooltipRootApiDescriptions, + }, + hooksApiPageContents: { + useTooltipPositioner: useTooltipPositionerApiJsonPageContent, + useTooltipRoot: useTooltipRootApiJsonPageContent, + }, + }, + }; +}; diff --git a/docs/pages/experiments/tooltip.tsx b/docs/pages/experiments/tooltip.tsx new file mode 100644 index 000000000..2a18baac4 --- /dev/null +++ b/docs/pages/experiments/tooltip.tsx @@ -0,0 +1,301 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { styled, keyframes } from '@mui/system'; +import { motion, AnimatePresence } from 'framer-motion'; + +const scaleIn = keyframes` + from { + opacity: 0; + transform: scale(0.8); + } +`; + +const scaleOut = keyframes` + to { + opacity: 0; + transform: scale(0); + } +`; + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipPopup = styled(Tooltip.Popup)` + font-family: 'IBM Plex Sans', sans-serif; + background: black; + color: white; + padding: 4px 6px; + border-radius: 4px; + font-size: 95%; + cursor: default; + transform-origin: var(--transform-origin); + + &[data-instant] { + transition-duration: 0s !important; + animation-duration: 0s !important; + } + + &[data-type='css-animation'] { + &[data-state='open'] { + visibility: visible; + animation: ${scaleIn} 0.2s forwards; + } + + &[data-exiting] { + animation: ${scaleOut} 0.2s forwards; + } + } + + &[data-type='css-animation-keep-mounted'] { + visibility: hidden; + + &[data-state='open'] { + visibility: visible; + animation: ${scaleIn} 0.2s forwards; + } + + &[data-exiting] { + visibility: visible; + animation: ${scaleOut} 0.2s forwards; + } + } + + &[data-type='css-transition'] { + transition-property: opacity, transform, visibility; + transition-duration: 0.2s; + opacity: 0; + transform: scale(0); + + &[data-state='open'] { + opacity: 1; + transform: scale(1); + } + + &[data-entering] { + opacity: 0; + transform: scale(0.8); + } + } + + &[data-type='css-transition-keep-mounted'] { + transition-property: opacity, transform, visibility; + transition-duration: 0.2s; + opacity: 0; + transform: scale(0.8); + visibility: hidden; + + &[data-state='open'] { + opacity: 1; + transform: scale(1); + visibility: visible; + } + + &[data-exiting] { + opacity: 0; + transform: scale(0); + } + } + + &[data-type='css-transition-starting-style'] { + transition-property: opacity, transform, visibility; + transition-duration: 0.2s; + opacity: 0; + transform: scale(0); + + &[data-state='open'] { + opacity: 1; + transform: scale(1); + } + + @starting-style { + &[data-state='open'] { + opacity: 0; + transform: scale(0.8); + } + } + } +`; + +export const AnchorButton = styled(Tooltip.Trigger)` + 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]}; + } +`; + +export default function TooltipTransitionExperiment() { + return ( +
+

Base UI Tooltip Popup Animations

+
+ +

Conditional Rendering

+ +

CSS Animation Group

+
+ + + Anchor + + Tooltip + + + + Anchor + + Tooltip + + + +
+

CSS Animation

+ + Anchor + + Tooltip + + + +

CSS Transition Group

+
+ + + Anchor + + Tooltip + + + + Anchor + + Tooltip + + + +
+

CSS Transition

+ + Anchor + + Tooltip + + + +

CSS Transition with `@starting-style`

+ + Anchor + + Tooltip + + + +
+ +

Keep Mounted

+ +

CSS Animation Group

+
+ + + Anchor + + Tooltip + + + + Anchor + + Tooltip + + + +
+

CSS Animation

+ + Anchor + + Tooltip + + + +

CSS Transition Group

+
+ + + Anchor + + Tooltip + + + + Anchor + + Tooltip + + + +
+

CSS Transition

+ + Anchor + + Tooltip + + + +
+ +

JavaScript Animation (`framer-motion`)

+ +
+ ); +} + +function FramerMotion() { + const [isOpen, setIsOpen] = React.useState(false); + return ( + + Anchor + + {isOpen && ( + + + } + > + Tooltip + + + )} + + + ); +} diff --git a/docs/translations/api-docs-base/tooltip-arrow/tooltip-arrow.json b/docs/translations/api-docs-base/tooltip-arrow/tooltip-arrow.json new file mode 100644 index 000000000..53ba86752 --- /dev/null +++ b/docs/translations/api-docs-base/tooltip-arrow/tooltip-arrow.json @@ -0,0 +1,17 @@ +{ + "componentDescription": "The tooltip arrow caret element.", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "hideWhenUncentered": { + "description": "If true, the arrow will be hidden when it can't point to the center of the anchor element." + }, + "render": { "description": "A function to customize rendering of the component." }, + "staticOffset": { + "description": "Forces a static offset over dynamic positioning under a certain condition." + }, + "tipRadius": { "description": "The corner radius (rounding) of the arrow tip." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs-base/tooltip-root/tooltip-root.json b/docs/translations/api-docs-base/tooltip-root/tooltip-root.json new file mode 100644 index 000000000..2ee40170d --- /dev/null +++ b/docs/translations/api-docs-base/tooltip-root/tooltip-root.json @@ -0,0 +1,22 @@ +{ + "componentDescription": "The foundation for building custom-styled tooltips.", + "propDescriptions": { + "closeDelay": { + "description": "The delay in milliseconds until the tooltip content is closed." + }, + "defaultOpen": { + "description": "Specifies whether the tooltip is open initially when uncontrolled." + }, + "delay": { "description": "The delay in milliseconds until the tooltip content is opened." }, + "delayType": { + "description": "The delay type to use. rest means the delay represents how long the user's cursor must rest on the anchor before the tooltip content is opened. hover means the delay represents how long to wait once the user's cursor has entered the anchor." + }, + "onOpenChange": { + "description": "Callback fired when the tooltip content is requested to be opened or closed. Use when controlled." + }, + "open": { + "description": "If true, the tooltip content is open. Use when controlled." + } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs-base/tooltip-trigger/tooltip-trigger.json b/docs/translations/api-docs-base/tooltip-trigger/tooltip-trigger.json new file mode 100644 index 000000000..7fff056a0 --- /dev/null +++ b/docs/translations/api-docs-base/tooltip-trigger/tooltip-trigger.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "Provides props for its child element to trigger the tooltip, anchoring it to the child element.", + "propDescriptions": {}, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/tooltip-arrow/tooltip-arrow.json b/docs/translations/api-docs/tooltip-arrow/tooltip-arrow.json new file mode 100644 index 000000000..6b2cc8c91 --- /dev/null +++ b/docs/translations/api-docs/tooltip-arrow/tooltip-arrow.json @@ -0,0 +1,13 @@ +{ + "componentDescription": "The tooltip arrow caret element.", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "hideWhenUncentered": { + "description": "If true, the arrow will be hidden when it can't point to the center of the anchor element." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/tooltip-popup/tooltip-popup.json b/docs/translations/api-docs/tooltip-popup/tooltip-popup.json new file mode 100644 index 000000000..933da608c --- /dev/null +++ b/docs/translations/api-docs/tooltip-popup/tooltip-popup.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "The tooltip popup element.", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/tooltip-positioner/tooltip-positioner.json b/docs/translations/api-docs/tooltip-positioner/tooltip-positioner.json new file mode 100644 index 000000000..b831b6480 --- /dev/null +++ b/docs/translations/api-docs/tooltip-positioner/tooltip-positioner.json @@ -0,0 +1,45 @@ +{ + "componentDescription": "The tooltip positioner element.", + "propDescriptions": { + "alignment": { + "description": "The alignment of the tooltip element to the anchor element along its cross axis." + }, + "alignmentOffset": { + "description": "The offset of the tooltip element along its alignment axis." + }, + "anchor": { "description": "The anchor element of the tooltip popup." }, + "arrowPadding": { + "description": "Determines the padding between the arrow and the tooltip popup edges. Useful when the tooltip popup has rounded corners via border-radius." + }, + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "collisionBoundary": { + "description": "The boundary that the tooltip element should be constrained to." + }, + "collisionPadding": { + "description": "The padding of the collision boundary to add whitespace between the tooltip popup and the boundary edges to prevent them from touching." + }, + "container": { + "description": "The container element to which the tooltip positioner is appended to." + }, + "hideWhenDetached": { + "description": "If true, the tooltip will be hidden if it is detached from its anchor element due to differing clipping contexts." + }, + "keepMounted": { + "description": "If true, the tooltip popup remains mounted in the DOM even when closed." + }, + "positionStrategy": { + "description": "The CSS position strategy for positioning the tooltip popup element." + }, + "render": { "description": "A function to customize rendering of the component." }, + "side": { + "description": "The side of the anchor element that the tooltip element should be placed at." + }, + "sideOffset": { "description": "The gap between the anchor element and the tooltip element." }, + "sticky": { + "description": "If true, allow the tooltip to remain stuck in view while the anchor element is scrolled out of view." + } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/tooltip-provider/tooltip-provider.json b/docs/translations/api-docs/tooltip-provider/tooltip-provider.json new file mode 100644 index 000000000..b88c0aaf5 --- /dev/null +++ b/docs/translations/api-docs/tooltip-provider/tooltip-provider.json @@ -0,0 +1,15 @@ +{ + "componentDescription": "Provides a shared delay for tooltips so that once a tooltip is shown, the rest of the tooltips in\nthe group will not wait for the delay before showing.", + "propDescriptions": { + "closeDelay": { + "description": "he delay in milliseconds until tooltips within the group are closed." + }, + "delay": { + "description": "The delay in milliseconds until tooltips within the group are open." + }, + "timeout": { + "description": "The timeout in milliseconds until the grouping logic is no longer active after the last tooltip in the group has closed." + } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/tooltip-root/tooltip-root.json b/docs/translations/api-docs/tooltip-root/tooltip-root.json new file mode 100644 index 000000000..91758e57d --- /dev/null +++ b/docs/translations/api-docs/tooltip-root/tooltip-root.json @@ -0,0 +1,29 @@ +{ + "componentDescription": "The foundation for building custom-styled tooltips.", + "propDescriptions": { + "animated": { + "description": "Whether the tooltip can animate, adding animation-related attributes and allowing for exit animations to play. Useful to disable in tests to remove async behavior." + }, + "closeDelay": { "description": "The delay in milliseconds until the tooltip popup is closed." }, + "defaultOpen": { + "description": "Specifies whether the tooltip is open initially when uncontrolled." + }, + "delay": { "description": "The delay in milliseconds until the tooltip popup is opened." }, + "delayType": { + "description": "The delay type to use. rest means the delay represents how long the user's cursor must rest on the trigger before the tooltip popup is opened. hover means the delay represents how long to wait as soon as the user's cursor has entered the trigger." + }, + "followCursorAxis": { + "description": "Determines which axis the tooltip should follow the cursor on." + }, + "hoverable": { + "description": "Whether the user can move their cursor from the trigger to the tooltip popup without it closing." + }, + "onOpenChange": { + "description": "Callback fired when the tooltip popup is requested to be opened or closed. Use when controlled." + }, + "open": { + "description": "If true, the tooltip popup is open. Use when controlled." + } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/tooltip-trigger/tooltip-trigger.json b/docs/translations/api-docs/tooltip-trigger/tooltip-trigger.json new file mode 100644 index 000000000..53290b974 --- /dev/null +++ b/docs/translations/api-docs/tooltip-trigger/tooltip-trigger.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "Renders a trigger element that will open the tooltip.", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/use-tooltip-positioner/use-tooltip-positioner.json b/docs/translations/api-docs/use-tooltip-positioner/use-tooltip-positioner.json new file mode 100644 index 000000000..31f203023 --- /dev/null +++ b/docs/translations/api-docs/use-tooltip-positioner/use-tooltip-positioner.json @@ -0,0 +1,61 @@ +{ + "hookDescription": "Manages the popup state for a tooltip including positioning.", + "parametersDescriptions": { + "alignment": { + "description": "The alignment of the tooltip element to the anchor element along its cross axis." + }, + "alignmentOffset": { + "description": "The offset of the tooltip element along its alignment axis." + }, + "anchor": { "description": "The anchor element of the tooltip popup." }, + "arrowPadding": { + "description": "Determines the padding between the arrow and the tooltip popup edges. Useful when the tooltip popup has rounded corners via border-radius." + }, + "collisionBoundary": { + "description": "The boundary that the tooltip element should be constrained to." + }, + "collisionPadding": { + "description": "The padding of the collision boundary to add whitespace between the tooltip popup and the boundary edges to prevent them from touching." + }, + "followCursorAxis": { + "description": "Determines which axis the tooltip should follow the cursor on." + }, + "getRootPositionerProps": { + "description": "Root props to spread on the tooltip positioner element." + }, + "hideWhenDetached": { + "description": "If true, the tooltip will be hidden if it is detached from its anchor element due to differing clipping contexts." + }, + "instant": { + "description": "If true, the tooltip is in an instant phase where animations should be removed." + }, + "keepMounted": { + "description": "If true, the tooltip popup remains mounted in the DOM even when closed." + }, + "mounted": { "description": "If true, the tooltip is mounted." }, + "open": { "description": "If true, the tooltip is open." }, + "positionStrategy": { + "description": "The CSS position strategy for positioning the tooltip popup element." + }, + "rootContext": { "description": "The tooltip root context." }, + "setMounted": { "description": "Callback fired when the mounted state changes." }, + "side": { + "description": "The side of the anchor element that the tooltip element should be placed at." + }, + "sideOffset": { "description": "The gap between the anchor element and the tooltip element." }, + "sticky": { + "description": "If true, allow the tooltip to remain stuck in view while the anchor element is scrolled out of view." + } + }, + "returnValueDescriptions": { + "alignment": { "description": "The rendered alignment of the tooltip popup element." }, + "arrowRef": { "description": "The ref for the arrow element." }, + "arrowUncentered": { "description": "Determines if the arrow cannot be centered." }, + "getArrowProps": { "description": "Props to spread on the popup arrow element." }, + "getPositionerProps": { "description": "Props to spread on the positioner element." }, + "mounted": { + "description": "Whether the tooltip is mounted, including CSS transitions or animations." + }, + "side": { "description": "The rendered side of the tooltip popup element." } + } +} diff --git a/docs/translations/api-docs/use-tooltip-root/use-tooltip-root.json b/docs/translations/api-docs/use-tooltip-root/use-tooltip-root.json new file mode 100644 index 000000000..bdc297ebd --- /dev/null +++ b/docs/translations/api-docs/use-tooltip-root/use-tooltip-root.json @@ -0,0 +1,48 @@ +{ + "hookDescription": "Manages the root state for a tooltip.", + "parametersDescriptions": { + "animated": { + "description": "Whether the tooltip can animate, adding animation-related attributes and allowing for exit animations to play. Useful to disable in tests to remove async behavior." + }, + "closeDelay": { "description": "The delay in milliseconds until the tooltip popup is closed." }, + "defaultOpen": { + "description": "If true, the tooltip popup will be open by default. Use when uncontrolled." + }, + "delay": { "description": "The delay in milliseconds until the tooltip popup is opened." }, + "delayType": { + "description": "The delay type to use. rest means the delay represents how long the user's cursor must rest on the trigger before the tooltip popup is opened. hover means the delay represents how long to wait as soon as the user's cursor has entered the trigger." + }, + "followCursorAxis": { + "description": "Determines which axis the tooltip should follow the cursor on." + }, + "hoverable": { + "description": "If true, the user can move from the trigger toward the tooltip without it closing." + }, + "keepMounted": { + "description": "If true, the tooltip popup remains mounted in the DOM even when closed." + }, + "onOpenChange": { + "description": "Callback fired when the tooltip popup is requested to be opened or closed." + }, + "open": { + "description": "If true, the tooltip popup will be open. Use when controlled." + }, + "popupElement": { "description": "The popup element. Store in state." }, + "triggerElement": { "description": "The trigger element. Store in state." } + }, + "returnValueDescriptions": { + "getRootPositionerProps": { + "description": "Prop getter to spread root props on the positioner element." + }, + "getTriggerProps": { "description": "Prop getter to spread props on the trigger element." }, + "instantType": { + "description": "The type of instant phase the tooltip is in to remove animations." + }, + "mounted": { "description": "If true, the tooltip is mounted." }, + "open": { "description": "If true, the tooltip is open." }, + "rootContext": { "description": "The root context object." }, + "setMounted": { "description": "Sets the mounted state of the tooltip." }, + "setOpen": { "description": "Sets the open state of the tooltip." }, + "transitionStatus": { "description": "The transition status of the tooltip." } + } +} diff --git a/docs/translations/translations.json b/docs/translations/translations.json index 2084061a4..569c621e4 100644 --- a/docs/translations/translations.json +++ b/docs/translations/translations.json @@ -223,6 +223,8 @@ "/base-ui/react-checkbox": "Checkbox", "/base-ui/react-number-field": "Number Field", "/base-ui/react-switch": "Switch", + "data-display": "Data display", + "/base-ui/react-tooltip": "Tooltip", "navigation": "Navigation", "/base-ui/react-tabs": "Tabs", "/base-ui/guides": "How-to guides", diff --git a/packages/mui-base/package.json b/packages/mui-base/package.json index 365902225..bc957e1b4 100644 --- a/packages/mui-base/package.json +++ b/packages/mui-base/package.json @@ -40,7 +40,9 @@ }, "dependencies": { "@babel/runtime": "^7.24.5", - "@floating-ui/react-dom": "^2.1.0", + "@floating-ui/react": "^0.26.12", + "@floating-ui/react-dom": "^2.0.8", + "@floating-ui/utils": "^0.2.1", "@mui/types": "^7.2.14", "@mui/utils": "^5.15.14", "@popperjs/core": "^2.11.8", @@ -93,6 +95,7 @@ "Portal/", "Switch/", "Tabs/", + "Tooltip/", "Transitions/", "useButton/", "useCompound", @@ -109,6 +112,7 @@ "node/Portal/", "node/Switch/", "node/Tabs/", + "node/Tooltip/", "node/Transitions/", "node/useButton/", "node/useCompound/", diff --git a/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.test.tsx b/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.test.tsx new file mode 100644 index 000000000..6d64c20eb --- /dev/null +++ b/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.test.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.Element, + render(node) { + return render( + + + {node} + + , + ); + }, + skip: ['reactTestRenderer'], + })); +}); diff --git a/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.tsx b/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.tsx new file mode 100644 index 000000000..d39099cae --- /dev/null +++ b/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.tsx @@ -0,0 +1,88 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { TooltipArrowOwnerState, TooltipArrowProps } from './TooltipArrow.types'; +import { tooltipArrowStyleHookMapping } from './styleHooks'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useForkRef } from '../../utils/useForkRef'; +import { useTooltipPositionerContext } from '../Positioner/TooltipPositionerContext'; + +/** + * The tooltip arrow caret element. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [TooltipArrow API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip-arrow) + */ +const TooltipArrow = React.forwardRef(function TooltipArrow( + props: TooltipArrowProps, + forwardedRef: React.ForwardedRef, +) { + const { className, render, hideWhenUncentered = false, ...otherProps } = props; + + const { open, arrowRef, side, alignment, arrowUncentered, getArrowProps } = + useTooltipPositionerContext(); + + const ownerState: TooltipArrowOwnerState = React.useMemo( + () => ({ + open, + side, + alignment, + }), + [open, side, alignment], + ); + + const mergedRef = useForkRef(arrowRef, forwardedRef); + + const { renderElement } = useComponentRenderer({ + propGetter: getArrowProps, + render: render ?? 'div', + ownerState, + className, + ref: mergedRef, + extraProps: { + ...otherProps, + style: { + ...(hideWhenUncentered && arrowUncentered && { visibility: 'hidden' }), + ...otherProps.style, + }, + }, + customStyleHookMapping: tooltipArrowStyleHookMapping, + }); + + return renderElement(); +}); + +TooltipArrow.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * If `true`, the arrow will be hidden when it can't point to the center of the anchor element. + * @default false + */ + hideWhenUncentered: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + style: PropTypes.object, +} as any; + +export { TooltipArrow }; diff --git a/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.types.ts b/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.types.ts new file mode 100644 index 000000000..09b59d540 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Arrow/TooltipArrow.types.ts @@ -0,0 +1,16 @@ +import type { Side } from '@floating-ui/react'; +import type { BaseUIComponentProps } from '../../utils/BaseUI.types'; + +export type TooltipArrowOwnerState = { + open: boolean; + side: Side; + alignment: 'start' | 'center' | 'end'; +}; + +export interface TooltipArrowProps extends BaseUIComponentProps<'div', TooltipArrowOwnerState> { + /** + * If `true`, the arrow will be hidden when it can't point to the center of the anchor element. + * @default false + */ + hideWhenUncentered?: boolean; +} diff --git a/packages/mui-base/src/Tooltip/Arrow/styleHooks.ts b/packages/mui-base/src/Tooltip/Arrow/styleHooks.ts new file mode 100644 index 000000000..3bb6de2d2 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Arrow/styleHooks.ts @@ -0,0 +1,10 @@ +import type { TooltipArrowOwnerState } from './TooltipArrow.types'; +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; + +export const tooltipArrowStyleHookMapping: CustomStyleHookMapping = { + open(value) { + return { + 'data-state': value ? 'open' : 'closed', + }; + }, +}; diff --git a/packages/mui-base/src/Tooltip/Popup/TooltipPopup.test.tsx b/packages/mui-base/src/Tooltip/Popup/TooltipPopup.test.tsx new file mode 100644 index 000000000..9ed327514 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Popup/TooltipPopup.test.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { createRenderer, screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + inheritComponent: 'div', + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + skip: ['reactTestRenderer'], + })); + + it('should render the children', () => { + render( + + + Content + + , + ); + + expect(screen.getByText('Content')).not.to.equal(null); + }); +}); diff --git a/packages/mui-base/src/Tooltip/Popup/TooltipPopup.tsx b/packages/mui-base/src/Tooltip/Popup/TooltipPopup.tsx new file mode 100644 index 000000000..d3e4f6a15 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Popup/TooltipPopup.tsx @@ -0,0 +1,86 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { TooltipPopupOwnerState, TooltipPopupProps } from './TooltipPopup.types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { tooltipPopupStyleHookMapping } from './styleHooks'; +import { useTooltipRootContext } from '../Root/TooltipRootContext'; +import { useTooltipPositionerContext } from '../Positioner/TooltipPositionerContext'; + +/** + * The tooltip popup element. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [TooltipPopup API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip-popup) + */ +const TooltipPopup = React.forwardRef(function TooltipPopup( + props: TooltipPopupProps, + forwardedRef: React.ForwardedRef, +) { + const { className, render, ...otherProps } = props; + + const { open, instantType, transitionStatus } = useTooltipRootContext(); + const { side, alignment } = useTooltipPositionerContext(); + + const ownerState: TooltipPopupOwnerState = React.useMemo( + () => ({ + open, + side, + alignment, + instant: instantType, + entering: transitionStatus === 'entering', + exiting: transitionStatus === 'exiting', + }), + [open, side, alignment, instantType, transitionStatus], + ); + + // 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 { renderElement } = useComponentRenderer({ + render: render ?? 'div', + className, + ownerState, + extraProps: { + ...otherProps, + style: { + // must be relative to the inner popup element. + position: 'relative', + ...otherProps.style, + }, + }, + ref: forwardedRef, + customStyleHookMapping: tooltipPopupStyleHookMapping, + }); + + return renderElement(); +}); + +TooltipPopup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + style: PropTypes.object, +} as any; + +export { TooltipPopup }; diff --git a/packages/mui-base/src/Tooltip/Popup/TooltipPopup.types.ts b/packages/mui-base/src/Tooltip/Popup/TooltipPopup.types.ts new file mode 100644 index 000000000..48e484447 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Popup/TooltipPopup.types.ts @@ -0,0 +1,13 @@ +import type { Side } from '@floating-ui/react'; +import type { BaseUIComponentProps } from '../../utils/BaseUI.types'; + +export type TooltipPopupOwnerState = { + open: boolean; + side: Side; + alignment: 'start' | 'end' | 'center'; + instant: 'delay' | 'focus' | 'dismiss' | undefined; + entering: boolean; + exiting: boolean; +}; + +export interface TooltipPopupProps extends BaseUIComponentProps<'div', TooltipPopupOwnerState> {} diff --git a/packages/mui-base/src/Tooltip/Popup/styleHooks.ts b/packages/mui-base/src/Tooltip/Popup/styleHooks.ts new file mode 100644 index 000000000..622468113 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Popup/styleHooks.ts @@ -0,0 +1,16 @@ +import type { TooltipPopupOwnerState } from './TooltipPopup.types'; +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; + +export const tooltipPopupStyleHookMapping: CustomStyleHookMapping = { + entering(value) { + return value ? { 'data-entering': '' } : null; + }, + exiting(value) { + return value ? { 'data-exiting': '' } : null; + }, + open(value) { + return { + 'data-state': value ? 'open' : 'closed', + }; + }, +}; diff --git a/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.test.tsx b/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.test.tsx new file mode 100644 index 000000000..5e9672c18 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.test.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + inheritComponent: 'div', + refInstanceof: window.HTMLDivElement, + render(node) { + return render({node}); + }, + skip: ['reactTestRenderer'], + })); +}); diff --git a/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.tsx b/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.tsx new file mode 100644 index 000000000..a52c9c1c6 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.tsx @@ -0,0 +1,239 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingPortal } from '@floating-ui/react'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { HTMLElementType } from '../../utils/types'; +import { useForkRef } from '../../utils/useForkRef'; +import type { + TooltipPositionerContextValue, + TooltipPositionerOwnerState, + TooltipPositionerProps, +} from './TooltipPositioner.types'; +import { useTooltipRootContext } from '../Root/TooltipRootContext'; +import { useTooltipPositioner } from './useTooltipPositioner'; +import { TooltipPositionerContext } from './TooltipPositionerContext'; + +/** + * The tooltip positioner element. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [TooltipPositioner API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip-positioner) + */ +const TooltipPositioner = React.forwardRef(function TooltipPositioner( + props: TooltipPositionerProps, + forwardedRef: React.ForwardedRef, +) { + const { + render, + className, + anchor, + container, + positionStrategy = 'absolute', + side = 'top', + alignment = 'center', + sideOffset = 0, + alignmentOffset = 0, + collisionBoundary, + collisionPadding = 5, + arrowPadding = 5, + hideWhenDetached = false, + keepMounted = false, + sticky = false, + ...otherProps + } = props; + + const { + open, + triggerElement, + setPopupElement, + getRootPositionerProps, + mounted, + setMounted, + rootContext, + followCursorAxis, + } = useTooltipRootContext(); + + const positioner = useTooltipPositioner({ + anchor: anchor || triggerElement, + rootContext, + positionStrategy, + open, + mounted, + setMounted, + getRootPositionerProps, + keepMounted, + side, + sideOffset, + alignment, + alignmentOffset, + collisionBoundary, + collisionPadding, + hideWhenDetached, + sticky, + followCursorAxis, + arrowPadding, + }); + + const mergedRef = useForkRef(setPopupElement, forwardedRef); + + const ownerState: TooltipPositionerOwnerState = React.useMemo( + () => ({ + open, + side: positioner.side, + alignment: positioner.alignment, + }), + [open, positioner.side, positioner.alignment], + ); + + const contextValue: TooltipPositionerContextValue = React.useMemo( + () => ({ + ...ownerState, + arrowRef: positioner.arrowRef, + getArrowProps: positioner.getArrowProps, + arrowUncentered: positioner.arrowUncentered, + }), + [ownerState, positioner.arrowRef, positioner.getArrowProps, positioner.arrowUncentered], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: positioner.getPositionerProps, + render: render ?? 'div', + className, + ownerState, + ref: mergedRef, + extraProps: { + role: 'presentation', + ...otherProps, + }, + }); + + const shouldRender = keepMounted || positioner.mounted; + if (!shouldRender) { + return null; + } + + return ( + + {renderElement()} + + ); +}); + +TooltipPositioner.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The alignment of the tooltip element to the anchor element along its cross axis. + * @default 'center' + */ + alignment: PropTypes.oneOf(['center', 'end', 'start']), + /** + * The offset of the tooltip element along its alignment axis. + * @default 0 + */ + alignmentOffset: PropTypes.number, + /** + * The anchor element of the tooltip popup. + */ + anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.object, + PropTypes.func, + ]), + /** + * Determines the padding between the arrow and the tooltip popup edges. Useful when the tooltip + * popup has rounded corners via `border-radius`. + * @default 5 + */ + arrowPadding: PropTypes.number, + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * The boundary that the tooltip element should be constrained to. + * @default 'clippingAncestors' + */ + collisionBoundary: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.arrayOf(HTMLElementType), + PropTypes.string, + PropTypes.shape({ + height: PropTypes.number, + width: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number, + }), + ]), + /** + * The padding of the collision boundary to add whitespace between the tooltip popup and the + * boundary edges to prevent them from touching. + * @default 5 + */ + collisionPadding: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + top: PropTypes.number, + }), + ]), + /** + * The container element to which the tooltip positioner is appended to. + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.func, + ]), + /** + * If `true`, the tooltip will be hidden if it is detached from its anchor element due to + * differing clipping contexts. + * @default false + */ + hideWhenDetached: PropTypes.bool, + /** + * If `true`, the tooltip popup remains mounted in the DOM even when closed. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * The CSS position strategy for positioning the tooltip popup element. + * @default 'absolute' + */ + positionStrategy: PropTypes.oneOf(['absolute', 'fixed']), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * The side of the anchor element that the tooltip element should be placed at. + * @default 'top' + */ + side: PropTypes.oneOf(['bottom', 'left', 'right', 'top']), + /** + * The gap between the anchor element and the tooltip element. + * @default 0 + */ + sideOffset: PropTypes.number, + /** + * If `true`, allow the tooltip to remain stuck in view while the anchor element is scrolled out + * of view. + * @default false + */ + sticky: PropTypes.bool, +} as any; + +export { TooltipPositioner }; diff --git a/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.types.ts b/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.types.ts new file mode 100644 index 000000000..3041c8140 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Positioner/TooltipPositioner.types.ts @@ -0,0 +1,27 @@ +import type { Side } from '@floating-ui/react'; +import type { BaseUIComponentProps, GenericHTMLProps } from '../../utils/BaseUI.types'; +import { TooltipPositionerParameters } from './useTooltipPositioner.types'; + +export interface TooltipPositionerContextValue { + open: boolean; + side: Side; + alignment: 'start' | 'end' | 'center'; + arrowRef: React.MutableRefObject; + arrowUncentered: boolean; + getArrowProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; +} + +export type TooltipPositionerOwnerState = { + open: boolean; + side: Side; + alignment: 'start' | 'end' | 'center'; +}; + +export interface TooltipPositionerProps + extends TooltipPositionerParameters, + BaseUIComponentProps<'div', TooltipPositionerOwnerState> { + /** + * The container element to which the tooltip positioner is appended to. + */ + container?: HTMLElement | null | React.MutableRefObject; +} diff --git a/packages/mui-base/src/Tooltip/Positioner/TooltipPositionerContext.ts b/packages/mui-base/src/Tooltip/Positioner/TooltipPositionerContext.ts new file mode 100644 index 000000000..4439c3aa5 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Positioner/TooltipPositionerContext.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; +import type { TooltipPositionerContextValue } from './TooltipPositioner.types'; + +export const TooltipPositionerContext = React.createContext( + null, +); + +export function useTooltipPositionerContext() { + const context = React.useContext(TooltipPositionerContext); + if (context === null) { + throw new Error( + ' and must be used within the component', + ); + } + return context; +} diff --git a/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.ts b/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.ts new file mode 100644 index 000000000..35b7d7b5d --- /dev/null +++ b/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.ts @@ -0,0 +1,255 @@ +'use client'; +import * as React from 'react'; +import { + autoUpdate, + flip, + limitShift, + offset, + shift, + arrow, + useFloating, + size, + hide, + type Placement, + type UseFloatingOptions, +} from '@floating-ui/react'; +import { getSide, getAlignment } from '@floating-ui/utils'; +import { isElement } from '@floating-ui/utils/dom'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import type { + UseTooltipPositionerParameters, + UseTooltipPositionerReturnValue, +} from './useTooltipPositioner.types'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useLatestRef } from '../../utils/useLatestRef'; + +/** + * Manages the popup state for a tooltip including positioning. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/#hooks) + * + * API: + * + * - [useTooltipPositioner API](https://mui.com/base-ui/react-tooltip/hooks-api/#use-tooltip-positioner) + */ +export function useTooltipPositioner( + params: UseTooltipPositionerParameters, +): UseTooltipPositionerReturnValue { + const { + open, + anchor, + positionStrategy = 'absolute', + side = 'top', + sideOffset = 0, + alignment = 'center', + alignmentOffset = 0, + collisionBoundary, + collisionPadding = 5, + hideWhenDetached = false, + sticky = false, + keepMounted = false, + arrowPadding = 5, + mounted = true, + getRootPositionerProps, + rootContext, + followCursorAxis = 'none', + } = params; + + // 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); + + const placement = alignment === 'center' ? side : (`${side}-${alignment}` as Placement); + + const middleware: UseFloatingOptions['middleware'] = [ + offset({ + mainAxis: sideOffset, + crossAxis: alignmentOffset, + alignmentAxis: alignmentOffset, + }), + ]; + + const flipMiddleware = flip({ + fallbackAxisSideDirection: 'start', + padding: collisionPadding, + boundary: collisionBoundary, + }); + const shiftMiddleware = shift({ + limiter: sticky + ? undefined + : limitShift(() => { + if (!arrowRef.current) { + return {}; + } + const { height } = arrowRef.current.getBoundingClientRect(); + return { + offset: height, + }; + }), + padding: collisionPadding, + boundary: collisionBoundary, + }); + + // https://floating-ui.com/docs/flip#combining-with-shift + if (alignment !== 'center') { + middleware.push(flipMiddleware, shiftMiddleware); + } else { + middleware.push(shiftMiddleware, flipMiddleware); + } + + middleware.push( + size({ + boundary: collisionBoundary, + padding: collisionPadding, + apply({ elements: { floating }, rects: { reference }, availableWidth, availableHeight }) { + Object.entries({ + '--available-width': `${availableWidth}px`, + '--available-height': `${availableHeight}px`, + '--anchor-width': `${reference.width}px`, + '--anchor-height': `${reference.height}px`, + }).forEach(([key, value]) => { + floating.style.setProperty(key, value); + }); + }, + }), + arrow( + () => ({ + // `transform-origin` calculations rely on an element existing. If the arrow hasn't been set, + // we'll create a fake element. + element: arrowRef.current || document.createElement('div'), + padding: arrowPadding, + }), + [arrowPadding], + ), + hideWhenDetached && hide(), + { + name: 'transformOrigin', + 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; + + const transformOrigin = { + top: `${transformX}px calc(100% + ${arrowHeight}px)`, + bottom: `${transformX}px ${-arrowHeight}px`, + left: `calc(100% + ${arrowHeight}px) ${transformY}px`, + right: `${-arrowHeight}px ${transformY}px`, + }[currentRenderedSide]; + + elements.floating.style.setProperty('--transform-origin', transformOrigin); + + return {}; + }, + }, + ); + + const { + refs, + elements, + floatingStyles, + middlewareData, + update, + placement: renderedPlacement, + } = useFloating({ + rootContext, + placement, + strategy: positionStrategy, + middleware, + whileElementsMounted: keepMounted ? undefined : autoUpdate, + }); + + // The `anchor` prop is non-reactive. + const anchorRef = useLatestRef(anchor); + + useEnhancedEffect(() => { + function isRef(param: any): param is React.MutableRefObject { + return {}.hasOwnProperty.call(param, 'current'); + } + const resolvedAnchor = + typeof anchorRef.current === 'function' ? anchorRef.current() : anchorRef.current; + if (resolvedAnchor && !isElement(resolvedAnchor)) { + refs.setPositionReference(isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor); + } + }, [refs, anchorRef]); + + React.useEffect(() => { + if (keepMounted && mounted && elements.domReference && elements.floating) { + return autoUpdate(elements.domReference, elements.floating, update); + } + return undefined; + }, [keepMounted, mounted, elements, update]); + + const renderedSide = getSide(renderedPlacement); + const renderedAlignment = getAlignment(renderedPlacement) || 'center'; + const isHidden = hideWhenDetached && middlewareData.hide?.referenceHidden; + + const getPositionerProps: UseTooltipPositionerReturnValue['getPositionerProps'] = + React.useCallback( + (externalProps = {}) => { + const hiddenStyles: React.CSSProperties = {}; + + if (isHidden) { + hiddenStyles.visibility = 'hidden'; + } + + if ((keepMounted && !open) || isHidden) { + hiddenStyles.pointerEvents = 'none'; + } + + if (followCursorAxis === 'both') { + hiddenStyles.pointerEvents = 'none'; + } + + return mergeReactProps( + externalProps, + getRootPositionerProps({ + style: { + ...floatingStyles, + ...hiddenStyles, + maxWidth: 'var(--available-width)', + maxHeight: 'var(--available-height)', + zIndex: 2147483647, // max z-index + }, + }), + ); + }, + [getRootPositionerProps, floatingStyles, isHidden, followCursorAxis, open, keepMounted], + ); + + const getArrowProps: UseTooltipPositionerReturnValue['getArrowProps'] = React.useCallback( + (externalProps = {}) => { + return mergeReactProps(externalProps, { + 'aria-hidden': true, + style: { + position: 'absolute', + top: middlewareData.arrow?.y, + left: middlewareData.arrow?.x, + }, + }); + }, + [middlewareData], + ); + + const arrowUncentered = middlewareData.arrow?.centerOffset !== 0; + + return React.useMemo( + () => ({ + mounted, + getPositionerProps, + getArrowProps, + arrowRef, + arrowUncentered, + side: renderedSide, + alignment: renderedAlignment, + }), + [mounted, getPositionerProps, getArrowProps, arrowUncentered, renderedSide, renderedAlignment], + ); +} diff --git a/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.types.ts b/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.types.ts new file mode 100644 index 000000000..f94f0e74c --- /dev/null +++ b/packages/mui-base/src/Tooltip/Positioner/useTooltipPositioner.types.ts @@ -0,0 +1,137 @@ +import type * as React from 'react'; +import type { Boundary, Padding, VirtualElement, FloatingRootContext } from '@floating-ui/react'; +import type { GenericHTMLProps } from '../../utils/BaseUI.types'; + +export interface TooltipPositionerParameters { + /** + * The anchor element of the tooltip popup. + */ + anchor?: + | Element + | null + | VirtualElement + | React.MutableRefObject + | (() => Element | VirtualElement | null); + /** + * If `true`, the tooltip is open. + */ + open?: boolean; + /** + * The CSS position strategy for positioning the tooltip popup element. + * @default 'absolute' + */ + positionStrategy?: 'absolute' | 'fixed'; + /** + * The side of the anchor element that the tooltip element should be placed at. + * @default 'top' + */ + side?: 'top' | 'right' | 'bottom' | 'left'; + /** + * The gap between the anchor element and the tooltip element. + * @default 0 + */ + sideOffset?: number; + /** + * The alignment of the tooltip element to the anchor element along its cross axis. + * @default 'center' + */ + alignment?: 'start' | 'end' | 'center'; + /** + * The offset of the tooltip element along its alignment axis. + * @default 0 + */ + alignmentOffset?: number; + /** + * The boundary that the tooltip element should be constrained to. + * @default 'clippingAncestors' + */ + collisionBoundary?: Boundary; + /** + * The padding of the collision boundary to add whitespace between the tooltip popup and the + * boundary edges to prevent them from touching. + * @default 5 + */ + collisionPadding?: Padding; + /** + * If `true`, the tooltip will be hidden if it is detached from its anchor element due to + * differing clipping contexts. + * @default false + */ + hideWhenDetached?: boolean; + /** + * If `true`, allow the tooltip to remain stuck in view while the anchor element is scrolled out + * of view. + * @default false + */ + sticky?: boolean; + /** + * Determines the padding between the arrow and the tooltip popup edges. Useful when the tooltip + * popup has rounded corners via `border-radius`. + * @default 5 + */ + arrowPadding?: number; + /** + * If `true`, the tooltip popup remains mounted in the DOM even when closed. + * @default false + */ + keepMounted?: boolean; +} + +export interface UseTooltipPositionerParameters extends TooltipPositionerParameters { + /** + * Root props to spread on the tooltip positioner element. + */ + getRootPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * If `true`, the tooltip is in an instant phase where animations should be removed. + */ + instant?: boolean; + /** + * If `true`, the tooltip is mounted. + */ + mounted?: boolean; + /** + * Callback fired when the mounted state changes. + */ + setMounted?: React.Dispatch>; + /** + * The tooltip root context. + */ + rootContext?: FloatingRootContext; + /** + * Determines which axis the tooltip should follow the cursor on. + * @default 'none' + */ + followCursorAxis?: 'none' | 'x' | 'y' | 'both'; +} + +export interface UseTooltipPositionerReturnValue { + /** + * Props to spread on the positioner element. + */ + getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * Props to spread on the popup arrow element. + */ + getArrowProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * The ref for the arrow element. + */ + arrowRef: React.MutableRefObject; + /** + * Determines if the arrow cannot be centered. + */ + arrowUncentered: boolean; + /** + * The rendered side of the tooltip popup element. + */ + side: 'top' | 'right' | 'bottom' | 'left'; + /** + * The rendered alignment of the tooltip popup element. + */ + alignment: 'start' | 'end' | 'center'; + /** + * Whether the tooltip is mounted, including CSS transitions or animations. + */ + mounted: boolean; +} diff --git a/packages/mui-base/src/Tooltip/Provider/TooltipProvider.test.tsx b/packages/mui-base/src/Tooltip/Provider/TooltipProvider.test.tsx new file mode 100644 index 000000000..9935c4fbe --- /dev/null +++ b/packages/mui-base/src/Tooltip/Provider/TooltipProvider.test.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { createRenderer, act } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { fireEvent, screen } from '@testing-library/react'; + +const waitForPosition = async () => act(async () => {}); + +describe('', () => { + const { render, clock } = createRenderer(); + + describe('prop: delay', () => { + clock.withFakeTimers(); + + it('waits for the delay before showing the tooltip', async () => { + render( + + + + + Content + + + , + ); + + const trigger = document.querySelector('button')!; + + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + expect(screen.queryByText('Content')).to.equal(null); + + clock.tick(1_000); + + expect(screen.queryByText('Content')).to.equal(null); + + clock.tick(9_000); + + await waitForPosition(); + + expect(screen.queryByText('Content')).not.to.equal(null); + }); + }); + + describe('prop: closeDelay', () => { + clock.withFakeTimers(); + + it('waits for the closeDelay before hiding the tooltip', async () => { + render( + + + + + Content + + + , + ); + + const trigger = document.querySelector('button')!; + + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + clock.tick(300); + + await waitForPosition(); + + expect(screen.queryByText('Content')).not.to.equal(null); + + fireEvent.mouseLeave(trigger); + + clock.tick(300); + + expect(screen.queryByText('Content')).not.to.equal(null); + + clock.tick(300); + + expect(screen.queryByText('Content')).to.equal(null); + }); + }); +}); diff --git a/packages/mui-base/src/Tooltip/Provider/TooltipProvider.tsx b/packages/mui-base/src/Tooltip/Provider/TooltipProvider.tsx new file mode 100644 index 000000000..ed4156002 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Provider/TooltipProvider.tsx @@ -0,0 +1,55 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingDelayGroup } from '@floating-ui/react'; +import type { TooltipProviderProps } from './TooltipProvider.types'; + +/** + * Provides a shared delay for tooltips so that once a tooltip is shown, the rest of the tooltips in + * the group will not wait for the delay before showing. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [TooltipProvider API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip-provider) + */ +function TooltipProvider(props: TooltipProviderProps) { + const { delay = 0, closeDelay = 0, timeout = 400 } = props; + return ( + + {props.children} + + ); +} + +TooltipProvider.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * he delay in milliseconds until tooltips within the group are closed. + * @default 0 + */ + closeDelay: PropTypes.number, + /** + * The delay in milliseconds until tooltips within the group are open. + * @default 0 + */ + delay: PropTypes.number, + /** + * The timeout in milliseconds until the grouping logic is no longer active after the last tooltip + * in the group has closed. + * @default 400 + */ + timeout: PropTypes.number, +} as any; + +export { TooltipProvider }; diff --git a/packages/mui-base/src/Tooltip/Provider/TooltipProvider.types.ts b/packages/mui-base/src/Tooltip/Provider/TooltipProvider.types.ts new file mode 100644 index 000000000..ef2e68667 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Provider/TooltipProvider.types.ts @@ -0,0 +1,19 @@ +export interface TooltipProviderProps { + children?: React.ReactNode; + /** + * The delay in milliseconds until tooltips within the group are open. + * @default 0 + */ + delay?: number; + /** + * he delay in milliseconds until tooltips within the group are closed. + * @default 0 + */ + closeDelay?: number; + /** + * The timeout in milliseconds until the grouping logic is no longer active after the last tooltip + * in the group has closed. + * @default 400 + */ + timeout?: number; +} diff --git a/packages/mui-base/src/Tooltip/Root/TooltipRoot.test.tsx b/packages/mui-base/src/Tooltip/Root/TooltipRoot.test.tsx new file mode 100644 index 000000000..787288b1e --- /dev/null +++ b/packages/mui-base/src/Tooltip/Root/TooltipRoot.test.tsx @@ -0,0 +1,389 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { act, createRenderer, fireEvent, screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { spy } from 'sinon'; + +const waitForPosition = async () => act(async () => {}); + +function Root(props: Tooltip.RootProps) { + return ; +} + +describe('', () => { + const { render, clock } = createRenderer(); + + describe('uncontrolled open', () => { + clock.withFakeTimers(); + + it('should open when the trigger is hovered', async () => { + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + fireEvent.pointerDown(trigger, { pointerType: 'mouse' }); + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + clock.tick(300); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + + it('should close when the trigger is unhovered', async () => { + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + fireEvent.pointerDown(trigger, { pointerType: 'mouse' }); + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + clock.tick(300); + + await waitForPosition(); + + fireEvent.mouseLeave(trigger); + + expect(screen.queryByText('Content')).to.equal(null); + }); + + it('should open when the trigger is focused', async () => { + if (!/jsdom/.test(window.navigator.userAgent)) { + // Ignore due to `:focus-visible` being required in the browser. + return; + } + + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + act(() => trigger.focus()); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + + it('should close when the trigger is blurred', async () => { + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + act(() => trigger.focus()); + + clock.tick(300); + + await waitForPosition(); + + act(() => trigger.blur()); + + clock.tick(300); + + expect(screen.queryByText('Content')).to.equal(null); + }); + }); + + describe('controlled open', () => { + clock.withFakeTimers(); + + it('should open when controlled open is true', async () => { + render( + + + Content + + , + ); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + + it('should close when controlled open is false', async () => { + render( + + + Content + + , + ); + + expect(screen.queryByText('Content')).to.equal(null); + }); + + it('should call onOpenChange when the open state changes', async () => { + const handleChange = spy(); + + function App() { + const [open, setOpen] = React.useState(false); + + return ( + { + handleChange(open); + setOpen(nextOpen); + }} + > + + + Content + + + ); + } + + render(); + + expect(screen.queryByText('Content')).to.equal(null); + + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + clock.tick(300); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + + fireEvent.mouseLeave(trigger); + + expect(screen.queryByText('Content')).to.equal(null); + expect(handleChange.callCount).to.equal(2); + expect(handleChange.firstCall.args[0]).to.equal(false); + expect(handleChange.secondCall.args[0]).to.equal(true); + }); + + it('should not call onChange when the open state does not change', async () => { + const handleChange = spy(); + + function App() { + const [open, setOpen] = React.useState(false); + + return ( + { + handleChange(open); + setOpen(nextOpen); + }} + > + + + Content + + + ); + } + + render(); + + expect(screen.queryByText('Content')).to.equal(null); + + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + clock.tick(300); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.firstCall.args[0]).to.equal(false); + }); + }); + + describe('prop: defaultOpen', () => { + it('should open when the component is rendered', async () => { + render( + + + + Content + + , + ); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + + it('should not open when the component is rendered and open is controlled', async () => { + render( + + + + Content + + , + ); + + await waitForPosition(); + + expect(screen.queryByText('Content')).to.equal(null); + }); + + it('should not close when the component is rendered and open is controlled', async () => { + render( + + + + Content + + , + ); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + + it('should remain uncontrolled', async () => { + render( + + + + Content + + , + ); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + + const trigger = screen.getByRole('button'); + + fireEvent.mouseLeave(trigger); + + await waitForPosition(); + + expect(screen.queryByText('Content')).to.equal(null); + }); + }); + + describe('prop: delay', () => { + clock.withFakeTimers(); + + it('should open after delay with rest type by default', async () => { + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + await waitForPosition(); + + expect(screen.queryByText('Content')).to.equal(null); + + clock.tick(100); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + + it('should open after delay with hover type', async () => { + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + clock.tick(200); + + await waitForPosition(); + + expect(screen.queryByText('Content')).to.equal(null); + + clock.tick(100); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + }); + }); + + describe('prop: closeDelay', () => { + clock.withFakeTimers(); + + it('should close after delay', async () => { + render( + + + + Content + + , + ); + + const trigger = screen.getByRole('button'); + + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + + clock.tick(300); + + await waitForPosition(); + + expect(screen.getByText('Content')).not.to.equal(null); + + fireEvent.mouseLeave(trigger); + + expect(screen.getByText('Content')).not.to.equal(null); + + clock.tick(100); + + expect(screen.queryByText('Content')).to.equal(null); + }); + }); +}); diff --git a/packages/mui-base/src/Tooltip/Root/TooltipRoot.tsx b/packages/mui-base/src/Tooltip/Root/TooltipRoot.tsx new file mode 100644 index 000000000..785c87f20 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Root/TooltipRoot.tsx @@ -0,0 +1,161 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { TooltipRootProps } from './TooltipRoot.types'; +import { TooltipRootContext } from './TooltipRootContext'; +import { useTooltipRoot } from './useTooltipRoot'; + +/** + * The foundation for building custom-styled tooltips. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [TooltipRoot API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip-root) + */ +function TooltipRoot(props: TooltipRootProps) { + const { + delayType = 'rest', + delay, + closeDelay, + hoverable = true, + animated = true, + followCursorAxis = 'none', + } = props; + + const delayWithDefault = delay ?? 300; + const closeDelayWithDefault = closeDelay ?? 0; + + const [triggerElement, setTriggerElement] = React.useState(null); + const [popupElement, setPopupElement] = React.useState(null); + + const { + open, + setOpen, + mounted, + setMounted, + instantType, + getTriggerProps, + getRootPositionerProps, + rootContext, + transitionStatus, + } = useTooltipRoot({ + popupElement, + triggerElement, + hoverable, + animated, + followCursorAxis, + delay, + delayType, + closeDelay, + open: props.open, + onOpenChange: props.onOpenChange, + defaultOpen: props.defaultOpen, + }); + + const contextValue = React.useMemo( + () => ({ + delay: delayWithDefault, + delayType, + closeDelay: closeDelayWithDefault, + open, + setOpen, + triggerElement, + setTriggerElement, + popupElement, + setPopupElement, + mounted, + setMounted, + instantType, + getTriggerProps, + getRootPositionerProps, + rootContext, + followCursorAxis, + transitionStatus, + }), + [ + delayWithDefault, + delayType, + closeDelayWithDefault, + open, + setOpen, + triggerElement, + popupElement, + mounted, + setMounted, + instantType, + getTriggerProps, + getRootPositionerProps, + rootContext, + followCursorAxis, + transitionStatus, + ], + ); + + return ( + {props.children} + ); +} + +TooltipRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * Whether the tooltip can animate, adding animation-related attributes and allowing for exit + * animations to play. Useful to disable in tests to remove async behavior. + * @default true + */ + animated: PropTypes.bool, + /** + * @ignore + */ + children: PropTypes.node, + /** + * The delay in milliseconds until the tooltip popup is closed. + * @default 0 + */ + closeDelay: PropTypes.number, + /** + * Specifies whether the tooltip is open initially when uncontrolled. + */ + defaultOpen: PropTypes.bool, + /** + * The delay in milliseconds until the tooltip popup is opened. + * @default 300 + */ + delay: PropTypes.number, + /** + * The delay type to use. `rest` means the `delay` represents how long the user's cursor must + * rest on the trigger before the tooltip popup is opened. `hover` means the `delay` represents + * how long to wait as soon as the user's cursor has entered the trigger. + * @default 'rest' + */ + delayType: PropTypes.oneOf(['hover', 'rest']), + /** + * Determines which axis the tooltip should follow the cursor on. + * @default 'none' + */ + followCursorAxis: PropTypes.oneOf(['both', 'none', 'x', 'y']), + /** + * Whether the user can move their cursor from the trigger to the tooltip popup without it + * closing. + * @default true + */ + hoverable: PropTypes.bool, + /** + * Callback fired when the tooltip popup is requested to be opened or closed. Use when + * controlled. + */ + onOpenChange: PropTypes.func, + /** + * If `true`, the tooltip popup is open. Use when controlled. + */ + open: PropTypes.bool, +} as any; + +export { TooltipRoot }; diff --git a/packages/mui-base/src/Tooltip/Root/TooltipRoot.types.ts b/packages/mui-base/src/Tooltip/Root/TooltipRoot.types.ts new file mode 100644 index 000000000..9cfc5e38c --- /dev/null +++ b/packages/mui-base/src/Tooltip/Root/TooltipRoot.types.ts @@ -0,0 +1,77 @@ +import type { + FloatingRootContext, + OpenChangeReason, + UseInteractionsReturn, +} from '@floating-ui/react'; +import type { TransitionStatus } from '../../utils/useTransitionStatus'; + +export interface TooltipRootContextValue { + open: boolean; + setOpen: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; + triggerElement: Element | null; + setTriggerElement: (el: Element | null) => void; + popupElement: HTMLElement | null; + setPopupElement: (el: HTMLElement | null) => void; + delay: number; + closeDelay: number; + delayType: 'rest' | 'hover'; + mounted: boolean; + setMounted: React.Dispatch>; + getTriggerProps: UseInteractionsReturn['getReferenceProps']; + getRootPositionerProps: UseInteractionsReturn['getFloatingProps']; + instantType: 'delay' | 'dismiss' | 'focus' | undefined; + rootContext: FloatingRootContext; + followCursorAxis: 'none' | 'x' | 'y' | 'both'; + transitionStatus: TransitionStatus; +} + +export interface TooltipRootProps { + children: React.ReactNode; + /** + * If `true`, the tooltip popup is open. Use when controlled. + */ + open?: boolean; + /** + * Callback fired when the tooltip popup is requested to be opened or closed. Use when + * controlled. + */ + onOpenChange?: (isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void; + /** + * Specifies whether the tooltip is open initially when uncontrolled. + */ + defaultOpen?: boolean; + /** + * The delay in milliseconds until the tooltip popup is opened. + * @default 300 + */ + delay?: number; + /** + * The delay in milliseconds until the tooltip popup is closed. + * @default 0 + */ + closeDelay?: number; + /** + * The delay type to use. `rest` means the `delay` represents how long the user's cursor must + * rest on the trigger before the tooltip popup is opened. `hover` means the `delay` represents + * how long to wait as soon as the user's cursor has entered the trigger. + * @default 'rest' + */ + delayType?: 'rest' | 'hover'; + /** + * Whether the user can move their cursor from the trigger to the tooltip popup without it + * closing. + * @default true + */ + hoverable?: boolean; + /** + * Whether the tooltip can animate, adding animation-related attributes and allowing for exit + * animations to play. Useful to disable in tests to remove async behavior. + * @default true + */ + animated?: boolean; + /** + * Determines which axis the tooltip should follow the cursor on. + * @default 'none' + */ + followCursorAxis?: 'none' | 'x' | 'y' | 'both'; +} diff --git a/packages/mui-base/src/Tooltip/Root/TooltipRootContext.ts b/packages/mui-base/src/Tooltip/Root/TooltipRootContext.ts new file mode 100644 index 000000000..7cdab58e5 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Root/TooltipRootContext.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import type { TooltipRootContextValue } from './TooltipRoot.types'; + +export const TooltipRootContext = React.createContext(null); + +export function useTooltipRootContext() { + const context = React.useContext(TooltipRootContext); + if (context === null) { + throw new Error('Tooltip components must be used within the component'); + } + return context; +} diff --git a/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts b/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts new file mode 100644 index 000000000..041696897 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts @@ -0,0 +1,174 @@ +'use client'; +import * as React from 'react'; +import useEventCallback from '@mui/utils/useEventCallback'; +import { + safePolygon, + useClientPoint, + useDelayGroup, + useDismiss, + useFloatingRootContext, + useFocus, + useHover, + useInteractions, + type OpenChangeReason, +} from '@floating-ui/react'; +import type { UseTooltipRootParameters, UseTooltipRootReturnValue } from './useTooltipRoot.types'; +import { useControlled } from '../../utils/useControlled'; +import { useTransitionStatus } from '../../utils/useTransitionStatus'; +import { useAnimationsFinished } from '../../utils/useAnimationsFinished'; + +/** + * Manages the root state for a tooltip. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/#hooks) + * + * API: + * + * - [useTooltipRoot API](https://mui.com/base-ui/react-tooltip/hooks-api/#use-tooltip-root) + */ +export function useTooltipRoot(params: UseTooltipRootParameters): UseTooltipRootReturnValue { + const { + open: externalOpen, + onOpenChange: onOpenChangeProp = () => {}, + defaultOpen = false, + keepMounted = false, + triggerElement = null, + popupElement = null, + hoverable = true, + animated = true, + followCursorAxis = 'none', + delayType = 'rest', + delay, + closeDelay, + } = params; + + const delayWithDefault = delay ?? 300; + const closeDelayWithDefault = closeDelay ?? 0; + + const [instantTypeState, setInstantTypeState] = React.useState<'dismiss' | 'focus'>(); + + const [open, setOpenUnwrapped] = useControlled({ + controlled: externalOpen, + default: defaultOpen, + name: 'Tooltip', + state: 'open', + }); + + const onOpenChange = useEventCallback(onOpenChangeProp); + + const setOpen = React.useCallback( + (nextOpen: boolean, event?: Event, reason?: OpenChangeReason) => { + onOpenChange(nextOpen, event, reason); + setOpenUnwrapped(nextOpen); + }, + [onOpenChange, setOpenUnwrapped], + ); + + const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, animated); + + const runOnceAnimationsFinish = useAnimationsFinished(() => popupElement?.firstElementChild); + + const context = useFloatingRootContext({ + elements: { reference: triggerElement, floating: popupElement }, + open, + onOpenChange(openValue, eventValue, reasonValue) { + setOpen(openValue, eventValue, reasonValue); + + const isFocusOpen = openValue && reasonValue === 'focus'; + const isDismissClose = + !openValue && (reasonValue === 'reference-press' || reasonValue === 'escape-key'); + + if (isFocusOpen || isDismissClose) { + setInstantTypeState(isFocusOpen ? 'focus' : 'dismiss'); + } else if (reasonValue === 'hover') { + setInstantTypeState(undefined); + } + + if (!keepMounted && !openValue) { + if (animated) { + runOnceAnimationsFinish(() => setMounted(false)); + } else { + setMounted(false); + } + } + }, + }); + + const { delay: groupDelay, isInstantPhase, currentId } = useDelayGroup(context); + const openGroupDelay = typeof groupDelay === 'object' ? groupDelay.open : groupDelay; + const closeGroupDelay = typeof groupDelay === 'object' ? groupDelay.close : groupDelay; + + let instantType = isInstantPhase ? ('delay' as const) : instantTypeState; + if (!open && context.floatingId === currentId) { + instantType = instantTypeState; + } + + const computedRestMs = delayType === 'rest' ? openGroupDelay || delayWithDefault : undefined; + let computedOpenDelay: number | undefined = delayType === 'hover' ? delayWithDefault : undefined; + let computedCloseDelay: number | undefined = closeDelayWithDefault; + + if (delayType === 'hover') { + if (delay == null) { + computedOpenDelay = + groupDelay === 0 + ? // A provider is not present. + delayWithDefault + : // A provider is present. + openGroupDelay; + } else { + computedOpenDelay = delay; + } + } + + // A provider is present and the close delay is not set. + if (closeDelay == null && groupDelay !== 0) { + computedCloseDelay = closeGroupDelay; + } + + const hover = useHover(context, { + mouseOnly: true, + move: false, + handleClose: hoverable && followCursorAxis !== 'both' ? safePolygon() : null, + restMs: computedRestMs, + delay: { + open: computedOpenDelay, + close: computedCloseDelay, + }, + }); + const focus = useFocus(context); + const dismiss = useDismiss(context, { referencePress: true }); + const clientPoint = useClientPoint(context, { + enabled: followCursorAxis !== 'none', + axis: followCursorAxis === 'none' ? undefined : followCursorAxis, + }); + + const { getReferenceProps: getTriggerProps, getFloatingProps: getRootPositionerProps } = + useInteractions([hover, focus, dismiss, clientPoint]); + + return React.useMemo( + () => ({ + open, + setOpen, + mounted, + setMounted, + getTriggerProps, + getRootPositionerProps, + rootContext: context, + instantType, + transitionStatus, + }), + [ + mounted, + open, + setMounted, + setOpen, + getTriggerProps, + getRootPositionerProps, + context, + instantType, + transitionStatus, + ], + ); +} diff --git a/packages/mui-base/src/Tooltip/Root/useTooltipRoot.types.ts b/packages/mui-base/src/Tooltip/Root/useTooltipRoot.types.ts new file mode 100644 index 000000000..d48c1d7f4 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Root/useTooltipRoot.types.ts @@ -0,0 +1,108 @@ +import type { + FloatingRootContext, + OpenChangeReason, + UseInteractionsReturn, +} from '@floating-ui/react'; +import type { TransitionStatus } from '../../utils/useTransitionStatus'; + +export interface UseTooltipRootParameters { + /** + * If `true`, the tooltip popup will be open by default. Use when uncontrolled. + */ + defaultOpen?: boolean; + /** + * If `true`, the tooltip popup will be open. Use when controlled. + */ + open?: boolean; + /** + * Callback fired when the tooltip popup is requested to be opened or closed. + */ + onOpenChange?: (isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void; + /** + * The trigger element. Store in state. + * @default null + */ + triggerElement?: Element | null; + /** + * The popup element. Store in state. + * @default null + */ + popupElement?: HTMLElement | null; + /** + * If `true`, the user can move from the trigger toward the tooltip without it closing. + * @default true + */ + hoverable?: boolean; + /** + * Whether the tooltip can animate, adding animation-related attributes and allowing for exit + * animations to play. Useful to disable in tests to remove async behavior. + * @default true + */ + animated?: boolean; + /** + * Determines which axis the tooltip should follow the cursor on. + * @default 'none' + */ + followCursorAxis?: 'none' | 'x' | 'y' | 'both'; + /** + * The delay in milliseconds until the tooltip popup is opened. + * @default 300 + */ + delay?: number; + /** + * The delay in milliseconds until the tooltip popup is closed. + * @default 0 + */ + closeDelay?: number; + /** + * The delay type to use. `rest` means the `delay` represents how long the user's cursor must + * rest on the trigger before the tooltip popup is opened. `hover` means the `delay` represents + * how long to wait as soon as the user's cursor has entered the trigger. + * @default 'rest' + */ + delayType?: 'rest' | 'hover'; + /** + * If `true`, the tooltip popup remains mounted in the DOM even when closed. + * @default false + */ + keepMounted?: boolean; +} + +export interface UseTooltipRootReturnValue { + /** + * If `true`, the tooltip is open. + */ + open: boolean; + /** + * Sets the open state of the tooltip. + */ + setOpen: (value: boolean, event?: Event, reason?: OpenChangeReason) => void; + /** + * If `true`, the tooltip is mounted. + */ + mounted: boolean; + /** + * Sets the mounted state of the tooltip. + */ + setMounted: React.Dispatch>; + /** + * Prop getter to spread props on the trigger element. + */ + getTriggerProps: UseInteractionsReturn['getReferenceProps']; + /** + * Prop getter to spread root props on the positioner element. + */ + getRootPositionerProps: UseInteractionsReturn['getFloatingProps']; + /** + * The root context object. + */ + rootContext: FloatingRootContext; + /** + * The type of instant phase the tooltip is in to remove animations. + */ + instantType: 'delay' | 'dismiss' | 'focus' | undefined; + /** + * The transition status of the tooltip. + */ + transitionStatus: TransitionStatus; +} diff --git a/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.test.tsx b/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.test.tsx new file mode 100644 index 000000000..1f2661317 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.test.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import * as Tooltip from '@base_ui/react/Tooltip'; +import { createRenderer } from '@mui/internal-test-utils'; +import { describeConformance } from '../../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + inheritComponent: 'button', + refInstanceof: window.HTMLButtonElement, + render(node) { + return render({node}); + }, + skip: ['reactTestRenderer'], + })); +}); diff --git a/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.tsx b/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.tsx new file mode 100644 index 000000000..e551e3f9e --- /dev/null +++ b/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.tsx @@ -0,0 +1,60 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useTooltipRootContext } from '../Root/TooltipRootContext'; +import type { TooltipTriggerProps } from './TooltipTrigger.types'; +import { tooltipTriggerStyleHookMapping } from './styleHooks'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useForkRef } from '../../utils/useForkRef'; + +/** + * Renders a trigger element that will open the tooltip. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [TooltipTrigger API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip-trigger) + */ +const TooltipTrigger = React.forwardRef(function TooltipTrigger( + props: TooltipTriggerProps, + forwardedRef: React.ForwardedRef, +) { + const { className, render, ...otherProps } = props; + + const { open, setTriggerElement, getTriggerProps } = useTooltipRootContext(); + + const ownerState = React.useMemo(() => ({ open }), [open]); + const mergedRef = useForkRef(setTriggerElement, forwardedRef); + + const { renderElement } = useComponentRenderer({ + propGetter: getTriggerProps, + render: render ?? 'button', + className, + ownerState, + extraProps: otherProps, + ref: mergedRef, + customStyleHookMapping: tooltipTriggerStyleHookMapping, + }); + + return renderElement(); +}); + +TooltipTrigger.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { TooltipTrigger }; diff --git a/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.types.ts b/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.types.ts new file mode 100644 index 000000000..5fafae532 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Trigger/TooltipTrigger.types.ts @@ -0,0 +1,7 @@ +import type { BaseUIComponentProps } from '../../utils/BaseUI.types'; + +export type TooltipTriggerOwnerState = { + open: boolean; +}; + +export interface TooltipTriggerProps extends BaseUIComponentProps {} diff --git a/packages/mui-base/src/Tooltip/Trigger/styleHooks.ts b/packages/mui-base/src/Tooltip/Trigger/styleHooks.ts new file mode 100644 index 000000000..de1c0505a --- /dev/null +++ b/packages/mui-base/src/Tooltip/Trigger/styleHooks.ts @@ -0,0 +1,10 @@ +import type { TooltipTriggerOwnerState } from './TooltipTrigger.types'; +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; + +export const tooltipTriggerStyleHookMapping: CustomStyleHookMapping = { + open(value) { + return { + 'data-state': value ? 'open' : 'closed', + }; + }, +}; diff --git a/packages/mui-base/src/Tooltip/index.barrel.ts b/packages/mui-base/src/Tooltip/index.barrel.ts new file mode 100644 index 000000000..683570524 --- /dev/null +++ b/packages/mui-base/src/Tooltip/index.barrel.ts @@ -0,0 +1,17 @@ +export { TooltipRoot } from './Root/TooltipRoot'; +export { TooltipTrigger } from './Trigger/TooltipTrigger'; +export { TooltipPositioner } from './Positioner/TooltipPositioner'; +export { TooltipPopup } from './Popup/TooltipPopup'; +export { TooltipArrow } from './Arrow/TooltipArrow'; +export { TooltipProvider } from './Provider/TooltipProvider'; +export { useTooltipPositioner } from './Positioner/useTooltipPositioner'; +export { useTooltipRoot } from './Root/useTooltipRoot'; + +export type { TooltipRootProps } from './Root/TooltipRoot.types'; +export type * from './Trigger/TooltipTrigger.types'; +export type * from './Positioner/TooltipPositioner.types'; +export type { TooltipPopupOwnerState, TooltipPopupProps } from './Popup/TooltipPopup.types'; +export type * from './Arrow/TooltipArrow.types'; +export type * from './Provider/TooltipProvider.types'; +export type * from './Positioner/useTooltipPositioner.types'; +export type * from './Root/useTooltipRoot.types'; diff --git a/packages/mui-base/src/Tooltip/index.ts b/packages/mui-base/src/Tooltip/index.ts new file mode 100644 index 000000000..d616a49b6 --- /dev/null +++ b/packages/mui-base/src/Tooltip/index.ts @@ -0,0 +1,35 @@ +export { TooltipRoot as Root } from './Root/TooltipRoot'; +export { TooltipTrigger as Trigger } from './Trigger/TooltipTrigger'; +export { TooltipPositioner as Positioner } from './Positioner/TooltipPositioner'; +export { TooltipPopup as Popup } from './Popup/TooltipPopup'; +export { TooltipArrow as Arrow } from './Arrow/TooltipArrow'; +export { TooltipProvider as Provider } from './Provider/TooltipProvider'; +export { useTooltipRoot as useRoot } from './Root/useTooltipRoot'; +export { useTooltipPositioner as usePositioner } from './Positioner/useTooltipPositioner'; + +export type { TooltipRootProps as RootProps } from './Root/TooltipRoot.types'; +export type { + TooltipTriggerProps as TriggerProps, + TooltipTriggerOwnerState as TriggerOwnerState, +} from './Trigger/TooltipTrigger.types'; +export type { + TooltipPositionerProps as PositionerProps, + TooltipPositionerOwnerState as PositionerOwnerState, +} from './Positioner/TooltipPositioner.types'; +export type { + TooltipPopupProps as PopupProps, + TooltipPopupOwnerState as PopupOwnerState, +} from './Popup/TooltipPopup.types'; +export type { + TooltipArrowProps as ArrowProps, + TooltipArrowOwnerState as ArrowOwnerState, +} from './Arrow/TooltipArrow.types'; +export type { TooltipProviderProps as ProviderProps } from './Provider/TooltipProvider.types'; +export type { + UseTooltipRootParameters as UseRootParameters, + UseTooltipRootReturnValue as UseRootReturnValue, +} from './Root/useTooltipRoot.types'; +export type { + UseTooltipPositionerParameters as UsePositionerParameters, + UseTooltipPositionerReturnValue as UsePositionerReturnValue, +} from './Positioner/useTooltipPositioner.types'; diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts index b4c783b6a..01576785c 100644 --- a/packages/mui-base/src/index.ts +++ b/packages/mui-base/src/index.ts @@ -2,3 +2,4 @@ export * from './Checkbox/index.barrel'; export * from './NumberField/index.barrel'; export * from './Switch/index.barrel'; export * from './Tabs/index.barrel'; +export * from './Tooltip/index.barrel'; diff --git a/packages/mui-base/src/utils/BaseUI.types.ts b/packages/mui-base/src/utils/BaseUI.types.ts index 9c9a1f225..5181aa5ec 100644 --- a/packages/mui-base/src/utils/BaseUI.types.ts +++ b/packages/mui-base/src/utils/BaseUI.types.ts @@ -1,5 +1,7 @@ import * as React from 'react'; +export type GenericHTMLProps = React.HTMLAttributes & { ref?: React.Ref | undefined }; + export type BaseUIEvent> = E & { preventBaseUIHandler: () => void; }; diff --git a/packages/mui-base/src/utils/getStyleHookProps.ts b/packages/mui-base/src/utils/getStyleHookProps.ts index 2ee23013a..1dfeef788 100644 --- a/packages/mui-base/src/utils/getStyleHookProps.ts +++ b/packages/mui-base/src/utils/getStyleHookProps.ts @@ -18,7 +18,7 @@ export function getStyleHookProps>( return; } - if (value !== false) { + if (value !== false && value !== undefined) { props[`data-${key.toLowerCase()}`] = value.toString(); } }); diff --git a/packages/mui-base/src/utils/types.ts b/packages/mui-base/src/utils/types.ts index 4d2a96ce8..2a9eb8d8d 100644 --- a/packages/mui-base/src/utils/types.ts +++ b/packages/mui-base/src/utils/types.ts @@ -1,5 +1,7 @@ import * as React from 'react'; +export { HTMLElementType } from '@mui/utils'; + export type EventHandlers = Record>; export type WithOptionalOwnerState = Omit< diff --git a/packages/mui-base/test/conformanceTests/propForwarding.tsx b/packages/mui-base/test/conformanceTests/propForwarding.tsx index 209e03100..ec8e70110 100644 --- a/packages/mui-base/test/conformanceTests/propForwarding.tsx +++ b/packages/mui-base/test/conformanceTests/propForwarding.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { expect } from 'chai'; -import { randomStringValue } from '@mui/internal-test-utils'; +import { act, randomStringValue } from '@mui/internal-test-utils'; import { throwMissingPropError } from './utils'; import { type BaseUiConformanceTestsOptions } from '../describeConformance'; @@ -25,6 +25,8 @@ export function testPropForwarding( React.cloneElement(element, { 'data-testid': 'root', ...otherProps }), ); + await act(async () => {}); + const customRoot = getByTestId('root'); expect(customRoot).to.have.attribute('lang', otherProps.lang); expect(customRoot).to.have.attribute('data-foobar', otherProps['data-foobar']); @@ -43,6 +45,8 @@ export function testPropForwarding( }), ); + await act(async () => {}); + const customRoot = getByTestId('custom-root'); expect(customRoot).to.have.attribute('lang', otherProps.lang); expect(customRoot).to.have.attribute('data-foobar', otherProps['data-foobar']); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fe41a305..dea05bcf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: cross-fetch: specifier: ^4.0.0 version: 4.0.0(encoding@0.1.13) + framer-motion: + specifier: ^11.2.5 + version: 11.2.5(@emotion/is-prop-valid@1.2.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) marked: specifier: ^12.0.2 version: 12.0.2 @@ -572,9 +575,15 @@ importers: '@babel/runtime': specifier: ^7.24.5 version: 7.24.5 + '@floating-ui/react': + specifier: ^0.26.12 + version: 0.26.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@floating-ui/react-dom': - specifier: ^2.1.0 + specifier: ^2.0.8 version: 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@floating-ui/utils': + specifier: ^0.2.1 + version: 0.2.2 '@mui/types': specifier: ^7.2.14 version: 7.2.14(@types/react@18.3.1) @@ -1783,8 +1792,8 @@ packages: '@floating-ui/core@1.6.0': resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} - '@floating-ui/dom@1.6.3': - resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} + '@floating-ui/dom@1.6.5': + resolution: {integrity: sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==} '@floating-ui/react-dom@2.1.0': resolution: {integrity: sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==} @@ -1792,8 +1801,14 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.1': - resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + '@floating-ui/react@0.26.16': + resolution: {integrity: sha512-HEf43zxZNAI/E781QIVpYSF3K2VH4TTYZpqecjdsFkjsaU1EbaWcM++kw0HXFffj7gDUcBFevX8s0rQGQpxkow==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.2': + resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} '@gitbeaker/core@38.12.1': resolution: {integrity: sha512-8XMVcBIdVAAoxn7JtqmZ2Ee8f+AZLcCPmqEmPFOXY2jPS84y/DERISg/+sbhhb18iRy+ZsZhpWgQ/r3CkYNJOQ==} @@ -4880,6 +4895,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@11.2.5: + resolution: {integrity: sha512-X22i42hWY423wx2C1TlQlC4UnWonD+udND0qX1Fkt0dDlreSmuNY76obO6Y2d/UdJPhqVd5Zn6g1jAIwF6Xx9A==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -8027,6 +8056,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + table@6.8.2: resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==} engines: {node: '>=10.0.0'} @@ -9892,20 +9924,28 @@ snapshots: '@floating-ui/core@1.6.0': dependencies: - '@floating-ui/utils': 0.2.1 + '@floating-ui/utils': 0.2.2 - '@floating-ui/dom@1.6.3': + '@floating-ui/dom@1.6.5': dependencies: '@floating-ui/core': 1.6.0 - '@floating-ui/utils': 0.2.1 + '@floating-ui/utils': 0.2.2 '@floating-ui/react-dom@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@floating-ui/dom': 1.6.3 + '@floating-ui/dom': 1.6.5 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@floating-ui/utils@0.2.1': {} + '@floating-ui/react@0.26.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@floating-ui/utils': 0.2.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.2': {} '@gitbeaker/core@38.12.1': dependencies: @@ -13799,6 +13839,14 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@11.2.5(@emotion/is-prop-valid@1.2.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + tslib: 2.6.2 + optionalDependencies: + '@emotion/is-prop-valid': 1.2.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + fresh@0.5.2: {} from@0.1.7: {} @@ -17512,6 +17560,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.2.0: {} + table@6.8.2: dependencies: ajv: 8.12.0