MM-63451 Rely on MUI's MenuList to handle focus when opening menus (#30585)

* MM-63451 Rely on MUI's MenuList to handle focus when opening menus

* MM-63451 Add E2E tests for keyboard accessibility in the account menu

* Run prettier on E2E tests

* And check in the rest of those changes

* Fix lint
Этот коммит содержится в:
Harrison Healey
2025-04-03 14:22:42 -04:00
коммит произвёл GitHub
родитель a9f09cadc2
Коммит 2dcf6bffb1
3 изменённых файлов: 111 добавлений и 10 удалений

Просмотреть файл

@@ -6,6 +6,7 @@ import {Locator, expect} from '@playwright/test';
export default class GlobalHeader {
readonly container: Locator;
readonly accountMenuButton;
readonly productSwitchMenu;
readonly recentMentionsButton;
readonly settingsButton;
@@ -14,6 +15,7 @@ export default class GlobalHeader {
constructor(container: Locator) {
this.container = container;
this.accountMenuButton = container.getByRole('button', {name: "'s account menu"});
this.productSwitchMenu = container.getByRole('button', {name: 'Product switch menu'});
this.recentMentionsButton = container.getByRole('button', {name: 'Recent mentions'});
this.settingsButton = container.getByRole('button', {name: 'Settings'});

Просмотреть файл

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, ChannelsPage} from '@mattermost/playwright-lib';
import {UserProfile} from '@mattermost/types/users';
import {Page} from '@playwright/test';
test('MM-63451 should be able to navigate the account settings menu with the keyboard after opening it with the mouse', async ({
pw,
}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Click on the account menu button
await channelsPage.globalHeader.accountMenuButton.click();
await testMenuWithKeyboard(user, page, channelsPage);
});
test('MM-63451 should be able to navigate the account settings menu with the keyboard after opening it with the keyboard', async ({
pw,
}) => {
// # Create and sign in a new user
const {user} = await pw.initSetup();
// # Log in a user in new browser context
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit a default channel page
await channelsPage.goto();
await channelsPage.toBeVisible();
// # Focus the account menu button
await channelsPage.globalHeader.accountMenuButton.focus();
await expect(channelsPage.globalHeader.accountMenuButton).toBeFocused();
await page.keyboard.press('Space');
await testMenuWithKeyboard(user, page, channelsPage);
});
async function testMenuWithKeyboard(user: UserProfile, page: Page, channelsPage: ChannelsPage) {
// * Should start focused on the first menu item
await expect(page.getByRole('menuitem', {name: '@' + user.username})).toBeFocused();
// * Should be able to scroll down through the menu with the keyboard
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Set custom status'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Online'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Away'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Do not disturb Disables all notifications'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Offline'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Profile'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Log Out'})).toBeFocused();
// * Should be able to scroll back up through the menu with the keyboard
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Profile'})).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Offline'})).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Do not disturb Disables all notifications'})).toBeFocused();
// * Should be able to move into the submenu by pressing the right arrow
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('menuitem', {name: "Don't clear"})).toBeFocused();
// * Should be able to scroll through the submenu with the keyboard
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: '30 mins'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: '1 hour'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: '2 hours'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Tomorrow'})).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: 'Choose date and time'})).toBeFocused();
// * Should wrap around when you reach the end
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', {name: "Don't clear"})).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitem', {name: 'Choose date and time'})).toBeFocused();
// * Should be able to close the submenu by pressing the left arrow
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('menuitem', {name: 'Do not disturb Disables all notifications'})).toBeFocused();
// * Should be able to close the menu by pressing escape
await page.keyboard.press('Escape');
await expect(page.getByRole('menuitem')).toHaveCount(0);
// * Should be focused back on the menu button
await expect(channelsPage.globalHeader.accountMenuButton).toBeFocused();
}

Просмотреть файл

@@ -113,20 +113,17 @@ export function Menu(props: Props) {
const dispatch = useDispatch();
const [anchorElement, setAnchorElement] = useState<null | HTMLElement>(null);
const [disableAutoFocusItem, setDisableAutoFocusItem] = useState(false);
const isMenuOpen = Boolean(anchorElement);
// Callback function handler called when menu is closed by escapeKeyDown, backdropClick or tabKeyDown
function handleMenuClose(event: MouseEvent<HTMLDivElement>) {
event.preventDefault();
setAnchorElement(null);
setDisableAutoFocusItem(false);
}
// Handle function injected into menu items to close the menu
const closeMenu = useCallback(() => {
setAnchorElement(null);
setDisableAutoFocusItem(false);
}, []);
function handleMenuModalClose(modalId: MenuProps['id']) {
@@ -195,11 +192,6 @@ export function Menu(props: Props) {
}
}
// Function to prevent focus-visible from being set on clicking menu items with the mouse
function handleMenuButtonMouseDown() {
setDisableAutoFocusItem(true);
}
// We construct the menu button so we can set onClick correctly here to support both web and mobile view
function renderMenuButton() {
const MenuButtonComponent = props.menuButton?.as ?? 'button';
@@ -216,7 +208,6 @@ export function Menu(props: Props) {
aria-describedby={props.menuButton?.['aria-describedby']}
className={props.menuButton?.class ?? ''}
onClick={handleMenuButtonClick}
onMouseDown={handleMenuButtonMouseDown}
>
{props.menuButton.children}
</MenuButtonComponent>
@@ -293,11 +284,11 @@ export function Menu(props: Props) {
id={props.menu.id}
aria-label={props.menu?.['aria-label']}
aria-labelledby={props.menu['aria-labelledby']}
autoFocusItem={!disableAutoFocusItem}
className={props.menu.className}
style={{
width: props.menu.width,
}}
autoFocusItem={isMenuOpen}
>
{props.children}
</MuiMenuList>