Skip to content

Commit 97f225c

Browse files
[base-ui][Menu] Focus last item after opening a menu using up arrow (#40764)
1 parent c479c9b commit 97f225c

File tree

13 files changed

+232
-24
lines changed

13 files changed

+232
-24
lines changed

packages/mui-base/src/Menu/Menu.test.tsx

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import {
1111
import { Menu, menuClasses } from '@mui/base/Menu';
1212
import { MenuItem, MenuItemRootSlotProps } from '@mui/base/MenuItem';
1313
import { DropdownContext, DropdownContextValue } from '@mui/base/useDropdown';
14+
import { Popper } from '@mui/base/Popper';
15+
import { MenuProvider, useMenu } from '@mui/base/useMenu';
1416

1517
const testContext: DropdownContextValue = {
1618
dispatch: () => {},
1719
popupId: 'menu-popup',
1820
registerPopup: () => {},
1921
registerTrigger: () => {},
20-
state: { open: true },
22+
state: { open: true, changeReason: null },
2123
triggerElement: null,
2224
};
2325

@@ -72,6 +74,116 @@ describe('<Menu />', () => {
7274
expect(item.tabIndex).to.equal(-1);
7375
});
7476
});
77+
78+
it('highlights first item when down arrow key opens the menu', () => {
79+
const context: DropdownContextValue = {
80+
...testContext,
81+
state: {
82+
...testContext.state,
83+
open: true,
84+
changeReason: {
85+
type: 'keydown',
86+
key: 'ArrowDown',
87+
} as React.KeyboardEvent,
88+
},
89+
};
90+
const { getAllByRole } = render(
91+
<DropdownContext.Provider value={context}>
92+
<Menu>
93+
<MenuItem>1</MenuItem>
94+
<MenuItem>2</MenuItem>
95+
<MenuItem>3</MenuItem>
96+
</Menu>
97+
</DropdownContext.Provider>,
98+
);
99+
const [firstItem, ...otherItems] = getAllByRole('menuitem');
100+
101+
expect(firstItem.tabIndex).to.equal(0);
102+
otherItems.forEach((item) => {
103+
expect(item.tabIndex).to.equal(-1);
104+
});
105+
});
106+
107+
it('highlights last item when up arrow key opens the menu', () => {
108+
const context: DropdownContextValue = {
109+
...testContext,
110+
state: {
111+
...testContext.state,
112+
open: true,
113+
changeReason: {
114+
key: 'ArrowUp',
115+
type: 'keydown',
116+
} as React.KeyboardEvent,
117+
},
118+
};
119+
const { getAllByRole } = render(
120+
<DropdownContext.Provider value={context}>
121+
<Menu>
122+
<MenuItem>1</MenuItem>
123+
<MenuItem>2</MenuItem>
124+
<MenuItem>3</MenuItem>
125+
</Menu>
126+
</DropdownContext.Provider>,
127+
);
128+
129+
const [firstItem, secondItem, lastItem] = getAllByRole('menuitem');
130+
131+
expect(lastItem.tabIndex).to.equal(0);
132+
[firstItem, secondItem].forEach((item) => {
133+
expect(item.tabIndex).to.equal(-1);
134+
});
135+
});
136+
137+
it('highlights last non-disabled item when disabledItemsFocusable is set to false', () => {
138+
const CustomMenu = React.forwardRef(function CustomMenu(
139+
props: React.ComponentPropsWithoutRef<'ul'>,
140+
ref: React.Ref<HTMLUListElement>,
141+
) {
142+
const { children, ...other } = props;
143+
144+
const { open, triggerElement, contextValue, getListboxProps } = useMenu({
145+
listboxRef: ref,
146+
disabledItemsFocusable: false,
147+
});
148+
149+
const anchorEl = triggerElement ?? document.createElement('div');
150+
151+
return (
152+
<Popper open={open} anchorEl={anchorEl}>
153+
<ul className="menu-root" {...other} {...getListboxProps()}>
154+
<MenuProvider value={contextValue}>{children}</MenuProvider>
155+
</ul>
156+
</Popper>
157+
);
158+
});
159+
160+
const context: DropdownContextValue = {
161+
...testContext,
162+
state: {
163+
...testContext.state,
164+
open: true,
165+
changeReason: {
166+
key: 'ArrowUp',
167+
type: 'keydown',
168+
} as React.KeyboardEvent,
169+
},
170+
};
171+
const { getAllByRole } = render(
172+
<DropdownContext.Provider value={context}>
173+
<CustomMenu>
174+
<MenuItem>1</MenuItem>
175+
<MenuItem>2</MenuItem>
176+
<MenuItem disabled>3</MenuItem>
177+
</CustomMenu>
178+
</DropdownContext.Provider>,
179+
);
180+
const [firstItem, secondItem, lastItem] = getAllByRole('menuitem');
181+
182+
expect(secondItem.tabIndex).to.equal(0);
183+
[firstItem, lastItem].forEach((item) => {
184+
expect(item.tabIndex).to.equal(-1);
185+
});
186+
});
75187
});
76188

77189
describe('keyboard navigation', () => {

packages/mui-base/src/MenuButton/MenuButton.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const testContext: DropdownContextValue = {
2020
popupId: 'menu-popup',
2121
registerPopup: () => {},
2222
registerTrigger: () => {},
23-
state: { open: true },
23+
state: { open: true, changeReason: null },
2424
triggerElement: null,
2525
};
2626

@@ -67,7 +67,7 @@ describe('<MenuButton />', () => {
6767
const dispatchSpy = spy();
6868
const context = {
6969
...testContext,
70-
state: { open: false },
70+
state: { open: false, changeReason: null },
7171
dispatch: dispatchSpy,
7272
};
7373

@@ -117,7 +117,7 @@ describe('<MenuButton />', () => {
117117
const dispatchSpy = spy();
118118
const context = {
119119
...testContext,
120-
state: { open: false },
120+
state: { open: false, changeReason: null },
121121
dispatch: dispatchSpy,
122122
};
123123

@@ -142,7 +142,7 @@ describe('<MenuButton />', () => {
142142
const dispatchSpy = spy();
143143
const context = {
144144
...testContext,
145-
state: { open: false },
145+
state: { open: false, changeReason: null },
146146
dispatch: dispatchSpy,
147147
};
148148

@@ -167,7 +167,7 @@ describe('<MenuButton />', () => {
167167
const dispatchSpy = spy();
168168
const context = {
169169
...testContext,
170-
state: { open: false },
170+
state: { open: false, changeReason: null },
171171
dispatch: dispatchSpy,
172172
};
173173

@@ -204,7 +204,7 @@ describe('<MenuButton />', () => {
204204
it('has the aria-expanded=false attribute when closed', () => {
205205
const context = {
206206
...testContext,
207-
state: { open: false },
207+
state: { open: false, changeReason: null },
208208
};
209209

210210
const { getByRole } = render(
@@ -219,7 +219,7 @@ describe('<MenuButton />', () => {
219219
it('has the aria-expanded=true attribute when open', () => {
220220
const context = {
221221
...testContext,
222-
state: { open: true },
222+
state: { open: true, changeReason: null },
223223
};
224224

225225
const { getByRole } = render(

packages/mui-base/src/useDropdown/dropdownReducer.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { DropdownAction, DropdownActionTypes, DropdownState } from './useDropdow
33
export function dropdownReducer(state: DropdownState, action: DropdownAction): DropdownState {
44
switch (action.type) {
55
case DropdownActionTypes.blur:
6-
return { open: false };
6+
return { open: false, changeReason: action.event };
77
case DropdownActionTypes.escapeKeyDown:
8-
return { open: false };
8+
return { open: false, changeReason: action.event };
99
case DropdownActionTypes.toggle:
10-
return { open: !state.open };
10+
return { open: !state.open, changeReason: action.event };
1111
case DropdownActionTypes.open:
12-
return { open: true };
12+
return { open: true, changeReason: action.event };
1313
case DropdownActionTypes.close:
14-
return { open: false };
14+
return { open: false, changeReason: action.event };
1515
default:
1616
throw new Error(`Unhandled action`);
1717
}

packages/mui-base/src/useDropdown/useDropdown.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ export function useDropdown(parameters: UseDropdownParameters = {}) {
2525
const handleStateChange: StateChangeCallback<DropdownState> = React.useCallback(
2626
(event, field, value, reason) => {
2727
if (field === 'open') {
28-
onOpenChange?.(event as React.MouseEvent | React.KeyboardEvent | React.FocusEvent, value);
28+
onOpenChange?.(
29+
event as React.MouseEvent | React.KeyboardEvent | React.FocusEvent,
30+
value as boolean,
31+
);
2932
}
3033

3134
lastActionType.current = reason;
@@ -40,7 +43,9 @@ export function useDropdown(parameters: UseDropdownParameters = {}) {
4043

4144
const [state, dispatch] = useControllableReducer({
4245
controlledProps,
43-
initialState: defaultOpen ? { open: true } : { open: false },
46+
initialState: defaultOpen
47+
? { open: true, changeReason: null }
48+
: { open: false, changeReason: null },
4449
onStateChange: handleStateChange,
4550
reducer: dropdownReducer,
4651
componentName,

packages/mui-base/src/useDropdown/useDropdown.types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,7 @@ export type DropdownAction =
7676
| DropdownOpenAction
7777
| DropdownCloseAction;
7878

79-
export type DropdownState = { open: boolean };
79+
export type DropdownState = {
80+
open: boolean;
81+
changeReason: React.MouseEvent | React.KeyboardEvent | React.FocusEvent | null;
82+
};

packages/mui-base/src/useList/listActions.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const ListActionTypes = {
66
itemsChange: 'list:itemsChange',
77
keyDown: 'list:keyDown',
88
resetHighlight: 'list:resetHighlight',
9+
highlightLast: 'list:highlightLast',
910
textNavigation: 'list:textNavigation',
1011
clearSelection: 'list:clearSelection',
1112
} as const;
@@ -56,6 +57,11 @@ interface ResetHighlightAction {
5657
event: React.SyntheticEvent | null;
5758
}
5859

60+
interface HighlightLastAction {
61+
type: typeof ListActionTypes.highlightLast;
62+
event: React.SyntheticEvent | null;
63+
}
64+
5965
interface ClearSelectionAction {
6066
type: typeof ListActionTypes.clearSelection;
6167
}
@@ -71,5 +77,6 @@ export type ListAction<ItemValue> =
7177
| ItemsChangeAction<ItemValue>
7278
| KeyDownAction
7379
| ResetHighlightAction
80+
| HighlightLastAction
7481
| TextNavigationAction
7582
| ClearSelectionAction;

packages/mui-base/src/useList/listReducer.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,62 @@ describe('listReducer', () => {
11431143
});
11441144
});
11451145

1146+
describe('action: highlightLast', () => {
1147+
it('highlights the last item', () => {
1148+
const state: ListState<string> = {
1149+
highlightedValue: 'one',
1150+
selectedValues: [],
1151+
};
1152+
1153+
const action: ListReducerAction<string> = {
1154+
type: ListActionTypes.highlightLast,
1155+
event: null,
1156+
context: {
1157+
items: ['one', 'two', 'three'],
1158+
disableListWrap: false,
1159+
disabledItemsFocusable: false,
1160+
focusManagement: 'DOM',
1161+
isItemDisabled: () => false,
1162+
itemComparer: (o, v) => o === v,
1163+
getItemAsString: (option) => option,
1164+
orientation: 'vertical',
1165+
pageSize: 5,
1166+
selectionMode: 'none',
1167+
},
1168+
};
1169+
1170+
const result = listReducer(state, action);
1171+
expect(result.highlightedValue).to.equal('three');
1172+
});
1173+
1174+
it('highlights the last non-disabled item', () => {
1175+
const state: ListState<string> = {
1176+
highlightedValue: 'one',
1177+
selectedValues: [],
1178+
};
1179+
1180+
const action: ListReducerAction<string> = {
1181+
type: ListActionTypes.highlightLast,
1182+
event: null,
1183+
context: {
1184+
items: ['one', 'two', 'three'],
1185+
disableListWrap: false,
1186+
disabledItemsFocusable: false,
1187+
focusManagement: 'DOM',
1188+
isItemDisabled: (item) => item === 'three',
1189+
itemComparer: (o, v) => o === v,
1190+
getItemAsString: (option) => option,
1191+
orientation: 'vertical',
1192+
pageSize: 5,
1193+
selectionMode: 'none',
1194+
},
1195+
};
1196+
1197+
const result = listReducer(state, action);
1198+
expect(result.highlightedValue).to.equal('two');
1199+
});
1200+
});
1201+
11461202
describe('action: clearSelection', () => {
11471203
it('clears the selection', () => {
11481204
const state: ListState<string> = {

packages/mui-base/src/useList/listReducer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,16 @@ function handleResetHighlight<ItemValue, State extends ListState<ItemValue>>(
431431
};
432432
}
433433

434+
function handleHighlightLast<ItemValue, State extends ListState<ItemValue>>(
435+
state: State,
436+
context: ListActionContext<ItemValue>,
437+
) {
438+
return {
439+
...state,
440+
highlightedValue: moveHighlight(null, 'end', context),
441+
};
442+
}
443+
434444
function handleClearSelection<ItemValue, State extends ListState<ItemValue>>(
435445
state: State,
436446
context: ListActionContext<ItemValue>,
@@ -461,6 +471,8 @@ export function listReducer<ItemValue, State extends ListState<ItemValue>>(
461471
return handleItemsChange(action.items, action.previousItems, state, context);
462472
case ListActionTypes.resetHighlight:
463473
return handleResetHighlight(state, context);
474+
case ListActionTypes.highlightLast:
475+
return handleHighlightLast(state, context);
464476
case ListActionTypes.clearSelection:
465477
return handleClearSelection(state, context);
466478
default:

0 commit comments

Comments
 (0)