Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/data/material/components/speed-dial/speed-dial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}

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

Expand Down
18 changes: 15 additions & 3 deletions docs/pages/material-ui/api/speed-dial-action.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,27 @@
"staticTooltipClosed": {
"description": "Styles applied to {{nodeName}} if {{conditions}}.",
"nodeName": "the root element",
"conditions": "<code>tooltipOpen={true}</code> and <code>open={false}</code>"
"conditions": "<code>slotProps.tooltip.open</code> is <code>true</code> and <code>open</code> is <code>false</code>"
},
"tooltipPlacementBottom": {
"description": "Styles applied to {{nodeName}} if {{conditions}}.",
"nodeName": "the root element",
"conditions": "<code>slotProps.tooltip.open</code> is <code>true</code> and <code>slotProps.tooltip.placement</code> contains &quot;bottom&quot;"
},
"tooltipPlacementLeft": {
"description": "Styles applied to the root element if <code>tooltipOpen={true}</code> and `tooltipPlacement=&quot;left&quot;``"
"description": "Styles applied to {{nodeName}} if {{conditions}}.",
"nodeName": "the root element",
"conditions": "<code>slotProps.tooltip.open</code> is <code>true</code> and <code>slotProps.tooltip.placement</code> contains &quot;left&quot;"
},
"tooltipPlacementRight": {
"description": "Styles applied to the root element if <code>tooltipOpen={true}</code> and `tooltipPlacement=&quot;right&quot;``"
"description": "Styles applied to {{nodeName}} if {{conditions}}.",
"nodeName": "the root element",
"conditions": "<code>slotProps.tooltip.open</code> is <code>true</code> and <code>slotProps.tooltip.placement</code> contains &quot;right&quot;"
},
"tooltipPlacementTop": {
"description": "Styles applied to {{nodeName}} if {{conditions}}.",
"nodeName": "the root element",
"conditions": "<code>slotProps.tooltip.open</code> is <code>true</code> and <code>slotProps.tooltip.placement</code> contains &quot;top&quot;"
}
},
"slotDescriptions": {
Expand Down
89 changes: 88 additions & 1 deletion packages/mui-material/src/SpeedDial/SpeedDial.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -207,7 +207,94 @@ describe('<SpeedDial />', () => {
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(
<SpeedDial {...defaultProps} direction={direction}>
<SpeedDialAction
icon={icon}
slotProps={{ tooltip: { open: true, title: 'action1' } }}
/>
</SpeedDial>,
);

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(
<SpeedDial {...defaultProps} direction={direction}>
{titles.map((title) => (
<SpeedDialAction
key={title}
icon={icon}
slotProps={{
fab: title === 'Large' ? { size: 'large' } : {},
tooltip: { open: true, title },
}}
/>
))}
</SpeedDial>,
);

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(
<SpeedDial {...defaultProps} direction="up">
<SpeedDialAction icon={icon} slotProps={{ tooltip: { open: true, title: 'Small' } }} />
<SpeedDialAction
icon={icon}
slotProps={{ fab: { size: 'large' }, tooltip: { open: true, title: 'Large' } }}
/>
</SpeedDial>,
);

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', () => {
Expand Down
49 changes: 48 additions & 1 deletion packages/mui-material/src/SpeedDialAction/SpeedDialAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
],
})),
);
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions packages/mui-material/src/SpeedDialAction/SpeedDialAction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,63 @@ describe('<SpeedDialAction />', () => {
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(
<SpeedDialAction
icon={<Icon>add</Icon>}
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(
<div style={{ display: 'flex' }}>
<SpeedDialAction
icon={<Icon>add</Icon>}
open
slotProps={{ tooltip: { open: true, placement, title: 'placeholder' } }}
/>
</div>,
);

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) => (
<div {...props} ref={ref}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +38,8 @@ const speedDialActionClasses: SpeedDialActionClasses = generateUtilityClasses(
'staticTooltipLabel',
'tooltipPlacementLeft',
'tooltipPlacementRight',
'tooltipPlacementTop',
'tooltipPlacementBottom',
],
);

Expand Down
6 changes: 4 additions & 2 deletions test/regressions/fixtures/SpeedDial/Directions.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ SimpleSpeedDial.propTypes = {

function Directions() {
return (
<Box sx={{ position: 'relative', height: 360, width: 400 }}>
{['up', 'down'].map((direction) => (
<Box sx={{ position: 'relative', height: 360, width: 560 }}>
{['up', 'down', 'left', 'right'].map((direction) => (
<SimpleSpeedDial
sx={{
position: 'absolute',
...(direction === 'up' && { bottom: 0, right: 0 }),
...(direction === 'down' && { top: 0, left: 0 }),
...(direction === 'left' && { top: 0, right: 0 }),
...(direction === 'right' && { bottom: 0, left: 0 }),
}}
key={direction}
ariaLabel={direction}
Expand Down
Loading