diff --git a/docs/data/material/components/speed-dial/speed-dial.md b/docs/data/material/components/speed-dial/speed-dial.md index bb13c916cc39e2..345322b17034f5 100644 --- a/docs/data/material/components/speed-dial/speed-dial.md +++ b/docs/data/material/components/speed-dial/speed-dial.md @@ -41,9 +41,17 @@ of the `SpeedDialIcon` component. ## Persistent action tooltips -The SpeedDialActions tooltips can be displayed persistently so that users don't have to long-press to see the tooltip on touch devices. +The SpeedDialActions tooltips can be displayed persistently with `slotProps.tooltip.open` so that users don't have to long-press to see the tooltip on touch devices. -It is enabled here across all devices for demo purposes, but in production it could use the `isTouch` logic to conditionally set the prop. +It is enabled here across all devices for demo purposes, but in production it could use the `isTouch` logic to conditionally set it. + +The labels are placed to the left of the actions for the `up` and `down` directions, and above them for `left` and `right`. +Use `slotProps.tooltip.placement` to move them to the opposite side: `right` for vertical speed dials, or `bottom` for horizontal ones. +Other combinations make the labels cover the neighboring actions. +Compound placements like `top-start` are centered like `top`. + +In horizontal speed dials, each action is as wide as its label so that the labels don't overlap. +The actions keep that space while the speed dial is closed. {{"demo": "SpeedDialTooltipOpen.js"}} @@ -66,7 +74,7 @@ Use `transitionDuration` to control the timing. - The Fab has `aria-haspopup`, `aria-expanded` and `aria-controls` attributes. - The speed dial actions container has `role="menu"` and `aria-orientation` set according to the direction. -- The speed dial actions have `role="menuitem"`, and an `aria-describedby` attribute that references the associated tooltip. +- The speed dial actions have `role="menuitem"` and are labeled by their tooltip title. ### Keyboard diff --git a/docs/pages/material-ui/api/speed-dial-action.json b/docs/pages/material-ui/api/speed-dial-action.json index 3e9e0e225d1857..e5daca77e660b3 100644 --- a/docs/pages/material-ui/api/speed-dial-action.json +++ b/docs/pages/material-ui/api/speed-dial-action.json @@ -68,19 +68,31 @@ { "key": "staticTooltipClosed", "className": "MuiSpeedDialAction-staticTooltipClosed", - "description": "Styles applied to the root element if `tooltipOpen={true}` and `open={false}`.", + "description": "Styles applied to the root element if `slotProps.tooltip.open` is `true` and `open` is `false`.", + "isGlobal": false + }, + { + "key": "tooltipPlacementBottom", + "className": "MuiSpeedDialAction-tooltipPlacementBottom", + "description": "Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains \"bottom\".", "isGlobal": false }, { "key": "tooltipPlacementLeft", "className": "MuiSpeedDialAction-tooltipPlacementLeft", - "description": "Styles applied to the root element if `tooltipOpen={true}` and `tooltipPlacement=\"left\"``", + "description": "Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains \"left\".", "isGlobal": false }, { "key": "tooltipPlacementRight", "className": "MuiSpeedDialAction-tooltipPlacementRight", - "description": "Styles applied to the root element if `tooltipOpen={true}` and `tooltipPlacement=\"right\"``", + "description": "Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains \"right\".", + "isGlobal": false + }, + { + "key": "tooltipPlacementTop", + "className": "MuiSpeedDialAction-tooltipPlacementTop", + "description": "Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains \"top\".", "isGlobal": false } ], diff --git a/docs/translations/api-docs/speed-dial-action/speed-dial-action.json b/docs/translations/api-docs/speed-dial-action/speed-dial-action.json index 76cf8ad3e6888c..e13f0424cee957 100644 --- a/docs/translations/api-docs/speed-dial-action/speed-dial-action.json +++ b/docs/translations/api-docs/speed-dial-action/speed-dial-action.json @@ -25,13 +25,27 @@ "staticTooltipClosed": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", - "conditions": "tooltipOpen={true} and open={false}" + "conditions": "slotProps.tooltip.open is true and open is false" + }, + "tooltipPlacementBottom": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "slotProps.tooltip.open is true and slotProps.tooltip.placement contains "bottom"" }, "tooltipPlacementLeft": { - "description": "Styles applied to the root element if tooltipOpen={true} and `tooltipPlacement="left"``" + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "slotProps.tooltip.open is true and slotProps.tooltip.placement contains "left"" }, "tooltipPlacementRight": { - "description": "Styles applied to the root element if tooltipOpen={true} and `tooltipPlacement="right"``" + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "slotProps.tooltip.open is true and slotProps.tooltip.placement contains "right"" + }, + "tooltipPlacementTop": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "slotProps.tooltip.open is true and slotProps.tooltip.placement contains "top"" } }, "slotDescriptions": { diff --git a/packages/mui-material/src/SpeedDial/SpeedDial.test.js b/packages/mui-material/src/SpeedDial/SpeedDial.test.js index 6b85c65d8b0c8e..1fa9d139f0d726 100644 --- a/packages/mui-material/src/SpeedDial/SpeedDial.test.js +++ b/packages/mui-material/src/SpeedDial/SpeedDial.test.js @@ -11,7 +11,7 @@ import { } from '@mui/internal-test-utils'; import Icon from '@mui/material/Icon'; import SpeedDial, { speedDialClasses as classes } from '@mui/material/SpeedDial'; -import SpeedDialAction from '@mui/material/SpeedDialAction'; +import SpeedDialAction, { speedDialActionClasses } from '@mui/material/SpeedDialAction'; import { tooltipClasses } from '@mui/material/Tooltip'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import describeConformance from '../../test/describeConformance'; @@ -207,7 +207,94 @@ describe('', () => { clock.runAll(); expect(screen.getByRole('tooltip').firstChild).to.have.class(tooltipClasses[className]); }); + + it(`should place the persistent tooltip in the correct position when direction=${direction}`, () => { + const { container } = render( + + + , + ); + + expect(container.querySelector(`.${speedDialActionClasses.staticTooltip}`)).to.have.class( + speedDialActionClasses[className], + ); + }); }); + + ['left', 'right'].forEach((direction) => { + it.skipIf(isJsdom())( + `should lay out persistent tooltips side by side when direction=${direction}`, + () => { + const titles = ['Copy', 'Save', 'Print this page', 'Large']; + const { setProps } = render( + + {titles.map((title) => ( + + ))} + , + ); + + const fabs = screen.getAllByRole('menuitem').map((fab) => fab.getBoundingClientRect()); + const labels = titles.map((title) => screen.getByText(title).getBoundingClientRect()); + const verticalCenter = (rect) => rect.top + rect.height / 2; + + // Each label sits 16px above its own Fab, on a single line. + labels.forEach((label, index) => { + expect(Math.abs(fabs[index].top - label.bottom - 16)).to.be.lessThan(1); + expect(label.height).to.equal(labels[0].height); + }); + // Labels stay 16px apart, in whichever order the direction shows them. + const labelsFromLeft = [...labels].sort((a, b) => a.left - b.left); + for (let i = 1; i < labelsFromLeft.length; i += 1) { + expect(Math.round(labelsFromLeft[i].left - labelsFromLeft[i - 1].right)).to.be.at.least( + 16, + ); + } + // Fabs of different sizes stay on one center line. + expect(fabs[3].height).to.be.greaterThan(fabs[0].height); + fabs.forEach((fab) => { + expect(Math.abs(verticalCenter(fab) - verticalCenter(fabs[0]))).to.be.lessThan(1); + }); + + // The labels keep their space while the speed dial is closed, so closing it moves nothing. + const openActions = screen.getByRole('menu').getBoundingClientRect(); + setProps({ open: false }); + const closedActions = screen.getByRole('menu').getBoundingClientRect(); + expect(closedActions.left).to.equal(openActions.left); + expect(closedActions.width).to.equal(openActions.width); + }, + ); + }); + + it.skipIf(isJsdom())( + 'should line up persistent tooltips when direction is vertical and action sizes differ', + () => { + render( + + + + , + ); + + const small = screen.getByText('Small').getBoundingClientRect(); + const large = screen.getByText('Large').getBoundingClientRect(); + + expect(Math.abs(small.right - large.right)).to.be.lessThan(1); + }, + ); }); describe('keyboard', () => { diff --git a/packages/mui-material/src/SpeedDialAction/SpeedDialAction.js b/packages/mui-material/src/SpeedDialAction/SpeedDialAction.js index 86c6a96f7a9810..d7f2cdae25641c 100644 --- a/packages/mui-material/src/SpeedDialAction/SpeedDialAction.js +++ b/packages/mui-material/src/SpeedDialAction/SpeedDialAction.js @@ -125,6 +125,48 @@ const SpeedDialActionStaticTooltip = styled('span', { }, }, }, + { + props: { + tooltipPlacement: 'top', + }, + style: { + // The label sits in a zero-height row, so it widens the action to keep + // neighboring labels apart without making the action taller. + display: 'grid', + gridTemplateRows: '0 auto', + justifyItems: 'center', + // Stay as tall as the Fab when a sibling action is larger. + alignSelf: 'center', + [`& .${speedDialActionClasses.staticTooltipLabel}`]: { + position: 'static', + alignSelf: 'end', + transformOrigin: '50% 100%', + marginBottom: 8, + marginLeft: 8, + marginRight: 8, + }, + }, + }, + { + props: { + tooltipPlacement: 'bottom', + }, + style: { + display: 'grid', + gridTemplateRows: 'auto 0', + justifyItems: 'center', + alignSelf: 'center', + [`& .${speedDialActionClasses.staticTooltipLabel}`]: { + position: 'static', + gridRow: 2, + alignSelf: 'start', + transformOrigin: '50% 0%', + marginTop: 8, + marginLeft: 8, + marginRight: 8, + }, + }, + }, ], })), ); @@ -153,7 +195,12 @@ const SpeedDialAction = React.forwardRef(function SpeedDialAction(inProps, ref) const resolvedTooltipSlotProps = typeof slotProps.tooltip === 'function' ? slotProps.tooltip(props) : (slotProps.tooltip ?? {}); - const tooltipPlacement = resolvedTooltipSlotProps.placement ?? 'left'; + // The label is styled per side: compound placements like `top-start` use their side, + // and placements without one, like `auto`, fall back to the left. + const placementSide = String(resolvedTooltipSlotProps.placement).split('-')[0]; + const tooltipPlacement = ['top', 'right', 'bottom', 'left'].includes(placementSide) + ? placementSide + : 'left'; const ownerState = { ...props, tooltipPlacement }; const classes = useUtilityClasses(ownerState); diff --git a/packages/mui-material/src/SpeedDialAction/SpeedDialAction.test.js b/packages/mui-material/src/SpeedDialAction/SpeedDialAction.test.js index 7d07866a91719a..908009d432bca2 100644 --- a/packages/mui-material/src/SpeedDialAction/SpeedDialAction.test.js +++ b/packages/mui-material/src/SpeedDialAction/SpeedDialAction.test.js @@ -199,6 +199,63 @@ describe('', () => { expect(staticToolTipLabel).to.have.class(classes.staticTooltipLabel); }); + [ + ['top-start', 'tooltipPlacementTop'], + ['auto', 'tooltipPlacementLeft'], + ].forEach(([placement, className]) => { + it(`styles the static tooltip with ${className} when placement is ${placement}`, () => { + const { container } = render( + add} + slotProps={{ tooltip: { open: true, placement, title: 'placeholder' } }} + />, + ); + + expect(container.querySelector(`.${classes.staticTooltip}`)).to.have.class( + classes[className], + ); + }); + }); + + describe.skipIf(isJsdom())('static tooltip label layout', () => { + function center(rect, axis) { + return axis === 'x' ? rect.left + rect.width / 2 : rect.top + rect.height / 2; + } + + [ + ['top', 'x', (label, fab) => fab.top - label.bottom], + ['bottom', 'x', (label, fab) => label.top - fab.bottom], + ['left', 'y', (label, fab) => fab.left - label.right], + ['right', 'y', (label, fab) => label.left - fab.right], + ['top-start', 'x', (label, fab) => fab.top - label.bottom], + ['left-end', 'y', (label, fab) => fab.left - label.right], + ['auto', 'y', (label, fab) => fab.left - label.right], + ].forEach(([placement, axis, getGap]) => { + it(`places the label next to the Fab when placement is ${placement}`, () => { + // SpeedDial lays out its actions in a flex container. + const { container } = render( +
+ add} + open + slotProps={{ tooltip: { open: true, placement, title: 'placeholder' } }} + /> +
, + ); + + const root = container.querySelector(`.${classes.staticTooltip}`).getBoundingClientRect(); + const label = screen.getByText('placeholder').getBoundingClientRect(); + const fab = screen.getByRole('menuitem').getBoundingClientRect(); + + // 8px margin on the label plus 8px margin on the Fab. + expect(Math.abs(getGap(label, fab) - 16)).to.be.lessThan(1); + expect(Math.abs(center(label, axis) - center(fab, axis))).to.be.lessThan(1); + // The label doesn't make the action taller. + expect(Math.abs(root.height - (fab.height + 16))).to.be.lessThan(1); + }); + }); + }); + it('should have staticToolTip and staticToolTipLabel classes if slotProps.tooltip.open is true and custom slots are provided', () => { const CustomStaticTooltip = React.forwardRef(({ ownerState, ...props }, ref) => (
diff --git a/packages/mui-material/src/SpeedDialAction/speedDialActionClasses.ts b/packages/mui-material/src/SpeedDialAction/speedDialActionClasses.ts index 500cb4d75c6d29..680b36a56e51aa 100644 --- a/packages/mui-material/src/SpeedDialAction/speedDialActionClasses.ts +++ b/packages/mui-material/src/SpeedDialAction/speedDialActionClasses.ts @@ -6,16 +6,20 @@ export interface SpeedDialActionClasses { fab: string; /** Styles applied to the Fab component if `open={false}`. */ fabClosed: string; - /** Styles applied to the root element if `tooltipOpen={true}`. */ + /** Styles applied to the root element if `slotProps.tooltip.open` is `true`. */ staticTooltip: string; - /** Styles applied to the root element if `tooltipOpen={true}` and `open={false}`. */ + /** Styles applied to the root element if `slotProps.tooltip.open` is `true` and `open` is `false`. */ staticTooltipClosed: string; - /** Styles applied to the static tooltip label if `tooltipOpen={true}`. */ + /** Styles applied to the static tooltip label if `slotProps.tooltip.open` is `true`. */ staticTooltipLabel: string; - /** Styles applied to the root element if `tooltipOpen={true}` and `tooltipPlacement="left"`` */ + /** Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains "left". */ tooltipPlacementLeft: string; - /** Styles applied to the root element if `tooltipOpen={true}` and `tooltipPlacement="right"`` */ + /** Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains "right". */ tooltipPlacementRight: string; + /** Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains "top". */ + tooltipPlacementTop: string; + /** Styles applied to the root element if `slotProps.tooltip.open` is `true` and `slotProps.tooltip.placement` contains "bottom". */ + tooltipPlacementBottom: string; } export type SpeedDialActionClassKey = keyof SpeedDialActionClasses; @@ -34,6 +38,8 @@ const speedDialActionClasses: SpeedDialActionClasses = generateUtilityClasses( 'staticTooltipLabel', 'tooltipPlacementLeft', 'tooltipPlacementRight', + 'tooltipPlacementTop', + 'tooltipPlacementBottom', ], ); diff --git a/test/regressions/fixtures/SpeedDial/Directions.js b/test/regressions/fixtures/SpeedDial/Directions.js index 84b5eeed054e56..06f621894e78d5 100644 --- a/test/regressions/fixtures/SpeedDial/Directions.js +++ b/test/regressions/fixtures/SpeedDial/Directions.js @@ -39,13 +39,15 @@ SimpleSpeedDial.propTypes = { function Directions() { return ( - - {['up', 'down'].map((direction) => ( + + {['up', 'down', 'left', 'right'].map((direction) => (