Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement options to make the Menu compatible with WAI-ARIA recommendations for navigation #5335

Merged
merged 5 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 15 additions & 8 deletions docs/src/pages/core/menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ Set `trigger="hover"` to reveal dropdown when hovers over menu target and dropdo
`closeDelay` and `openDelay` props can be used to control open and close delay in ms.
Note that:

- If you set `closeDelay={0}` then menu will close before user will reach dropdown, set `offset={0}` to remove space between target element and dropdown
- Menu with `trigger="hover"` is not accessible – users that navigate with keyboard will not be able to use it
- If you set `closeDelay={0}` then menu will close before user will reach dropdown, set `offset={0}` to remove space between target element and dropdown.
- Menu with `trigger="hover"` is not accessible – users that navigate with keyboard will not be able to use it. If you need click-hover hover and click triggers, use `trigger="click-hover"`.

<Demo data={MenuDemos.hover} />

Expand Down Expand Up @@ -104,23 +104,28 @@ function Demo() {

## Accessibility

Menu follows [WAI-ARIA recommendations](https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html):
Menu follows [WAI-ARIA recommendations](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links/):

- Dropdown element has `role="menu"` and `aria-labelledby="target-id"` attributes
- Target element has `aria-haspopup="menu"`, `aria-expanded`, `aria-controls="dropdown-id"` attributes
- Menu item has `role="menuitem"` attribute

## Supported target elements
### Supported target elements

Uncontrolled Menu with `trigger="click"` (default) will be accessible only when used with `button` element or component that renders it ([Button](/core/button/), [ActionIcon](/core/action-icon/), etc.).
Other elements will not support `Space` and `Enter` key presses.

## Hover menu
### Hover menu

Menu with `trigger="hover"` is not accessible – it cannot be accessed with keyboard,
use it only if you do not care about accessibility.
Menu with `trigger="hover"` is not accessible – it cannot be accessed with keyboard, use it only if you do not care about accessibility. If you need click-hover hover and click triggers, use `trigger="click-hover"`.

## Keyboard interactions
### Navigation

If you are using the Menu to build a Navigation, you can use the options from the demo below to follow the [WAI-ARIA recommendations for navigation](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/).

<Demo data={MenuDemos.navigation} />

### Keyboard interactions

<KeyboardEventsTable
data={[
Expand Down Expand Up @@ -156,3 +161,5 @@ use it only if you do not care about accessibility.
},
]}
/>

If you also need to support `Tab` and `Shift + Tab` then set `menuItemTabIndex={0}`.
53 changes: 53 additions & 0 deletions packages/@docs/demos/src/demos/core/Menu/Menu.demo.navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import { Menu, Group } from '@mantine/core';
import { MantineDemo } from '@mantinex/demo';
import { DemoMenuItems } from './_menu-items';

const code = `
import { Menu, Group } from '@mantine/core';

function Demo() {
return (
<Group>
<Menu trigger="click-hover" loop={false} withinPortal={false} trapFocus={false} menuItemTabIndex={0}>
{/* ... menu items */}
</Menu>
<Menu trigger="click-hover" loop={false} withinPortal={false} trapFocus={false} menuItemTabIndex={0}>
{/* ... menu items */}
</Menu>
</Group>
);
}
`;

function Demo() {
return (
<Group>
<Menu
trigger="click-hover"
loop={false}
withinPortal={false}
trapFocus={false}
menuItemTabIndex={0}
>
<DemoMenuItems />
</Menu>
<Menu
trigger="click-hover"
loop={false}
withinPortal={false}
trapFocus={false}
menuItemTabIndex={0}
>
<DemoMenuItems />
</Menu>
</Group>
);
}

export const navigation: MantineDemo = {
type: 'code',
code,
component: Demo,
centered: true,
};
5 changes: 5 additions & 0 deletions packages/@docs/demos/src/demos/core/Menu/Menu.demos.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ export const DemoClickHover = {
name: '⭐ Demo: clickHover',
render: renderDemo(demos.clickHover),
};

export const DemoNavigation = {
name: '⭐ Demo: navigation',
render: renderDemo(demos.navigation),
};
1 change: 1 addition & 0 deletions packages/@docs/demos/src/demos/core/Menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { positionConfigurator } from './Menu.demo.positionConfigurator';
export { disabled } from './Menu.demo.disabled';
export { customControl } from './Menu.demo.customControl';
export { clickHover } from './Menu.demo.clickHover';
export { navigation } from './Menu.demo.navigation';
1 change: 1 addition & 0 deletions packages/@mantine/core/src/components/Menu/Menu.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface MenuContext {
opened: boolean;
unstyled: boolean | undefined;
getStyles: GetStylesApi<MenuFactory>;
menuItemTabIndex: -1 | 0 | undefined;
}

export const [MenuContextProvider, useMenuContext] = createSafeContext<MenuContext>(
Expand Down
18 changes: 15 additions & 3 deletions packages/@mantine/core/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export interface MenuProps extends __PopoverProps, StylesApiProps<MenuFactory> {
/** Uncontrolled menu initial opened state */
defaultOpened?: boolean;

/** Determines whether dropdown should trap focus of keyboard events */
trapFocus?: boolean;

/** Called when menu opened state changes */
onChange?: (opened: boolean) => void;

Expand All @@ -61,7 +64,7 @@ export interface MenuProps extends __PopoverProps, StylesApiProps<MenuFactory> {
/** Determines whether arrow key presses should loop though items (first to last and last to first) */
loop?: boolean;

/** Determines whether dropdown should be closed when Escape key is pressed, defaults to true */
/** Determines whether dropdown should be closed when Escape key is pressed */
closeOnEscape?: boolean;

/** Event which should open menu */
Expand All @@ -73,22 +76,28 @@ export interface MenuProps extends __PopoverProps, StylesApiProps<MenuFactory> {
/** Close delay in ms, applicable only to trigger="hover" variant */
closeDelay?: number;

/** Determines whether dropdown should be closed on outside clicks, default to true */
/** Determines whether dropdown should be closed on outside clicks */
closeOnClickOutside?: boolean;

/** Events that trigger outside clicks */
clickOutsideEvents?: string[];

/** id base to create accessibility connections */
id?: string;

/** Set the `tabindex` on all menu items. Defaults to -1 */
menuItemTabIndex?: -1 | 0;
}

const defaultProps: Partial<MenuProps> = {
trapFocus: true,
closeOnItemClick: true,
clickOutsideEvents: ['mousedown', 'touchstart', 'keydown'],
loop: true,
trigger: 'click',
openDelay: 0,
closeDelay: 100,
menuItemTabIndex: -1,
};

export function Menu(_props: MenuProps) {
Expand All @@ -99,6 +108,7 @@ export function Menu(_props: MenuProps) {
onClose,
opened,
defaultOpened,
trapFocus,
onChange,
closeOnItemClick,
loop,
Expand All @@ -111,6 +121,7 @@ export function Menu(_props: MenuProps) {
unstyled,
variant,
vars,
menuItemTabIndex,
...others
} = props;

Expand Down Expand Up @@ -176,14 +187,15 @@ export function Menu(_props: MenuProps) {
loop,
trigger,
unstyled,
menuItemTabIndex,
}}
>
<Popover
{...others}
opened={_opened}
onChange={toggleDropdown}
defaultOpened={defaultOpened}
trapFocus={trigger === 'click' && _opened}
trapFocus={trapFocus}
closeOnEscape={closeOnEscape}
__staticSelector="Menu"
classNames={resolvedClassNames}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const MenuItem = polymorphicFactory<MenuItemFactory>((props, ref) => {
<UnstyledButton
{...others}
unstyled={ctx.unstyled}
tabIndex={-1}
tabIndex={ctx.menuItemTabIndex}
onFocus={handleFocus}
{...ctx.getStyles('item', { className, style, styles, classNames })}
ref={useMergedRef(itemRef, ref)}
Expand Down