Skip to content

Commit 0a343ce

Browse files
fix(Select-next,Dropdown-next): updated logic for keyboard interaction (#8496)
* fix(Select-next,Dropdown-next): updated logic for keyboard interaction * Added prop to apply a11y attributes * Fixed a11y errors * Updated new props * Removed selectVariant from examples * Updated prop name, added example verbiage
1 parent 2385b96 commit 0a343ce

File tree

12 files changed

+93
-55
lines changed

12 files changed

+93
-55
lines changed

packages/react-core/src/components/Menu/Menu.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export interface MenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'r
6868
ouiaId?: number | string;
6969
/** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */
7070
ouiaSafe?: boolean;
71+
/** @beta Determines the accessible role of the menu. For a non-checkbox menu that can have
72+
* one or more items selected, pass in "listbox". */
73+
role?: string;
7174
}
7275

7376
export interface MenuState {
@@ -89,7 +92,8 @@ class MenuBase extends React.Component<MenuProps, MenuState> {
8992
ouiaSafe: true,
9093
isRootMenu: true,
9194
isPlain: false,
92-
isScrollable: false
95+
isScrollable: false,
96+
role: 'menu'
9397
};
9498

9599
constructor(props: MenuProps) {
@@ -272,6 +276,7 @@ class MenuBase extends React.Component<MenuProps, MenuState> {
272276
innerRef,
273277
isRootMenu,
274278
activeMenu,
279+
role,
275280
/* eslint-enable @typescript-eslint/no-unused-vars */
276281
...props
277282
} = this.props;
@@ -292,7 +297,8 @@ class MenuBase extends React.Component<MenuProps, MenuState> {
292297
onGetMenuHeight,
293298
flyoutRef: this.state.flyoutRef,
294299
setFlyoutRef: flyoutRef => this.setState({ flyoutRef }),
295-
disableHover: this.state.disableHover
300+
disableHover: this.state.disableHover,
301+
role
296302
}}
297303
>
298304
{isRootMenu && (

packages/react-core/src/components/Menu/MenuContext.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const MenuContext = React.createContext<{
2020
flyoutRef?: React.Ref<HTMLLIElement>;
2121
setFlyoutRef?: (ref: React.Ref<HTMLLIElement>) => void;
2222
disableHover?: boolean;
23+
role?: string;
2324
}>({
2425
menuId: null,
2526
parentMenu: null,
@@ -34,7 +35,8 @@ export const MenuContext = React.createContext<{
3435
onGetMenuHeight: () => null,
3536
flyoutRef: null,
3637
setFlyoutRef: () => null,
37-
disableHover: false
38+
disableHover: false,
39+
role: 'menu'
3840
});
3941

4042
export const MenuItemContext = React.createContext<{

packages/react-core/src/components/Menu/MenuItem.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ const MenuItemBase: React.FunctionComponent<MenuItemProps> = ({
114114
onDrillOut,
115115
flyoutRef,
116116
setFlyoutRef,
117-
disableHover
117+
disableHover,
118+
role: menuRole
118119
} = React.useContext(MenuContext);
119120
let Component = (to ? 'a' : component) as any;
120121
if (hasCheck && !to) {
@@ -290,6 +291,7 @@ const MenuItemBase: React.FunctionComponent<MenuItemProps> = ({
290291
setFlyoutRef(null);
291292
}
292293
};
294+
const isSelectMenu = menuRole === 'listbox';
293295

294296
return (
295297
<li
@@ -316,7 +318,8 @@ const MenuItemBase: React.FunctionComponent<MenuItemProps> = ({
316318
className={css(styles.menuItem, getIsSelected() && !hasCheck && styles.modifiers.selected, className)}
317319
aria-current={getAriaCurrent()}
318320
{...(!hasCheck && { disabled: isDisabled })}
319-
{...(!hasCheck && !flyoutMenu && { role: 'menuitem' })}
321+
{...(!hasCheck && !flyoutMenu && { role: isSelectMenu ? 'option' : 'menuitem' })}
322+
{...(!hasCheck && !flyoutMenu && isSelectMenu && { 'aria-selected': getIsSelected() })}
320323
ref={innerRef}
321324
{...(!hasCheck && {
322325
onClick: (event: React.KeyboardEvent | React.MouseEvent) => {
Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import * as React from 'react';
22
import styles from '@patternfly/react-styles/css/components/Menu/menu';
33
import { css } from '@patternfly/react-styles';
4+
import { MenuContext } from './MenuContext';
45

56
export interface MenuListProps extends React.HTMLProps<HTMLUListElement> {
67
/** Anything that can be rendered inside of menu list */
78
children: React.ReactNode;
89
/** Additional classes added to the menu list */
910
className?: string;
11+
/** @beta Indicates to assistive technologies whether more than one item can be selected
12+
* for a non-checkbox menu. Only applies when the menu's role is "listbox".
13+
*/
14+
isAriaMultiselectable?: boolean;
1015
}
1116

1217
export const MenuList: React.FunctionComponent<MenuListProps> = ({
1318
children = null,
1419
className,
20+
isAriaMultiselectable = false,
1521
...props
16-
}: MenuListProps) => (
17-
<ul role="menu" className={css(styles.menuList, className)} {...props}>
18-
{children}
19-
</ul>
20-
);
22+
}: MenuListProps) => {
23+
const { role } = React.useContext(MenuContext);
24+
25+
return (
26+
<ul
27+
role={role}
28+
{...(role === 'listbox' && { 'aria-multiselectable': isAriaMultiselectable })}
29+
className={css(styles.menuList, className)}
30+
{...props}
31+
>
32+
{children}
33+
</ul>
34+
);
35+
};
2136
MenuList.displayName = 'MenuList';

packages/react-core/src/components/Menu/examples/Menu.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ A menu may contain multiple variations of `<MenuItem>` components. The following
2424

2525
- Use the `itemId` property to link to callbacks. In this example, the `onSelect` property logs information to the console when a menu item is selected. In practice, specific actions can be linked to `onSelect` callbacks.
2626
- Use the `to` property to direct users to other resources or webpages after selecting a menu item, and the `onClick` property to pass in a callback for specific menu items.
27-
- Use the `isDisabled` property to disable a menu item.
28-
- Use the `isPlain` property to remove the outer box shadow and style the menu plainly instead.
27+
- Use the `isDisabled` property to disable a menu item.
28+
- Use the `isPlain` property to remove the outer box shadow and style the menu plainly instead.
2929

3030
```ts file="MenuBasic.tsx"
3131
```
@@ -46,11 +46,11 @@ Use the `icon` property to add a familiar icon before a `<MenuItem>` to accelera
4646

4747
### With actions
4848

49-
To connect a menu item to an action icon, add a `<MenuItemAction>` to a `<MenuItem>`, and use the `icon` property to load an easily recognizable icon.
49+
To connect a menu item to an action icon, add a `<MenuItemAction>` to a `<MenuItem>`, and use the `icon` property to load an easily recognizable icon.
5050

51-
To trigger an action when any menu action icon is selected, pass a callback to the `onActionClick` property of the `<Menu>`. The following example logs to the console any time any action icon is selected.
51+
To trigger an action when any menu action icon is selected, pass a callback to the `onActionClick` property of the `<Menu>`. The following example logs to the console any time any action icon is selected.
5252

53-
To trigger an action when a specific item's action icon is selected, pass in the `onClick` property to that `<MenuItemAction>`. The following example logs "clicked on code icon" to the console when the "code" icon is selected.
53+
To trigger an action when a specific item's action icon is selected, pass in the `onClick` property to that `<MenuItemAction>`. The following example logs "clicked on code icon" to the console when the "code" icon is selected.
5454

5555
```ts file="MenuWithActions.tsx"
5656
```
@@ -64,7 +64,7 @@ Use the `to` property to add a link to a `<MenuItem>` that directs users to a ne
6464

6565
### With descriptions
6666

67-
Use the `description` property to add short descriptive text below any menu item that needs additional context.
67+
Use the `description` property to add short descriptive text below any menu item that needs additional context.
6868

6969
```ts file="MenuWithDescription.tsx"
7070
```
@@ -85,14 +85,14 @@ Add a `<MenuFooter>` that contains separate, but related actions at the bottom o
8585

8686
### Separated items
8787

88-
Use a [divider](/components/divider) to visually separate `<MenuContent>`. Use the `component` property to specify the type of divider component to use.
88+
Use a [divider](/components/divider) to visually separate `<MenuContent>`. Use the `component` property to specify the type of divider component to use.
8989

9090
```ts file="MenuWithSeparators.tsx"
9191
```
9292

9393
### Titled groups of items
9494

95-
Add a `<MenuGroup>` to organize `<MenuContent>` and use the `label` property to title a group of menu items. Use the `labelHeadingLevel` property to assign a heading level to the menu group label.
95+
Add a `<MenuGroup>` to organize `<MenuContent>` and use the `label` property to title a group of menu items. Use the `labelHeadingLevel` property to assign a heading level to the menu group label.
9696

9797
```ts file="MenuWithTitledGroups.tsx"
9898
```
@@ -115,12 +115,16 @@ A [search input](/components/search-input) component can be placed within `<Menu
115115

116116
The following example demonstrates a single option select menu that persists a selected menu item. Use the `selected` property on the `<Menu>` to label a selected item with a checkmark. You can also use the `isSelected` property on a `<MenuItem>` to indicate that it is selected.
117117

118+
You must also use the `role` property on the `<Menu>` with a value of `"listbox"` when using a non-checkbox select menu.
119+
118120
```ts file="MenuOptionSingleSelect.tsx"
119121
```
120122

121123
### Option multi select menu
122124

123-
To persist multiple selections that a user makes, use a multiple option select menu. To enable multi select, pass an array containing each selected `itemId` to the `selected` property.
125+
To persist multiple selections that a user makes, use a multiple option select menu. To enable multi select, pass an array containing each selected `itemId` to the `selected` property on the `<Menu>`, and pass the `isAriaMultiselectable` property on the `<MenuList>`.
126+
127+
Similar to a single select menu, you must also pass `role="listbox"` to the `<Menu>`.
124128

125129
```ts file="MenuOptionMultiSelect.tsx"
126130
```
@@ -150,12 +154,12 @@ In this example, 3 additional menu items are revealed each time the "view more"
150154

151155
### With drilldown
152156

153-
Use a drilldown menu to contain different levels of menu items. When a parent menu item (an item that has a submenu of children) is selected, the menu is replaced with the children items.
157+
Use a drilldown menu to contain different levels of menu items. When a parent menu item (an item that has a submenu of children) is selected, the menu is replaced with the children items.
154158

155159
- To indicate that a menu contains a drilldown, use the `containsDrilldown` property.
156-
- To indicate the path of drilled-in menu item ids, use the `drilldownItemPath` property.
160+
- To indicate the path of drilled-in menu item ids, use the `drilldownItemPath` property.
157161
- Pass in an array of drilled-in menus with the `drilledInMenus` property.
158-
- Use the `onDrillIn` and `onDrillOut` properties to contain callbacks for drilling into and drilling out of a submenu, respectively.
162+
- Use the `onDrillIn` and `onDrillOut` properties to contain callbacks for drilling into and drilling out of a submenu, respectively.
159163
- To account for updated heights as menus drill in and out of use, use the `onGetMenuHeight` property. When starting from a drilled-in state, the `onGetMenuHeight` property must define the height of the root menu.
160164

161165
```ts file="./MenuWithDrilldown.tsx" isBeta
@@ -187,4 +191,4 @@ To control the height of a menu, use the `maxMenuHeight` property. Selecting the
187191
### With drilldown and inline filter
188192

189193
```ts file="MenuFilterDrilldown.tsx"
190-
```
194+
```

packages/react-core/src/components/Menu/examples/MenuOptionMultiSelect.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ export const MenuOptionMultiSelect: React.FunctionComponent = () => {
1515
};
1616

1717
return (
18-
<Menu onSelect={onSelect} activeItemId={0} selected={selectedItems}>
18+
<Menu role="listbox" onSelect={onSelect} activeItemId={0} selected={selectedItems}>
1919
<MenuContent>
20-
<MenuList>
20+
<MenuList isAriaMultiselectable aria-label="Menu multi select example">
2121
<MenuItem itemId={0}>Option 1</MenuItem>
2222
<MenuItem itemId={1}>Option 2</MenuItem>
2323
<MenuItem icon={<TableIcon aria-hidden />} itemId={2}>

packages/react-core/src/components/Menu/examples/MenuOptionSingleSelect.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ export const MenuOptionSingleSelect: React.FunctionComponent = () => {
1212
};
1313

1414
return (
15-
<Menu onSelect={onSelect} activeItemId={activeItem} selected={selectedItem}>
15+
<Menu role="listbox" onSelect={onSelect} activeItemId={activeItem} selected={selectedItem}>
1616
<MenuContent>
17-
<MenuList>
17+
<MenuList aria-label="Menu single select example">
1818
<MenuItem itemId={0}>Option 1</MenuItem>
1919
<MenuItem itemId={1}>Option 2</MenuItem>
2020
<MenuItem icon={<TableIcon aria-hidden />} itemId={2}>

packages/react-core/src/next/components/Dropdown/Dropdown.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,27 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
5858
const menuRef = (innerRef as React.RefObject<HTMLDivElement>) || localMenuRef;
5959
React.useEffect(() => {
6060
const handleMenuKeys = (event: KeyboardEvent) => {
61-
if (!isOpen && toggleRef.current?.contains(event.target as Node)) {
62-
// toggle was clicked open, focus on first menu item
63-
if (event.key === 'Enter') {
64-
setTimeout(() => {
65-
const firstElement = menuRef.current.querySelector('li > button:not(:disabled)');
66-
firstElement && (firstElement as HTMLElement).focus();
67-
}, 0);
68-
}
69-
}
7061
// Close the menu on tab or escape if onOpenChange is provided
7162
if (
7263
(isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) ||
7364
toggleRef.current?.contains(event.target as Node)
7465
) {
7566
if (event.key === 'Escape' || event.key === 'Tab') {
76-
onOpenChange(!isOpen);
67+
onOpenChange(false);
7768
toggleRef.current?.focus();
7869
}
7970
}
8071
};
8172

82-
const handleClickOutside = (event: MouseEvent) => {
73+
const handleClick = (event: MouseEvent) => {
74+
// toggle was clicked open via keyboard, focus on first menu item
75+
if (isOpen && toggleRef.current?.contains(event.target as Node) && event.detail === 0) {
76+
setTimeout(() => {
77+
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
78+
firstElement && (firstElement as HTMLElement).focus();
79+
}, 0);
80+
}
81+
8382
// If the event is not on the toggle and onOpenChange callback is provided, close the menu
8483
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
8584
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
@@ -89,11 +88,11 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
8988
};
9089

9190
window.addEventListener('keydown', handleMenuKeys);
92-
window.addEventListener('click', handleClickOutside);
91+
window.addEventListener('click', handleClick);
9392

9493
return () => {
9594
window.removeEventListener('keydown', handleMenuKeys);
96-
window.removeEventListener('click', handleClickOutside);
95+
window.removeEventListener('click', handleClick);
9796
};
9897
}, [isOpen, menuRef, onOpenChange]);
9998

packages/react-core/src/next/components/Select/Select.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface SelectProps extends MenuProps, OUIAProps {
2828
innerRef?: React.Ref<HTMLDivElement>;
2929
/** z-index of the select menu */
3030
zIndex?: number;
31+
/** @beta Determines the accessible role of the select. For a checkbox select pass in "menu". */
32+
role?: string;
3133
}
3234

3335
const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
@@ -42,6 +44,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
4244
minWidth,
4345
innerRef,
4446
zIndex = 9999,
47+
role = 'listbox',
4548
...props
4649
}: SelectProps & OUIAProps) => {
4750
const localMenuRef = React.useRef<HTMLDivElement>();
@@ -51,28 +54,27 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
5154
const menuRef = (innerRef as React.RefObject<HTMLDivElement>) || localMenuRef;
5255
React.useEffect(() => {
5356
const handleMenuKeys = (event: KeyboardEvent) => {
54-
if (!isOpen && toggleRef.current?.contains(event.target as Node)) {
55-
// toggle was clicked open, focus on first menu item
56-
if (event.key === 'Enter' || event.key === 'Space') {
57-
setTimeout(() => {
58-
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
59-
firstElement && (firstElement as HTMLElement).focus();
60-
}, 0);
61-
}
62-
}
6357
// Close the menu on tab or escape if onOpenChange is provided
6458
if (
6559
(isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) ||
6660
toggleRef.current?.contains(event.target as Node)
6761
) {
6862
if (event.key === 'Escape' || event.key === 'Tab') {
69-
onOpenChange(!isOpen);
63+
onOpenChange(false);
7064
toggleRef.current?.focus();
7165
}
7266
}
7367
};
7468

75-
const handleClickOutside = (event: MouseEvent) => {
69+
const handleClick = (event: MouseEvent) => {
70+
// toggle was clicked open via keyboard, focus on first menu item
71+
if (isOpen && toggleRef.current?.contains(event.target as Node) && event.detail === 0) {
72+
setTimeout(() => {
73+
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
74+
firstElement && (firstElement as HTMLElement).focus();
75+
}, 0);
76+
}
77+
7678
// If the event is not on the toggle and onOpenChange callback is provided, close the menu
7779
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
7880
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
@@ -82,16 +84,17 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
8284
};
8385

8486
window.addEventListener('keydown', handleMenuKeys);
85-
window.addEventListener('click', handleClickOutside);
87+
window.addEventListener('click', handleClick);
8688

8789
return () => {
8890
window.removeEventListener('keydown', handleMenuKeys);
89-
window.removeEventListener('click', handleClickOutside);
91+
window.removeEventListener('click', handleClick);
9092
};
9193
}, [isOpen, menuRef, onOpenChange]);
9294

9395
const menu = (
9496
<Menu
97+
role={role}
9598
className={css(className)}
9699
ref={menuRef}
97100
onSelect={(event, itemId) => onSelect(event, itemId)}

packages/react-core/src/next/components/Select/SelectList.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@ export interface SelectListProps extends MenuListProps {
77
children: React.ReactNode;
88
/** Classes applied to root element of select list */
99
className?: string;
10+
/** @beta Indicates to assistive technologies whether more than one item can be selected
11+
* for a non-checkbox select.
12+
*/
13+
isAriaMultiselectable?: boolean;
1014
}
1115

1216
export const SelectList: React.FunctionComponent<MenuListProps> = ({
1317
children,
1418
className,
19+
isAriaMultiselectable = false,
1520
...props
1621
}: SelectListProps) => (
17-
<MenuList className={css(className)} {...props}>
22+
<MenuList isAriaMultiselectable={isAriaMultiselectable} className={css(className)} {...props}>
1823
{children}
1924
</MenuList>
2025
);

0 commit comments

Comments
 (0)