From b9e7d51231959b293d2c3a1a3b5a15bd0e950c1b Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 30 Mar 2026 10:43:22 +0800 Subject: [PATCH 1/3] Add FloatingPopup --- .../components/autocomplete/autocomplete.md | 2 +- .../material/components/tooltips/tooltips.md | 2 +- docs/data/material/pagesApi.js | 1 + docs/package.json | 1 + .../material-ui/floating-autocomplete.tsx | 215 +++++ .../material-ui/floating-tooltip.tsx | 278 ++++++ docs/pages/material-ui/api/floating-popup.js | 20 + .../pages/material-ui/api/floating-popup.json | 23 + .../floating-popup/floating-popup.json | 9 + packages/mui-material/package.json | 1 + .../src/Autocomplete/Autocomplete.d.ts | 8 +- .../Autocomplete.floating.test.tsx | 662 ++++++++++++++ .../src/FloatingPopup/FloatingPopup.test.tsx | 310 +++++++ .../src/FloatingPopup/FloatingPopup.tsx | 475 ++++++++++ .../src/FloatingPopup/FloatingPopup.types.ts | 65 ++ .../mui-material/src/FloatingPopup/index.ts | 2 + .../mui-material/src/Popper/BasePopper.tsx | 4 + .../mui-material/src/Tooltip/Tooltip.d.ts | 6 +- .../src/Tooltip/Tooltip.floating.test.tsx | 836 ++++++++++++++++++ packages/mui-material/src/Tooltip/Tooltip.js | 11 +- packages/mui-material/src/index.d.ts | 3 + packages/mui-material/src/index.js | 3 + pnpm-lock.yaml | 6 + 23 files changed, 2934 insertions(+), 9 deletions(-) create mode 100644 docs/pages/experiments/material-ui/floating-autocomplete.tsx create mode 100644 docs/pages/experiments/material-ui/floating-tooltip.tsx create mode 100644 docs/pages/material-ui/api/floating-popup.js create mode 100644 docs/pages/material-ui/api/floating-popup.json create mode 100644 docs/translations/api-docs/floating-popup/floating-popup.json create mode 100644 packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx create mode 100644 packages/mui-material/src/FloatingPopup/FloatingPopup.test.tsx create mode 100644 packages/mui-material/src/FloatingPopup/FloatingPopup.tsx create mode 100644 packages/mui-material/src/FloatingPopup/FloatingPopup.types.ts create mode 100644 packages/mui-material/src/FloatingPopup/index.ts create mode 100644 packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx diff --git a/docs/data/material/components/autocomplete/autocomplete.md b/docs/data/material/components/autocomplete/autocomplete.md index 77760edefb0924..18144c8d8a168e 100644 --- a/docs/data/material/components/autocomplete/autocomplete.md +++ b/docs/data/material/components/autocomplete/autocomplete.md @@ -1,7 +1,7 @@ --- productId: material-ui title: React Autocomplete component -components: TextField, Popper, Autocomplete +components: Autocomplete, TextField, Popper, FloatingPopup githubLabel: 'scope: autocomplete' waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ githubSource: packages/mui-material/src/Autocomplete diff --git a/docs/data/material/components/tooltips/tooltips.md b/docs/data/material/components/tooltips/tooltips.md index 8ae7dc7bcd488c..be6d5972dfba1b 100644 --- a/docs/data/material/components/tooltips/tooltips.md +++ b/docs/data/material/components/tooltips/tooltips.md @@ -1,7 +1,7 @@ --- productId: material-ui title: React Tooltip component -components: Tooltip +components: Tooltip, FloatingPopup githubLabel: 'scope: tooltip' materialDesign: https://m2.material.io/components/tooltips waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ diff --git a/docs/data/material/pagesApi.js b/docs/data/material/pagesApi.js index ee76034af9e54a..3b69f24d66e008 100644 --- a/docs/data/material/pagesApi.js +++ b/docs/data/material/pagesApi.js @@ -41,6 +41,7 @@ export default [ { pathname: '/material-ui/api/fab' }, { pathname: '/material-ui/api/fade' }, { pathname: '/material-ui/api/filled-input' }, + { pathname: '/material-ui/api/floating-popup' }, { pathname: '/material-ui/api/form-control' }, { pathname: '/material-ui/api/form-control-label' }, { pathname: '/material-ui/api/form-group' }, diff --git a/docs/package.json b/docs/package.json index d31c97b566a3a7..d558b493d3ada0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -25,6 +25,7 @@ "@emotion/react": "catalog:docs", "@emotion/server": "catalog:docs", "@emotion/styled": "catalog:docs", + "@floating-ui/react-dom": "^2.1.6", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.6", diff --git a/docs/pages/experiments/material-ui/floating-autocomplete.tsx b/docs/pages/experiments/material-ui/floating-autocomplete.tsx new file mode 100644 index 00000000000000..7c1d4a47581683 --- /dev/null +++ b/docs/pages/experiments/material-ui/floating-autocomplete.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; +import CssBaseline from '@mui/material/CssBaseline'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; +import FloatingPopup from '@mui/material/FloatingPopup'; + +const TOP_FILMS = [ + { label: 'The Shawshank Redemption', year: 1994 }, + { label: 'The Godfather', year: 1972 }, + { label: 'The Dark Knight', year: 2008 }, + { label: "Schindler's List", year: 1993 }, + { label: 'Pulp Fiction', year: 1994 }, + { label: 'The Lord of the Rings: The Return of the King', year: 2003 }, + { label: 'Forrest Gump', year: 1994 }, + { label: 'Inception', year: 2010 }, + { label: 'Fight Club', year: 1999 }, + { label: 'The Matrix', year: 1999 }, + { label: 'Goodfellas', year: 1990 }, + { label: 'Star Wars: Episode V', year: 1980 }, + { label: 'City of God', year: 2002 }, + { label: 'Se7en', year: 1995 }, + { label: 'The Silence of the Lambs', year: 1991 }, + { label: "It's a Wonderful Life", year: 1946 }, + { label: 'Life Is Beautiful', year: 1997 }, + { label: 'The Usual Suspects', year: 1995 }, + { label: 'Spirited Away', year: 2001 }, + { label: 'Saving Private Ryan', year: 1998 }, +]; + +function BasicDemo() { + return ( +
+

Basic

+
+
+

Default (Popper.js)

+ } + /> +
+
+

FloatingPopup

+ } + /> +
+
+
+ ); +} + +function DisablePortalDemo() { + return ( +
+

disablePortal

+
+ } + /> +
+
+ ); +} + +function ScrollContainerDemo() { + return ( +
+

Inside scroll container

+

+ Dropdown flips when there is not enough space below. +

+
+
+ } + /> +
+
+
+ ); +} + +function MultipleDemo() { + return ( +
+

Multiple values

+ } + /> +
+ ); +} + +function GroupedDemo() { + const sorted = [...TOP_FILMS].sort((a, b) => { + const decadeA = Math.floor(a.year / 10) * 10; + const decadeB = Math.floor(b.year / 10) * 10; + return decadeB - decadeA || a.label.localeCompare(b.label); + }); + + return ( +
+

Grouped

+ `${Math.floor(option.year / 10) * 10}s`} + sx={{ width: 300 }} + slots={{ popper: FloatingPopup }} + renderInput={(params) => } + /> +
+ ); +} + +function FreeSoloDemo() { + return ( +
+

Free solo

+ option.label)} + sx={{ width: 300 }} + slots={{ popper: FloatingPopup }} + renderInput={(params) => } + /> +
+ ); +} + +function CSSVariablesDemo() { + return ( +
+

CSS variables (--anchor-width)

+

+ The dropdown uses var(--anchor-width) to match the input width. Autocomplete + already passes style.width via additionalProps, but the CSS + variable is also available. +

+ } + /> +
+ ); +} + +function KeepMountedDemo() { + return ( +
+

keepMounted (via slotProps)

+

+ The dropdown stays in the DOM when closed (inspect to verify). +

+ } + /> +
+ ); +} + +export default function MaterialUIFloatingAutocomplete() { + return ( + + +
+

FloatingPopup + Autocomplete

+

+ {''} +

+
+ + + + + + + + +
+
+
+ ); +} diff --git a/docs/pages/experiments/material-ui/floating-tooltip.tsx b/docs/pages/experiments/material-ui/floating-tooltip.tsx new file mode 100644 index 00000000000000..5c365f91d0b3c0 --- /dev/null +++ b/docs/pages/experiments/material-ui/floating-tooltip.tsx @@ -0,0 +1,278 @@ +import * as React from 'react'; +import CssBaseline from '@mui/material/CssBaseline'; +import Tooltip from '@mui/material/Tooltip'; +import Button from '@mui/material/Button'; +import FloatingPopup from '@mui/material/FloatingPopup'; +import { offset, flip, shift, autoPlacement } from '@floating-ui/react-dom'; +import type { Placement } from '@floating-ui/react-dom'; + +const PLACEMENTS: Placement[] = [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', +]; + +function PlacementDemo() { + const [placement, setPlacement] = React.useState('top'); + + return ( +
+

Placement

+
+ {PLACEMENTS.map((p) => ( + + ))} +
+ + + +
+ ); +} + +function ArrowDemo() { + return ( +
+

Arrow

+
+ + + + + + +
+
+ ); +} + +function FollowCursorDemo() { + return ( +
+

Follow cursor

+ +
+ Move cursor here +
+
+
+ ); +} + +function OffsetDemo() { + const [offsetVal, setOffsetVal] = React.useState(8); + + return ( +
+

Custom middleware (offset)

+
+ + setOffsetVal(Number(event.target.value))} + /> +
+ + + +
+ ); +} + +function AutoPlacementDemo() { + const [boundary, setBoundary] = React.useState(null); + + const middleware = React.useMemo( + () => [ + offset(8), + autoPlacement({ + allowedPlacements: ['top', 'bottom'], + ...(boundary && { boundary }), + }), + shift(), + ], + [boundary], + ); + + return ( +
+

Auto placement (middleware)

+

+ Uses autoPlacement() middleware. Scroll the button near an edge, then hover. +

+
+
+ + + +
+
+
+ ); +} + +function StrategyDemo() { + const [strategy, setStrategy] = React.useState<'absolute' | 'fixed'>('absolute'); + + return ( +
+

Strategy

+
+ {(['absolute', 'fixed'] as const).map((s) => ( + + ))} +
+ + + +
+ ); +} + +function CSSVariablesDemo() { + return ( +
+

CSS variables

+

+ FloatingPopup sets --anchor-width, --anchor-height,{' '} + --available-width, --available-height on the floating element. +

+ + + +
+ ); +} + +function ComparisonDemo() { + return ( +
+

Side-by-side: Popper.js vs FloatingPopup

+
+
+

Default (Popper.js)

+ + + +
+
+

FloatingPopup

+ + + +
+
+
+ ); +} + +export default function MaterialUIFloatingPopup() { + return ( + + +
+

FloatingPopup experiment

+

+ {''} +

+
+ + + + + + + + +
+
+
+ ); +} diff --git a/docs/pages/material-ui/api/floating-popup.js b/docs/pages/material-ui/api/floating-popup.js new file mode 100644 index 00000000000000..e7d4c0529b234e --- /dev/null +++ b/docs/pages/material-ui/api/floating-popup.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './floating-popup.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const req = require.context( + 'docs/translations/api-docs/floating-popup', + false, + /\.\/floating-popup.*\.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/floating-popup.json b/docs/pages/material-ui/api/floating-popup.json new file mode 100644 index 00000000000000..652cfe84d6ebe8 --- /dev/null +++ b/docs/pages/material-ui/api/floating-popup.json @@ -0,0 +1,23 @@ +{ + "props": { + "arrowRef": { + "type": { + "name": "union", + "description": "(props, propName) => {\n if (props[propName] == null) {\n return new Error(`Prop '${propName}' is required but wasn't specified`);\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n}
| { current?: (props, propName) => {\n if (props[propName] == null) {\n return null;\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n} }" + } + } + }, + "name": "FloatingPopup", + "imports": [ + "import FloatingPopup from '@mui/material/FloatingPopup';", + "import { FloatingPopup } from '@mui/material';" + ], + "classes": [], + "spread": true, + "themeDefaultProps": null, + "muiName": "MuiFloatingPopup", + "filename": "/packages/mui-material/src/FloatingPopup/FloatingPopup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/translations/api-docs/floating-popup/floating-popup.json b/docs/translations/api-docs/floating-popup/floating-popup.json new file mode 100644 index 00000000000000..20fff0bbe2293d --- /dev/null +++ b/docs/translations/api-docs/floating-popup/floating-popup.json @@ -0,0 +1,9 @@ +{ + "componentDescription": "FloatingPopup — opt-in replacement for Popper.js-based positioning.\nUses @floating-ui/react-dom under the hood.\n\nDesigned to be used via `slots={{ popper: FloatingPopup }}` on\nAutocomplete and Tooltip.", + "propDescriptions": { + "arrowRef": { + "description": "The arrow element to position. Preferred: pass the element from useState. Also works: RefObject (resolved lazily via Derivable pattern + useLayoutEffect)." + } + }, + "classDescriptions": {} +} diff --git a/packages/mui-material/package.json b/packages/mui-material/package.json index c048e78287d664..2b41bb1def1c5a 100644 --- a/packages/mui-material/package.json +++ b/packages/mui-material/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@babel/runtime": "^7.29.2", + "@floating-ui/react-dom": "^2.1.6", "@mui/core-downloads-tracker": "workspace:^", "@mui/system": "workspace:*", "@mui/types": "workspace:^", diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts index cf58f50550c5e3..fa0771cfb120d7 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts +++ b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts @@ -20,9 +20,13 @@ import useAutocomplete, { } from '../useAutocomplete'; import { AutocompleteClasses } from './autocompleteClasses'; import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types'; +import { FloatingPopupProps } from '../FloatingPopup'; export interface AutocompletePaperSlotPropsOverrides {} -export interface AutocompletePopperSlotPropsOverrides {} +export interface AutocompletePopperSlotPropsOverrides extends Pick< + FloatingPopupProps, + 'middleware' | 'strategy' | 'transform' | 'arrowPadding' +> {} export { AutocompleteChangeDetails, @@ -145,7 +149,7 @@ export interface AutocompleteSlots { * The component used to position the popup. * @default Popper */ - popper: React.JSXElementConstructor; + popper: React.ElementType; } export type AutocompleteSlotsAndSlotProps< diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx b/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx new file mode 100644 index 00000000000000..9243a63bc27226 --- /dev/null +++ b/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx @@ -0,0 +1,662 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { + createRenderer, + fireEvent, + flushMicrotasks, + screen, +} from '@mui/internal-test-utils'; +import TextField from '@mui/material/TextField'; +import Autocomplete, { autocompleteClasses as classes } from '@mui/material/Autocomplete'; +import FloatingPopup from '@mui/material/FloatingPopup'; +import type { UserEvent } from '@testing-library/user-event'; + +const options = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon']; + +function checkHighlightIs(listbox: HTMLElement, expected: string | null) { + const focused = listbox.querySelector(`.${classes.focused}`); + if (expected) { + expect(focused).to.have.text(expected); + } else { + expect(focused).to.equal(null); + } +} + +/** + * Opens an Autocomplete via keyboard, then flushes microtasks so + * FloatingPopup's async computePosition completes and visibility:hidden + * is removed (making the popup accessible to getByRole). + */ +async function openAutocomplete(user: UserEvent) { + const input = screen.getByRole('combobox'); + await user.click(input); + await flushMicrotasks(); + return input; +} + +/** + * Integration tests for Autocomplete with FloatingPopup as the popper slot. + * + * FloatingPopup uses async computePosition from floating-ui. Until positioning + * completes, the popup has `visibility: hidden` which hides it from the + * accessibility tree. Tests that query popup DOM call `await flushMicrotasks()` + * after interactions that open the popup. + */ +describe('', () => { + const { render } = createRenderer(); + + function renderAutocomplete(props: Record = {}) { + return render( + } + {...props} + />, + ); + } + + // ────────────────────────────────────────────── + // Opening and closing + // ────────────────────────────────────────────── + + describe('opening and closing', () => { + it('should open the listbox on click', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + expect(screen.getByRole('listbox')).not.to.equal(null); + }); + + it('should open the listbox on ArrowDown', async () => { + const { user } = renderAutocomplete(); + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}'); + await flushMicrotasks(); + expect(screen.getByRole('listbox')).not.to.equal(null); + }); + + it('should close the listbox on Escape', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + expect(screen.getByRole('listbox')).not.to.equal(null); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + + it('should close the listbox when an option is selected', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.click(screen.getAllByRole('option')[0]); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + + it('should close the listbox on blur', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + expect(screen.getByRole('listbox')).not.to.equal(null); + await user.tab(); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + + it('should not open on right click', async () => { + const { user } = renderAutocomplete(); + await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('combobox') }); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Selection + // ────────────────────────────────────────────── + + describe('selection', () => { + it('should select an option by clicking', async () => { + const onChange = spy(); + const { user } = renderAutocomplete({ onChange }); + await openAutocomplete(user); + await user.click(screen.getAllByRole('option')[1]); + expect(onChange.callCount).to.equal(1); + expect(onChange.args[0][1]).to.equal('Beta'); + }); + + it('should select an option with Enter after keyboard navigation', async () => { + const onChange = spy(); + const { user } = renderAutocomplete({ onChange }); + await openAutocomplete(user); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onChange.callCount).to.equal(1); + expect(onChange.args[0][1]).to.equal('Alpha'); + }); + + it('should display selected value in the input', () => { + renderAutocomplete({ defaultValue: 'Gamma' }); + expect(screen.getByRole('combobox')).to.have.value('Gamma'); + }); + + it('should clear the value when the clear button is clicked', async () => { + const onChange = spy(); + const { container, user } = renderAutocomplete({ defaultValue: 'Alpha', onChange }); + const clearButton = container.querySelector(`.${classes.clearIndicator}`) as HTMLElement; + await user.click(clearButton); + expect(onChange.callCount).to.equal(1); + expect(onChange.args[0][1]).to.equal(null); + expect(screen.getByRole('combobox')).to.have.value(''); + }); + }); + + // ────────────────────────────────────────────── + // Keyboard navigation + // ────────────────────────────────────────────── + + describe('keyboard navigation', () => { + it('should highlight the first option on ArrowDown', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.keyboard('{ArrowDown}'); + checkHighlightIs(screen.getByRole('listbox'), 'Alpha'); + }); + + it('should highlight the last option on ArrowUp', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.keyboard('{ArrowUp}'); + checkHighlightIs(screen.getByRole('listbox'), 'Epsilon'); + }); + + it('should cycle through options with repeated ArrowDown', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}'); + checkHighlightIs(screen.getByRole('listbox'), 'Gamma'); + }); + + it('should set aria-activedescendant on the input', async () => { + const { user } = renderAutocomplete(); + const input = await openAutocomplete(user); + await user.keyboard('{ArrowDown}'); + const firstOption = screen.getAllByRole('option')[0]; + expect(input).to.have.attribute('aria-activedescendant', firstOption.getAttribute('id')); + }); + + it('should clear input on blur when no option is selected', async () => { + const { user } = renderAutocomplete(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.keyboard('xyz'); + expect(input).to.have.value('xyz'); + await user.tab(); + expect(input).to.have.value(''); + }); + }); + + // ────────────────────────────────────────────── + // Filtering + // ────────────────────────────────────────────── + + describe('filtering', () => { + it('should filter options as the user types', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.keyboard('Al'); + const renderedOptions = screen.getAllByRole('option'); + expect(renderedOptions).to.have.length(1); + expect(renderedOptions[0]).to.have.text('Alpha'); + }); + + it('should show "No options" when filter matches nothing', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.keyboard('zzz'); + expect(screen.queryByRole('option')).to.equal(null); + expect(screen.getByText('No options')).not.to.equal(null); + }); + + it('should restore full list when input is cleared', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + await user.keyboard('Al'); + expect(screen.getAllByRole('option')).to.have.length(1); + await user.clear(screen.getByRole('combobox')); + await flushMicrotasks(); + expect(screen.getAllByRole('option')).to.have.length(options.length); + }); + }); + + // ────────────────────────────────────────────── + // Multiple selection + // ────────────────────────────────────────────── + + describe('multiple', () => { + it('should call onChange with accumulated values when selecting multiple', async () => { + const onChange = spy(); + const { user } = renderAutocomplete({ + multiple: true, + onChange, + open: true, + onClose: () => {}, + }); + await flushMicrotasks(); + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onChange.callCount).to.equal(1); + expect(onChange.args[0][1]).to.deep.equal(['Alpha']); + }); + + it('should render chips for selected values', () => { + const { container } = renderAutocomplete({ + multiple: true, + defaultValue: ['Alpha', 'Beta'], + }); + const chips = container.querySelectorAll(`.${classes.tag}`); + expect(chips).to.have.length(2); + }); + + it('should remove a chip when its delete button is clicked', async () => { + const onChange = spy(); + const { container, user } = renderAutocomplete({ + multiple: true, + defaultValue: ['Alpha', 'Beta'], + onChange, + }); + const deleteButtons = container.querySelectorAll(`.${classes.tag} svg`); + await user.click(deleteButtons[0] as Element); + expect(onChange.callCount).to.equal(1); + expect(onChange.args[0][1]).to.deep.equal(['Beta']); + }); + }); + + // ────────────────────────────────────────────── + // Free solo + // ────────────────────────────────────────────── + + describe('freeSolo', () => { + it('should allow arbitrary input values', async () => { + const onChange = spy(); + const { user } = renderAutocomplete({ freeSolo: true, onChange }); + await user.click(screen.getByRole('combobox')); + await user.keyboard('custom value{Enter}'); + expect(onChange.callCount).to.equal(1); + expect(onChange.args[0][1]).to.equal('custom value'); + }); + + it('should not clear input on blur in freeSolo mode', async () => { + const { user } = renderAutocomplete({ freeSolo: true }); + await user.click(screen.getByRole('combobox')); + await user.keyboard('custom'); + await user.tab(); + expect(screen.getByRole('combobox')).to.have.value('custom'); + }); + }); + + // ────────────────────────────────────────────── + // Loading + // ────────────────────────────────────────────── + + describe('loading', () => { + it('should show loading text when loading and no options', async () => { + const { user } = renderAutocomplete({ options: [], loading: true }); + await openAutocomplete(user); + expect(screen.getByText('Loading…')).not.to.equal(null); + }); + + it('should show options even when loading flag is set', async () => { + const { user } = renderAutocomplete({ loading: true }); + await openAutocomplete(user); + expect(screen.getAllByRole('option')).to.have.length(options.length); + }); + }); + + // ────────────────────────────────────────────── + // Disabled states + // ────────────────────────────────────────────── + + describe('disabled', () => { + it('should not open when disabled', async () => { + const { user } = renderAutocomplete({ disabled: true }); + await user.click(screen.getByRole('combobox')); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + + it('should disable individual options via getOptionDisabled', async () => { + const { user } = renderAutocomplete({ + getOptionDisabled: (option: string) => option === 'Beta', + }); + await openAutocomplete(user); + const betaOption = screen.getAllByRole('option')[1]; + expect(betaOption).to.have.attribute('aria-disabled', 'true'); + }); + }); + + // ────────────────────────────────────────────── + // Accessibility + // ────────────────────────────────────────────── + + describe('accessibility', () => { + it('should have role="combobox" on the input', () => { + renderAutocomplete(); + expect(screen.getByRole('combobox')).not.to.equal(null); + }); + + it('should have aria-expanded when open', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + expect(screen.getByRole('combobox')).to.have.attribute('aria-expanded', 'true'); + }); + + it('should have aria-expanded=false when closed', () => { + renderAutocomplete(); + expect(screen.getByRole('combobox')).to.have.attribute('aria-expanded', 'false'); + }); + + it('should have role="listbox" on the popup list', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + expect(screen.getByRole('listbox')).not.to.equal(null); + }); + + it('should have role="option" on each option', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + expect(screen.getAllByRole('option')).to.have.length(options.length); + }); + + it('should have aria-controls linking input to listbox', async () => { + const { user } = renderAutocomplete(); + const input = await openAutocomplete(user); + const listbox = screen.getByRole('listbox'); + expect(input).to.have.attribute('aria-controls', listbox.getAttribute('id')); + }); + + it('should have role="presentation" on the popup container', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + const popup = document.querySelector('[data-popper-placement]'); + expect(popup).to.have.attribute('role', 'presentation'); + }); + }); + + // ────────────────────────────────────────────── + // Popup positioning + // ────────────────────────────────────────────── + + describe('popup positioning', () => { + it('should set data-popper-placement on the floating element', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + const floating = document.querySelector('[data-popper-placement]'); + expect(floating).not.to.equal(null); + }); + + it('should match the width of the input', async () => { + const { user } = renderAutocomplete({ sx: { width: 300 } }); + await openAutocomplete(user); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating).not.to.equal(null); + expect(floating!.style.width).not.to.equal(''); + }); + + it('should remove visibility:hidden after positioning completes', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating!.style.visibility).not.to.equal('hidden'); + }); + }); + + // ────────────────────────────────────────────── + // CSS variables + // ────────────────────────────────────────────── + + describe('CSS variables', () => { + it('should set --anchor-width on the floating element', async () => { + const { user } = renderAutocomplete({ sx: { width: 300 } }); + await openAutocomplete(user); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + const anchorWidth = floating!.style.getPropertyValue('--anchor-width'); + expect(anchorWidth).not.to.equal(''); + }); + + it('should set --available-height on the floating element', async () => { + const { user } = renderAutocomplete(); + await openAutocomplete(user); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + const availableHeight = floating!.style.getPropertyValue('--available-height'); + expect(availableHeight).not.to.equal(''); + }); + }); + + // ────────────────────────────────────────────── + // disablePortal + // ────────────────────────────────────────────── + + describe('disablePortal', () => { + it('should render the popup inline', async () => { + const { container, user } = renderAutocomplete({ disablePortal: true }); + await openAutocomplete(user); + const floating = container.querySelector('[data-popper-placement]'); + expect(floating).not.to.equal(null); + expect(container.contains(floating)).to.equal(true); + }); + }); + + // ────────────────────────────────────────────── + // Callbacks + // ────────────────────────────────────────────── + + describe('callbacks', () => { + it('should call onOpen when the listbox opens', async () => { + const onOpen = spy(); + const { user } = renderAutocomplete({ onOpen }); + await openAutocomplete(user); + expect(onOpen.callCount).to.equal(1); + }); + + it('should call onClose when the listbox closes', async () => { + const onClose = spy(); + const { user } = renderAutocomplete({ onClose }); + await openAutocomplete(user); + await user.keyboard('{Escape}'); + expect(onClose.callCount).to.equal(1); + }); + + it('should call onInputChange when the input value changes', async () => { + const onInputChange = spy(); + const { user } = renderAutocomplete({ onInputChange }); + await user.click(screen.getByRole('combobox')); + await user.keyboard('test'); + expect(onInputChange.callCount).to.equal(4); // one per character + expect(onInputChange.lastCall.args[1]).to.equal('test'); + }); + }); + + // ────────────────────────────────────────────── + // Grouped options + // ────────────────────────────────────────────── + + describe('grouped', () => { + const groupedOptions = [ + { label: 'A1', group: 'A' }, + { label: 'A2', group: 'A' }, + { label: 'B1', group: 'B' }, + ]; + + it('should render group headers', async () => { + const { user } = render( + option.group} + getOptionLabel={(option) => option.label} + slots={{ popper: FloatingPopup }} + renderInput={(params) => } + />, + ); + await openAutocomplete(user); + const groups = document.querySelectorAll(`.${classes.groupLabel}`); + expect(groups).to.have.length(2); + }); + + it('should navigate options across groups with keyboard', async () => { + const { user } = render( + option.group} + getOptionLabel={(option) => option.label} + slots={{ popper: FloatingPopup }} + renderInput={(params) => } + />, + ); + await openAutocomplete(user); + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}'); + checkHighlightIs(screen.getByRole('listbox'), 'B1'); + }); + }); + + // ────────────────────────────────────────────── + // Object options + // ────────────────────────────────────────────── + + describe('object options', () => { + const films = [ + { title: 'The Godfather', year: 1972 }, + { title: 'Pulp Fiction', year: 1994 }, + ]; + + it('should render with getOptionLabel', async () => { + const { user } = render( + option.title} + slots={{ popper: FloatingPopup }} + renderInput={(params) => } + />, + ); + await openAutocomplete(user); + expect(screen.getAllByRole('option')[0]).to.have.text('The Godfather'); + }); + + it('should select object values', async () => { + const onChange = spy(); + const { user } = render( + option.title} + onChange={onChange} + slots={{ popper: FloatingPopup }} + renderInput={(params) => } + />, + ); + await openAutocomplete(user); + await user.click(screen.getAllByRole('option')[0]); + expect(onChange.args[0][1]).to.deep.equal(films[0]); + }); + }); + + // ────────────────────────────────────────────── + // slotProps.popper passthrough + // ────────────────────────────────────────────── + + describe('slotProps.popper', () => { + it('should forward keepMounted to FloatingPopup', () => { + renderAutocomplete({ + slotProps: { popper: { keepMounted: true } as any }, + }); + // Closed — floating element in DOM but with visibility:hidden (FOUC guard) + const floating = document.querySelector('[data-popper-placement]'); + expect(floating).not.to.equal(null); + }); + + it('should forward data attributes to the floating element', async () => { + const { user } = renderAutocomplete({ + slotProps: { popper: { 'data-testid': 'custom-popup' } as any }, + }); + await openAutocomplete(user); + expect(screen.getByTestId('custom-popup')).not.to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Controlled mode + // ────────────────────────────────────────────── + + describe('controlled', () => { + it('should respect controlled value', () => { + renderAutocomplete({ value: 'Beta' }); + expect(screen.getByRole('combobox')).to.have.value('Beta'); + }); + + it('should respect controlled inputValue', () => { + renderAutocomplete({ inputValue: 'typed', onInputChange: () => {} }); + expect(screen.getByRole('combobox')).to.have.value('typed'); + }); + + it('should respect controlled open', async () => { + const { user } = renderAutocomplete({ open: true, onOpen: () => {}, onClose: () => {} }); + await flushMicrotasks(); + expect(screen.getByRole('listbox')).not.to.equal(null); + await user.click(screen.getByRole('combobox')); + await user.keyboard('{Escape}'); + // Still open — controlled (onClose doesn't update state) + expect(screen.getByRole('listbox')).not.to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Parity with default Popper + // ────────────────────────────────────────────── + + describe('parity with default Popper', () => { + it('should render the same number of options', async () => { + const { unmount } = render( + } + />, + ); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + const defaultCount = screen.getAllByRole('option').length; + unmount(); + + const { user } = render( + } + />, + ); + await openAutocomplete(user); + const floatingCount = screen.getAllByRole('option').length; + expect(floatingCount).to.equal(defaultCount); + }); + + it('should produce the same ARIA attributes on the input', async () => { + const { unmount } = render( + } + />, + ); + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + const defaultInput = screen.getByRole('combobox'); + const defaultAttrs = { + expanded: defaultInput.getAttribute('aria-expanded'), + hasPopup: defaultInput.getAttribute('aria-haspopup'), + autocomplete: defaultInput.getAttribute('aria-autocomplete'), + }; + unmount(); + + const { user } = render( + } + />, + ); + await openAutocomplete(user); + const floatingInput = screen.getByRole('combobox'); + expect(floatingInput.getAttribute('aria-expanded')).to.equal(defaultAttrs.expanded); + expect(floatingInput.getAttribute('aria-haspopup')).to.equal(defaultAttrs.hasPopup); + expect(floatingInput.getAttribute('aria-autocomplete')).to.equal(defaultAttrs.autocomplete); + }); + }); +}); diff --git a/packages/mui-material/src/FloatingPopup/FloatingPopup.test.tsx b/packages/mui-material/src/FloatingPopup/FloatingPopup.test.tsx new file mode 100644 index 00000000000000..56f5fe8d0ce038 --- /dev/null +++ b/packages/mui-material/src/FloatingPopup/FloatingPopup.test.tsx @@ -0,0 +1,310 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen } from '@mui/internal-test-utils'; +import { ThemeProvider, Theme } from '@mui/system'; +import createTheme from '@mui/system/createTheme'; +import Grow from '@mui/material/Grow'; +import FloatingPopup from '@mui/material/FloatingPopup'; +import { FloatingPopupProps, FloatingPopupChildrenProps } from './FloatingPopup.types'; + +describe('', () => { + let rtlTheme: Theme; + const { clock, render } = createRenderer({ clock: 'fake' }); + + let defaultAnchorEl: HTMLDivElement | null = null; + + const defaultProps: FloatingPopupProps = { + anchorEl: () => defaultAnchorEl!, + children: Hello World, + open: true, + }; + + beforeAll(() => { + rtlTheme = createTheme({ + direction: 'rtl', + }); + defaultAnchorEl = document.createElement('div'); + document.body.appendChild(defaultAnchorEl); + }); + + afterAll(() => { + document.body.removeChild(defaultAnchorEl!); + }); + + describe('rendering', () => { + it('should render children when open', () => { + render(); + expect(screen.getByText('Hello World')).not.to.equal(null); + }); + + it('should not render when closed', () => { + render(); + expect(screen.queryByText('Hello World')).to.equal(null); + }); + + it('should set data-popper-placement attribute', () => { + render(); + const floating = screen.getByText('Hello World').parentElement!; + expect(floating).to.have.attribute('data-popper-placement', 'top'); + }); + }); + + describe('prop: open', () => { + it('should open without any issue', () => { + const { setProps } = render(); + expect(screen.queryByText('Hello World')).to.equal(null); + setProps({ open: true }); + expect(screen.getByText('Hello World')).not.to.equal(null); + }); + + it('should close without any issue', () => { + const { setProps } = render(); + expect(screen.getByText('Hello World')).not.to.equal(null); + setProps({ open: false }); + expect(screen.queryByText('Hello World')).to.equal(null); + }); + }); + + describe('prop: placement', () => { + ( + [ + { in: 'bottom-end', out: 'bottom-start' }, + { in: 'bottom-start', out: 'bottom-end' }, + { in: 'top-end', out: 'top-start' }, + { in: 'top-start', out: 'top-end' }, + { in: 'top', out: 'top' }, + ] as const + ).forEach((test) => { + it(`should ${test.in === test.out ? 'not ' : ''}flip ${test.in} when direction=rtl`, () => { + function Test() { + const [anchorEl, setAnchorEl] = React.useState(null); + + return ( + +
+ + {({ placement }: FloatingPopupChildrenProps) => { + return
{placement}
; + }} +
+ + ); + } + render(); + + expect(screen.getByTestId('placement')).to.have.text(test.out); + }); + }); + }); + + describe('prop: keepMounted', () => { + it('should keep the children mounted in the DOM with display:none', () => { + render(); + const floating = screen.getByText('Hello World').parentElement!; + expect(floating.style.display).to.equal('none'); + }); + }); + + describe('prop: transition', () => { + clock.withFakeTimers(); + + it('should pass TransitionProps to children function', () => { + render( + + {({ TransitionProps }: FloatingPopupChildrenProps) => ( + + Hello World + + )} + , + ); + + expect(screen.getByText('Hello World')).not.to.equal(null); + }); + + it('should unmount after transition exits', () => { + const { setProps } = render( + + {({ TransitionProps }: FloatingPopupChildrenProps) => ( + + Hello World + + )} + , + ); + + expect(screen.getByText('Hello World')).not.to.equal(null); + + setProps({ open: false }); + clock.tick(0); + + expect(screen.queryByText('Hello World')).to.equal(null); + }); + }); + + describe('prop: disablePortal', () => { + it('should render inline when disablePortal is true', () => { + const { container } = render(); + // When disablePortal, the floating element is inside the container, not in document.body + expect(container.querySelector('[data-popper-placement]')).not.to.equal(null); + }); + }); + + describe('prop: container', () => { + it('should render inside the specified container', () => { + const customContainer = document.createElement('div'); + customContainer.setAttribute('data-testid', 'custom-container'); + document.body.appendChild(customContainer); + + render(); + + expect(customContainer.querySelector('[data-popper-placement]')).not.to.equal(null); + + document.body.removeChild(customContainer); + }); + }); + + describe('prop: strategy', () => { + it('should use position:fixed when strategy is fixed', () => { + render(); + const floating = screen.getByText('Hello World').parentElement!; + expect(floating.style.position).to.equal('fixed'); + }); + }); + + describe('prop: popperRef', () => { + it('should expose an update function', () => { + const ref = React.createRef<{ update: () => void }>(); + render(); + expect(ref.current).not.to.equal(null); + expect(typeof ref.current!.update).to.equal('function'); + }); + }); + + describe('ref forwarding', () => { + it('should forward ref to the root DOM element', () => { + const ref = React.createRef(); + render(); + expect(ref.current).not.to.equal(null); + expect(ref.current).to.be.instanceof(window.HTMLDivElement); + }); + }); + + describe('FOUC prevention', () => { + it('should have visibility:hidden before positioning', () => { + // On first render before useFloating computes, isPositioned is false + render(); + const floating = screen.getByText('Hello World').parentElement!; + // After effects flush in jsdom, isPositioned should be true and visibility removed. + // But we can at least verify the element renders successfully. + expect(floating).not.to.equal(null); + }); + }); + + describe('auto placement warning', () => { + it('should warn when auto placement is used', () => { + expect(() => { + render( + , + ); + }).toWarnDev('FloatingPopup: "auto" placement is not supported.'); + }); + }); + + describe('ignored Popper.js props', () => { + it('should not leak popperOptions, modifiers, direction, ownerState to DOM', () => { + render( + , + ); + const floating = screen.getByText('Hello World').parentElement!; + expect(floating.getAttribute('popperoptions')).to.equal(null); + expect(floating.getAttribute('modifiers')).to.equal(null); + expect(floating.getAttribute('direction')).to.equal(null); + expect(floating.getAttribute('ownerstate')).to.equal(null); + }); + }); + + describe('virtual element', () => { + it('should work with a virtual element (followCursor compat)', () => { + const virtualEl = { + getBoundingClientRect: () => ({ + top: 100, + left: 200, + right: 200, + bottom: 100, + width: 0, + height: 0, + x: 200, + y: 100, + }), + }; + + render( + + content + , + ); + expect(screen.getByText('content')).not.to.equal(null); + }); + }); + + describe('prop: anchorEl as function', () => { + it('should accept anchorEl as a function', () => { + const anchorEl = document.createElement('div'); + document.body.appendChild(anchorEl); + + render( + anchorEl} open> + content + , + ); + expect(screen.getByText('content')).not.to.equal(null); + + document.body.removeChild(anchorEl); + }); + }); + + describe('children as function', () => { + it('should pass placement and isPositioned to children', () => { + render( + + {({ placement, isPositioned }: FloatingPopupChildrenProps) => ( +
+ )} + , + ); + + const child = screen.getByTestId('child'); + expect(child).to.have.attribute('data-placement', 'top'); + }); + + it('should pass arrowStyles and anchorHidden to children', () => { + render( + + {({ arrowStyles, anchorHidden }: FloatingPopupChildrenProps) => ( +
+ )} + , + ); + + const child = screen.getByTestId('child'); + expect(child).to.have.attribute('data-arrow-position', 'absolute'); + expect(child).to.have.attribute('data-anchor-hidden', 'false'); + }); + }); +}); diff --git a/packages/mui-material/src/FloatingPopup/FloatingPopup.tsx b/packages/mui-material/src/FloatingPopup/FloatingPopup.tsx new file mode 100644 index 00000000000000..00e302c69f1778 --- /dev/null +++ b/packages/mui-material/src/FloatingPopup/FloatingPopup.tsx @@ -0,0 +1,475 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + hide, + arrow, + detectOverflow, + Placement, + Middleware, +} from '@floating-ui/react-dom'; +import { useRtl } from '@mui/system/RtlProvider'; +import ownerDocument from '@mui/utils/ownerDocument'; +import useForkRef from '@mui/utils/useForkRef'; +import Portal from '../Portal'; +import { + FloatingPopupProps, + FloatingPopupChildrenProps, + VirtualElement, +} from './FloatingPopup.types'; + +// Adapted from BasePopper.tsx flipPlacement() — flips start/end for RTL +function flipPlacement(placement: Placement, isRtl: boolean): Placement { + if (!isRtl) { + return placement; + } + + switch (placement) { + case 'bottom-end': + return 'bottom-start'; + case 'bottom-start': + return 'bottom-end'; + case 'top-end': + return 'top-start'; + case 'top-start': + return 'top-end'; + default: + return placement; + } +} + +// Adapted from BasePopper.tsx isHTMLElement() — local utility +function isHTMLElement(element: HTMLElement | VirtualElement): element is HTMLElement { + return (element as HTMLElement).nodeType !== undefined; +} + +// Custom middleware that computes anchor dimensions and available space, +// returning data via middlewareData (React-friendly) instead of imperative DOM mutation. +const anchorMetrics: Middleware = { + name: 'anchorMetrics', + async fn(state) { + const overflow = await detectOverflow(state); + const { rects, placement } = state; + + // Snap anchor dimensions to device pixels (from Base UI pattern) + const dpr = window.devicePixelRatio || 1; + const { x, y, width, height } = rects.reference; + const anchorWidth = (Math.round((x + width) * dpr) - Math.round(x * dpr)) / dpr; + const anchorHeight = (Math.round((y + height) * dpr) - Math.round(y * dpr)) / dpr; + + // Compute available space from overflow on the placement side + const side = placement.split('-')[0]; + const availableWidth = + rects.floating.width + Math.max(0, -overflow.left) + Math.max(0, -overflow.right); + const availableHeight = + side === 'top' || side === 'bottom' + ? rects.floating.height + Math.max(0, -overflow[side]) + : rects.floating.height + Math.max(0, -overflow.top) + Math.max(0, -overflow.bottom); + + return { + data: { anchorWidth, anchorHeight, availableWidth, availableHeight }, + }; + }, +}; + +function buildDefaultMiddleware(): Middleware[] { + return [offset(0), flip(), shift(), anchorMetrics, hide({ strategy: 'referenceHidden' })]; +} + +/** + * FloatingPopup — opt-in replacement for Popper.js-based positioning. + * Uses @floating-ui/react-dom under the hood. + * + * Designed to be used via `slots={{ popper: FloatingPopup }}` on + * Autocomplete and Tooltip. + * + * Demos: + * + * - [Autocomplete](https://next.mui.com/material-ui/react-autocomplete/) + * - [Tooltip](https://next.mui.com/material-ui/react-tooltip/) + * + * API: + * + * - [FloatingPopup API](https://next.mui.com/material-ui/api/floating-popup/) + */ +const FloatingPopup = React.forwardRef( + function FloatingPopup(inProps, forwardedRef) { + const { + anchorEl: anchorElProp, + open, + placement: placementProp = 'bottom', + strategy: strategyProp = 'absolute', + middleware: middlewareProp, + transform: transformProp = true, + children, + transition = false, + arrowRef: arrowRefProp, + arrowPadding = 0, + disablePortal = false, + container: containerProp, + keepMounted = false, + className, + style, + id, + popperRef: popperRefProp, + // Root element override (from slotProps.popper.component via useSlot) + // @ts-ignore — injected by useSlot when slotProps.popper.component is provided + as: RootComponent = 'div', + // Destructure and discard Popper.js / useSlot props to prevent DOM leaks + // @ts-ignore — Popper.js compat + popperOptions, + // @ts-ignore — Popper.js compat + modifiers, + // @ts-ignore — BasePopper wrapper injects this; we use useRtl() directly + direction, + // @ts-ignore — MUI internal from useSlot/appendOwnerState + ownerState, + ...other + } = inProps as FloatingPopupProps & Record; + + // Resolve placement — auto* not supported by floating-ui, fall back to 'bottom' + const safePlacement: Placement = + typeof placementProp === 'string' && placementProp.startsWith('auto') + ? 'bottom' + : placementProp; + + const hasWarnedAutoPlacement = React.useRef(false); + if (process.env.NODE_ENV !== 'production') { + if (safePlacement !== placementProp && !hasWarnedAutoPlacement.current) { + hasWarnedAutoPlacement.current = true; + console.warn( + 'FloatingPopup: "auto" placement is not supported. ' + + 'Use the autoPlacement() middleware instead. Falling back to "bottom".', + ); + } + } + + // RTL — handled internally, no direction prop needed + const isRtl = useRtl(); + const rtlPlacement = flipPlacement(safePlacement, isRtl); + + // Resolve anchorEl (function or element) + const resolvedAnchorEl = typeof anchorElProp === 'function' ? anchorElProp() : anchorElProp; + + // Build middleware — arrow() uses Derivable pattern to read RefObject.current lazily + const middleware = React.useMemo(() => { + const stack = middlewareProp ? [...middlewareProp] : buildDefaultMiddleware(); + // Always append arrow() when arrowRef is present, even with custom middleware. + // The arrow is a parent concern (e.g. Tooltip's ArrowSlot), not a middleware concern — + // users passing custom middleware shouldn't need to know about arrow positioning. + if (arrowRefProp) { + // arrow(() => ...) is a Derivable — evaluated on every positioning computation, + // not at middleware creation time. This avoids stale closure when arrowRef is a RefObject + // (referentially stable, .current changes after mount without triggering useMemo). + stack.push( + arrow(() => ({ + element: ('current' in arrowRefProp ? arrowRefProp.current : arrowRefProp) as Element, + padding: arrowPadding, + })), + ); + } + return stack; + }, [middlewareProp, arrowRefProp, arrowPadding]); + + // Core positioning + const { + refs, + floatingStyles, + placement: computedPlacement, + middlewareData, + isPositioned, + update, + } = useFloating({ + elements: { reference: resolvedAnchorEl as Element | null }, + placement: rtlPlacement, + strategy: strategyProp, + middleware, + transform: transformProp, + whileElementsMounted: autoUpdate, + }); + + // Merge forwardedRef with floating-ui's setFloating ref + const mergedRef = useForkRef(refs.setFloating, forwardedRef); + + // Imperative handle — exposes .update() for followCursor compat + React.useImperativeHandle(popperRefProp, () => ({ update }), [update]); + + // Force recomputation when RefObject.current becomes available (before first paint). + // No dep array — RefObject is referentially stable so deps can't track .current changes. + // update() is cheap (one computePosition call) and idempotent. + React.useLayoutEffect(() => { + if ( + arrowRefProp && + typeof arrowRefProp === 'object' && + 'current' in arrowRefProp && + arrowRefProp.current + ) { + update(); + } + }); + + // Apply arrow styles imperatively to the arrow DOM element (same approach as Popper.js's + // arrow modifier, which writes styles directly). This is necessary because the arrow element + // is rendered by the parent (e.g. Tooltip's ArrowSlot), not by FloatingPopup's render prop. + React.useLayoutEffect(() => { + const arrowElement = + arrowRefProp && typeof arrowRefProp === 'object' && 'current' in arrowRefProp + ? arrowRefProp.current + : (arrowRefProp as HTMLElement | null); + if (!arrowElement || !middlewareData.arrow) { + return; + } + const { x, y } = middlewareData.arrow; + Object.assign(arrowElement.style, { + left: x != null ? `${x}px` : '', + top: y != null ? `${y}px` : '', + position: 'absolute', + }); + }, [arrowRefProp, middlewareData.arrow]); + + // Transition state (same pattern as BasePopper) + const [exited, setExited] = React.useState(true); + const handleEnter = () => setExited(false); + const handleExited = () => setExited(true); + + if (!keepMounted && !open && (!transition || exited)) { + return null; + } + + const display = !open && keepMounted && (!transition || exited) ? 'none' : undefined; + const anchorHidden = Boolean(middlewareData.hide?.referenceHidden); + const metrics = middlewareData.anchorMetrics as + | { + anchorWidth: number; + anchorHeight: number; + availableWidth: number; + availableHeight: number; + } + | undefined; + const arrowStyles: React.CSSProperties = { + position: 'absolute', + top: middlewareData.arrow?.y, + left: middlewareData.arrow?.x, + }; + + const childrenProps: FloatingPopupChildrenProps = { + placement: computedPlacement, + ...(transition && { + TransitionProps: { + in: open, + onEnter: handleEnter, + onExited: handleExited, + }, + }), + arrowStyles, + anchorHidden, + isPositioned, + }; + + // Resolve Portal container (isHTMLElement matches BasePopper pattern) + const container = + containerProp || + (resolvedAnchorEl && isHTMLElement(resolvedAnchorEl as HTMLElement | VirtualElement) + ? ownerDocument(resolvedAnchorEl as HTMLElement).body + : ownerDocument(null).body); + + return ( + + + {typeof children === 'function' ? children(childrenProps) : children} + + + ); + }, +); + +FloatingPopup.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 + */ + anchorEl: PropTypes.oneOfType([ + (props, propName) => { + if (props[propName] == null) { + return new Error(`Prop '${propName}' is required but wasn't specified`); + } + if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { + return new Error(`Expected prop '${propName}' to be of type Element`); + } + return null; + }, + PropTypes.func, + PropTypes.shape({ + getBoundingClientRect: PropTypes.func.isRequired, + }), + ]), + /** + * @ignore + */ + arrowPadding: PropTypes.number, + /** + * The arrow element to position. Preferred: pass the element from `useState`. + * Also works: `RefObject` (resolved lazily via Derivable pattern + useLayoutEffect). + */ + arrowRef: PropTypes.oneOfType([ + (props, propName) => { + if (props[propName] == null) { + return new Error(`Prop '${propName}' is required but wasn't specified`); + } + if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { + return new Error(`Expected prop '${propName}' to be of type Element`); + } + return null; + }, + PropTypes.shape({ + current: (props, propName) => { + if (props[propName] == null) { + return null; + } + if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { + return new Error(`Expected prop '${propName}' to be of type Element`); + } + return null; + }, + }), + ]), + /** + * @ignore + */ + children: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.func, + PropTypes.number, + PropTypes.shape({ + '__@toStringTag@4090': PropTypes.oneOf(['BigInt']).isRequired, + toLocaleString: PropTypes.func.isRequired, + toString: PropTypes.func.isRequired, + valueOf: PropTypes.func.isRequired, + }), + PropTypes.shape({ + '__@iterator@3853': PropTypes.func.isRequired, + }), + PropTypes.shape({ + children: PropTypes.node, + key: PropTypes.string, + props: PropTypes.any.isRequired, + type: PropTypes.oneOfType([PropTypes.func, PropTypes.string]).isRequired, + }), + PropTypes.shape({ + '__@toStringTag@4090': PropTypes.string.isRequired, + catch: PropTypes.func.isRequired, + finally: PropTypes.func.isRequired, + then: PropTypes.func.isRequired, + }), + PropTypes.string, + PropTypes.bool, + ]), + /** + * @ignore + */ + container: PropTypes.oneOfType([ + (props, propName) => { + if (props[propName] == null) { + return new Error(`Prop '${propName}' is required but wasn't specified`); + } + if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { + return new Error(`Expected prop '${propName}' to be of type Element`); + } + return null; + }, + PropTypes.func, + ]), + /** + * @ignore + */ + disablePortal: PropTypes.bool, + /** + * @ignore + */ + keepMounted: PropTypes.bool, + /** + * @ignore + */ + middleware: PropTypes.arrayOf( + PropTypes.shape({ + fn: PropTypes.func.isRequired, + name: PropTypes.string.isRequired, + options: PropTypes.any, + }), + ), + /** + * @ignore + */ + open: PropTypes.bool.isRequired, + /** + * @ignore + */ + placement: PropTypes.oneOf([ + 'bottom-end', + 'bottom-start', + 'bottom', + 'left-end', + 'left-start', + 'left', + 'right-end', + 'right-start', + 'right', + 'top-end', + 'top-start', + 'top', + ]), + /** + * @ignore + */ + popperRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ + current: PropTypes.shape({ + update: PropTypes.func.isRequired, + }), + }), + ]), + /** + * @ignore + */ + strategy: PropTypes.oneOf(['absolute', 'fixed']), + /** + * @ignore + */ + transform: PropTypes.bool, + /** + * @ignore + */ + transition: PropTypes.bool, +} as any; + +export default FloatingPopup; diff --git a/packages/mui-material/src/FloatingPopup/FloatingPopup.types.ts b/packages/mui-material/src/FloatingPopup/FloatingPopup.types.ts new file mode 100644 index 00000000000000..f6b3f8181769fc --- /dev/null +++ b/packages/mui-material/src/FloatingPopup/FloatingPopup.types.ts @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { Placement, Middleware } from '@floating-ui/react-dom'; + +export interface FloatingPopupActions { + update: () => void; +} + +export interface FloatingPopupChildrenProps { + placement: Placement; + TransitionProps?: FloatingPopupTransitionProps | undefined; + arrowStyles: React.CSSProperties; + anchorHidden: boolean; + isPositioned: boolean; +} + +export interface FloatingPopupTransitionProps { + in: boolean; + onEnter: () => void; + onExited: () => void; +} + +export interface VirtualElement { + getBoundingClientRect: () => + | DOMRect + | { + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; + x?: number | undefined; + y?: number | undefined; + }; +} + +export interface FloatingPopupProps extends Omit, 'children'> { + // --- Core positioning --- + anchorEl?: HTMLElement | VirtualElement | (() => HTMLElement | VirtualElement) | null | undefined; + open: boolean; + placement?: Placement | undefined; + strategy?: 'absolute' | 'fixed' | undefined; + middleware?: Middleware[] | undefined; + transform?: boolean | undefined; + + // --- Children --- + children?: React.ReactNode | ((props: FloatingPopupChildrenProps) => React.ReactNode); + transition?: boolean | undefined; + + // --- Arrow --- + /** + * The arrow element to position. Preferred: pass the element from `useState`. + * Also works: `RefObject` (resolved lazily via Derivable pattern + useLayoutEffect). + */ + arrowRef?: React.RefObject | HTMLElement | null | undefined; + arrowPadding?: number | undefined; + + // --- DOM --- + disablePortal?: boolean | undefined; + container?: Element | (() => Element) | undefined; + keepMounted?: boolean | undefined; + + // --- Imperative --- + popperRef?: React.Ref | undefined; +} diff --git a/packages/mui-material/src/FloatingPopup/index.ts b/packages/mui-material/src/FloatingPopup/index.ts new file mode 100644 index 00000000000000..878b69a45dd273 --- /dev/null +++ b/packages/mui-material/src/FloatingPopup/index.ts @@ -0,0 +1,2 @@ +export { default } from './FloatingPopup'; +export * from './FloatingPopup.types'; diff --git a/packages/mui-material/src/Popper/BasePopper.tsx b/packages/mui-material/src/Popper/BasePopper.tsx index 4c32775a506c15..f4e0eaca287c1b 100644 --- a/packages/mui-material/src/Popper/BasePopper.tsx +++ b/packages/mui-material/src/Popper/BasePopper.tsx @@ -94,6 +94,10 @@ const PopperTooltip = React.forwardRef(funct TransitionProps, // @ts-ignore internal logic ownerState: ownerStateProp, // prevent from spreading to DOM, it can come from the parent component e.g. Select. + // @ts-ignore — Tooltip passes these for FloatingPopup compat; not used by BasePopper + arrowRef, + // @ts-ignore + arrowPadding, ...other } = props; diff --git a/packages/mui-material/src/Tooltip/Tooltip.d.ts b/packages/mui-material/src/Tooltip/Tooltip.d.ts index 6f915e493a3eff..f45e2d37a991a0 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.d.ts +++ b/packages/mui-material/src/Tooltip/Tooltip.d.ts @@ -5,9 +5,13 @@ import { Theme } from '../styles'; import { InternalStandardProps as StandardProps } from '../internal'; import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types'; import { TransitionProps } from '../transitions/transition'; +import { FloatingPopupProps } from '../FloatingPopup'; import { TooltipClasses } from './tooltipClasses'; -export interface TooltipPopperSlotPropsOverrides {} +export interface TooltipPopperSlotPropsOverrides extends Pick< + FloatingPopupProps, + 'middleware' | 'strategy' | 'transform' | 'arrowPadding' +> {} export interface TooltipTransitionSlotPropsOverrides {} diff --git a/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx b/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx new file mode 100644 index 00000000000000..1c219a8b7528a1 --- /dev/null +++ b/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx @@ -0,0 +1,836 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { + act, + createRenderer, + fireEvent, + flushMicrotasks, + screen, + simulatePointerDevice, + isJsdom, +} from '@mui/internal-test-utils'; +import { offset } from '@floating-ui/react-dom'; +import Tooltip from '@mui/material/Tooltip'; +import FloatingPopup from '@mui/material/FloatingPopup'; +// @ts-ignore — testReset is exported from Tooltip.js but not declared in Tooltip.d.ts (test-only export) +import { testReset } from './Tooltip'; + +/** + * Integration tests for Tooltip with FloatingPopup as the popper slot. + * + * FloatingPopup uses async computePosition from floating-ui. + * All fake-timer tests must use `await clock.tickAsync()` (not `clock.tick()`) + * to flush the microtask queue so positioning completes and the FOUC guard + * (`visibility: hidden`) is removed. + * + * user-event is not used here because createRenderer's userEvent.setup() + * does not configure `advanceTimers`, causing deadlocks with fake timers. + * Tooltip's enterDelay/leaveDelay/transition behavior requires fake timers + * for all interactive tests. + */ +describe('', () => { + const { clock, render } = createRenderer({ clock: 'fake' }); + + beforeEach(() => { + testReset(); + }); + + // ────────────────────────────────────────────── + // Basic rendering + // ────────────────────────────────────────────── + + describe('rendering', () => { + it('should render the tooltip content when open', async () => { + render( + + + , + ); + // Flush async computePosition so visibility:hidden is removed + await flushMicrotasks(); + expect(screen.getByText('Hello World')).not.to.equal(null); + }); + + it('should not render tooltip content when closed', () => { + render( + + + , + ); + expect(screen.queryByText('Hello World')).to.equal(null); + }); + + it('should have role="tooltip" on the popup', async () => { + render( + + + , + ); + // Flush async computePosition so visibility:hidden is removed — + // getByRole skips elements with visibility:hidden + await flushMicrotasks(); + expect(screen.getByRole('tooltip')).not.to.equal(null); + }); + + it('should set data-popper-placement on the floating element', () => { + render( + + + , + ); + const floating = document.querySelector('[data-popper-placement]'); + expect(floating).not.to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Mouse interaction + // ────────────────────────────────────────────── + + describe('mouse interaction', () => { + clock.withFakeTimers(); + + it('should open on mouseOver and close on mouseLeave', async () => { + const transitionTimeout = 10; + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + + fireEvent.mouseLeave(screen.getByRole('button')); + await clock.tickAsync(0); + await clock.tickAsync(transitionTimeout); + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + + it('should respect enterDelay', async () => { + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); + expect(screen.queryByRole('tooltip')).to.equal(null); + + await clock.tickAsync(100); + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('should respect leaveDelay', async () => { + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + + fireEvent.mouseLeave(screen.getByRole('button')); + await clock.tickAsync(100); + // Still visible during leaveDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // Keyboard interaction + // ────────────────────────────────────────────── + + describe('keyboard interaction', () => { + clock.withFakeTimers(); + + it('should close on Escape', async () => { + const transitionTimeout = 10; + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + + fireEvent.keyDown(document.activeElement || document.body, { key: 'Escape' }); + await clock.tickAsync(transitionTimeout); + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + + it.skipIf(isJsdom())('should open on focus-visible', async () => { + simulatePointerDevice(); + render( + + + , + ); + + const button = screen.getByRole('button'); + await act(async () => { + button.blur(); + }); + fireEvent.keyDown(document.body, { key: 'Tab' }); + await act(async () => { + button.focus(); + }); + + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // Touch interaction + // ────────────────────────────────────────────── + + describe('touch interaction', () => { + clock.withFakeTimers(); + + it('should open on long press', async () => { + render( + + + , + ); + + fireEvent.touchStart(screen.getByRole('button')); + await clock.tickAsync(700 + 100); // enterTouchDelay + enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // Accessibility + // ────────────────────────────────────────────── + + describe('accessibility', () => { + it('should label the trigger with the tooltip title', () => { + render( + + + , + ); + const button = screen.getByRole('button'); + expect(button).to.have.attribute('aria-label', 'Accessible tooltip'); + }); + + it('should set aria-describedby when describeChild is true', () => { + render( + + + , + ); + const button = screen.getByRole('button'); + expect(button.getAttribute('aria-describedby')).not.to.equal(null); + }); + + it('should not set aria-describedby when closed', () => { + render( + + + , + ); + const button = screen.getByRole('button'); + expect(button.getAttribute('aria-describedby')).to.equal(null); + }); + + it('should connect trigger aria-describedby to tooltip id', async () => { + render( + + + , + ); + await flushMicrotasks(); + const button = screen.getByRole('button'); + const describedById = button.getAttribute('aria-describedby'); + const tooltip = document.getElementById(describedById!); + expect(tooltip).not.to.equal(null); + expect(tooltip!.textContent).to.include('Connected'); + }); + }); + + // ────────────────────────────────────────────── + // Placement + // ────────────────────────────────────────────── + + describe('placement', () => { + const placements = [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', + ] as const; + + placements.forEach((placement) => { + it(`should render with placement="${placement}"`, () => { + render( + + + , + ); + expect(screen.getByText(`Placement ${placement}`)).not.to.equal(null); + }); + }); + }); + + // ────────────────────────────────────────────── + // Arrow + // ────────────────────────────────────────────── + + describe('arrow', () => { + it('should render the arrow element', () => { + render( + + + , + ); + expect(screen.getByText('Arrow tooltip')).not.to.equal(null); + const arrowEl = document.querySelector('.MuiTooltip-arrow'); + expect(arrowEl).not.to.equal(null); + }); + + it('should apply position:absolute to the arrow element', async () => { + render( + + + , + ); + // Flush async computePosition so middlewareData.arrow is populated + await flushMicrotasks(); + const arrowEl = document.querySelector('.MuiTooltip-arrow') as HTMLElement; + expect(arrowEl).not.to.equal(null); + expect(arrowEl!.style.position).to.equal('absolute'); + }); + }); + + // ────────────────────────────────────────────── + // Transition + // ────────────────────────────────────────────── + + describe('transition', () => { + clock.withFakeTimers(); + + it('should unmount after exit transition', async () => { + const transitionTimeout = 10; + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + + fireEvent.mouseLeave(screen.getByRole('button')); + await clock.tickAsync(0); + await clock.tickAsync(transitionTimeout); + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // followCursor + // ────────────────────────────────────────────── + + describe('followCursor', () => { + clock.withFakeTimers(); + + it('should open and track cursor', async () => { + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button'), { + clientX: 100, + clientY: 200, + }); + await clock.tickAsync(100); // default enterDelay + + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('should stay visible after mouse move within trigger', async () => { + render( + + + , + ); + + const button = screen.getByRole('button'); + fireEvent.mouseOver(button, { clientX: 50, clientY: 50 }); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + + fireEvent.mouseMove(button, { clientX: 100, clientY: 100 }); + await flushMicrotasks(); + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // keepMounted + // ────────────────────────────────────────────── + + describe('keepMounted', () => { + it('should keep the tooltip in the DOM when closed', () => { + render( + + + , + ); + expect(screen.getByText('Persistent')).not.to.equal(null); + }); + + it('should hide with display:none when closed and keepMounted', () => { + render( + + + , + ); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating!.style.display).to.equal('none'); + }); + }); + + // ────────────────────────────────────────────── + // slotProps passthrough + // ────────────────────────────────────────────── + + describe('slotProps.popper', () => { + it('should forward data attributes to the floating element', () => { + render( + + + , + ); + expect(screen.getByTestId('my-popper')).not.to.equal(null); + }); + + it('should forward strategy=fixed to FloatingPopup', () => { + render( + + + , + ); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating!.style.position).to.equal('fixed'); + }); + }); + + // ────────────────────────────────────────────── + // CSS variables + // ────────────────────────────────────────────── + + describe('CSS variables', () => { + it.skipIf(isJsdom())( + 'should set --anchor-width and --anchor-height on the floating element', + async () => { + render( + + + , + ); + // Flush async computePosition so anchorMetrics middleware populates data + await flushMicrotasks(); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating).not.to.equal(null); + const anchorWidth = floating!.style.getPropertyValue('--anchor-width'); + const anchorHeight = floating!.style.getPropertyValue('--anchor-height'); + expect(anchorWidth).to.match(/^\d+(\.\d+)?px$/); + expect(anchorHeight).to.match(/^\d+(\.\d+)?px$/); + }, + ); + }); + + // ────────────────────────────────────────────── + // Callbacks + // ────────────────────────────────────────────── + + describe('callbacks', () => { + clock.withFakeTimers(); + + it('should call onOpen when tooltip opens', async () => { + const handleOpen = spy(); + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); // default enterDelay + expect(handleOpen.callCount).to.equal(1); + }); + + it('should call onClose when tooltip closes', async () => { + const handleClose = spy(); + const transitionTimeout = 10; + render( + + + , + ); + + fireEvent.mouseOver(screen.getByRole('button')); + await clock.tickAsync(100); // default enterDelay + + fireEvent.mouseLeave(screen.getByRole('button')); + await clock.tickAsync(0); + expect(handleClose.callCount).to.equal(1); + }); + }); + + // ────────────────────────────────────────────── + // Dynamic title + // ────────────────────────────────────────────── + + describe('dynamic title', () => { + it('should update content when title changes', () => { + const { setProps } = render( + + + , + ); + expect(screen.getByText('First')).not.to.equal(null); + + setProps({ title: 'Second' }); + expect(screen.getByText('Second')).not.to.equal(null); + }); + + it('should close when title becomes empty string', () => { + const { setProps } = render( + + + , + ); + expect(screen.getByText('Hello')).not.to.equal(null); + + setProps({ title: '' }); + expect(screen.queryByText('Hello')).to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // disablePortal + // ────────────────────────────────────────────── + + describe('disablePortal', () => { + it('should render inline when disablePortal is true', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('[data-popper-placement]')).not.to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Multiple tooltips + // ────────────────────────────────────────────── + + describe('multiple tooltips', () => { + clock.withFakeTimers(); + + it('should switch between tooltips', async () => { + const transitionTimeout = 10; + render( + + + + + + + + , + ); + + fireEvent.mouseOver(screen.getByText('Button A')); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByText('Tooltip A')).not.to.equal(null); + + fireEvent.mouseLeave(screen.getByText('Button A')); + await clock.tickAsync(0); + await clock.tickAsync(transitionTimeout); + fireEvent.mouseOver(screen.getByText('Button B')); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByText('Tooltip B')).not.to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Custom middleware + // ────────────────────────────────────────────── + + describe('custom middleware', () => { + it('should accept custom middleware via slotProps.popper', () => { + render( + + + , + ); + expect(screen.getByText('With middleware')).not.to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Arrow + custom middleware + // ────────────────────────────────────────────── + + describe('arrow with custom middleware', () => { + it('should position arrow even with custom middleware', async () => { + render( + + + , + ); + await flushMicrotasks(); + const arrowEl = document.querySelector('.MuiTooltip-arrow') as HTMLElement; + expect(arrowEl).not.to.equal(null); + expect(arrowEl!.style.position).to.equal('absolute'); + }); + }); + + // ────────────────────────────────────────────── + // Rapid interactions + // ────────────────────────────────────────────── + + describe('rapid interactions', () => { + clock.withFakeTimers(); + + it('should not crash during rapid hover in/out', async () => { + const transitionTimeout = 10; + render( + + + , + ); + const button = screen.getByRole('button'); + + for (let i = 0; i < 10; i += 1) { + fireEvent.mouseOver(button); + // eslint-disable-next-line no-await-in-loop + await clock.tickAsync(10); + fireEvent.mouseLeave(button); + // eslint-disable-next-line no-await-in-loop + await clock.tickAsync(10); + } + + await clock.tickAsync(transitionTimeout + 50); + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // Ref forwarding on trigger + // ────────────────────────────────────────────── + + describe('ref forwarding', () => { + it('should forward ref on the trigger element', () => { + const ref = React.createRef(); + render( + + + , + ); + expect(ref.current).not.to.equal(null); + expect(ref.current).to.be.instanceof(window.HTMLButtonElement); + }); + }); + + // ────────────────────────────────────────────── + // Disabled trigger + // ────────────────────────────────────────────── + + describe('disabled trigger', () => { + clock.withFakeTimers(); + + it('should open on hover for disabled button (via internal span wrapper)', async () => { + expect(() => { + render( + + + , + ); + }).toWarnDev('MUI: You are providing a disabled `button` child to the Tooltip component.'); + + const wrapper = screen.getByText('Disabled').closest('span'); + if (wrapper) { + fireEvent.mouseOver(wrapper); + await clock.tickAsync(100); // default enterDelay + expect(screen.getByRole('tooltip')).toBeVisible(); + } + }); + }); + + // ────────────────────────────────────────────── + // Controlled open + // ────────────────────────────────────────────── + + describe('controlled open', () => { + it('should stay open when open=true regardless of interactions', async () => { + render( + + + , + ); + await flushMicrotasks(); + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('should stay closed when open=false regardless of interactions', () => { + render( + + + , + ); + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + }); + + // ────────────────────────────────────────────── + // JSX title + // ────────────────────────────────────────────── + + describe('JSX title', () => { + it('should render JSX content as tooltip title', () => { + render( + + Bold text + + } + open + slots={{ popper: FloatingPopup }} + > + + , + ); + expect(screen.getByText('Bold')).not.to.equal(null); + expect(screen.getByText('Bold').tagName).to.equal('STRONG'); + }); + }); + + // ────────────────────────────────────────────── + // FOUC prevention + // ────────────────────────────────────────────── + + describe('FOUC prevention', () => { + it('should have visibility:hidden before positioning completes', () => { + render( + + + , + ); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating).not.to.equal(null); + expect(floating!.style.visibility).to.equal('hidden'); + }); + + it('should remove visibility:hidden after positioning completes', async () => { + render( + + + , + ); + await flushMicrotasks(); + const floating = document.querySelector('[data-popper-placement]') as HTMLElement; + expect(floating!.style.visibility).not.to.equal('hidden'); + }); + }); +}); diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js index 68e397b0855f62..2fc1f6dfae1ff4 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.js +++ b/packages/mui-material/src/Tooltip/Tooltip.js @@ -702,7 +702,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { }; const [PopperSlot, popperSlotProps] = useSlot('popper', { - elementType: TooltipPopper, + elementType: Popper, externalForwardedProps, ownerState, className: classes.popper, @@ -732,8 +732,8 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { return ( {React.cloneElement(children, childrenProps)} - {({ TransitionProps: TransitionPropsInner }) => ( )} - + ); }); diff --git a/packages/mui-material/src/index.d.ts b/packages/mui-material/src/index.d.ts index e0bd2c9ff92a40..8db65f3fec8bc9 100644 --- a/packages/mui-material/src/index.d.ts +++ b/packages/mui-material/src/index.d.ts @@ -139,6 +139,9 @@ export * from './Fade'; export { default as FilledInput } from './FilledInput'; export * from './FilledInput'; +export { default as FloatingPopup } from './FloatingPopup'; +export * from './FloatingPopup'; + export { default as FormControl } from './FormControl'; export * from './FormControl'; diff --git a/packages/mui-material/src/index.js b/packages/mui-material/src/index.js index 40d568f41ac205..ef1da1d45c6232 100644 --- a/packages/mui-material/src/index.js +++ b/packages/mui-material/src/index.js @@ -136,6 +136,9 @@ export * from './Fade'; export { default as FilledInput } from './FilledInput'; export * from './FilledInput'; +export { default as FloatingPopup } from './FloatingPopup'; +export * from './FloatingPopup'; + export { default as FormControl } from './FormControl'; export * from './FormControl'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ef98beb688675..12fd6147d41744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: '@emotion/styled': specifier: catalog:docs version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@floating-ui/react-dom': + specifier: ^2.1.6 + version: 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fortawesome/fontawesome-svg-core': specifier: ^6.7.2 version: 6.7.2 @@ -1138,6 +1141,9 @@ importers: '@emotion/styled': specifier: ^11.3.0 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@floating-ui/react-dom': + specifier: ^2.1.6 + version: 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@mui/core-downloads-tracker': specifier: workspace:^ version: link:../mui-core-downloads-tracker From 7cb7e5b7d546ccd87ffeaae031ebe1137ada3d93 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 30 Mar 2026 16:45:24 +0800 Subject: [PATCH 2/3] tests --- .../material-ui/floating-autocomplete.tsx | 2 +- .../material-ui/floating-tooltip.tsx | 6 +- .../Autocomplete.floating.spec.tsx | 75 ++++++++++++ .../Autocomplete.floating.test.tsx | 20 ++-- .../src/Tooltip/Tooltip.floating.spec.tsx | 111 ++++++++++++++++++ .../src/Tooltip/Tooltip.floating.test.tsx | 14 +-- packages/mui-material/src/Tooltip/Tooltip.js | 2 +- 7 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 packages/mui-material/src/Autocomplete/Autocomplete.floating.spec.tsx create mode 100644 packages/mui-material/src/Tooltip/Tooltip.floating.spec.tsx diff --git a/docs/pages/experiments/material-ui/floating-autocomplete.tsx b/docs/pages/experiments/material-ui/floating-autocomplete.tsx index 7c1d4a47581683..222e29a266174c 100644 --- a/docs/pages/experiments/material-ui/floating-autocomplete.tsx +++ b/docs/pages/experiments/material-ui/floating-autocomplete.tsx @@ -182,7 +182,7 @@ function KeepMountedDemo() { sx={{ width: 300 }} slots={{ popper: FloatingPopup }} slotProps={{ - popper: { keepMounted: true } as any, + popper: { keepMounted: true }, }} renderInput={(params) => } /> diff --git a/docs/pages/experiments/material-ui/floating-tooltip.tsx b/docs/pages/experiments/material-ui/floating-tooltip.tsx index 5c365f91d0b3c0..c701bd75ce7280 100644 --- a/docs/pages/experiments/material-ui/floating-tooltip.tsx +++ b/docs/pages/experiments/material-ui/floating-tooltip.tsx @@ -111,7 +111,7 @@ function OffsetDemo() { slotProps={{ popper: { middleware: [offset(offsetVal), flip(), shift()], - } as any, + }, }} > @@ -158,7 +158,7 @@ function AutoPlacementDemo() { popper: { middleware, disablePortal: true, - } as any, + }, }} > @@ -192,7 +192,7 @@ function StrategyDemo() { arrow slots={{ popper: FloatingPopup }} slotProps={{ - popper: { strategy } as any, + popper: { strategy }, }} > diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.floating.spec.tsx b/packages/mui-material/src/Autocomplete/Autocomplete.floating.spec.tsx new file mode 100644 index 00000000000000..9bd0389afa5263 --- /dev/null +++ b/packages/mui-material/src/Autocomplete/Autocomplete.floating.spec.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { offset, flip, shift, Middleware } from '@floating-ui/react-dom'; +import Autocomplete from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; +import FloatingPopup from '@mui/material/FloatingPopup'; + +// FloatingPopup can be passed as the popper slot + } +/>; + +// FloatingPopup-specific slotProps compile without `as any` + } +/>; + +// Standard Popper props still accepted alongside FloatingPopup props + } +/>; + +// Middleware as a variable +const middleware: Middleware[] = [offset(8), flip(), shift()]; + } +/>; + +// Without FloatingPopup slot — standard Popper slotProps still work + } +/>; + +// slotProps.popper as a function (ownerState callback form) + ({ + strategy: 'fixed' as const, + middleware: [offset(8)], + }), + }} + renderInput={(params) => } +/>; diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx b/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx index 9243a63bc27226..1b0d937e67ff65 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx +++ b/packages/mui-material/src/Autocomplete/Autocomplete.floating.test.tsx @@ -1,16 +1,10 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import { - createRenderer, - fireEvent, - flushMicrotasks, - screen, -} from '@mui/internal-test-utils'; +import { createRenderer, fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils'; import TextField from '@mui/material/TextField'; import Autocomplete, { autocompleteClasses as classes } from '@mui/material/Autocomplete'; import FloatingPopup from '@mui/material/FloatingPopup'; -import type { UserEvent } from '@testing-library/user-event'; const options = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon']; @@ -28,7 +22,9 @@ function checkHighlightIs(listbox: HTMLElement, expected: string | null) { * FloatingPopup's async computePosition completes and visibility:hidden * is removed (making the popup accessible to getByRole). */ -async function openAutocomplete(user: UserEvent) { +async function openAutocomplete( + user: ReturnType['render']>['user'], +) { const input = screen.getByRole('combobox'); await user.click(input); await flushMicrotasks(); @@ -176,7 +172,7 @@ describe('', () => { const input = await openAutocomplete(user); await user.keyboard('{ArrowDown}'); const firstOption = screen.getAllByRole('option')[0]; - expect(input).to.have.attribute('aria-activedescendant', firstOption.getAttribute('id')); + expect(input).to.have.attribute('aria-activedescendant', firstOption.id); }); it('should clear input on blur when no option is selected', async () => { @@ -365,7 +361,7 @@ describe('', () => { const { user } = renderAutocomplete(); const input = await openAutocomplete(user); const listbox = screen.getByRole('listbox'); - expect(input).to.have.attribute('aria-controls', listbox.getAttribute('id')); + expect(input).to.have.attribute('aria-controls', listbox.id); }); it('should have role="presentation" on the popup container', async () => { @@ -559,7 +555,7 @@ describe('', () => { describe('slotProps.popper', () => { it('should forward keepMounted to FloatingPopup', () => { renderAutocomplete({ - slotProps: { popper: { keepMounted: true } as any }, + slotProps: { popper: { keepMounted: true } }, }); // Closed — floating element in DOM but with visibility:hidden (FOUC guard) const floating = document.querySelector('[data-popper-placement]'); @@ -568,7 +564,7 @@ describe('', () => { it('should forward data attributes to the floating element', async () => { const { user } = renderAutocomplete({ - slotProps: { popper: { 'data-testid': 'custom-popup' } as any }, + slotProps: { popper: { 'data-testid': 'custom-popup' } }, }); await openAutocomplete(user); expect(screen.getByTestId('custom-popup')).not.to.equal(null); diff --git a/packages/mui-material/src/Tooltip/Tooltip.floating.spec.tsx b/packages/mui-material/src/Tooltip/Tooltip.floating.spec.tsx new file mode 100644 index 00000000000000..8581206db6e413 --- /dev/null +++ b/packages/mui-material/src/Tooltip/Tooltip.floating.spec.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { offset, flip, shift, Middleware } from '@floating-ui/react-dom'; +import Tooltip from '@mui/material/Tooltip'; +import FloatingPopup from '@mui/material/FloatingPopup'; + +// FloatingPopup can be passed as the popper slot + + +; + +// FloatingPopup-specific slotProps compile without `as any` + + +; + +// Arrow + FloatingPopup-specific props + + +; + +// Standard Popper props still accepted alongside FloatingPopup props + + +; + +// Middleware as a variable +const middleware: Middleware[] = [offset(8), flip(), shift()]; + + +; + +// Without FloatingPopup slot — standard Popper slotProps still work + + +; + +// slotProps.popper as a function (ownerState callback form) + ({ + strategy: 'fixed' as const, + middleware: [offset(8)], + }), + }} +> + +; + +// All slot overrides together + + +; diff --git a/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx b/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx index 1c219a8b7528a1..d48dae174d7bf2 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx +++ b/packages/mui-material/src/Tooltip/Tooltip.floating.test.tsx @@ -415,7 +415,7 @@ describe('', () => { title="Persistent" open={false} slots={{ popper: FloatingPopup }} - slotProps={{ popper: { keepMounted: true } as any }} + slotProps={{ popper: { keepMounted: true } }} > , @@ -429,7 +429,7 @@ describe('', () => { title="Hidden persistent" open={false} slots={{ popper: FloatingPopup }} - slotProps={{ popper: { keepMounted: true } as any }} + slotProps={{ popper: { keepMounted: true } }} > , @@ -450,7 +450,7 @@ describe('', () => { title="Data attr" open slots={{ popper: FloatingPopup }} - slotProps={{ popper: { 'data-testid': 'my-popper' } as any }} + slotProps={{ popper: { 'data-testid': 'my-popper' } as Record }} > , @@ -464,7 +464,7 @@ describe('', () => { title="Fixed strategy" open slots={{ popper: FloatingPopup }} - slotProps={{ popper: { strategy: 'fixed' } as any }} + slotProps={{ popper: { strategy: 'fixed' } }} > , @@ -583,7 +583,7 @@ describe('', () => { title="Inline" open slots={{ popper: FloatingPopup }} - slotProps={{ popper: { disablePortal: true } as any }} + slotProps={{ popper: { disablePortal: true } }} > , @@ -644,7 +644,7 @@ describe('', () => { title="With middleware" open slots={{ popper: FloatingPopup }} - slotProps={{ popper: { middleware: [offset(20)] } as any }} + slotProps={{ popper: { middleware: [offset(20)] } }} > , @@ -665,7 +665,7 @@ describe('', () => { open arrow slots={{ popper: FloatingPopup }} - slotProps={{ popper: { middleware: [offset(12)] } as any }} + slotProps={{ popper: { middleware: [offset(12)] } }} > , diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js index 2fc1f6dfae1ff4..c24131c2b51615 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.js +++ b/packages/mui-material/src/Tooltip/Tooltip.js @@ -758,7 +758,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { role="tooltip" popperOptions={popperOptions} arrowRef={arrowRef} - arrowPadding={4} + arrowPadding={popperSlotProps.arrowPadding ?? 4} > {({ TransitionProps: TransitionPropsInner }) => ( Date: Tue, 31 Mar 2026 03:12:48 +0800 Subject: [PATCH 3/3] fix exports --- packages/mui-material/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mui-material/package.json b/packages/mui-material/package.json index 2b41bb1def1c5a..a9568d430a1140 100644 --- a/packages/mui-material/package.json +++ b/packages/mui-material/package.json @@ -100,6 +100,7 @@ "./ClickAwayListener": "./src/ClickAwayListener/index.ts", "./darkScrollbar": "./src/darkScrollbar/index.ts", "./DefaultPropsProvider": "./src/DefaultPropsProvider/index.ts", + "./FloatingPopup": "./src/FloatingPopup/index.ts", "./generateUtilityClass": "./src/generateUtilityClass/index.ts", "./generateUtilityClasses": "./src/generateUtilityClasses/index.ts", "./Grid": "./src/Grid/index.ts",