Skip to content

[material-next][ButtonGroup] Add ButtonGroup component with Material You design #39699

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 35 commits into from
Closed
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8f12caf
copy MD2 ButtonGroup component
lhilgert9 Nov 1, 2023
91ba67b
fix ButtonGroup capitalize import
lhilgert9 Nov 1, 2023
fb3d6fc
fix ButtonGroup generateUtilityClass import
lhilgert9 Nov 1, 2023
ce4ab8b
rearrange useUtilityClasses in ButtonGroup
lhilgert9 Nov 1, 2023
6f239e7
migrate index to TS
lhilgert9 Nov 1, 2023
0e515c5
fix ButtonGroup.d.ts OverridableComponent import
lhilgert9 Nov 1, 2023
26ef58b
migrate component types to .types
lhilgert9 Nov 1, 2023
9725b50
migrate component file to .tsx
lhilgert9 Nov 1, 2023
822b0c2
fix ButtonGroup.types Theme import
lhilgert9 Nov 1, 2023
796dc2b
delete default export from ButtonGroup.types
lhilgert9 Nov 1, 2023
5bea74d
fix type errors in ButtonGroup component
lhilgert9 Nov 1, 2023
ef86ee2
prettify buttonGroupClasses
lhilgert9 Nov 1, 2023
8e76568
add ButtonGroup to material-next index
lhilgert9 Nov 1, 2023
fb182a4
add missing exports to index
lhilgert9 Nov 1, 2023
ae571c4
[Button] add ButtonGroupContext logic
lhilgert9 Nov 1, 2023
b4fbbdb
Apply MD3 ButtonGroup style
lhilgert9 Nov 1, 2023
739a1fb
Add MD3 ButtonGroup playground
lhilgert9 Nov 1, 2023
ccc62b3
fix boxShadow was visible on focus
lhilgert9 Nov 1, 2023
99de694
add size prop to playground
lhilgert9 Nov 1, 2023
cd74152
prettify playground
lhilgert9 Nov 1, 2023
48172c8
export ButtonGroupRoot
lhilgert9 Nov 1, 2023
59e8baa
add classes to buttonGroupClasses
lhilgert9 Nov 1, 2023
c529dd9
fix ButtonGroup style
lhilgert9 Nov 1, 2023
c6925b3
fix imports for test
lhilgert9 Nov 1, 2023
4823cc7
prettify ButtonGroup
lhilgert9 Nov 1, 2023
6e1370b
fix classes in test file
lhilgert9 Nov 1, 2023
9999933
replace disableFocusRipple with disableTouchRipple
lhilgert9 Nov 1, 2023
4b96d60
prettify and update types
lhilgert9 Nov 1, 2023
e73b50f
add missing CssVarsProvider and extendTheme
lhilgert9 Nov 2, 2023
7a22f5c
.test: disableFocusRipple to disableTouchRipple
lhilgert9 Nov 2, 2023
40b8552
fix buttonClass.colorSecondary in .test file
lhilgert9 Nov 2, 2023
f47f775
.test: change hard typed classNames to classes
lhilgert9 Nov 2, 2023
3538ad9
convert .test file to TS
lhilgert9 Nov 3, 2023
723984f
prettify .test file
lhilgert9 Nov 3, 2023
98f9485
fix matchMedia error in unit test
lhilgert9 Nov 3, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import Button from '@mui/material-next/Button';
import ButtonGroup from '@mui/material-next/ButtonGroup';
import MaterialYouUsageDemo from 'docs/src/modules/components/MaterialYouUsageDemo';

export default function ButtonGroupMaterialYouPlayground() {
return (
<MaterialYouUsageDemo
componentName="ButtonGroup"
data={[
{
propName: 'variant',
defaultValue: 'outlined',
options: ['text', 'outlined', 'filled', 'filledTonal', 'elevated'],
knob: 'select',
},
{
propName: 'orientation',
defaultValue: 'horizontal',
options: ['vertical', 'horizontal'],
knob: 'select',
},
{
propName: 'color',
defaultValue: 'primary',
knob: 'select',
options: ['primary', 'secondary', 'tertiary'],
},
{
propName: 'size',
defaultValue: 'medium',
knob: 'select',
options: ['small', 'medium', 'large'],
},
{
propName: 'disabled',
defaultValue: false,
knob: 'switch',
},
]}
renderDemo={(props) => (
<ButtonGroup {...props}>
<Button>One</Button>
<Button>Two</Button>
<Button>Three</Button>
</ButtonGroup>
)}
/>
);
}
13 changes: 13 additions & 0 deletions docs/data/material/components/button-group/button-group.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ The button group can be displayed vertically using the `orientation` prop.
You can remove the elevation with the `disableElevation` prop.

{{"demo": "DisableElevation.js"}}

## Experimental API

### Material You version

The default ButtonGroup component follows the Material Design 2 specs.
For the Material Design 3 ([Material You](https://m3.material.io/)) version, you can use the new experimental `@mui/material-next` package:

```js
import ButtonGroup from '@mui/material-next/ButtonGroup';
```

{{"demo": "ButtonGroupMaterialYouPlayground.js", "hideToolbar": true}}
21 changes: 19 additions & 2 deletions packages/mui-material-next/src/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { unstable_capitalize as capitalize } from '@mui/utils';
import {
unstable_capitalize as capitalize,
internal_resolveProps as resolveProps,
} from '@mui/utils';
import { useSlotProps } from '@mui/base/utils';
import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses';
import { useThemeProps, alpha, shouldForwardProp } from '@mui/system';
Expand All @@ -10,6 +14,8 @@ import { getButtonUtilityClass } from './buttonClasses';
import buttonBaseClasses from '../ButtonBase/buttonBaseClasses';
import { ButtonProps, ExtendButton, ButtonTypeMap, ButtonOwnerState } from './Button.types';
import ButtonBase from '../ButtonBase';
import ButtonGroupContext from '../ButtonGroup/ButtonGroupContext';
import ButtonGroupButtonContext from '../ButtonGroup/ButtonGroupButtonContext';

const useUtilityClasses = (ownerState: ButtonOwnerState) => {
const { classes, color, disableElevation, fullWidth, size, variant } = ownerState;
Expand Down Expand Up @@ -366,10 +372,14 @@ const ButtonEndIcon = styled('span', {
const Button = React.forwardRef(function Button<
BaseComponentType extends React.ElementType = ButtonTypeMap['defaultComponent'],
>(inProps: ButtonProps<BaseComponentType>, ref: React.ForwardedRef<any>) {
const props = useThemeProps({ props: inProps, name: 'MuiButton' });
const contextProps = React.useContext(ButtonGroupContext);
const buttonGroupButtonContextPositionClassName = React.useContext(ButtonGroupButtonContext);
const resolvedProps = resolveProps(contextProps as ButtonProps<BaseComponentType>, inProps);
const props = useThemeProps({ props: resolvedProps, name: 'MuiButton' });
const {
children,
classes: classesProp,
className,
color = 'primary',
disableElevation = false,
endIcon: endIconProp,
Expand All @@ -392,6 +402,8 @@ const Button = React.forwardRef(function Button<

const classes = useUtilityClasses(ownerState);

const positionClassName = buttonGroupButtonContextPositionClassName || '';

const rootProps = useSlotProps({
elementType: ButtonRoot,
externalForwardedProps: other,
Expand All @@ -401,6 +413,7 @@ const Button = React.forwardRef(function Button<
ref,
},
ownerState,
className: clsx(contextProps.className, classes.root, className, positionClassName),
});

const startIcon = startIconProp && (
Expand Down Expand Up @@ -437,6 +450,10 @@ Button.propTypes /* remove-proptypes */ = {
* Override or extend the styles applied to the component.
*/
classes: PropTypes.object,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component.
* It supports both default and custom theme colors, which can be added as shown in the
Expand Down
257 changes: 257 additions & 0 deletions packages/mui-material-next/src/ButtonGroup/ButtonGroup.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import * as React from 'react';
import { expect } from 'chai';
import { createRenderer, describeConformance, screen } from '@mui-internal/test-utils';
import ButtonGroup, { buttonGroupClasses as classes } from '@mui/material-next/ButtonGroup';
import Button, { buttonClasses } from '@mui/material-next/Button';
import ButtonGroupContext from './ButtonGroupContext';
import { CssVarsProvider, extendTheme } from '../styles';

describe('<ButtonGroup />', () => {
Copy link
Member

@mj12albert mj12albert Nov 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To fix the unit test, you need to kind of mock window.matchMedia like this

And as part of this, the test should be converted to TS as well ~

const { render } = createRenderer();

describeConformance(
<ButtonGroup>
<Button>Conformance?</Button>
</ButtonGroup>,
() => ({
classes,
inheritComponent: 'div',
render,
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
muiName: 'MuiButtonGroup',
testVariantProps: { variant: 'filled' },
skip: ['componentsProp'],
ThemeProvider: CssVarsProvider,
createTheme: extendTheme,
}),
);

it('should render with the root class but no others', () => {
const { container } = render(
<ButtonGroup>
<Button>Hello World</Button>
</ButtonGroup>,
);
const buttonGroup = container.firstChild;
expect(buttonGroup).to.have.class(classes.root);
expect(buttonGroup).not.to.have.class(classes.filled);
expect(buttonGroup).not.to.have.class(classes.fullWidth);
});

it('should render an outlined button', () => {
const { getByRole } = render(
<ButtonGroup>
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
expect(button).to.have.class(buttonClasses.outlined);
expect(button).to.have.class(classes.grouped);
expect(button).to.have.class(classes.groupedOutlined);
expect(button).to.have.class(classes.groupedOutlinedPrimary);
expect(button).not.to.have.class(classes.groupedOutlinedSecondary);
});

it('can render an outlined primary button', () => {
const { getByRole } = render(
<ButtonGroup color="primary">
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
expect(button).to.have.class(buttonClasses.outlined);
expect(button).to.have.class(buttonClasses.colorPrimary);
expect(button).to.have.class(classes.grouped);
expect(button).to.have.class(classes.groupedOutlined);
expect(button).to.have.class(classes.groupedOutlinedPrimary);
expect(button).not.to.have.class(classes.groupedOutlinedSecondary);
});

it('can render a filled button', () => {
const { getByRole } = render(
<ButtonGroup variant="filled">
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
expect(button).to.have.class(buttonClasses.filled);
expect(button).to.have.class(classes.grouped);
expect(button).to.have.class(classes.groupedFilled);
expect(button).to.have.class(classes.groupedFilledPrimary);
expect(button).not.to.have.class(classes.groupedFilledSecondary);
});

it('can render a small button', () => {
const { getByRole } = render(
<ButtonGroup size="small">
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
expect(button).to.have.class(buttonClasses.sizeSmall);
});

it('can render a large button', () => {
const { getByRole } = render(
<ButtonGroup size="large">
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
expect(button).to.have.class(buttonClasses.sizeLarge);
});

it('should have a ripple by default', () => {
const { container } = render(
<ButtonGroup>
<Button TouchRippleProps={{ classes: { root: 'touchRipple' } }}>Hello World</Button>
</ButtonGroup>,
);
expect(container.querySelector('.touchRipple')).not.to.equal(null);
});

it('can disable the elevation', () => {
const { getByRole } = render(
<ButtonGroup disableElevation>
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
expect(button).to.have.class(buttonClasses.disableElevation);
});

it('can disable the ripple', () => {
const { container } = render(
<ButtonGroup disableRipple>
<Button TouchRippleProps={{ classes: { root: 'touchRipple' } }}>Hello World</Button>
</ButtonGroup>,
);
expect(container.querySelector('.touchRipple')).to.equal(null);
});

it('should not be fullWidth by default', () => {
const { container, getByRole } = render(
<ButtonGroup>
<Button>Hello World</Button>
</ButtonGroup>,
);
const button = getByRole('button');
const buttonGroup = container.firstChild;
expect(buttonGroup).not.to.have.class(classes.fullWidth);
expect(button).not.to.have.class(buttonClasses.fullWidth);
});

it('can pass fullWidth to Button', () => {
const { container, getByRole } = render(
<ButtonGroup fullWidth>
<Button>Hello World</Button>
</ButtonGroup>,
);
const buttonGroup = container.firstChild;
const button = getByRole('button');
expect(buttonGroup).to.have.class(classes.fullWidth);
expect(button).to.have.class(buttonClasses.fullWidth);
});

it('classes.grouped should be merged with Button className', () => {
render(
<ButtonGroup>
<Button className="foo-bar">Hello World</Button>
</ButtonGroup>,
);
expect(screen.getByRole('button')).to.have.class(classes.grouped);
expect(screen.getByRole('button')).to.have.class('foo-bar');
});

it('should forward the context to children', () => {
let context;
render(
<ButtonGroup size="large" variant="filled">
<ButtonGroupContext.Consumer>
{(value) => {
context = value;
}}
</ButtonGroupContext.Consumer>
</ButtonGroup>,
);
expect(context.variant).to.equal('filled');
expect(context.size).to.equal('large');
expect(context.fullWidth).to.equal(false);
expect(context.disableRipple).to.equal(false);
expect(context.disableTouchRipple).to.equal(false);
expect(context.disableElevation).to.equal(false);
expect(context.disabled).to.equal(false);
expect(context.color).to.equal('primary');
});

describe('theme default props on Button', () => {
it('should override default variant prop', () => {
render(
<CssVarsProvider
theme={extendTheme({
components: {
MuiButton: {
defaultProps: {
color: 'primary',
size: 'medium',
variant: 'filled',
},
},
},
})}
>
<ButtonGroup variant="outlined" size="small" color="secondary">
<Button>Hello World</Button>
</ButtonGroup>
</CssVarsProvider>,
);

expect(screen.getByRole('button')).to.have.class(buttonClasses.outlined);
expect(screen.getByRole('button')).to.have.class(buttonClasses.sizeSmall);
expect(screen.getByRole('button')).to.have.class(buttonClasses.colorSecondary);
});
});

describe('position classes', () => {
it('correctly applies position classes to buttons', () => {
render(
<ButtonGroup>
<Button>Button 1</Button>
<Button>Button 2</Button>
<Button>Button 3</Button>
</ButtonGroup>,
);

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(
<ButtonGroup>
<Button>Single Button</Button>
</ButtonGroup>,
);

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);
});
});
});
Loading