diff --git a/docs/pages/material-ui/api/button-group.json b/docs/pages/material-ui/api/button-group.json index ef1eb072c05e1b..a89d57e49e857b 100644 --- a/docs/pages/material-ui/api/button-group.json +++ b/docs/pages/material-ui/api/button-group.json @@ -54,6 +54,7 @@ "text", "disableElevation", "disabled", + "firstButton", "fullWidth", "vertical", "grouped", @@ -73,7 +74,9 @@ "groupedContainedHorizontal", "groupedContainedVertical", "groupedContainedPrimary", - "groupedContainedSecondary" + "groupedContainedSecondary", + "lastButton", + "middleButton" ], "globalClasses": { "disabled": "Mui-disabled" }, "name": "MuiButtonGroup" diff --git a/docs/translations/api-docs/button-group/button-group.json b/docs/translations/api-docs/button-group/button-group.json index 4f813acc0ba716..d2479a7bb3dee2 100644 --- a/docs/translations/api-docs/button-group/button-group.json +++ b/docs/translations/api-docs/button-group/button-group.json @@ -56,6 +56,10 @@ "nodeName": "the child elements", "conditions": "disabled={true}" }, + "firstButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the first button in the button group" + }, "fullWidth": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", @@ -151,6 +155,14 @@ "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the children", "conditions": "variant=\"contained\" and color=\"secondary\"" + }, + "lastButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the last button in the button group" + }, + "middleButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "buttons in the middle of the button group" } } } diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index dd26bdd925f4f2..ad9fb46f49afc8 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -11,6 +11,7 @@ import ButtonBase from '../ButtonBase'; import capitalize from '../utils/capitalize'; import buttonClasses, { getButtonUtilityClass } from './buttonClasses'; import ButtonGroupContext from '../ButtonGroup/ButtonGroupContext'; +import ButtonGroupButtonContext from '../ButtonGroup/ButtonGroupButtonContext'; const useUtilityClasses = (ownerState) => { const { color, disableElevation, fullWidth, size, variant, classes } = ownerState; @@ -298,6 +299,7 @@ const ButtonEndIcon = styled('span', { const Button = React.forwardRef(function Button(inProps, ref) { // props priority: `inProps` > `contextProps` > `themeDefaultProps` const contextProps = React.useContext(ButtonGroupContext); + const buttonGroupButtonContextPositionClassName = React.useContext(ButtonGroupButtonContext); const resolvedProps = resolveProps(contextProps, inProps); const props = useThemeProps({ props: resolvedProps, name: 'MuiButton' }); const { @@ -345,10 +347,12 @@ const Button = React.forwardRef(function Button(inProps, ref) { ); + const positionClassName = buttonGroupButtonContextPositionClassName || ''; + return ( { const { ownerState } = props; @@ -27,6 +28,15 @@ const overridesResolver = (props, styles) => { [`& .${buttonGroupClasses.grouped}`]: styles[`grouped${capitalize(ownerState.variant)}${capitalize(ownerState.color)}`], }, + { + [`& .${buttonGroupClasses.firstButton}`]: styles.firstButton, + }, + { + [`& .${buttonGroupClasses.lastButton}`]: styles.lastButton, + }, + { + [`& .${buttonGroupClasses.middleButton}`]: styles.middleButton, + }, styles.root, styles[ownerState.variant], ownerState.disableElevation === true && styles.disableElevation, @@ -55,6 +65,9 @@ const useUtilityClasses = (ownerState) => { `grouped${capitalize(variant)}${capitalize(color)}`, disabled && 'disabled', ], + firstButton: ['firstButton'], + lastButton: ['lastButton'], + middleButton: ['middleButton'], }; return composeClasses(slots, getButtonGroupUtilityClass, classes); @@ -81,106 +94,106 @@ const ButtonGroupRoot = styled('div', { }), [`& .${buttonGroupClasses.grouped}`]: { minWidth: 40, - '&:not(:first-of-type)': { - ...(ownerState.orientation === 'horizontal' && { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - }), - ...(ownerState.orientation === 'vertical' && { - borderTopRightRadius: 0, - borderTopLeftRadius: 0, + '&:hover': { + ...(ownerState.variant === 'contained' && { + boxShadow: 'none', }), - ...(ownerState.variant === 'outlined' && - ownerState.orientation === 'horizontal' && { - marginLeft: -1, - }), - ...(ownerState.variant === 'outlined' && - ownerState.orientation === 'vertical' && { - marginTop: -1, - }), }, - '&:not(:last-of-type)': { - ...(ownerState.orientation === 'horizontal' && { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, + ...(ownerState.variant === 'contained' && { + boxShadow: 'none', + }), + }, + [`& .${buttonGroupClasses.firstButton},& .${buttonGroupClasses.middleButton}`]: { + ...(ownerState.orientation === 'horizontal' && { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }), + ...(ownerState.orientation === 'vertical' && { + borderBottomRightRadius: 0, + borderBottomLeftRadius: 0, + }), + ...(ownerState.variant === 'text' && + ownerState.orientation === 'horizontal' && { + borderRight: theme.vars + ? `1px solid rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.23)` + : `1px solid ${ + theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)' + }`, + [`&.${buttonGroupClasses.disabled}`]: { + borderRight: `1px solid ${(theme.vars || theme).palette.action.disabled}`, + }, }), - ...(ownerState.orientation === 'vertical' && { - borderBottomRightRadius: 0, - borderBottomLeftRadius: 0, + ...(ownerState.variant === 'text' && + ownerState.orientation === 'vertical' && { + borderBottom: theme.vars + ? `1px solid rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.23)` + : `1px solid ${ + theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)' + }`, + [`&.${buttonGroupClasses.disabled}`]: { + borderBottom: `1px solid ${(theme.vars || theme).palette.action.disabled}`, + }, }), - ...(ownerState.variant === 'text' && - ownerState.orientation === 'horizontal' && { - borderRight: theme.vars - ? `1px solid rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.23)` - : `1px solid ${ - theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)' - }`, - [`&.${buttonGroupClasses.disabled}`]: { - borderRight: `1px solid ${(theme.vars || theme).palette.action.disabled}`, - }, - }), - ...(ownerState.variant === 'text' && - ownerState.orientation === 'vertical' && { - borderBottom: theme.vars - ? `1px solid rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.23)` - : `1px solid ${ - theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)' - }`, - [`&.${buttonGroupClasses.disabled}`]: { - borderBottom: `1px solid ${(theme.vars || theme).palette.action.disabled}`, - }, - }), - ...(ownerState.variant === 'text' && - ownerState.color !== 'inherit' && { - borderColor: theme.vars - ? `rgba(${theme.vars.palette[ownerState.color].mainChannel} / 0.5)` - : alpha(theme.palette[ownerState.color].main, 0.5), - }), + ...(ownerState.variant === 'text' && + ownerState.color !== 'inherit' && { + borderColor: theme.vars + ? `rgba(${theme.vars.palette[ownerState.color].mainChannel} / 0.5)` + : alpha(theme.palette[ownerState.color].main, 0.5), + }), + ...(ownerState.variant === 'outlined' && + ownerState.orientation === 'horizontal' && { + borderRightColor: 'transparent', + }), + ...(ownerState.variant === 'outlined' && + ownerState.orientation === 'vertical' && { + borderBottomColor: 'transparent', + }), + ...(ownerState.variant === 'contained' && + ownerState.orientation === 'horizontal' && { + borderRight: `1px solid ${(theme.vars || theme).palette.grey[400]}`, + [`&.${buttonGroupClasses.disabled}`]: { + borderRight: `1px solid ${(theme.vars || theme).palette.action.disabled}`, + }, + }), + ...(ownerState.variant === 'contained' && + ownerState.orientation === 'vertical' && { + borderBottom: `1px solid ${(theme.vars || theme).palette.grey[400]}`, + [`&.${buttonGroupClasses.disabled}`]: { + borderBottom: `1px solid ${(theme.vars || theme).palette.action.disabled}`, + }, + }), + ...(ownerState.variant === 'contained' && + ownerState.color !== 'inherit' && { + borderColor: (theme.vars || theme).palette[ownerState.color].dark, + }), + '&:hover': { ...(ownerState.variant === 'outlined' && ownerState.orientation === 'horizontal' && { - borderRightColor: 'transparent', + borderRightColor: 'currentColor', }), ...(ownerState.variant === 'outlined' && ownerState.orientation === 'vertical' && { - borderBottomColor: 'transparent', + borderBottomColor: 'currentColor', }), - ...(ownerState.variant === 'contained' && - ownerState.orientation === 'horizontal' && { - borderRight: `1px solid ${(theme.vars || theme).palette.grey[400]}`, - [`&.${buttonGroupClasses.disabled}`]: { - borderRight: `1px solid ${(theme.vars || theme).palette.action.disabled}`, - }, - }), - ...(ownerState.variant === 'contained' && - ownerState.orientation === 'vertical' && { - borderBottom: `1px solid ${(theme.vars || theme).palette.grey[400]}`, - [`&.${buttonGroupClasses.disabled}`]: { - borderBottom: `1px solid ${(theme.vars || theme).palette.action.disabled}`, - }, - }), - ...(ownerState.variant === 'contained' && - ownerState.color !== 'inherit' && { - borderColor: (theme.vars || theme).palette[ownerState.color].dark, - }), - '&:hover': { - ...(ownerState.variant === 'outlined' && - ownerState.orientation === 'horizontal' && { - borderRightColor: 'currentColor', - }), - ...(ownerState.variant === 'outlined' && - ownerState.orientation === 'vertical' && { - borderBottomColor: 'currentColor', - }), - }, }, - '&:hover': { - ...(ownerState.variant === 'contained' && { - boxShadow: 'none', - }), - }, - ...(ownerState.variant === 'contained' && { - boxShadow: 'none', + }, + [`& .${buttonGroupClasses.lastButton},& .${buttonGroupClasses.middleButton}`]: { + ...(ownerState.orientation === 'horizontal' && { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }), + ...(ownerState.orientation === 'vertical' && { + borderTopRightRadius: 0, + borderTopLeftRadius: 0, }), + ...(ownerState.variant === 'outlined' && + ownerState.orientation === 'horizontal' && { + marginLeft: -1, + }), + ...(ownerState.variant === 'outlined' && + ownerState.orientation === 'vertical' && { + marginTop: -1, + }), }, })); @@ -243,6 +256,22 @@ const ButtonGroup = React.forwardRef(function ButtonGroup(inProps, ref) { ], ); + const getButtonPositionClassName = (index, childrenParam) => { + const isFirstButton = index === 0; + const isLastButton = index === React.Children.count(childrenParam) - 1; + + if (isFirstButton && isLastButton) { + return ''; + } + if (isFirstButton) { + return classes.firstButton; + } + if (isLastButton) { + return classes.lastButton; + } + return classes.middleButton; + }; + return ( - {children} + + {React.Children.map(children, (child, index) => { + if (!React.isValidElement(child)) { + return child; + } + + return ( + + {child} + + ); + })} + ); }); diff --git a/packages/mui-material/src/ButtonGroup/ButtonGroup.test.js b/packages/mui-material/src/ButtonGroup/ButtonGroup.test.js index 283625bd5ef426..e79439f7b13cba 100644 --- a/packages/mui-material/src/ButtonGroup/ButtonGroup.test.js +++ b/packages/mui-material/src/ButtonGroup/ButtonGroup.test.js @@ -209,4 +209,46 @@ describe('', () => { expect(screen.getByRole('button')).to.have.class(buttonClasses.outlinedSecondary); }); }); + + describe('position classes', () => { + it('correctly applies position classes to buttons', () => { + render( + + + + + , + ); + + const firstButton = screen.getAllByRole('button')[0]; + const middleButton = screen.getAllByRole('button')[1]; + const lastButton = screen.getAllByRole('button')[2]; + + expect(firstButton).to.have.class(classes.firstButton); + expect(firstButton).not.to.have.class(classes.middleButton); + expect(firstButton).not.to.have.class(classes.lastButton); + + expect(middleButton).to.have.class(classes.middleButton); + expect(middleButton).not.to.have.class(classes.firstButton); + expect(middleButton).not.to.have.class(classes.lastButton); + + expect(lastButton).to.have.class(classes.lastButton); + expect(lastButton).not.to.have.class(classes.middleButton); + expect(lastButton).not.to.have.class(classes.firstButton); + }); + + it('does not apply any position classes to a single button', () => { + render( + + + , + ); + + const button = screen.getByRole('button'); + + expect(button).not.to.have.class(classes.firstButton); + expect(button).not.to.have.class(classes.middleButton); + expect(button).not.to.have.class(classes.lastButton); + }); + }); }); diff --git a/packages/mui-material/src/ButtonGroup/ButtonGroupButtonContext.ts b/packages/mui-material/src/ButtonGroup/ButtonGroupButtonContext.ts new file mode 100644 index 00000000000000..8a93fe171954ef --- /dev/null +++ b/packages/mui-material/src/ButtonGroup/ButtonGroupButtonContext.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; + +type ButtonPositionClassName = string; + +/** + * @ignore - internal component. + */ +const ButtonGroupButtonContext = React.createContext( + undefined, +); + +if (process.env.NODE_ENV !== 'production') { + ButtonGroupButtonContext.displayName = 'ButtonGroupButtonContext'; +} + +export default ButtonGroupButtonContext; diff --git a/packages/mui-material/src/ButtonGroup/buttonGroupClasses.ts b/packages/mui-material/src/ButtonGroup/buttonGroupClasses.ts index 55dec11fdfe720..433083e3ae20f9 100644 --- a/packages/mui-material/src/ButtonGroup/buttonGroupClasses.ts +++ b/packages/mui-material/src/ButtonGroup/buttonGroupClasses.ts @@ -14,6 +14,8 @@ export interface ButtonGroupClasses { disableElevation: string; /** State class applied to the child elements if `disabled={true}`. */ disabled: string; + /** Styles applied to the first button in the button group. */ + firstButton: string; /** Styles applied to the root element if `fullWidth={true}`. */ fullWidth: string; /** Styles applied to the root element if `orientation="vertical"`. */ @@ -54,6 +56,10 @@ export interface ButtonGroupClasses { groupedContainedPrimary: string; /** Styles applied to the children if `variant="contained"` and `color="secondary"`. */ groupedContainedSecondary: string; + /** Styles applied to the last button in the button group. */ + lastButton: string; + /** Styles applied to buttons in the middle of the button group. */ + middleButton: string; } export type ButtonGroupClassKey = keyof ButtonGroupClasses; @@ -69,6 +75,7 @@ const buttonGroupClasses: ButtonGroupClasses = generateUtilityClasses('MuiButton 'text', 'disableElevation', 'disabled', + 'firstButton', 'fullWidth', 'vertical', 'grouped', @@ -89,6 +96,8 @@ const buttonGroupClasses: ButtonGroupClasses = generateUtilityClasses('MuiButton 'groupedContainedVertical', 'groupedContainedPrimary', 'groupedContainedSecondary', + 'lastButton', + 'middleButton', ]); export default buttonGroupClasses; diff --git a/test/regressions/fixtures/ButtonGroup/DifferentChildren.js b/test/regressions/fixtures/ButtonGroup/DifferentChildren.js new file mode 100644 index 00000000000000..44f107dcc0badd --- /dev/null +++ b/test/regressions/fixtures/ButtonGroup/DifferentChildren.js @@ -0,0 +1,40 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; + +export default function DifferentChildren() { + return ( + + {/* It has one button with href which is rendered as anchor tag */} + + + + + + + {/* With tooltip */} + + + + + + + + + + + + + + + + + {/* Single button */} + + + + + ); +}