Skip to content

Commit e3a37cb

Browse files
[Select] Fix incorrect selecting of first element (#36024)
Co-authored-by: Marija Najdova <mnajdova@gmail.com>
1 parent e7dc027 commit e3a37cb

File tree

5 files changed

+142
-27
lines changed

5 files changed

+142
-27
lines changed

docs/data/material/components/selects/selects.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,67 @@ Display categories with the `ListSubheader` component or the native `<optgroup>`
127127

128128
{{"demo": "GroupedSelect.js"}}
129129

130+
:::warning
131+
If you wish to wrap the ListSubheader in a custom component, you'll have to annotate it so Material UI can handle it properly when determining focusable elements.
132+
133+
You have two options for solving this:
134+
Option 1: Define a static boolean field called `muiSkipListHighlight` on your component function, and set it to `true`:
135+
136+
```tsx
137+
function MyListSubheader(props: ListSubheaderProps) {
138+
return <ListSubheader {...props} />;
139+
}
140+
141+
MyListSubheader.muiSkipListHighlight = true;
142+
export default MyListSubheader;
143+
144+
// elsewhere:
145+
146+
return (
147+
<Select>
148+
<MyListSubheader>Group 1</MyListSubheader>
149+
<MenuItem value={1}>Option 1</MenuItem>
150+
<MenuItem value={2}>Option 2</MenuItem>
151+
<MyListSubheader>Group 2</MyListSubheader>
152+
<MenuItem value={3}>Option 3</MenuItem>
153+
<MenuItem value={4}>Option 4</MenuItem>
154+
{/* ... */}
155+
</Select>
156+
```
157+
158+
Option 2: Place a `muiSkipListHighlight` prop on each instance of your component.
159+
The prop doesn't have to be forwarded to the ListSubheader, nor present in the underlying DOM element.
160+
It just has to be placed on a component that's used as a subheader.
161+
162+
```tsx
163+
export default function MyListSubheader(
164+
props: ListSubheaderProps & { muiSkipListHighlight: boolean },
165+
) {
166+
const { muiSkipListHighlight, ...other } = props;
167+
return <ListSubheader {...other} />;
168+
}
169+
170+
// elsewhere:
171+
172+
return (
173+
<Select>
174+
<MyListSubheader muiSkipListHighlight>Group 1</MyListSubheader>
175+
<MenuItem value={1}>Option 1</MenuItem>
176+
<MenuItem value={2}>Option 2</MenuItem>
177+
<MyListSubheader muiSkipListHighlight>Group 2</MyListSubheader>
178+
<MenuItem value={3}>Option 3</MenuItem>
179+
<MenuItem value={4}>Option 4</MenuItem>
180+
{/* ... */}
181+
</Select>
182+
);
183+
```
184+
185+
We recommend the first option as it doesn't require updating all the usage sites of the component.
186+
187+
Keep in mind this is **only necessary** if you wrap the ListSubheader in a custom component.
188+
If you use the ListSubheader directly, **no additional code is required**.
189+
:::
190+
130191
## Accessibility
131192
132193
To properly label your `Select` input you need an extra element with an `id` that contains a label.

packages/mui-material/src/ListSubheader/ListSubheader.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ const ListSubheader = React.forwardRef(function ListSubheader(inProps, ref) {
100100
);
101101
});
102102

103+
ListSubheader.muiSkipListHighlight = true;
104+
103105
ListSubheader.propTypes /* remove-proptypes */ = {
104106
// ----------------------------- Warning --------------------------------
105107
// | These PropTypes are generated from the TypeScript type definitions |

packages/mui-material/src/MenuList/MenuList.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,17 @@ const MenuList = React.forwardRef(function MenuList(props, ref) {
232232
activeItemIndex = index;
233233
}
234234
}
235+
236+
if (
237+
activeItemIndex === index &&
238+
(child.props.disabled || child.props.muiSkipListHighlight || child.type.muiSkipListHighlight)
239+
) {
240+
activeItemIndex += 1;
241+
if (activeItemIndex >= children.length) {
242+
// there are no focusable items within the list.
243+
activeItemIndex = -1;
244+
}
245+
}
235246
});
236247

237248
const items = React.Children.map(children, (child, index) => {

packages/mui-material/src/Select/Select.test.js

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
screen,
1111
} from 'test/utils';
1212
import { createTheme, ThemeProvider } from '@mui/material/styles';
13-
import MenuItem from '@mui/material/MenuItem';
13+
import MenuItem, { menuItemClasses } from '@mui/material/MenuItem';
1414
import ListSubheader from '@mui/material/ListSubheader';
1515
import InputBase from '@mui/material/InputBase';
1616
import OutlinedInput from '@mui/material/OutlinedInput';
@@ -393,6 +393,21 @@ describe('<Select />', () => {
393393
});
394394
});
395395

396+
it('should not have the selectable option selected when inital value provided is empty string on Select with ListSubHeader item', () => {
397+
render(
398+
<Select open value="">
399+
<ListSubheader>Category 1</ListSubheader>
400+
<MenuItem value={10}>Ten</MenuItem>
401+
<ListSubheader>Category 2</ListSubheader>
402+
<MenuItem value={20}>Twenty</MenuItem>
403+
<MenuItem value={30}>Thirty</MenuItem>
404+
</Select>,
405+
);
406+
407+
const options = screen.getAllByRole('option');
408+
expect(options[1]).not.to.have.class(menuItemClasses.selected);
409+
});
410+
396411
describe('SVG icon', () => {
397412
it('should not present an SVG icon when native and multiple are specified', () => {
398413
const { container } = render(
@@ -549,8 +564,57 @@ describe('<Select />', () => {
549564
});
550565
});
551566

567+
describe('when the first child is a ListSubheader wrapped in a custom component', () => {
568+
describe('with the `muiSkipListHighlight` static field', () => {
569+
function WrappedListSubheader(props) {
570+
return <ListSubheader {...props} />;
571+
}
572+
573+
WrappedListSubheader.muiSkipListHighlight = true;
574+
575+
it('highlights the first selectable option below the header', () => {
576+
const { getByText } = render(
577+
<Select defaultValue="" open>
578+
<WrappedListSubheader>Category 1</WrappedListSubheader>
579+
<MenuItem value={1}>Option 1</MenuItem>
580+
<MenuItem value={2}>Option 2</MenuItem>
581+
<WrappedListSubheader>Category 2</WrappedListSubheader>
582+
<MenuItem value={3}>Option 3</MenuItem>
583+
<MenuItem value={4}>Option 4</MenuItem>
584+
</Select>,
585+
);
586+
587+
const expectedHighlightedOption = getByText('Option 1');
588+
expect(expectedHighlightedOption).to.have.attribute('tabindex', '0');
589+
});
590+
});
591+
592+
describe('with the `muiSkipListHighlight` prop', () => {
593+
function WrappedListSubheader(props) {
594+
const { muiSkipListHighlight, ...other } = props;
595+
return <ListSubheader {...other} />;
596+
}
597+
598+
it('highlights the first selectable option below the header', () => {
599+
const { getByText } = render(
600+
<Select defaultValue="" open>
601+
<WrappedListSubheader muiSkipListHighlight>Category 1</WrappedListSubheader>
602+
<MenuItem value={1}>Option 1</MenuItem>
603+
<MenuItem value={2}>Option 2</MenuItem>
604+
<WrappedListSubheader muiSkipListHighlight>Category 2</WrappedListSubheader>
605+
<MenuItem value={3}>Option 3</MenuItem>
606+
<MenuItem value={4}>Option 4</MenuItem>
607+
</Select>,
608+
);
609+
610+
const expectedHighlightedOption = getByText('Option 1');
611+
expect(expectedHighlightedOption).to.have.attribute('tabindex', '0');
612+
});
613+
});
614+
});
615+
552616
describe('when the first child is a MenuItem disabled', () => {
553-
it('first selectable option is focused to use the arrow', () => {
617+
it('highlights the first selectable option below the header', () => {
554618
const { getAllByRole } = render(
555619
<Select defaultValue="" open>
556620
<MenuItem value="" disabled>

packages/mui-material/src/Select/SelectInput.js

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
350350
}
351351
}
352352

353-
const items = childrenArray.map((child, index, arr) => {
353+
const items = childrenArray.map((child) => {
354354
if (!React.isValidElement(child)) {
355355
return null;
356356
}
@@ -391,26 +391,6 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
391391
foundMatch = true;
392392
}
393393

394-
if (child.props.value === undefined) {
395-
return React.cloneElement(child, {
396-
'aria-readonly': true,
397-
role: 'option',
398-
});
399-
}
400-
401-
const isFirstSelectableElement = () => {
402-
if (value) {
403-
return selected;
404-
}
405-
const firstSelectableElement = arr.find(
406-
(item) => item?.props?.value !== undefined && item.props.disabled !== true,
407-
);
408-
if (child === firstSelectableElement) {
409-
return true;
410-
}
411-
return selected;
412-
};
413-
414394
return React.cloneElement(child, {
415395
'aria-selected': selected ? 'true' : 'false',
416396
onClick: handleItemClick(child),
@@ -427,10 +407,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
427407
}
428408
},
429409
role: 'option',
430-
selected:
431-
arr[0]?.props?.value === undefined || arr[0]?.props?.disabled === true
432-
? isFirstSelectableElement()
433-
: selected,
410+
selected,
434411
value: undefined, // The value is most likely not a valid HTML attribute.
435412
'data-value': child.props.value, // Instead, we provide it as a data attribute.
436413
});

0 commit comments

Comments
 (0)