Skip to content

Commit

Permalink
[base-ui][Menu] Focus last item after opening a menu using up arrow (m…
Browse files Browse the repository at this point in the history
  • Loading branch information
Jaswanth-Sriram-Veturi authored Jan 30, 2024
1 parent c479c9b commit 97f225c
Show file tree
Hide file tree
Showing 13 changed files with 232 additions and 24 deletions.
114 changes: 113 additions & 1 deletion packages/mui-base/src/Menu/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import {
import { Menu, menuClasses } from '@mui/base/Menu';
import { MenuItem, MenuItemRootSlotProps } from '@mui/base/MenuItem';
import { DropdownContext, DropdownContextValue } from '@mui/base/useDropdown';
import { Popper } from '@mui/base/Popper';
import { MenuProvider, useMenu } from '@mui/base/useMenu';

const testContext: DropdownContextValue = {
dispatch: () => {},
popupId: 'menu-popup',
registerPopup: () => {},
registerTrigger: () => {},
state: { open: true },
state: { open: true, changeReason: null },
triggerElement: null,
};

Expand Down Expand Up @@ -72,6 +74,116 @@ describe('<Menu />', () => {
expect(item.tabIndex).to.equal(-1);
});
});

it('highlights first item when down arrow key opens the menu', () => {
const context: DropdownContextValue = {
...testContext,
state: {
...testContext.state,
open: true,
changeReason: {
type: 'keydown',
key: 'ArrowDown',
} as React.KeyboardEvent,
},
};
const { getAllByRole } = render(
<DropdownContext.Provider value={context}>
<Menu>
<MenuItem>1</MenuItem>
<MenuItem>2</MenuItem>
<MenuItem>3</MenuItem>
</Menu>
</DropdownContext.Provider>,
);
const [firstItem, ...otherItems] = getAllByRole('menuitem');

expect(firstItem.tabIndex).to.equal(0);
otherItems.forEach((item) => {
expect(item.tabIndex).to.equal(-1);
});
});

it('highlights last item when up arrow key opens the menu', () => {
const context: DropdownContextValue = {
...testContext,
state: {
...testContext.state,
open: true,
changeReason: {
key: 'ArrowUp',
type: 'keydown',
} as React.KeyboardEvent,
},
};
const { getAllByRole } = render(
<DropdownContext.Provider value={context}>
<Menu>
<MenuItem>1</MenuItem>
<MenuItem>2</MenuItem>
<MenuItem>3</MenuItem>
</Menu>
</DropdownContext.Provider>,
);

const [firstItem, secondItem, lastItem] = getAllByRole('menuitem');

expect(lastItem.tabIndex).to.equal(0);
[firstItem, secondItem].forEach((item) => {
expect(item.tabIndex).to.equal(-1);
});
});

it('highlights last non-disabled item when disabledItemsFocusable is set to false', () => {
const CustomMenu = React.forwardRef(function CustomMenu(
props: React.ComponentPropsWithoutRef<'ul'>,
ref: React.Ref<HTMLUListElement>,
) {
const { children, ...other } = props;

const { open, triggerElement, contextValue, getListboxProps } = useMenu({
listboxRef: ref,
disabledItemsFocusable: false,
});

const anchorEl = triggerElement ?? document.createElement('div');

return (
<Popper open={open} anchorEl={anchorEl}>
<ul className="menu-root" {...other} {...getListboxProps()}>
<MenuProvider value={contextValue}>{children}</MenuProvider>
</ul>
</Popper>
);
});

const context: DropdownContextValue = {
...testContext,
state: {
...testContext.state,
open: true,
changeReason: {
key: 'ArrowUp',
type: 'keydown',
} as React.KeyboardEvent,
},
};
const { getAllByRole } = render(
<DropdownContext.Provider value={context}>
<CustomMenu>
<MenuItem>1</MenuItem>
<MenuItem>2</MenuItem>
<MenuItem disabled>3</MenuItem>
</CustomMenu>
</DropdownContext.Provider>,
);
const [firstItem, secondItem, lastItem] = getAllByRole('menuitem');

expect(secondItem.tabIndex).to.equal(0);
[firstItem, lastItem].forEach((item) => {
expect(item.tabIndex).to.equal(-1);
});
});
});

describe('keyboard navigation', () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/mui-base/src/MenuButton/MenuButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const testContext: DropdownContextValue = {
popupId: 'menu-popup',
registerPopup: () => {},
registerTrigger: () => {},
state: { open: true },
state: { open: true, changeReason: null },
triggerElement: null,
};

Expand Down Expand Up @@ -67,7 +67,7 @@ describe('<MenuButton />', () => {
const dispatchSpy = spy();
const context = {
...testContext,
state: { open: false },
state: { open: false, changeReason: null },
dispatch: dispatchSpy,
};

Expand Down Expand Up @@ -117,7 +117,7 @@ describe('<MenuButton />', () => {
const dispatchSpy = spy();
const context = {
...testContext,
state: { open: false },
state: { open: false, changeReason: null },
dispatch: dispatchSpy,
};

Expand All @@ -142,7 +142,7 @@ describe('<MenuButton />', () => {
const dispatchSpy = spy();
const context = {
...testContext,
state: { open: false },
state: { open: false, changeReason: null },
dispatch: dispatchSpy,
};

Expand All @@ -167,7 +167,7 @@ describe('<MenuButton />', () => {
const dispatchSpy = spy();
const context = {
...testContext,
state: { open: false },
state: { open: false, changeReason: null },
dispatch: dispatchSpy,
};

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

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

const { getByRole } = render(
Expand Down
10 changes: 5 additions & 5 deletions packages/mui-base/src/useDropdown/dropdownReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { DropdownAction, DropdownActionTypes, DropdownState } from './useDropdow
export function dropdownReducer(state: DropdownState, action: DropdownAction): DropdownState {
switch (action.type) {
case DropdownActionTypes.blur:
return { open: false };
return { open: false, changeReason: action.event };
case DropdownActionTypes.escapeKeyDown:
return { open: false };
return { open: false, changeReason: action.event };
case DropdownActionTypes.toggle:
return { open: !state.open };
return { open: !state.open, changeReason: action.event };
case DropdownActionTypes.open:
return { open: true };
return { open: true, changeReason: action.event };
case DropdownActionTypes.close:
return { open: false };
return { open: false, changeReason: action.event };
default:
throw new Error(`Unhandled action`);
}
Expand Down
9 changes: 7 additions & 2 deletions packages/mui-base/src/useDropdown/useDropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export function useDropdown(parameters: UseDropdownParameters = {}) {
const handleStateChange: StateChangeCallback<DropdownState> = React.useCallback(
(event, field, value, reason) => {
if (field === 'open') {
onOpenChange?.(event as React.MouseEvent | React.KeyboardEvent | React.FocusEvent, value);
onOpenChange?.(
event as React.MouseEvent | React.KeyboardEvent | React.FocusEvent,
value as boolean,
);
}

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

const [state, dispatch] = useControllableReducer({
controlledProps,
initialState: defaultOpen ? { open: true } : { open: false },
initialState: defaultOpen
? { open: true, changeReason: null }
: { open: false, changeReason: null },
onStateChange: handleStateChange,
reducer: dropdownReducer,
componentName,
Expand Down
5 changes: 4 additions & 1 deletion packages/mui-base/src/useDropdown/useDropdown.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,7 @@ export type DropdownAction =
| DropdownOpenAction
| DropdownCloseAction;

export type DropdownState = { open: boolean };
export type DropdownState = {
open: boolean;
changeReason: React.MouseEvent | React.KeyboardEvent | React.FocusEvent | null;
};
7 changes: 7 additions & 0 deletions packages/mui-base/src/useList/listActions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const ListActionTypes = {
itemsChange: 'list:itemsChange',
keyDown: 'list:keyDown',
resetHighlight: 'list:resetHighlight',
highlightLast: 'list:highlightLast',
textNavigation: 'list:textNavigation',
clearSelection: 'list:clearSelection',
} as const;
Expand Down Expand Up @@ -56,6 +57,11 @@ interface ResetHighlightAction {
event: React.SyntheticEvent | null;
}

interface HighlightLastAction {
type: typeof ListActionTypes.highlightLast;
event: React.SyntheticEvent | null;
}

interface ClearSelectionAction {
type: typeof ListActionTypes.clearSelection;
}
Expand All @@ -71,5 +77,6 @@ export type ListAction<ItemValue> =
| ItemsChangeAction<ItemValue>
| KeyDownAction
| ResetHighlightAction
| HighlightLastAction
| TextNavigationAction
| ClearSelectionAction;
56 changes: 56 additions & 0 deletions packages/mui-base/src/useList/listReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,62 @@ describe('listReducer', () => {
});
});

describe('action: highlightLast', () => {
it('highlights the last item', () => {
const state: ListState<string> = {
highlightedValue: 'one',
selectedValues: [],
};

const action: ListReducerAction<string> = {
type: ListActionTypes.highlightLast,
event: null,
context: {
items: ['one', 'two', 'three'],
disableListWrap: false,
disabledItemsFocusable: false,
focusManagement: 'DOM',
isItemDisabled: () => false,
itemComparer: (o, v) => o === v,
getItemAsString: (option) => option,
orientation: 'vertical',
pageSize: 5,
selectionMode: 'none',
},
};

const result = listReducer(state, action);
expect(result.highlightedValue).to.equal('three');
});

it('highlights the last non-disabled item', () => {
const state: ListState<string> = {
highlightedValue: 'one',
selectedValues: [],
};

const action: ListReducerAction<string> = {
type: ListActionTypes.highlightLast,
event: null,
context: {
items: ['one', 'two', 'three'],
disableListWrap: false,
disabledItemsFocusable: false,
focusManagement: 'DOM',
isItemDisabled: (item) => item === 'three',
itemComparer: (o, v) => o === v,
getItemAsString: (option) => option,
orientation: 'vertical',
pageSize: 5,
selectionMode: 'none',
},
};

const result = listReducer(state, action);
expect(result.highlightedValue).to.equal('two');
});
});

describe('action: clearSelection', () => {
it('clears the selection', () => {
const state: ListState<string> = {
Expand Down
12 changes: 12 additions & 0 deletions packages/mui-base/src/useList/listReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,16 @@ function handleResetHighlight<ItemValue, State extends ListState<ItemValue>>(
};
}

function handleHighlightLast<ItemValue, State extends ListState<ItemValue>>(
state: State,
context: ListActionContext<ItemValue>,
) {
return {
...state,
highlightedValue: moveHighlight(null, 'end', context),
};
}

function handleClearSelection<ItemValue, State extends ListState<ItemValue>>(
state: State,
context: ListActionContext<ItemValue>,
Expand Down Expand Up @@ -461,6 +471,8 @@ export function listReducer<ItemValue, State extends ListState<ItemValue>>(
return handleItemsChange(action.items, action.previousItems, state, context);
case ListActionTypes.resetHighlight:
return handleResetHighlight(state, context);
case ListActionTypes.highlightLast:
return handleHighlightLast(state, context);
case ListActionTypes.clearSelection:
return handleClearSelection(state, context);
default:
Expand Down
Loading

0 comments on commit 97f225c

Please sign in to comment.