Skip to content

Commit 88b6eac

Browse files
authored
feat(ui5-menu): improve accessibility (#11846)
This PR introduces the following accessibility improvements in ui5-menu component: Adds aria-label attributes on menu item group wrappers, which already have role="group". The labels are according to the group's checkMode property. Adds the possibility to check/uncheck menu items in groups when the checkMode property is set to Single or Multiple. This is achieved by holding down the Shift key while selecting (with mouse or keyboard) the menu items in these groups. Selecting menu items that don't belong to any group, or items inside groups with the checkMode property set to None will always close the menu regardless if Shift is pressed or not. Fixes: #11594
1 parent 87412b5 commit 88b6eac

File tree

12 files changed

+213
-26
lines changed

12 files changed

+213
-26
lines changed

packages/main/cypress/specs/Menu.cy.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,68 @@ describe("Menu interaction", () => {
286286
.should("be.focused");
287287
});
288288

289+
it("should not close menu when selecting 'single' checkable menu item with 'Shift'", () => {
290+
cy.mount(
291+
<>
292+
<Button id="btnOpen">Open Menu</Button>
293+
<Menu opener="btnOpen">
294+
<MenuItemGroup checkMode="Single">
295+
<MenuItem text="Single"></MenuItem>
296+
</MenuItemGroup>
297+
</Menu>
298+
</>
299+
);
300+
301+
cy.get("[ui5-menu]")
302+
.ui5MenuItemCheckShiftClickAndPress("[text='Single']", "have.attr");
303+
});
304+
305+
it("should not close menu when selecting 'multi' checkable menu item with 'Shift'", () => {
306+
cy.mount(
307+
<>
308+
<Button id="btnOpen">Open Menu</Button>
309+
<Menu opener="btnOpen">
310+
<MenuItemGroup checkMode="Multiple">
311+
<MenuItem text="Multiple"></MenuItem>
312+
</MenuItemGroup>
313+
</Menu>
314+
</>
315+
);
316+
317+
cy.get("[ui5-menu]")
318+
.ui5MenuItemCheckShiftClickAndPress("[text='Multiple']", "have.attr");
319+
});
320+
321+
it("should close menu when selecting non checkable menu item with 'Shift'", () => {
322+
cy.mount(
323+
<>
324+
<Button id="btnOpen">Open Menu</Button>
325+
<Menu opener="btnOpen">
326+
<MenuItemGroup checkMode="None">
327+
<MenuItem text="None"></MenuItem>
328+
</MenuItemGroup>
329+
</Menu>
330+
</>
331+
);
332+
333+
cy.get("[ui5-menu]")
334+
.ui5MenuItemCheckShiftClickAndPress("[text='None']", "not.have.attr");
335+
});
336+
337+
it("should close menu when selecting menu item, not in a group, with 'Shift'", () => {
338+
cy.mount(
339+
<>
340+
<Button id="btnOpen">Open Menu</Button>
341+
<Menu opener="btnOpen">
342+
<MenuItem text="Outside"></MenuItem>
343+
</Menu>
344+
</>
345+
);
346+
347+
cy.get("[ui5-menu]")
348+
.ui5MenuItemCheckShiftClickAndPress("[text='Outside']", "not.have.attr");
349+
});
350+
289351
describe("Event firing", () => {
290352
it("Event firing - 'ui5-item-click' after 'click' on menu item", () => {
291353
cy.mount(

packages/main/cypress/support/commands.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,7 @@ declare global {
6767
namespace Cypress {
6868
interface Chainable {
6969
ui5SimulateDevice(device?: SimulationDevices): Chainable<void>
70-
ui5MenuOpen(options?: { opener?: string }): Chainable<void>
71-
ui5MenuOpened(): Chainable<void>
72-
ui5MenuItemClick(): Chainable<void>
7370
ui5DOMRef(): Chainable<void>
74-
ui5MenuItemPress(key: any): Chainable<void>
7571
ui5CalendarGetDay(calendarSelector: string, timestamp: string): Chainable<JQuery<HTMLElement>>
7672
ui5CalendarGetMonth(calendarSelector: string, timestamp: string): Chainable<JQuery<HTMLElement>>
7773
ui5CalendarShowYearRangePicker(): Chainable<void>

packages/main/cypress/support/commands/Menu.commands.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,69 @@ Cypress.Commands.add("ui5MenuItemClick", { prevSubject: true }, subject => {
4444

4545
Cypress.Commands.add("ui5MenuItemPress", { prevSubject: true }, (subject, key) => {
4646
cy.get(subject)
47-
.should("have.focused")
47+
.should("be.focused")
4848
.and("be.visible");
4949

5050
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
5151
cy.realPress(key);
5252
});
53+
54+
Cypress.Commands.add("ui5MenuItemCheckShiftClickAndPress", { prevSubject: true }, (subject, menuItem, shouldStatement) => {
55+
cy.get(subject)
56+
.as("menu");
57+
58+
cy.get("@menu")
59+
.find(menuItem)
60+
.as("menuItem");
61+
62+
cy.get("@menu")
63+
.ui5MenuOpen();
64+
65+
cy.get("@menuItem")
66+
.should("be.visible")
67+
.and("be.focused");
68+
69+
cy.get("@menuItem")
70+
.realClick({ shiftKey: true });
71+
72+
cy.get("@menu")
73+
.should(shouldStatement, "open");
74+
75+
cy.get("@menu")
76+
.ui5MenuOpen();
77+
78+
cy.get("@menuItem")
79+
.should("be.visible")
80+
.and("be.focused");
81+
82+
cy.get("@menuItem")
83+
.realPress(["Shift", "Enter"]);
84+
85+
cy.get("@menu")
86+
.should(shouldStatement, "open");
87+
88+
cy.get("@menu")
89+
.ui5MenuOpen();
90+
91+
cy.get("@menuItem")
92+
.should("be.visible")
93+
.and("be.focused");
94+
95+
cy.get("@menuItem")
96+
.realPress(["Shift", "Space"]);
97+
98+
cy.get("@menu")
99+
.should(shouldStatement, "open");
100+
});
101+
102+
declare global {
103+
namespace Cypress {
104+
interface Chainable {
105+
ui5MenuOpen(options?: { opener?: string }): Chainable<void>
106+
ui5MenuOpened(): Chainable<void>
107+
ui5MenuItemClick(): Chainable<void>
108+
ui5MenuItemPress(key: any): Chainable<void>
109+
ui5MenuItemCheckShiftClickAndPress(menuItem: string, shouldStatement: string): Chainable<void>
110+
}
111+
}
112+
}

packages/main/src/ListItemBase.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,11 @@ class ListItemBase extends UI5Element implements ITabbable {
146146
return;
147147
}
148148

149-
if (isSpace(e)) {
149+
if (this._isSpace(e)) {
150150
e.preventDefault();
151151
}
152152

153-
if (isEnter(e)) {
153+
if (this._isEnter(e)) {
154154
this.fireItemPress(e);
155155
}
156156
}
@@ -159,7 +159,7 @@ class ListItemBase extends UI5Element implements ITabbable {
159159
if (this.getFocusDomRef()!.matches(":has(:focus-within)")) {
160160
return;
161161
}
162-
if (isSpace(e)) {
162+
if (this._isSpace(e)) {
163163
this.fireItemPress(e);
164164
}
165165
}
@@ -171,6 +171,20 @@ class ListItemBase extends UI5Element implements ITabbable {
171171
this.fireItemPress(e);
172172
}
173173

174+
/**
175+
* Override from subcomponent, if needed
176+
*/
177+
_isSpace(e: KeyboardEvent) {
178+
return isSpace(e);
179+
}
180+
181+
/**
182+
* Override from subcomponent, if needed
183+
*/
184+
_isEnter(e: KeyboardEvent) {
185+
return isEnter(e);
186+
}
187+
174188
fireItemPress(e: Event) {
175189
if (this.disabled || !this._pressable) {
176190
return;

packages/main/src/Menu.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,8 @@ import {
77
isLeft,
88
isRight,
99
isEnter,
10-
isSpace,
1110
isTabNext,
1211
isTabPrevious,
13-
isDown,
14-
isUp,
1512
} from "@ui5/webcomponents-base/dist/Keys.js";
1613
import {
1714
isPhone,
@@ -415,7 +412,7 @@ class Menu extends UI5Element {
415412

416413
if (!prevented) {
417414
item._updateCheckedState();
418-
this._popover && item.fireDecoratorEvent("close-menu");
415+
this._popover && !item._shiftPressed && item.fireDecoratorEvent("close-menu");
419416
}
420417
} else {
421418
this._openItemSubMenu(item);
@@ -430,12 +427,8 @@ class Menu extends UI5Element {
430427
return;
431428
}
432429

433-
const menuItemInMenu = this._allMenuItems.includes(item);
434-
const isItemNavigation = isUp(e) || isDown(e);
435-
const isItemSelection = isEnter(e) || isSpace(e);
436430
const isEndContentNavigation = isRight(e) || isLeft(e);
437431
const shouldOpenMenu = this.isRtl ? isLeft(e) : isRight(e);
438-
const shouldCloseMenu = menuItemInMenu && !(isItemNavigation || isItemSelection || isEndContentNavigation);
439432

440433
if (isEnter(e) || isTabNextPrevious) {
441434
e.preventDefault();
@@ -447,7 +440,7 @@ class Menu extends UI5Element {
447440

448441
if (shouldOpenMenu) {
449442
this._openItemSubMenu(item);
450-
} else if ((shouldCloseMenu || isTabNextPrevious)) {
443+
} else if (isTabNextPrevious) {
451444
this._close();
452445
}
453446
}

packages/main/src/MenuItem.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
isRight,
1111
isEnter,
1212
isSpace,
13+
isEnterShift,
14+
isSpaceShift,
15+
isShift,
1316
isTabNext,
1417
isTabPrevious,
1518
isDown,
@@ -314,6 +317,7 @@ class MenuItem extends ListItem implements IMenuItem {
314317
static i18nBundle: I18nBundle;
315318

316319
_itemNavigation: ItemNavigation;
320+
_shiftPressed: boolean = false;
317321

318322
constructor() {
319323
super();
@@ -337,6 +341,10 @@ class MenuItem extends ListItem implements IMenuItem {
337341
});
338342
}
339343

344+
get _isCheckable() {
345+
return this._checkMode !== MenuItemGroupCheckMode.None;
346+
}
347+
340348
_navigateToEndContent(shouldNavigateToPreviousItem: boolean) {
341349
const navigatableItems = this._navigableItems;
342350
const item = shouldNavigateToPreviousItem
@@ -534,22 +542,40 @@ class MenuItem extends ListItem implements IMenuItem {
534542
this._closeOtherSubMenus(item);
535543
}
536544

545+
_isSpace(e: KeyboardEvent) {
546+
this._shiftPressed = this._isCheckable && isSpaceShift(e);
547+
return isSpace(e) || isSpaceShift(e);
548+
}
549+
550+
_isEnter(e: KeyboardEvent) {
551+
this._shiftPressed = this._isCheckable && isEnterShift(e);
552+
return isEnter(e) || isEnterShift(e);
553+
}
554+
555+
_onclick(e: MouseEvent) {
556+
this._shiftPressed = this._isCheckable && e.shiftKey;
557+
super._onclick(e);
558+
}
559+
537560
_itemKeyDown(e: KeyboardEvent) {
538561
const item = e.target as MenuItem;
539562
const itemInMenuItems = this._allMenuItems.includes(item);
540563
const isTabNextPrevious = isTabNext(e) || isTabPrevious(e);
541-
const isItemNavigation = isUp(e) || isDown(e);
542-
const isItemSelection = isSpace(e) || isEnter(e);
543-
const shouldOpenMenu = this.isRtl ? isLeft(e) : isRight(e);
544-
const shouldCloseMenu = !(isItemNavigation || isItemSelection || shouldOpenMenu) || isTabNextPrevious;
564+
const shouldCloseMenu = this.isRtl ? isRight(e) : isLeft(e);
545565

546-
if (itemInMenuItems && shouldCloseMenu) {
566+
if (itemInMenuItems && (isTabNextPrevious || shouldCloseMenu)) {
547567
this._close();
548568
this.focus();
549569
e.stopPropagation();
550570
}
551571
}
552572

573+
_itemKeyUp(e: KeyboardEvent) {
574+
if (isShift(e)) {
575+
this._shiftPressed = false;
576+
}
577+
}
578+
553579
_endContentKeyDown(e: KeyboardEvent) {
554580
const shouldNavigateOutOfEndContent = isUp(e) || isDown(e);
555581

packages/main/src/MenuItemGroup.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
22
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
33
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
44
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
5+
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
56
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
7+
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
68
import type MenuItem from "./MenuItem.js";
79
import { isInstanceOfMenuItem } from "./MenuItem.js";
810
import MenuItemGroupTemplate from "./MenuItemGroupTemplate.js";
911
import MenuItemGroupCheckMode from "./types/MenuItemGroupCheckMode.js";
1012
import type { IMenuItem } from "./Menu.js";
13+
import {
14+
MENU_ITEM_GROUP_NONE_ACCESSIBLE_NAME,
15+
MENU_ITEM_GROUP_SINGLE_ACCESSIBLE_NAME,
16+
MENU_ITEM_GROUP_MULTI_ACCESSIBLE_NAME,
17+
} from "./generated/i18n/i18n-defaults.js";
1118

1219
/**
1320
* @class
@@ -62,6 +69,22 @@ class MenuItemGroup extends UI5Element implements IMenuItem {
6269
@slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true })
6370
items!: Array<IMenuItem>;
6471

72+
@i18n("@ui5/webcomponents")
73+
static i18nBundle: I18nBundle;
74+
75+
get ariaLabelText(): string | undefined {
76+
switch (this.checkMode) {
77+
case MenuItemGroupCheckMode.None:
78+
return MenuItemGroup.i18nBundle.getText(MENU_ITEM_GROUP_NONE_ACCESSIBLE_NAME);
79+
case MenuItemGroupCheckMode.Single:
80+
return MenuItemGroup.i18nBundle.getText(MENU_ITEM_GROUP_SINGLE_ACCESSIBLE_NAME);
81+
case MenuItemGroupCheckMode.Multiple:
82+
return MenuItemGroup.i18nBundle.getText(MENU_ITEM_GROUP_MULTI_ACCESSIBLE_NAME);
83+
default:
84+
return undefined;
85+
}
86+
}
87+
6588
get isGroup(): boolean {
6689
return true;
6790
}

packages/main/src/MenuItemGroupTemplate.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export default function MenuItemGroupTemplate(this: MenuItemGroup) {
44
return (
55
<div
66
role="group"
7+
aria-label={this.ariaLabelText}
78
onui5-check={this._handleItemCheck}
89
>
910
<slot></slot>

packages/main/src/MenuItemTemplate.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ function listItemPostContent(this: MenuItem) {
138138
loadingDelay={this.loadingDelay}
139139
onMouseOver={this._itemMouseOver}
140140
onKeyDown={this._itemKeyDown}
141+
onKeyUp={this._itemKeyUp}
141142
// handles event from slotted children
142143
onui5-close-menu={this._close}
143144
onui5-exit-end-content={this._navigateOutOfEndContent}

packages/main/src/i18n/messagebundle.properties

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,15 @@ MENU_CLOSE_BUTTON_ARIA_LABEL=Decline
643643
#XACT: ARIA information for the Menu popover
644644
MENU_POPOVER_ACCESSIBLE_NAME=Select an option from the menu
645645

646+
#XACT: ARIA information for the Menu Item Group with itemSelectionMode "None"
647+
MENU_ITEM_GROUP_NONE_ACCESSIBLE_NAME=Contains Non-Selectable Items
648+
649+
#XACT: ARIA information for the Menu Item Group with itemSelectionMode "SingleSelect"
650+
MENU_ITEM_GROUP_SINGLE_ACCESSIBLE_NAME=Contains Selectable Items
651+
652+
#XACT: ARIA information for the Menu Item Group with itemSelectionMode "MultiSelect"
653+
MENU_ITEM_GROUP_MULTI_ACCESSIBLE_NAME=Contains Multi-Selectable Items
654+
646655
#XACT: ARIA announcement for roldesecription attribute of Dialog header
647656
DIALOG_HEADER_ARIA_ROLE_DESCRIPTION=Interactive Header
648657

0 commit comments

Comments
 (0)