Skip to content
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

[material-next][Select] Refactor to use Base UI's hooks #40210

Closed
Closed
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
96 changes: 96 additions & 0 deletions docs/pages/experiments/material-next/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as React from 'react';
import {
Box,
Stack,
createTheme,
InputLabel,
FormControl,
ThemeProvider,
Select as MaterialV5Select,
MenuItem,
} from '@mui/material';
import { Select as MaterialNextSelect, Option, SelectChangeEvent } from '@mui/material-next';
import Typography from 'docs/src/pages/premium-themes/onepirate/modules/components/Typography';

const theme = createTheme({});

const options = [
{ value: 10, label: 'Ten' },
{ value: 20, label: 'Twenty' },
{ value: 30, label: 'Thirty' },
{ value: 40, label: 'Forty' },
{ value: 50, label: 'Fifty' },
{ value: 60, label: 'Sixty' },
{ value: 70, label: 'Seventy' },
];

function MaterialV5Example() {
const [age, setAge] = React.useState('');

const handleChange = (event: SelectChangeEvent) => {
setAge(event.target.value);
};

return (
<Box sx={{ pl: 2, width: 320 }}>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">Age</InputLabel>
<MaterialV5Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={age}
onChange={handleChange}
label="Age"
>
{options.map(({ value, label, ...rest }) => (
<MenuItem {...rest} key={value} value={value}>
{label}
</MenuItem>
))}
</MaterialV5Select>
</FormControl>
</Box>
);
}

function MaterialNextExample() {
const [age, setAge] = React.useState('');

const handleChange = (event: SelectChangeEvent) => {
setAge(event.target.value);
};

return (
<Box sx={{ pl: 2, width: 320 }}>
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">Age</InputLabel>
<MaterialNextSelect
labelId="demo-simple-select-label"
id="demo-simple-select"
value={age}
onChange={handleChange}
label="Age"
>
{options.map(({ value, label, ...rest }) => (
<Option {...rest} key={value} value={value}>
{label}
</Option>
))}
</MaterialNextSelect>
</FormControl>
</Box>
);
}

export default function Experiment() {
return (
<ThemeProvider theme={theme}>
<Stack mt={4} spacing={4}>
<Typography variant="h4">V5:</Typography>
<MaterialV5Example />
<Typography variant="h4">Material Next:</Typography>
<MaterialNextExample />
</Stack>
</ThemeProvider>
);
}
26 changes: 26 additions & 0 deletions packages/mui-material-next/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,32 @@ If you were using the `skipFocusWhenDisabled` prop to explicitly make disabled c
/>
```

## Option

Added the `Option` component to be used within `Select` instead of `MenuItem`.
You should replace all `MenuItem`s inside `Select` components with `Option`s.
The `Option` component supports the same API as the `MenuItem`, except:

### No selected prop

The `Option` component doesn't support the `selected` prop.
You should remove it if you have it.
This won't have any impact as the `selected` prop doesn't have any effect when the `MenuItem` is inside a `Select` component.

## Select

### Replaced MenuItem with new Option component

The `Option` component was added to be used instead of `MenuItem` inside `Select`.
You should replace all `MenuItem`s inside `Select` components with `Option`s.
See the [`Option`](https://github.com/mui/material-ui/blob/master/packages/mui-material-next/migration.md#option) section of this guide for details.

### Removed Menu dependency

The menu dependency was removed, and the `MenuProps` prop was removed with it.

(Section WIP)
Copy link
Member Author

Choose a reason for hiding this comment

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

We will need to create a follow-up issue for this one as it will depend on the changes for the Material You design, which we're not doing right now.


## Slider

### Thumb and Value Label slots must accept refs
Expand Down
17 changes: 12 additions & 5 deletions packages/mui-material-next/src/Option/Option.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,29 @@ const CustomComponent: React.FC<{ stringProp: string; numberProp: number }> =

const props1: OptionProps<'div'> = {
component: 'div',
value: 1,
onChange: (event) => {
expectType<React.FormEvent<HTMLDivElement>, typeof event>(event);
},
};

const props2: OptionProps = {
value: 2,
onChange: (event) => {
expectType<React.FormEvent<HTMLLIElement>, typeof event>(event);
},
};

const props3: OptionProps<typeof CustomComponent> = {
component: CustomComponent,
value: 3,
stringProp: '2',
numberProp: 2,
};

const props4: OptionProps<typeof CustomComponent> = {
component: CustomComponent,
value: 4,
stringProp: '2',
numberProp: 2,
// @ts-expect-error CustomComponent does not accept incorrectProp
Expand All @@ -38,31 +42,34 @@ const props4: OptionProps<typeof CustomComponent> = {
// @ts-expect-error missing props
const props5: OptionProps<typeof CustomComponent> = {
component: CustomComponent,
value: 5,
};

const TestComponent = () => {
return (
<React.Fragment>
<Option />
<Option component={'a'} href="/test" />
<Option value={1} />
<Option component={'a'} value={2} href="/test" />

<Option component={CustomComponent} stringProp="s" numberProp={1} />
<Option component={CustomComponent} value={3} stringProp="s" numberProp={1} />
{
// @ts-expect-error missing props
<Option component={CustomComponent} />
<Option component={CustomComponent} value={4} />
}
<Option
value={5}
onChange={(event) => {
expectType<React.FormEvent<HTMLLIElement>, typeof event>(event);
}}
/>
<Option
component="span"
value={6}
onChange={(event) => {
expectType<React.FormEvent<HTMLSpanElement>, typeof event>(event);
}}
/>
<Option component={Link} />
<Option component={Link} value={7} />
</React.Fragment>
);
};
98 changes: 45 additions & 53 deletions packages/mui-material-next/src/Option/Option.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
fireEvent,
screen,
} from '@mui-internal/test-utils';
import { MenuProvider } from '@mui/base/useMenu';
import { SelectProvider } from '@mui/base/useSelect';
import Option, { optionClasses as classes } from '@mui/material-next/Option';
import Menu from '@mui/material-next/Menu';
import Select from '@mui/material-next/Select';
import ButtonBase from '@mui/material-next/ButtonBase';

const dummyGetItemState = () => ({
Expand All @@ -36,45 +36,45 @@ const testContext = {
describe('<Option />', () => {
const { render } = createRenderer({ clock: 'fake' });

// afterEach(() => {
// document.getElementsByTagName('html')[0].innerHTML = '';
// });

describeConformance(<Option data-testid="option">1</Option>, () => ({
render: (node) => {
return render(<MenuProvider value={testContext}>{node}</MenuProvider>);
},
wrapMount: (mount) => (node) => mount(<MenuProvider value={testContext}>{node}</MenuProvider>),
classes,
inheritComponent: ButtonBase,
refInstanceof: window.HTMLLIElement,
testComponentPropWith: 'a',
muiName: 'MuiOption',
testVariantProps: { dense: true },
skip: ['componentsProp', 'reactTestRenderer'],
}));

const renderWithMenu = (node: React.ReactNode) => {
describeConformance(
<Option value={1} data-testid="option">
1
</Option>,
() => ({
render: (node) => {
return render(<SelectProvider value={testContext}>{node}</SelectProvider>);
},
wrapMount: (mount) => (node) =>
mount(<SelectProvider value={testContext}>{node}</SelectProvider>),
classes,
inheritComponent: ButtonBase,
refInstanceof: window.HTMLLIElement,
testComponentPropWith: 'a',
muiName: 'MuiOption',
testVariantProps: { dense: true },
skip: ['componentsProp', 'reactTestRenderer'],
}),
);

const renderWithSelect = (node: React.ReactNode) => {
function Test() {
return (
<Menu anchorEl={document.createElement('div')} open>
{node}
</Menu>
);
return <Select open>{node}</Select>;
}

return render(<Test />);
};

it('should render a focusable option', () => {
renderWithMenu(<Option />);
renderWithSelect(<Option value={1} />);
const option = screen.getByRole('option');

expect(option).to.have.property('tabIndex', 0);
});

it('has a ripple when clicked', () => {
renderWithMenu(<Option TouchRippleProps={{ classes: { rippleVisible: 'ripple-visible' } }} />);
renderWithSelect(
<Option value={1} TouchRippleProps={{ classes: { rippleVisible: 'ripple-visible' } }} />,
);
const option = screen.getByRole('option');

// ripple starts on mousedown
Expand All @@ -83,20 +83,6 @@ describe('<Option />', () => {
expect(option.querySelectorAll('.ripple-visible')).to.have.length(1);
});

it('should render with the selected class but not aria-selected when `selected`', () => {
renderWithMenu(<Option selected />);
const option = screen.getByRole('option');

expect(option).to.have.class(classes.selected);
expect(option).not.to.have.attribute('aria-selected');
});

it('can have a role of option', () => {
renderWithMenu(<Option role="option" aria-selected={false} />);

expect(screen.queryByRole('option')).not.to.equal(null);
});

describe('event callbacks', () => {
const events: Array<keyof typeof fireEvent> = [
'click',
Expand All @@ -111,7 +97,7 @@ describe('<Option />', () => {
it(`should fire ${eventName}`, () => {
const handlerName = `on${eventName[0].toUpperCase()}${eventName.slice(1)}`;
const handler = spy();
renderWithMenu(<Option {...{ [handlerName]: handler }} />);
renderWithSelect(<Option value={1} {...{ [handlerName]: handler }} />);

fireEvent[eventName](screen.getByRole('option'));

Expand All @@ -124,8 +110,9 @@ describe('<Option />', () => {
const handleKeyDown = spy();
const handleKeyUp = spy();
const handleBlur = spy();
renderWithMenu(
renderWithSelect(
<Option
value={1}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
Expand Down Expand Up @@ -158,7 +145,7 @@ describe('<Option />', () => {
}

const handleTouchStart = spy();
renderWithMenu(<Option onTouchStart={handleTouchStart} />);
renderWithSelect(<Option value={1} onTouchStart={handleTouchStart} />);
const option = screen.getByRole('option');

const touch = new Touch({ identifier: 0, target: option, clientX: 0, clientY: 0 });
Expand All @@ -169,33 +156,38 @@ describe('<Option />', () => {
});

it('can be disabled', () => {
renderWithMenu(<Option disabled />);
renderWithSelect(<Option value={1} disabled />);
const option = screen.getByRole('option');

expect(option).to.have.attribute('aria-disabled', 'true');
});

it('can be selected', () => {
renderWithMenu(<Option selected />);
render(
<Select open value={1}>
<Option value={1} />
</Select>,
);
const option = screen.getByRole('option');

expect(option).to.have.class(classes.selected);
expect(option).to.have.attribute('aria-selected', 'true');
});

it('prop: disableGutters', () => {
const { rerender } = render(
<Menu anchorEl={document.createElement('div')} open>
<Option />
</Menu>,
<Select open>
<Option value={1} />
</Select>,
);
const option = screen.getByRole('option');

expect(option).to.have.class(classes.gutters);

rerender(
<Menu anchorEl={document.createElement('div')} open>
<Option disableGutters />
</Menu>,
<Select open>
<Option value={1} disableGutters />
</Select>,
);

expect(option).not.to.have.class(classes.gutters);
Expand Down
Loading
Loading