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 0000000000..d1d77de65a --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.js @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Tooltip } from '@mui/base/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipIntroduction() { + return ( + + + Anchor + + Tooltip + + ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipContent = styled(Tooltip.Content)` + ${({ theme }) => ` + background: ${theme.palette.mode === 'dark' ? 'white' : '#333'}; + color: ${theme.palette.mode === 'dark' ? 'black' : 'white'}; + padding: 4px 6px; + border-radius: 4px; + font-size: 95%; + `} +`; + +export const AnchorButton = styled('button')` + border: none; + background: ${blue[600]}; + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 16px; + + &:focus-visible { + outline: 2px solid ${blue[400]}; + outline-offset: 2px; + } + + &:hover, + &[data-state='open'] { + background: ${blue[800]}; + } +`; diff --git a/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx new file mode 100644 index 0000000000..d1d77de65a --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Tooltip } from '@mui/base/Tooltip'; +import { styled } from '@mui/system'; + +export default function UnstyledTooltipIntroduction() { + return ( + + + Anchor + + Tooltip + + ); +} + +const blue = { + 400: '#3399FF', + 600: '#0072E6', + 800: '#004C99', +}; + +export const TooltipContent = styled(Tooltip.Content)` + ${({ theme }) => ` + background: ${theme.palette.mode === 'dark' ? 'white' : '#333'}; + color: ${theme.palette.mode === 'dark' ? 'black' : 'white'}; + padding: 4px 6px; + border-radius: 4px; + font-size: 95%; + `} +`; + +export const AnchorButton = styled('button')` + border: none; + background: ${blue[600]}; + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 16px; + + &:focus-visible { + outline: 2px solid ${blue[400]}; + outline-offset: 2px; + } + + &:hover, + &[data-state='open'] { + background: ${blue[800]}; + } +`; diff --git a/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx.preview b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.tsx.preview new file mode 100644 index 0000000000..021f9bad1b --- /dev/null +++ b/docs/data/base/components/tooltip/UnstyledTooltipIntroduction/system/index.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 02ea58050d..1dd2f723f4 100644 --- a/docs/data/base/components/tooltip/tooltip.md +++ b/docs/data/base/components/tooltip/tooltip.md @@ -1,14 +1,136 @@ --- productId: base-ui title: React Tooltip component +components: Tooltip, TooltipContent, TooltipAnchorFragment, TooltipDelayGroup 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.

-:::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"}} + +## Component + +```jsx +import { Tooltip } from '@mui/base/Tooltip'; +``` + +### Anatomy + +The `Tooltip` component is composed of a root component that contains two main subcomponents: `AnchorFragment` and `Content`. + +```jsx + + + + + + +``` + +- `Content` is the tooltip element itself. +- `AnchorFragment` provides props for its child element that lets the `Content` element be anchored to it. +- `Arrow` is an optional element for displaying a caret (or triangle) that points to the center of the anchor. + +```jsx + + + + + Tooltip + +``` + +### Placement + +By default, the tooltip is placed on the top side of its anchor. To change this, use the `placement` prop: + +```jsx + + + + + Tooltip + +``` + +There are 12 possible placements: + +- Centered placements: `top`, `right`, `bottom`, `left`. +- Edge-aligned placements: `top-start`, `top-end`, `right-start`, `right-end`, `bottom-start`, `bottom-end`, `left-start`, `left-end`. + +The edge-aligned placements are logical, adapting to the writing direction (LTR or RTL) as expected. + +### Anchor gap + +To increase the gap between the anchor and its tooltip, use the `anchorGap` prop: + +```jsx + +``` + +### Delay + +To delay the tooltip from showing or hiding, use the `delay` prop, which represents how long the tooltip will wait after being triggered to show in milliseconds: + +```jsx + +``` + +The open and close delay can be separately configured: + +```jsx + +``` + +### Open state + +To control the tooltip with external state, use the `open` and `onOpenChange` props: + +```jsx +function App() { + const [open, setOpen] = React.useState(false); + return ( + + {/* Subcomponents */} + + ); +} +``` + +### Default open + +To show the tooltip initially while leaving it uncontrolled, use the `defaultOpen` prop: + +```jsx + +``` + +### Hoverable content + +To prevent the content inside from being hoverable, use the `disableHoverableContent` prop: + +```jsx + +``` + +:::info +This should only be disabled when necessary, such as in high-density UIs where tooltips can block other controls. ::: + +### Touch input + +To prevent the tooltip from showing when using touch input, use the `allowTouch` prop: + +```jsx + +``` diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js index 58829d7f65..5d74110dec 100644 --- a/docs/data/base/pagesApi.js +++ b/docs/data/base/pagesApi.js @@ -75,6 +75,19 @@ module.exports = [ pathname: '/base-ui/react-textarea-autosize/components-api/#textarea-autosize', title: 'TextareaAutosize', }, + { pathname: '/base-ui/react-tooltip/components-api/#tooltip', title: 'Tooltip' }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-anchor-fragment', + title: 'TooltipAnchorFragment', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-content', + title: 'TooltipContent', + }, + { + pathname: '/base-ui/react-tooltip/components-api/#tooltip-delay-group', + title: 'TooltipDelayGroup', + }, { pathname: '/base-ui/react-autocomplete/hooks-api/#use-autocomplete', title: 'useAutocomplete', diff --git a/docs/pages/base-ui/api/tooltip-anchor-fragment.json b/docs/pages/base-ui/api/tooltip-anchor-fragment.json new file mode 100644 index 0000000000..615306750a --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-anchor-fragment.json @@ -0,0 +1,14 @@ +{ + "props": {}, + "name": "TooltipAnchorFragment", + "imports": [ + "import { TooltipAnchorFragment } from '@mui/base/Tooltip';", + "import { TooltipAnchorFragment } from '@mui/base';" + ], + "classes": [], + "muiName": "TooltipAnchorFragment", + "filename": "/packages/mui-base/src/Tooltip/TooltipAnchorFragment.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-content.json b/docs/pages/base-ui/api/tooltip-content.json new file mode 100644 index 0000000000..1e4120c2d6 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-content.json @@ -0,0 +1,17 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "func" } } + }, + "name": "TooltipContent", + "imports": [ + "import { TooltipContent } from '@mui/base/Tooltip';", + "import { TooltipContent } from '@mui/base';" + ], + "classes": [], + "muiName": "TooltipContent", + "filename": "/packages/mui-base/src/Tooltip/TooltipContent.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip-delay-group.json b/docs/pages/base-ui/api/tooltip-delay-group.json new file mode 100644 index 0000000000..3622039e75 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip-delay-group.json @@ -0,0 +1,14 @@ +{ + "props": {}, + "name": "TooltipDelayGroup", + "imports": [ + "import { TooltipDelayGroup } from '@mui/base/Tooltip';", + "import { TooltipDelayGroup } from '@mui/base';" + ], + "classes": [], + "muiName": "TooltipDelayGroup", + "filename": "/packages/mui-base/src/Tooltip/TooltipDelayGroup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/tooltip.json b/docs/pages/base-ui/api/tooltip.json new file mode 100644 index 0000000000..b4d8a19d37 --- /dev/null +++ b/docs/pages/base-ui/api/tooltip.json @@ -0,0 +1,16 @@ +{ + "props": {}, + "name": "Tooltip", + "imports": [ + "import { Tooltip } from '@mui/base/Tooltip';", + "import { Tooltip } from '@mui/base';" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "Tooltip", + "filename": "/packages/mui-base/src/Tooltip/Tooltip.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/use-tooltip.json b/docs/pages/base-ui/api/use-tooltip.json new file mode 100644 index 0000000000..73e3dcc759 --- /dev/null +++ b/docs/pages/base-ui/api/use-tooltip.json @@ -0,0 +1,60 @@ +{ + "parameters": { + "alignmentOffset": { "type": { "name": "number", "description": "number" }, "default": "0" }, + "allowTouch": { "type": { "name": "boolean", "description": "boolean" }, "default": "true" }, + "anchorGap": { "type": { "name": "number", "description": "number" }, "default": "0" }, + "defaultOpen": { "type": { "name": "boolean", "description": "boolean" } }, + "delay": { + "type": { + "name": "number | { open?: number; close?: number }", + "description": "number | { open?: number; close?: number }" + }, + "default": "0" + }, + "disableHoverableContent": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false" + }, + "onOpenChange": { + "type": { + "name": "(isOpen: boolean, event: Event) => void", + "description": "(isOpen: boolean, event: Event) => void" + } + }, + "open": { "type": { "name": "boolean", "description": "boolean" } }, + "placement": { + "type": { "name": "Placement", "description": "Placement" }, + "default": "'top'" + }, + "type": { + "type": { + "name": "'label' | 'description' | 'visual-only'", + "description": "'label' | 'description' | 'visual-only'" + }, + "default": "'description'" + } + }, + "returnValue": { + "getAnchorProps": { + "type": { + "name": "(externalProps?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>", + "description": "(externalProps?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>" + }, + "required": true + }, + "getTooltipProps": { + "type": { + "name": "(externalProps?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>", + "description": "(externalProps?: React.HTMLAttributes<HTMLElement>) => React.HTMLAttributes<HTMLElement>" + }, + "required": true + } + }, + "name": "useTooltip", + "filename": "/packages/mui-base/src/useTooltip/useTooltip.ts", + "imports": [ + "import { useTooltip } from '@mui/base/useTooltip';", + "import { useTooltip } from '@mui/base';" + ], + "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 0000000000..49957f29fd --- /dev/null +++ b/docs/pages/base-ui/react-tooltip/[docsTab]/index.js @@ -0,0 +1,74 @@ +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/data/base/components/tooltip/tooltip.md?@mui/markdown'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import TooltipApiJsonPageContent from '../../api/tooltip.json'; +import TooltipAnchorFragmentApiJsonPageContent from '../../api/tooltip-anchor-fragment.json'; +import TooltipContentApiJsonPageContent from '../../api/tooltip-content.json'; +import TooltipDelayGroupApiJsonPageContent from '../../api/tooltip-delay-group.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 TooltipApiReq = require.context( + 'docs/translations/api-docs-base/tooltip', + false, + /tooltip.*.json$/, + ); + const TooltipApiDescriptions = mapApiPageTranslations(TooltipApiReq); + + const TooltipAnchorFragmentApiReq = require.context( + 'docs/translations/api-docs-base/tooltip-anchor-fragment', + false, + /tooltip-anchor-fragment.*.json$/, + ); + const TooltipAnchorFragmentApiDescriptions = mapApiPageTranslations(TooltipAnchorFragmentApiReq); + + const TooltipContentApiReq = require.context( + 'docs/translations/api-docs-base/tooltip-content', + false, + /tooltip-content.*.json$/, + ); + const TooltipContentApiDescriptions = mapApiPageTranslations(TooltipContentApiReq); + + const TooltipDelayGroupApiReq = require.context( + 'docs/translations/api-docs-base/tooltip-delay-group', + false, + /tooltip-delay-group.*.json$/, + ); + const TooltipDelayGroupApiDescriptions = mapApiPageTranslations(TooltipDelayGroupApiReq); + + return { + props: { + componentsApiDescriptions: { + Tooltip: TooltipApiDescriptions, + TooltipAnchorFragment: TooltipAnchorFragmentApiDescriptions, + TooltipContent: TooltipContentApiDescriptions, + TooltipDelayGroup: TooltipDelayGroupApiDescriptions, + }, + componentsApiPageContents: { + Tooltip: TooltipApiJsonPageContent, + TooltipAnchorFragment: TooltipAnchorFragmentApiJsonPageContent, + TooltipContent: TooltipContentApiJsonPageContent, + TooltipDelayGroup: TooltipDelayGroupApiJsonPageContent, + }, + hooksApiDescriptions: {}, + hooksApiPageContents: {}, + }, + }; +}; diff --git a/docs/translations/api-docs-base/tooltip-anchor-fragment/tooltip-anchor-fragment.json b/docs/translations/api-docs-base/tooltip-anchor-fragment/tooltip-anchor-fragment.json new file mode 100644 index 0000000000..f93d4cbd8c --- /dev/null +++ b/docs/translations/api-docs-base/tooltip-anchor-fragment/tooltip-anchor-fragment.json @@ -0,0 +1 @@ +{ "componentDescription": "", "propDescriptions": {}, "classDescriptions": {} } diff --git a/docs/translations/api-docs-base/tooltip-content/tooltip-content.json b/docs/translations/api-docs-base/tooltip-content/tooltip-content.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/translations/api-docs-base/tooltip-content/tooltip-content.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "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-base/tooltip-delay-group/tooltip-delay-group.json b/docs/translations/api-docs-base/tooltip-delay-group/tooltip-delay-group.json new file mode 100644 index 0000000000..f93d4cbd8c --- /dev/null +++ b/docs/translations/api-docs-base/tooltip-delay-group/tooltip-delay-group.json @@ -0,0 +1 @@ +{ "componentDescription": "", "propDescriptions": {}, "classDescriptions": {} } diff --git a/docs/translations/api-docs-base/tooltip/tooltip.json b/docs/translations/api-docs-base/tooltip/tooltip.json new file mode 100644 index 0000000000..1a2a7ae684 --- /dev/null +++ b/docs/translations/api-docs-base/tooltip/tooltip.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "The foundation for building custom-styled tooltips.", + "propDescriptions": {}, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/use-tooltip/use-tooltip.json b/docs/translations/api-docs/use-tooltip/use-tooltip.json new file mode 100644 index 0000000000..5b39475019 --- /dev/null +++ b/docs/translations/api-docs/use-tooltip/use-tooltip.json @@ -0,0 +1,33 @@ +{ + "hookDescription": "The basic building block for creating custom tooltips.", + "parametersDescriptions": { + "alignmentOffset": { + "description": "The offset of the tooltip element along its alignment axis." + }, + "allowTouch": { + "description": "If true, the tooltip will be shown when using touch input." + }, + "anchorGap": { "description": "The gap between the anchor element and the tooltip element." }, + "defaultOpen": { + "description": "If true, the tooltip will be open by default. Use when uncontrolled." + }, + "delay": { + "description": "The delay in milliseconds before the tooltip opens and closes after the anchor element is triggered." + }, + "disableHoverableContent": { + "description": "If true, the tooltip content will not be hoverable." + }, + "onOpenChange": { + "description": "Callback fired when the tooltip is requested to be opened or closed." + }, + "open": { + "description": "If true, the tooltip will be open. Use when controlled." + }, + "placement": { "description": "The placement of the tooltip element." }, + "type": { "description": "The type of the tooltip." } + }, + "returnValueDescriptions": { + "getAnchorProps": { "description": "Props to spread on the anchor element." }, + "getTooltipProps": { "description": "Props to spread on the tooltip element." } + } +} diff --git a/packages/mui-base/package.json b/packages/mui-base/package.json index 5d70f5df16..cba7feff29 100644 --- a/packages/mui-base/package.json +++ b/packages/mui-base/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@babel/runtime": "^7.24.1", + "@floating-ui/react": "^0.26.10", "@floating-ui/react-dom": "^2.0.8", "@mui/types": "^7.2.14", "@mui/utils": "^5.15.14", diff --git a/packages/mui-base/src/Tooltip/Tooltip.test.tsx b/packages/mui-base/src/Tooltip/Tooltip.test.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/mui-base/src/Tooltip/Tooltip.tsx b/packages/mui-base/src/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..ee548d8b56 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Tooltip.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { TooltipOwnerState, TooltipProps } from './Tooltip.types'; +import { TooltipContext } from './TooltipContext'; +import { useTooltip } from '../useTooltip'; + +/** + * The foundation for building custom-styled tooltips. + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [Tooltip API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip) + */ +function Tooltip(props: TooltipProps) { + const tooltip = useTooltip(props); + + const ownerState: TooltipOwnerState = React.useMemo( + () => ({ + open: tooltip.isOpen, + }), + [tooltip.isOpen], + ); + + const contextValue = React.useMemo( + () => ({ + ...tooltip, + ownerState, + }), + [tooltip, ownerState], + ); + + return {props.children}; +} + +Tooltip.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, +} as any; + +export { Tooltip }; diff --git a/packages/mui-base/src/Tooltip/Tooltip.types.ts b/packages/mui-base/src/Tooltip/Tooltip.types.ts new file mode 100644 index 0000000000..96a3e040c5 --- /dev/null +++ b/packages/mui-base/src/Tooltip/Tooltip.types.ts @@ -0,0 +1,30 @@ +import type { BaseUIComponentProps } from '../utils/BaseUI.types'; +import { UseTooltipParameters } from '../useTooltip'; + +type GenericHTMLProps = React.HTMLAttributes & { ref?: React.Ref | undefined }; + +export interface TooltipContextValue { + getAnchorProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + getTooltipProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + setAnchor: (value: HTMLElement | null) => void; + setTooltip: (value: HTMLElement | null) => void; + tooltipStyles: React.CSSProperties; + isOpen: boolean; + ownerState: TooltipOwnerState; +} + +export type TooltipOwnerState = { + open: boolean; +}; + +export interface TooltipProps extends UseTooltipParameters { + children: React.ReactNode; +} +export interface TooltipDelayGroupProps { + children: React.ReactNode; + delay?: number | { open?: number; close?: number }; +} +export interface TooltipContentProps extends BaseUIComponentProps<'div', TooltipOwnerState> {} +export interface TooltipAnchorFragmentProps { + children: React.ReactElement | ((htmlProps: GenericHTMLProps) => React.ReactElement); +} diff --git a/packages/mui-base/src/Tooltip/TooltipAnchorFragment.tsx b/packages/mui-base/src/Tooltip/TooltipAnchorFragment.tsx new file mode 100644 index 0000000000..0437adbb00 --- /dev/null +++ b/packages/mui-base/src/Tooltip/TooltipAnchorFragment.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { TooltipAnchorFragmentProps } from './Tooltip.types'; +import { useTooltipContext } from './TooltipContext'; +import { useForkRef } from '../utils/useForkRef'; +import { useTooltipStyleHooks } from './utils'; + +function TooltipAnchorFragment(props: TooltipAnchorFragmentProps) { + const { children } = props; + + const { setAnchor, getAnchorProps, ownerState } = useTooltipContext(); + + const mergedRef = useForkRef(setAnchor, (children as any).ref); + + const styleHooks = useTooltipStyleHooks(ownerState); + + const anchorProps = getAnchorProps({ + ref: mergedRef, + ...styleHooks, + }); + + if (typeof children === 'function') { + return children(anchorProps); + } + + return React.cloneElement(children, anchorProps); +} + +TooltipAnchorFragment.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.oneOfType([PropTypes.element, PropTypes.func]).isRequired, +} as any; + +export { TooltipAnchorFragment }; diff --git a/packages/mui-base/src/Tooltip/TooltipContent.tsx b/packages/mui-base/src/Tooltip/TooltipContent.tsx new file mode 100644 index 0000000000..50687c0791 --- /dev/null +++ b/packages/mui-base/src/Tooltip/TooltipContent.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingPortal } from '@floating-ui/react'; +import type { TooltipContentProps } from './Tooltip.types'; +import { resolveClassName } from '../utils/resolveClassName'; +import { useTooltipContext } from './TooltipContext'; +import { useForkRef } from '../utils/useForkRef'; + +function defaultRender(props: React.ComponentPropsWithRef<'div'>) { + return
    ; +} + +const TooltipContent = React.forwardRef(function TooltipContent( + props: TooltipContentProps, + forwardedRef: React.ForwardedRef, +) { + const { className, render: renderProp, style, ...otherProps } = props; + const render = renderProp ?? defaultRender; + + const { isOpen, setTooltip, tooltipStyles, getTooltipProps, ownerState } = useTooltipContext(); + + const mergedRef = useForkRef(setTooltip, forwardedRef); + + if (!isOpen) { + return null; + } + + const contentProps = getTooltipProps({ + ref: mergedRef, + className: resolveClassName(className, ownerState), + style: { + ...tooltipStyles, + ...style, + }, + ...otherProps, + }); + + return {render(contentProps, ownerState)}; +}); + +TooltipContent.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.func, + /** + * @ignore + */ + style: PropTypes.object, +} as any; + +export { TooltipContent }; diff --git a/packages/mui-base/src/Tooltip/TooltipContext.ts b/packages/mui-base/src/Tooltip/TooltipContext.ts new file mode 100644 index 0000000000..5907227760 --- /dev/null +++ b/packages/mui-base/src/Tooltip/TooltipContext.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import type { TooltipContextValue } from './Tooltip.types'; + +export const TooltipContext = React.createContext(null); + +export function useTooltipContext() { + const context = React.useContext(TooltipContext); + if (context === null) { + throw new Error('useTooltipContext must be used within a Tooltip component'); + } + return context; +} diff --git a/packages/mui-base/src/Tooltip/TooltipDelayGroup.tsx b/packages/mui-base/src/Tooltip/TooltipDelayGroup.tsx new file mode 100644 index 0000000000..aedbbfaa1c --- /dev/null +++ b/packages/mui-base/src/Tooltip/TooltipDelayGroup.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingDelayGroup } from '@floating-ui/react'; +import type { TooltipDelayGroupProps } from './Tooltip.types'; + +/** + * + * Demos: + * + * - [Tooltip](https://mui.com/base-ui/react-tooltip/) + * + * API: + * + * - [Tooltip API](https://mui.com/base-ui/react-tooltip/components-api/#tooltip) + */ +function TooltipDelayGroup(props: TooltipDelayGroupProps) { + const { delay = 0 } = props; + return ( + + {props.children} + + ); +} + +TooltipDelayGroup.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, + /** + * @ignore + */ + delay: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + close: PropTypes.number, + open: PropTypes.number, + }), + ]), +} as any; + +export { TooltipDelayGroup }; diff --git a/packages/mui-base/src/Tooltip/constants.ts b/packages/mui-base/src/Tooltip/constants.ts new file mode 100644 index 0000000000..a05329cbc7 --- /dev/null +++ b/packages/mui-base/src/Tooltip/constants.ts @@ -0,0 +1 @@ +export const BOUNDARY_PADDING = 5; diff --git a/packages/mui-base/src/Tooltip/index.ts b/packages/mui-base/src/Tooltip/index.ts new file mode 100644 index 0000000000..25e6263017 --- /dev/null +++ b/packages/mui-base/src/Tooltip/index.ts @@ -0,0 +1,14 @@ +'use client'; +import { Tooltip as TooltipRoot } from './Tooltip'; +import { TooltipContent } from './TooltipContent'; +import { TooltipAnchorFragment } from './TooltipAnchorFragment'; +import { combineComponentExports } from '../utils/combineComponentExports'; + +export { TooltipDelayGroup } from './TooltipDelayGroup'; + +export * from './Tooltip.types'; + +export const Tooltip = combineComponentExports(TooltipRoot, { + Content: TooltipContent, + AnchorFragment: TooltipAnchorFragment, +}); diff --git a/packages/mui-base/src/Tooltip/utils.ts b/packages/mui-base/src/Tooltip/utils.ts new file mode 100644 index 0000000000..57f6aa899e --- /dev/null +++ b/packages/mui-base/src/Tooltip/utils.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +import type { TooltipOwnerState } from './Tooltip.types'; +import { getStyleHookProps } from '../utils/getStyleHookProps'; + +export function useTooltipStyleHooks(ownerState: TooltipOwnerState) { + return React.useMemo(() => { + return getStyleHookProps(ownerState, { + open(value) { + return { + 'data-state': value ? 'open' : 'closed', + }; + }, + }); + }, [ownerState]); +} diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts index 233d193879..504e6c3d41 100644 --- a/packages/mui-base/src/index.ts +++ b/packages/mui-base/src/index.ts @@ -29,6 +29,7 @@ export * from './TabsList'; export * from './Tabs'; export * from './Tab'; export * from './TextareaAutosize'; +export * from './Tooltip'; export * from './Transitions'; export * from './useAutocomplete'; export * from './useBadge'; @@ -49,6 +50,7 @@ export * from './useTab'; export * from './useTabPanel'; export * from './useTabs'; export * from './useTabsList'; +export * from './useTooltip'; export * from './unstable_useModal'; export { diff --git a/packages/mui-base/src/useTooltip/index.ts b/packages/mui-base/src/useTooltip/index.ts new file mode 100644 index 0000000000..9c7834856b --- /dev/null +++ b/packages/mui-base/src/useTooltip/index.ts @@ -0,0 +1,3 @@ +'use client'; +export { useTooltip } from './useTooltip'; +export * from './useTooltip.types'; diff --git a/packages/mui-base/src/useTooltip/useTooltip.ts b/packages/mui-base/src/useTooltip/useTooltip.ts new file mode 100644 index 0000000000..5926ba25c5 --- /dev/null +++ b/packages/mui-base/src/useTooltip/useTooltip.ts @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { + autoUpdate, + flip, + limitShift, + offset, + shift, + useFloating, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + safePolygon, + useDelayGroupContext, + useDelayGroup, +} from '@floating-ui/react'; +import type { UseTooltipParameters, UseTooltipReturnValue } from './useTooltip.types'; +import { mergeReactProps } from '../utils/mergeReactProps'; +import { useControlled } from '../utils/useControlled'; +import { BOUNDARY_PADDING } from '../Tooltip/constants'; + +/** + * The basic building block for creating custom tooltips. + * + * API: + * + * - [useTooltip API](https://mui.com/base-ui/api/use-tooltip/) + */ +export function useTooltip(params: UseTooltipParameters) { + const { + open: externalOpen, + onOpenChange, + defaultOpen = false, + placement = 'top', + anchorGap = 0, + alignmentOffset = 0, + delay = 0, + disableHoverableContent = false, + allowTouch = true, + type = 'description', + } = params; + + const [isOpen, setIsOpen] = useControlled({ + controlled: externalOpen, + default: defaultOpen, + name: 'Tooltip', + state: 'open', + }); + + const flipMiddleware = flip({ padding: BOUNDARY_PADDING, fallbackAxisSideDirection: 'start' }); + const shiftMiddleware = shift({ padding: BOUNDARY_PADDING, limiter: limitShift() }); + + const middleware = [ + offset({ + mainAxis: anchorGap, + crossAxis: alignmentOffset, + alignmentAxis: alignmentOffset, + }), + ]; + + // https://floating-ui.com/docs/flip#combining-with-shift + if (placement.includes('-')) { + middleware.push(flipMiddleware, shiftMiddleware); + } else { + middleware.push(shiftMiddleware, flipMiddleware); + } + + const { refs, floatingStyles, context } = useFloating({ + placement, + middleware, + whileElementsMounted: autoUpdate, + open: isOpen, + onOpenChange(open, event) { + if (event) { + onOpenChange?.(open, event); + } + setIsOpen(open); + }, + }); + + useDelayGroup(context, { + id: context.floatingId, + }); + + const { delay: groupDelay } = useDelayGroupContext(); + + const hover = useHover(context, { + delay: groupDelay === 0 ? delay : groupDelay, + handleClose: disableHoverableContent ? null : safePolygon(), + mouseOnly: !allowTouch, + }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + const role = useRole(context, { + enabled: type !== 'visual-only', + role: type === 'description' ? 'tooltip' : 'label', + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const getAnchorProps: UseTooltipReturnValue['getAnchorProps'] = React.useCallback( + (externalProps = {}) => mergeReactProps(externalProps, getReferenceProps()), + [getReferenceProps], + ); + + const getTooltipProps: UseTooltipReturnValue['getTooltipProps'] = React.useCallback( + (externalProps = {}) => + mergeReactProps( + externalProps, + getFloatingProps({ + style: { + zIndex: 2147483647, // max z-index + // Ensure the tooltip is never larger than the viewport minus the boundary padding. + // Note: this does not handle scrollbars if visible, but it's mainly important on mobile + // where overlay scrollbars are common. + maxWidth: `calc(100vw - ${2 * BOUNDARY_PADDING}px)`, + }, + }), + ), + [getFloatingProps], + ); + + return React.useMemo( + () => ({ + getAnchorProps, + getTooltipProps, + isOpen, + setIsOpen, + setAnchor: refs.setReference, + setTooltip: refs.setFloating, + tooltipStyles: floatingStyles, + }), + [ + getAnchorProps, + getTooltipProps, + isOpen, + setIsOpen, + refs.setReference, + refs.setFloating, + floatingStyles, + ], + ); +} diff --git a/packages/mui-base/src/useTooltip/useTooltip.types.ts b/packages/mui-base/src/useTooltip/useTooltip.types.ts new file mode 100644 index 0000000000..770e38a29c --- /dev/null +++ b/packages/mui-base/src/useTooltip/useTooltip.types.ts @@ -0,0 +1,67 @@ +import type { Placement } from '@floating-ui/react-dom'; + +export interface UseTooltipParameters { + /** + * The placement of the tooltip element. + * @default 'top' + */ + placement?: Placement; + /** + * The gap between the anchor element and the tooltip element. + * @default 0 + */ + anchorGap?: number; + /** + * The offset of the tooltip element along its alignment axis. + * @default 0 + */ + alignmentOffset?: number; + /** + * The delay in milliseconds before the tooltip opens and closes after the anchor element is + * triggered. + * @default 0 + */ + delay?: number | { open?: number; close?: number }; + /** + * If `true`, the tooltip will be shown when using touch input. + * @default true + */ + allowTouch?: boolean; + /** + * If `true`, the tooltip content will not be hoverable. + * @default false + */ + disableHoverableContent?: boolean; + /** + * The type of the tooltip. + * @default 'description' + */ + type?: 'label' | 'description' | 'visual-only'; + /** + * If `true`, the tooltip will be open by default. Use when uncontrolled. + */ + defaultOpen?: boolean; + /** + * If `true`, the tooltip will be open. Use when controlled. + */ + open?: boolean; + /** + * Callback fired when the tooltip is requested to be opened or closed. + */ + onOpenChange?: (isOpen: boolean, event: Event) => void; +} + +export interface UseTooltipReturnValue { + /** + * Props to spread on the anchor element. + */ + getAnchorProps: ( + externalProps?: React.HTMLAttributes, + ) => React.HTMLAttributes; + /** + * Props to spread on the tooltip element. + */ + getTooltipProps: ( + externalProps?: React.HTMLAttributes, + ) => React.HTMLAttributes; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad43eceffa..900732ccf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,6 +616,9 @@ importers: '@babel/runtime': specifier: ^7.24.1 version: 7.24.1 + '@floating-ui/react': + specifier: ^0.26.10 + version: 0.26.10(react-dom@18.2.0)(react@18.2.0) '@floating-ui/react-dom': specifier: ^2.0.8 version: 2.0.8(react-dom@18.2.0)(react@18.2.0) @@ -2882,6 +2885,19 @@ packages: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + /@floating-ui/react@0.26.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-sh6f9gVvWQdEzLObrWbJ97c0clJObiALsFe0LiR/kb3tDRKwEhObASEH2QyfdoO/ZBPzwxa9j+nYFo+sqgbioA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 2.0.8(react-dom@18.2.0)(react@18.2.0) + '@floating-ui/utils': 0.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + dev: false + /@floating-ui/utils@0.2.1: resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} @@ -16021,6 +16037,10 @@ packages: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + /table@6.8.1: resolution: {integrity: sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==} engines: {node: '>=10.0.0'}