Skip to content

Commit

Permalink
fix(Select-next,Dropdown-next): updated logic for keyboard interaction (
Browse files Browse the repository at this point in the history
#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
  • Loading branch information
thatblindgeye authored Jan 20, 2023
1 parent 2385b96 commit 0a343ce
Show file tree
Hide file tree
Showing 12 changed files with 93 additions and 55 deletions.
10 changes: 8 additions & 2 deletions packages/react-core/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export interface MenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'r
ouiaId?: number | string;
/** 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. */
ouiaSafe?: boolean;
/** @beta Determines the accessible role of the menu. For a non-checkbox menu that can have
* one or more items selected, pass in "listbox". */
role?: string;
}

export interface MenuState {
Expand All @@ -89,7 +92,8 @@ class MenuBase extends React.Component<MenuProps, MenuState> {
ouiaSafe: true,
isRootMenu: true,
isPlain: false,
isScrollable: false
isScrollable: false,
role: 'menu'
};

constructor(props: MenuProps) {
Expand Down Expand Up @@ -272,6 +276,7 @@ class MenuBase extends React.Component<MenuProps, MenuState> {
innerRef,
isRootMenu,
activeMenu,
role,
/* eslint-enable @typescript-eslint/no-unused-vars */
...props
} = this.props;
Expand All @@ -292,7 +297,8 @@ class MenuBase extends React.Component<MenuProps, MenuState> {
onGetMenuHeight,
flyoutRef: this.state.flyoutRef,
setFlyoutRef: flyoutRef => this.setState({ flyoutRef }),
disableHover: this.state.disableHover
disableHover: this.state.disableHover,
role
}}
>
{isRootMenu && (
Expand Down
4 changes: 3 additions & 1 deletion packages/react-core/src/components/Menu/MenuContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const MenuContext = React.createContext<{
flyoutRef?: React.Ref<HTMLLIElement>;
setFlyoutRef?: (ref: React.Ref<HTMLLIElement>) => void;
disableHover?: boolean;
role?: string;
}>({
menuId: null,
parentMenu: null,
Expand All @@ -34,7 +35,8 @@ export const MenuContext = React.createContext<{
onGetMenuHeight: () => null,
flyoutRef: null,
setFlyoutRef: () => null,
disableHover: false
disableHover: false,
role: 'menu'
});

export const MenuItemContext = React.createContext<{
Expand Down
7 changes: 5 additions & 2 deletions packages/react-core/src/components/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ const MenuItemBase: React.FunctionComponent<MenuItemProps> = ({
onDrillOut,
flyoutRef,
setFlyoutRef,
disableHover
disableHover,
role: menuRole
} = React.useContext(MenuContext);
let Component = (to ? 'a' : component) as any;
if (hasCheck && !to) {
Expand Down Expand Up @@ -290,6 +291,7 @@ const MenuItemBase: React.FunctionComponent<MenuItemProps> = ({
setFlyoutRef(null);
}
};
const isSelectMenu = menuRole === 'listbox';

return (
<li
Expand All @@ -316,7 +318,8 @@ const MenuItemBase: React.FunctionComponent<MenuItemProps> = ({
className={css(styles.menuItem, getIsSelected() && !hasCheck && styles.modifiers.selected, className)}
aria-current={getAriaCurrent()}
{...(!hasCheck && { disabled: isDisabled })}
{...(!hasCheck && !flyoutMenu && { role: 'menuitem' })}
{...(!hasCheck && !flyoutMenu && { role: isSelectMenu ? 'option' : 'menuitem' })}
{...(!hasCheck && !flyoutMenu && isSelectMenu && { 'aria-selected': getIsSelected() })}
ref={innerRef}
{...(!hasCheck && {
onClick: (event: React.KeyboardEvent | React.MouseEvent) => {
Expand Down
25 changes: 20 additions & 5 deletions packages/react-core/src/components/Menu/MenuList.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import * as React from 'react';
import styles from '@patternfly/react-styles/css/components/Menu/menu';
import { css } from '@patternfly/react-styles';
import { MenuContext } from './MenuContext';

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

export const MenuList: React.FunctionComponent<MenuListProps> = ({
children = null,
className,
isAriaMultiselectable = false,
...props
}: MenuListProps) => (
<ul role="menu" className={css(styles.menuList, className)} {...props}>
{children}
</ul>
);
}: MenuListProps) => {
const { role } = React.useContext(MenuContext);

return (
<ul
role={role}
{...(role === 'listbox' && { 'aria-multiselectable': isAriaMultiselectable })}
className={css(styles.menuList, className)}
{...props}
>
{children}
</ul>
);
};
MenuList.displayName = 'MenuList';
30 changes: 17 additions & 13 deletions packages/react-core/src/components/Menu/examples/Menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ A menu may contain multiple variations of `<MenuItem>` components. The following

- 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.
- 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.
- Use the `isDisabled` property to disable a menu item.
- Use the `isPlain` property to remove the outer box shadow and style the menu plainly instead.
- Use the `isDisabled` property to disable a menu item.
- Use the `isPlain` property to remove the outer box shadow and style the menu plainly instead.

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

### With actions

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.
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.

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.
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.

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.
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.

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

### With descriptions

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

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

### Separated items

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

```ts file="MenuWithSeparators.tsx"
```

### Titled groups of items

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.
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.

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

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.

You must also use the `role` property on the `<Menu>` with a value of `"listbox"` when using a non-checkbox select menu.

```ts file="MenuOptionSingleSelect.tsx"
```

### Option multi select menu

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.
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>`.

Similar to a single select menu, you must also pass `role="listbox"` to the `<Menu>`.

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

### With drilldown

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.
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.

- To indicate that a menu contains a drilldown, use the `containsDrilldown` property.
- To indicate the path of drilled-in menu item ids, use the `drilldownItemPath` property.
- To indicate the path of drilled-in menu item ids, use the `drilldownItemPath` property.
- Pass in an array of drilled-in menus with the `drilledInMenus` property.
- Use the `onDrillIn` and `onDrillOut` properties to contain callbacks for drilling into and drilling out of a submenu, respectively.
- Use the `onDrillIn` and `onDrillOut` properties to contain callbacks for drilling into and drilling out of a submenu, respectively.
- 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.

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

```ts file="MenuFilterDrilldown.tsx"
```
```
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export const MenuOptionMultiSelect: React.FunctionComponent = () => {
};

return (
<Menu onSelect={onSelect} activeItemId={0} selected={selectedItems}>
<Menu role="listbox" onSelect={onSelect} activeItemId={0} selected={selectedItems}>
<MenuContent>
<MenuList>
<MenuList isAriaMultiselectable aria-label="Menu multi select example">
<MenuItem itemId={0}>Option 1</MenuItem>
<MenuItem itemId={1}>Option 2</MenuItem>
<MenuItem icon={<TableIcon aria-hidden />} itemId={2}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export const MenuOptionSingleSelect: React.FunctionComponent = () => {
};

return (
<Menu onSelect={onSelect} activeItemId={activeItem} selected={selectedItem}>
<Menu role="listbox" onSelect={onSelect} activeItemId={activeItem} selected={selectedItem}>
<MenuContent>
<MenuList>
<MenuList aria-label="Menu single select example">
<MenuItem itemId={0}>Option 1</MenuItem>
<MenuItem itemId={1}>Option 2</MenuItem>
<MenuItem icon={<TableIcon aria-hidden />} itemId={2}>
Expand Down
25 changes: 12 additions & 13 deletions packages/react-core/src/next/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,28 +58,27 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
const menuRef = (innerRef as React.RefObject<HTMLDivElement>) || localMenuRef;
React.useEffect(() => {
const handleMenuKeys = (event: KeyboardEvent) => {
if (!isOpen && toggleRef.current?.contains(event.target as Node)) {
// toggle was clicked open, focus on first menu item
if (event.key === 'Enter') {
setTimeout(() => {
const firstElement = menuRef.current.querySelector('li > button:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}, 0);
}
}
// Close the menu on tab or escape if onOpenChange is provided
if (
(isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) ||
toggleRef.current?.contains(event.target as Node)
) {
if (event.key === 'Escape' || event.key === 'Tab') {
onOpenChange(!isOpen);
onOpenChange(false);
toggleRef.current?.focus();
}
}
};

const handleClickOutside = (event: MouseEvent) => {
const handleClick = (event: MouseEvent) => {
// toggle was clicked open via keyboard, focus on first menu item
if (isOpen && toggleRef.current?.contains(event.target as Node) && event.detail === 0) {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}, 0);
}

// If the event is not on the toggle and onOpenChange callback is provided, close the menu
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
Expand All @@ -89,11 +88,11 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
};

window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);
window.addEventListener('click', handleClick);

return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
window.removeEventListener('click', handleClick);
};
}, [isOpen, menuRef, onOpenChange]);

Expand Down
29 changes: 16 additions & 13 deletions packages/react-core/src/next/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface SelectProps extends MenuProps, OUIAProps {
innerRef?: React.Ref<HTMLDivElement>;
/** z-index of the select menu */
zIndex?: number;
/** @beta Determines the accessible role of the select. For a checkbox select pass in "menu". */
role?: string;
}

const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
Expand All @@ -42,6 +44,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
minWidth,
innerRef,
zIndex = 9999,
role = 'listbox',
...props
}: SelectProps & OUIAProps) => {
const localMenuRef = React.useRef<HTMLDivElement>();
Expand All @@ -51,28 +54,27 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
const menuRef = (innerRef as React.RefObject<HTMLDivElement>) || localMenuRef;
React.useEffect(() => {
const handleMenuKeys = (event: KeyboardEvent) => {
if (!isOpen && toggleRef.current?.contains(event.target as Node)) {
// toggle was clicked open, focus on first menu item
if (event.key === 'Enter' || event.key === 'Space') {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}, 0);
}
}
// Close the menu on tab or escape if onOpenChange is provided
if (
(isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) ||
toggleRef.current?.contains(event.target as Node)
) {
if (event.key === 'Escape' || event.key === 'Tab') {
onOpenChange(!isOpen);
onOpenChange(false);
toggleRef.current?.focus();
}
}
};

const handleClickOutside = (event: MouseEvent) => {
const handleClick = (event: MouseEvent) => {
// toggle was clicked open via keyboard, focus on first menu item
if (isOpen && toggleRef.current?.contains(event.target as Node) && event.detail === 0) {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector('li button:not(:disabled),li input:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}, 0);
}

// If the event is not on the toggle and onOpenChange callback is provided, close the menu
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
Expand All @@ -82,16 +84,17 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
};

window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);
window.addEventListener('click', handleClick);

return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
window.removeEventListener('click', handleClick);
};
}, [isOpen, menuRef, onOpenChange]);

const menu = (
<Menu
role={role}
className={css(className)}
ref={menuRef}
onSelect={(event, itemId) => onSelect(event, itemId)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ export interface SelectListProps extends MenuListProps {
children: React.ReactNode;
/** Classes applied to root element of select list */
className?: string;
/** @beta Indicates to assistive technologies whether more than one item can be selected
* for a non-checkbox select.
*/
isAriaMultiselectable?: boolean;
}

export const SelectList: React.FunctionComponent<MenuListProps> = ({
children,
className,
isAriaMultiselectable = false,
...props
}: SelectListProps) => (
<MenuList className={css(className)} {...props}>
<MenuList isAriaMultiselectable={isAriaMultiselectable} className={css(className)} {...props}>
{children}
</MenuList>
);
Expand Down
Loading

0 comments on commit 0a343ce

Please sign in to comment.