diff --git a/docs/data/material/components/toggle-button/CustomizedDividers.js b/docs/data/material/components/toggle-button/CustomizedDividers.js index e4e7a4cc074706..1c7063d44caaaa 100644 --- a/docs/data/material/components/toggle-button/CustomizedDividers.js +++ b/docs/data/material/components/toggle-button/CustomizedDividers.js @@ -12,22 +12,24 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import ToggleButtonGroup, { + toggleButtonGroupClasses, +} from '@mui/material/ToggleButtonGroup'; const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ - '& .MuiToggleButtonGroup-grouped': { + [`& .${toggleButtonGroupClasses.grouped}`]: { margin: theme.spacing(0.5), border: 0, - '&.Mui-disabled': { + borderRadius: theme.shape.borderRadius, + [`&.${toggleButtonGroupClasses.disabled}`]: { border: 0, }, - '&:not(:first-of-type)': { - borderRadius: theme.shape.borderRadius, - }, - '&:first-of-type': { - borderRadius: theme.shape.borderRadius, - }, }, + [`& .${toggleButtonGroupClasses.middleButton},& .${toggleButtonGroupClasses.lastButton}`]: + { + marginLeft: -1, + borderLeft: '1px solid transparent', + }, })); export default function CustomizedDividers() { diff --git a/docs/data/material/components/toggle-button/CustomizedDividers.tsx b/docs/data/material/components/toggle-button/CustomizedDividers.tsx index 92dd37287a2798..63e67aaedbe755 100644 --- a/docs/data/material/components/toggle-button/CustomizedDividers.tsx +++ b/docs/data/material/components/toggle-button/CustomizedDividers.tsx @@ -12,22 +12,24 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import ToggleButtonGroup, { + toggleButtonGroupClasses, +} from '@mui/material/ToggleButtonGroup'; const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ - '& .MuiToggleButtonGroup-grouped': { + [`& .${toggleButtonGroupClasses.grouped}`]: { margin: theme.spacing(0.5), border: 0, - '&.Mui-disabled': { + borderRadius: theme.shape.borderRadius, + [`&.${toggleButtonGroupClasses.disabled}`]: { border: 0, }, - '&:not(:first-of-type)': { - borderRadius: theme.shape.borderRadius, - }, - '&:first-of-type': { - borderRadius: theme.shape.borderRadius, - }, }, + [`& .${toggleButtonGroupClasses.middleButton},& .${toggleButtonGroupClasses.lastButton}`]: + { + marginLeft: -1, + borderLeft: '1px solid transparent', + }, })); export default function CustomizedDividers() { diff --git a/docs/pages/material-ui/api/toggle-button-group.json b/docs/pages/material-ui/api/toggle-button-group.json index e0d7c66bc56cb0..e3d1c10eefb45c 100644 --- a/docs/pages/material-ui/api/toggle-button-group.json +++ b/docs/pages/material-ui/api/toggle-button-group.json @@ -51,6 +51,12 @@ "description": "State class applied to the root element if `disabled={true}`.", "isGlobal": true }, + { + "key": "firstButton", + "className": "MuiToggleButtonGroup-firstButton", + "description": "Styles applied to the first button in the toggle button group.", + "isGlobal": false + }, { "key": "fullWidth", "className": "MuiToggleButtonGroup-fullWidth", @@ -75,6 +81,18 @@ "description": "Styles applied to the children if `orientation=\"vertical\"`.", "isGlobal": false }, + { + "key": "lastButton", + "className": "MuiToggleButtonGroup-lastButton", + "description": "Styles applied to the last button in the toggle button group.", + "isGlobal": false + }, + { + "key": "middleButton", + "className": "MuiToggleButtonGroup-middleButton", + "description": "Styles applied to buttons in the middle of the toggle button group.", + "isGlobal": false + }, { "key": "root", "className": "MuiToggleButtonGroup-root", diff --git a/docs/translations/api-docs/toggle-button-group/toggle-button-group.json b/docs/translations/api-docs/toggle-button-group/toggle-button-group.json index 8768d86fb8b4d3..21eccbcd92b660 100644 --- a/docs/translations/api-docs/toggle-button-group/toggle-button-group.json +++ b/docs/translations/api-docs/toggle-button-group/toggle-button-group.json @@ -37,6 +37,10 @@ "nodeName": "the root element", "conditions": "disabled={true}" }, + "firstButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the first button in the toggle button group" + }, "fullWidth": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", @@ -53,6 +57,14 @@ "nodeName": "the children", "conditions": "orientation=\"vertical\"" }, + "lastButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the last button in the toggle button group" + }, + "middleButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "buttons in the middle of the toggle button group" + }, "root": { "description": "Styles applied to the root element." }, "vertical": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", diff --git a/packages/mui-material/src/ButtonGroup/ButtonGroupContext.ts b/packages/mui-material/src/ButtonGroup/ButtonGroupContext.ts index 63352996156cc5..d336154db99963 100644 --- a/packages/mui-material/src/ButtonGroup/ButtonGroupContext.ts +++ b/packages/mui-material/src/ButtonGroup/ButtonGroupContext.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import type { ButtonGroupProps } from './ButtonGroup'; -interface IButtonGroupContext { +interface ButtonGroupContextType { className?: string; color?: ButtonGroupProps['color']; disabled?: boolean; @@ -16,7 +16,7 @@ interface IButtonGroupContext { /** * @ignore - internal component. */ -const ButtonGroupContext = React.createContext({}); +const ButtonGroupContext = React.createContext({}); if (process.env.NODE_ENV !== 'production') { ButtonGroupContext.displayName = 'ButtonGroupContext'; diff --git a/packages/mui-material/src/ToggleButton/ToggleButton.js b/packages/mui-material/src/ToggleButton/ToggleButton.js index d31b2b23b15e2b..eb7c65a40aa97f 100644 --- a/packages/mui-material/src/ToggleButton/ToggleButton.js +++ b/packages/mui-material/src/ToggleButton/ToggleButton.js @@ -3,6 +3,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; +import { internal_resolveProps as resolveProps } from '@mui/utils'; import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses'; import { alpha } from '../styles'; import ButtonBase from '../ButtonBase'; @@ -10,6 +11,9 @@ import capitalize from '../utils/capitalize'; import useThemeProps from '../styles/useThemeProps'; import styled from '../styles/styled'; import toggleButtonClasses, { getToggleButtonUtilityClass } from './toggleButtonClasses'; +import ToggleButtonGroupContext from '../ToggleButtonGroup/ToggleButtonGroupContext'; +import ToggleButtonGroupButtonContext from '../ToggleButtonGroup/ToggleButtonGroupButtonContext'; +import isValueSelected from '../ToggleButtonGroup/isValueSelected'; const useUtilityClasses = (ownerState) => { const { classes, fullWidth, selected, disabled, size, color } = ownerState; @@ -108,7 +112,16 @@ const ToggleButtonRoot = styled(ButtonBase, { }); const ToggleButton = React.forwardRef(function ToggleButton(inProps, ref) { - const props = useThemeProps({ props: inProps, name: 'MuiToggleButton' }); + // props priority: `inProps` > `contextProps` > `themeDefaultProps` + const { value: contextValue, ...contextProps } = React.useContext(ToggleButtonGroupContext); + const toggleButtonGroupButtonContextPositionClassName = React.useContext( + ToggleButtonGroupButtonContext, + ); + const resolvedProps = resolveProps( + { ...contextProps, selected: isValueSelected(inProps.value, contextValue) }, + inProps, + ); + const props = useThemeProps({ props: resolvedProps, name: 'MuiToggleButton' }); const { children, className, @@ -148,9 +161,11 @@ const ToggleButton = React.forwardRef(function ToggleButton(inProps, ref) { } }; + const positionClassName = toggleButtonGroupButtonContextPositionClassName || ''; + return ( { const { classes, orientation, fullWidth, disabled } = ownerState; @@ -18,6 +21,9 @@ const useUtilityClasses = (ownerState) => { const slots = { root: ['root', orientation === 'vertical' && 'vertical', fullWidth && 'fullWidth'], grouped: ['grouped', `grouped${capitalize(orientation)}`, disabled && 'disabled'], + firstButton: ['firstButton'], + lastButton: ['lastButton'], + middleButton: ['middleButton'], }; return composeClasses(slots, getToggleButtonGroupUtilityClass, classes); @@ -35,6 +41,15 @@ const ToggleButtonGroupRoot = styled('div', { [`& .${toggleButtonGroupClasses.grouped}`]: styles[`grouped${capitalize(ownerState.orientation)}`], }, + { + [`& .${toggleButtonGroupClasses.firstButton}`]: styles.firstButton, + }, + { + [`& .${toggleButtonGroupClasses.lastButton}`]: styles.lastButton, + }, + { + [`& .${toggleButtonGroupClasses.middleButton}`]: styles.middleButton, + }, styles.root, ownerState.orientation === 'vertical' && styles.vertical, ownerState.fullWidth && styles.fullWidth, @@ -52,16 +67,6 @@ const ToggleButtonGroupRoot = styled('div', { [`& .${toggleButtonGroupClasses.grouped}`]: { ...(ownerState.orientation === 'horizontal' ? { - '&:not(:first-of-type)': { - marginLeft: -1, - borderLeft: '1px solid transparent', - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - }, - '&:not(:last-of-type)': { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - }, [`&.${toggleButtonGroupClasses.selected} + .${toggleButtonGroupClasses.grouped}.${toggleButtonGroupClasses.selected}`]: { borderLeft: 0, @@ -69,16 +74,6 @@ const ToggleButtonGroupRoot = styled('div', { }, } : { - '&:not(:first-of-type)': { - marginTop: -1, - borderTop: '1px solid transparent', - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - }, - '&:not(:last-of-type)': { - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - }, [`&.${toggleButtonGroupClasses.selected} + .${toggleButtonGroupClasses.grouped}.${toggleButtonGroupClasses.selected}`]: { borderTop: 0, @@ -86,6 +81,46 @@ const ToggleButtonGroupRoot = styled('div', { }, }), }, + ...(ownerState.orientation === 'horizontal' + ? { + [`& .${toggleButtonGroupClasses.firstButton},& .${toggleButtonGroupClasses.middleButton}`]: + { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + }, + [`& .${toggleButtonGroupClasses.lastButton},& .${toggleButtonGroupClasses.middleButton}`]: { + marginLeft: -1, + borderLeft: '1px solid transparent', + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }, + } + : { + [`& .${toggleButtonGroupClasses.firstButton},& .${toggleButtonGroupClasses.middleButton}`]: + { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + [`& .${toggleButtonGroupClasses.lastButton},& .${toggleButtonGroupClasses.middleButton}`]: { + marginTop: -1, + borderTop: '1px solid transparent', + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + }), + ...(ownerState.orientation === 'horizontal' + ? { + [`& .${toggleButtonGroupClasses.lastButton}.${toggleButtonClasses.disabled},& .${toggleButtonGroupClasses.middleButton}.${toggleButtonClasses.disabled}`]: + { + borderLeft: '1px solid transparent', + }, + } + : { + [`& .${toggleButtonGroupClasses.lastButton}.${toggleButtonClasses.disabled},& .${toggleButtonGroupClasses.middleButton}.${toggleButtonClasses.disabled}`]: + { + borderTop: '1px solid transparent', + }, + }), })); const ToggleButtonGroup = React.forwardRef(function ToggleButtonGroup(inProps, ref) { @@ -106,30 +141,78 @@ const ToggleButtonGroup = React.forwardRef(function ToggleButtonGroup(inProps, r const ownerState = { ...props, disabled, fullWidth, orientation, size }; const classes = useUtilityClasses(ownerState); - const handleChange = (event, buttonValue) => { - if (!onChange) { - return; - } + const handleChange = React.useCallback( + (event, buttonValue) => { + if (!onChange) { + return; + } - const index = value && value.indexOf(buttonValue); - let newValue; + const index = value && value.indexOf(buttonValue); + let newValue; - if (value && index >= 0) { - newValue = value.slice(); - newValue.splice(index, 1); - } else { - newValue = value ? value.concat(buttonValue) : [buttonValue]; - } + if (value && index >= 0) { + newValue = value.slice(); + newValue.splice(index, 1); + } else { + newValue = value ? value.concat(buttonValue) : [buttonValue]; + } - onChange(event, newValue); - }; + onChange(event, newValue); + }, + [onChange, value], + ); - const handleExclusiveChange = (event, buttonValue) => { - if (!onChange) { - return; - } + const handleExclusiveChange = React.useCallback( + (event, buttonValue) => { + if (!onChange) { + return; + } + + onChange(event, value === buttonValue ? null : buttonValue); + }, + [onChange, value], + ); + + const context = React.useMemo( + () => ({ + className: classes.grouped, + onChange: exclusive ? handleExclusiveChange : handleChange, + value, + size, + fullWidth, + color, + disabled, + }), + [ + classes.grouped, + exclusive, + handleExclusiveChange, + handleChange, + value, + size, + fullWidth, + color, + disabled, + ], + ); - onChange(event, value === buttonValue ? null : buttonValue); + const validChildren = getValidReactChildren(children); + const childrenCount = validChildren.length; + + const getButtonPositionClassName = (index) => { + const isFirstButton = index === 0; + const isLastButton = index === childrenCount - 1; + + if (isFirstButton && isLastButton) { + return ''; + } + if (isFirstButton) { + return classes.firstButton; + } + if (isLastButton) { + return classes.lastButton; + } + return classes.middleButton; }; return ( @@ -140,35 +223,29 @@ const ToggleButtonGroup = React.forwardRef(function ToggleButtonGroup(inProps, r ownerState={ownerState} {...other} > - {React.Children.map(children, (child) => { - if (!React.isValidElement(child)) { - return null; - } - - if (process.env.NODE_ENV !== 'production') { - if (isFragment(child)) { - console.error( - [ - "MUI: The ToggleButtonGroup component doesn't accept a Fragment as a child.", - 'Consider providing an array instead.', - ].join('\n'), - ); + + {validChildren.map((child, index) => { + if (process.env.NODE_ENV !== 'production') { + if (isFragment(child)) { + console.error( + [ + "MUI: The ToggleButtonGroup component doesn't accept a Fragment as a child.", + 'Consider providing an array instead.', + ].join('\n'), + ); + } } - } - return React.cloneElement(child, { - className: clsx(classes.grouped, child.props.className), - onChange: exclusive ? handleExclusiveChange : handleChange, - selected: - child.props.selected === undefined - ? isValueSelected(child.props.value, value) - : child.props.selected, - size: child.props.size || size, - fullWidth, - color: child.props.color || color, - disabled: child.props.disabled || disabled, - }); - })} + return ( + + {child} + + ); + })} + ); }); diff --git a/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroup.test.js b/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroup.test.js index 5bfca3c6004acb..ff8d7c19461d55 100644 --- a/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroup.test.js +++ b/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroup.test.js @@ -6,6 +6,7 @@ import ToggleButtonGroup, { toggleButtonGroupClasses as classes, } from '@mui/material/ToggleButtonGroup'; import ToggleButton, { toggleButtonClasses } from '@mui/material/ToggleButton'; +import Tooltip from '@mui/material/Tooltip'; describe('', () => { const { render } = createRenderer(); @@ -223,4 +224,60 @@ describe('', () => { expect(buttonGroup).to.have.class(classes.fullWidth); expect(button).to.have.class(toggleButtonClasses.fullWidth); }); + + describe('position classes', () => { + it('correctly applies position classes to buttons', () => { + render( + + + One + + + + + Two + + + + + + + Three + + + + , + ); + + 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( + + One + , + ); + + 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/ToggleButtonGroup/ToggleButtonGroupButtonContext.ts b/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroupButtonContext.ts new file mode 100644 index 00000000000000..df044c2b87ca7b --- /dev/null +++ b/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroupButtonContext.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; + +type ToggleButtonPositionClassName = string; + +/** + * @ignore - internal component. + */ +const ToggleButtonGroupButtonContext = React.createContext< + ToggleButtonPositionClassName | undefined +>(undefined); + +if (process.env.NODE_ENV !== 'production') { + ToggleButtonGroupButtonContext.displayName = 'ToggleButtonGroupButtonContext'; +} + +export default ToggleButtonGroupButtonContext; diff --git a/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroupContext.ts b/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroupContext.ts new file mode 100644 index 00000000000000..88c3153db2be8c --- /dev/null +++ b/packages/mui-material/src/ToggleButtonGroup/ToggleButtonGroupContext.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import type { ToggleButtonGroupProps } from './ToggleButtonGroup'; + +interface ToggleButtonGroupContextType { + className?: string; + onChange?: ToggleButtonGroupProps['onChange']; + value?: ToggleButtonGroupProps['value']; + size?: ToggleButtonGroupProps['size']; + fullWidth?: ToggleButtonGroupProps['fullWidth']; + color?: ToggleButtonGroupProps['color']; + disabled?: ToggleButtonGroupProps['disabled']; +} + +/** + * @ignore - internal component. + */ +const ToggleButtonGroupContext = React.createContext({}); + +if (process.env.NODE_ENV !== 'production') { + ToggleButtonGroupContext.displayName = 'ToggleButtonGroupContext'; +} + +export default ToggleButtonGroupContext; diff --git a/packages/mui-material/src/ToggleButtonGroup/toggleButtonGroupClasses.ts b/packages/mui-material/src/ToggleButtonGroup/toggleButtonGroupClasses.ts index bf1cd0b471b50e..75cf854a4d59fc 100644 --- a/packages/mui-material/src/ToggleButtonGroup/toggleButtonGroupClasses.ts +++ b/packages/mui-material/src/ToggleButtonGroup/toggleButtonGroupClasses.ts @@ -16,6 +16,12 @@ export interface ToggleButtonGroupClasses { groupedVertical: string; /** Styles applied to the root element if `fullWidth={true}`. */ fullWidth: string; + /** Styles applied to the first button in the toggle button group. */ + firstButton: string; + /** Styles applied to the last button in the toggle button group. */ + lastButton: string; + /** Styles applied to buttons in the middle of the toggle button group. */ + middleButton: string; } export type ToggleButtonGroupClassKey = keyof ToggleButtonGroupClasses; @@ -35,6 +41,9 @@ const toggleButtonGroupClasses: ToggleButtonGroupClasses = generateUtilityClasse 'groupedHorizontal', 'groupedVertical', 'fullWidth', + 'firstButton', + 'lastButton', + 'middleButton', ], ); diff --git a/test/regressions/fixtures/ToggleButtonGroup/DifferentChildren.js b/test/regressions/fixtures/ToggleButtonGroup/DifferentChildren.js new file mode 100644 index 00000000000000..8d4e2813ed4b6d --- /dev/null +++ b/test/regressions/fixtures/ToggleButtonGroup/DifferentChildren.js @@ -0,0 +1,46 @@ +import * as React from 'react'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; + +export default function DifferentChildren() { + const falsyCondition = 1 === 2; + + return ( + + {/* With tooltip */} + + + One + + + + + Two + + + + + + + Three + + + + + + {/* Single button */} + + One + + + {/* Conditional elements */} + + One + Two + {falsyCondition ? Three : undefined} + + + ); +}