@react-aria/menu
Version:
Spectrum UI components in React
297 lines (257 loc) • 8.99 kB
text/typescript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RouterOptions} from '@react-types/shared';
import {filterDOMProps, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils';
import {getItemCount} from '@react-stately/collections';
import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions';
import {menuData} from './useMenu';
import {RefObject} from 'react';
import {TreeState} from '@react-stately/tree';
import {useSelectableItem} from '@react-aria/selection';
export interface MenuItemAria {
/** Props for the menu item element. */
menuItemProps: DOMAttributes,
/** Props for the main text element inside the menu item. */
labelProps: DOMAttributes,
/** Props for the description text element inside the menu item, if any. */
descriptionProps: DOMAttributes,
/** Props for the keyboard shortcut text element inside the item, if any. */
keyboardShortcutProps: DOMAttributes,
/** Whether the item is currently focused. */
isFocused: boolean,
/** Whether the item is currently selected. */
isSelected: boolean,
/** Whether the item is currently in a pressed state. */
isPressed: boolean,
/** Whether the item is disabled. */
isDisabled: boolean
}
export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, KeyboardEvents, FocusEvents {
/**
* Whether the menu item is disabled.
* @deprecated - pass disabledKeys to useTreeState instead.
*/
isDisabled?: boolean,
/**
* Whether the menu item is selected.
* @deprecated - pass selectedKeys to useTreeState instead.
*/
isSelected?: boolean,
/** A screen reader only label for the menu item. */
'aria-label'?: string,
/** The unique key for the menu item. */
key?: Key,
/**
* Handler that is called when the menu should close after selecting an item.
* @deprecated - pass to the menu instead.
*/
onClose?: () => void,
/**
* Whether the menu should close when the menu item is selected.
* @default true
*/
closeOnSelect?: boolean,
/** Whether the menu item is contained in a virtual scrolling menu. */
isVirtualized?: boolean,
/**
* Handler that is called when the user activates the item.
* @deprecated - pass to the menu instead.
*/
onAction?: (key: Key) => void,
/** What kind of popup the item opens. */
'aria-haspopup'?: 'menu' | 'dialog',
/** Indicates whether the menu item's popup element is expanded or collapsed. */
'aria-expanded'?: boolean | 'true' | 'false',
/** Identifies the menu item's popup element whose contents or presence is controlled by the menu item. */
'aria-controls'?: string
}
/**
* Provides the behavior and accessibility implementation for an item in a menu.
* See `useMenu` for more details about menus.
* @param props - Props for the item.
* @param state - State for the menu, as returned by `useTreeState`.
*/
export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, ref: RefObject<FocusableElement>): MenuItemAria {
let {
key,
closeOnSelect,
isVirtualized,
'aria-haspopup': hasPopup,
onPressStart: pressStartProp,
onPressUp: pressUpProp,
onPress,
onPressChange,
onPressEnd,
onHoverStart: hoverStartProp,
onHoverChange,
onHoverEnd,
onKeyDown,
onKeyUp,
onFocus,
onFocusChange,
onBlur
} = props;
let isTrigger = !!hasPopup;
let isDisabled = props.isDisabled ?? state.selectionManager.isDisabled(key);
let isSelected = props.isSelected ?? state.selectionManager.isSelected(key);
let data = menuData.get(state);
let item = state.collection.getItem(key);
let onClose = props.onClose || data.onClose;
let router = useRouter();
let performAction = (e: PressEvent) => {
if (isTrigger) {
return;
}
if (item?.props?.onAction) {
item.props.onAction();
}
if (props.onAction) {
props.onAction(key);
} else if (data.onAction) {
data.onAction(key);
}
if (e.target instanceof HTMLAnchorElement) {
router.open(e.target, e, item.props.href, item.props.routerOptions as RouterOptions);
}
};
let role = 'menuitem';
if (!isTrigger) {
if (state.selectionManager.selectionMode === 'single') {
role = 'menuitemradio';
} else if (state.selectionManager.selectionMode === 'multiple') {
role = 'menuitemcheckbox';
}
}
let labelId = useSlotId();
let descriptionId = useSlotId();
let keyboardId = useSlotId();
let ariaProps = {
'aria-disabled': isDisabled || undefined,
role,
'aria-label': props['aria-label'],
'aria-labelledby': labelId,
'aria-describedby': [descriptionId, keyboardId].filter(Boolean).join(' ') || undefined,
'aria-controls': props['aria-controls'],
'aria-haspopup': hasPopup,
'aria-expanded': props['aria-expanded']
};
if (state.selectionManager.selectionMode !== 'none' && !isTrigger) {
ariaProps['aria-checked'] = isSelected;
}
if (isVirtualized) {
ariaProps['aria-posinset'] = item?.index;
ariaProps['aria-setsize'] = getItemCount(state.collection);
}
let onPressStart = (e: PressEvent) => {
if (e.pointerType === 'keyboard') {
performAction(e);
}
pressStartProp?.(e);
};
let onPressUp = (e: PressEvent) => {
if (e.pointerType !== 'keyboard') {
performAction(e);
// Pressing a menu item should close by default in single selection mode but not multiple
// selection mode, except if overridden by the closeOnSelect prop.
if (!isTrigger && onClose && (closeOnSelect ?? (state.selectionManager.selectionMode !== 'multiple' || state.selectionManager.isLink(key)))) {
onClose();
}
}
pressUpProp?.(e);
};
let {itemProps, isFocused} = useSelectableItem({
selectionManager: state.selectionManager,
key,
ref,
shouldSelectOnPressUp: true,
allowsDifferentPressOrigin: true,
// Disable all handling of links in useSelectable item
// because we handle it ourselves. The behavior of menus
// is slightly different from other collections because
// actions are performed on key down rather than key up.
linkBehavior: 'none'
});
let {pressProps, isPressed} = usePress({
onPressStart,
onPress,
onPressUp,
onPressChange,
onPressEnd,
isDisabled
});
let {hoverProps} = useHover({
isDisabled,
onHoverStart(e) {
if (!isFocusVisible()) {
state.selectionManager.setFocused(true);
state.selectionManager.setFocusedKey(key);
}
hoverStartProp?.(e);
},
onHoverChange,
onHoverEnd
});
let {keyboardProps} = useKeyboard({
onKeyDown: (e) => {
// Ignore repeating events, which may have started on the menu trigger before moving
// focus to the menu item. We want to wait for a second complete key press sequence.
if (e.repeat) {
e.continuePropagation();
return;
}
switch (e.key) {
case ' ':
if (!isDisabled && state.selectionManager.selectionMode === 'none' && !isTrigger && closeOnSelect !== false && onClose) {
onClose();
}
break;
case 'Enter':
// The Enter key should always close on select, except if overridden.
if (!isDisabled && closeOnSelect !== false && !isTrigger && onClose) {
onClose();
}
break;
default:
if (!isTrigger) {
e.continuePropagation();
}
onKeyDown?.(e);
break;
}
},
onKeyUp
});
let {focusProps} = useFocus({onBlur, onFocus, onFocusChange});
let domProps = filterDOMProps(item.props);
delete domProps.id;
let linkProps = useLinkProps(item.props);
return {
menuItemProps: {
...ariaProps,
...mergeProps(domProps, linkProps, isTrigger ? {onFocus: itemProps.onFocus, 'data-key': itemProps['data-key']} : itemProps, pressProps, hoverProps, keyboardProps, focusProps),
tabIndex: itemProps.tabIndex != null ? -1 : undefined
},
labelProps: {
id: labelId
},
descriptionProps: {
id: descriptionId
},
keyboardShortcutProps: {
id: keyboardId
},
isFocused,
isSelected,
isPressed,
isDisabled
};
}