Fix DropdownItem onPress (#2746)

* chore(dropdown): missing events added

* fix(menu): item selection events

* chore(menu): changeset
This commit is contained in:
Junior Garcia 2024-04-17 23:27:36 -03:00 committed by GitHub
parent 4ca6e2c1f6
commit 6b56e43a35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 679 additions and 24 deletions

View File

@ -0,0 +1,7 @@
---
"@nextui-org/dropdown": patch
"@nextui-org/menu": patch
"@nextui-org/use-aria-menu": patch
---
Fix #2743 #2751 internal react-aria use-menu hooks implemented to pass down the press events and control the pressUp one

View File

@ -139,7 +139,11 @@ export function useDropdown(props: UseDropdownProps) {
return {
ref: mergeRefs(_ref, menuRef),
menuProps,
...mergeProps(props, {onAction: () => onMenuAction(props?.closeOnSelect)}),
closeOnSelect,
...mergeProps(props, {
onAction: () => onMenuAction(props?.closeOnSelect),
onClose: state.close,
}),
} as MenuProps;
};

View File

@ -137,7 +137,7 @@ const Template = ({color, variant, ...args}: DropdownProps & DropdownMenuProps)
<DropdownTrigger>
<Button>Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" color={color} variant={variant} onAction={alert}>
<DropdownMenu aria-label="Actions" color={color} variant={variant}>
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
<DropdownItem key="edit">Edit file</DropdownItem>

View File

@ -109,6 +109,7 @@ describe("Menu", () => {
<Menu aria-label="Actions" items={menuItems}>
{(section: any) => (
<MenuSection aria-label={section.title} items={section.children} title={section.title}>
{/* @ts-ignore */}
{(item: any) => <MenuItem key={item.key}>{item.name}</MenuItem>}
</MenuSection>
)}
@ -273,4 +274,81 @@ describe("Menu", () => {
expect(checkmark1).toBeFalsy();
});
it("should dispatch onAction events correctly", async () => {
let onAction = jest.fn();
const wrapper = render(
<Menu aria-label="Actions" onAction={onAction}>
<MenuItem key="new">New file</MenuItem>
<MenuItem key="copy">Copy link</MenuItem>
<MenuItem key="edit">Edit file</MenuItem>
<MenuItem key="delete" color="danger">
Delete file
</MenuItem>
</Menu>,
);
let menuItems = wrapper.getAllByRole("menuitem");
await act(async () => {
await userEvent.click(menuItems[1]);
expect(onAction).toBeCalledTimes(1);
});
});
it("should not dispatch onAction events if item is disabled", async () => {
let onAction = jest.fn();
const wrapper = render(
<Menu aria-label="Actions" onAction={onAction}>
<MenuItem key="new">New file</MenuItem>
<MenuItem key="copy" isDisabled>
Copy link
</MenuItem>
<MenuItem key="edit">Edit file</MenuItem>
<MenuItem key="delete" color="danger">
Delete file
</MenuItem>
</Menu>,
);
let menuItems = wrapper.getAllByRole("menuitem");
await act(async () => {
await userEvent.click(menuItems[1]);
expect(onAction).toBeCalledTimes(0);
});
});
it("should dispatch onPress, onAction and onClick events", async () => {
let onPress = jest.fn();
let onClick = jest.fn();
let onAction = jest.fn();
const wrapper = render(
<Menu aria-label="Actions" onAction={onAction}>
<MenuItem key="new" onClick={onClick} onPress={onPress}>
New file
</MenuItem>
<MenuItem key="copy">Copy link</MenuItem>
<MenuItem key="edit">Edit file</MenuItem>
<MenuItem key="delete" color="danger">
Delete file
</MenuItem>
</Menu>,
);
let menuItems = wrapper.getAllByRole("menuitem");
await act(async () => {
await userEvent.click(menuItems[0]);
expect(onAction).toBeCalledTimes(1);
expect(onPress).toBeCalledTimes(1);
expect(onClick).toBeCalledTimes(1);
});
});
});

View File

@ -45,6 +45,7 @@
"@nextui-org/use-is-mobile": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/use-aria-menu": "workspace:*",
"@react-aria/focus": "^3.16.2",
"@react-aria/interactions": "^3.21.1",
"@react-aria/menu": "^3.13.1",
@ -57,6 +58,7 @@
"devDependencies": {
"@nextui-org/theme": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/test-utils": "workspace:*",
"clean-package": "2.2.0",
"@nextui-org/shared-icons": "workspace:*",
"react": "^18.0.0",

View File

@ -1,17 +1,16 @@
import type {MenuItemBaseProps} from "./base/menu-item-base";
import type {Node} from "@react-types/shared";
import {useMemo, useRef, useCallback} from "react";
import {menuItem} from "@nextui-org/theme";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {useFocusRing} from "@react-aria/focus";
import {Node} from "@react-types/shared";
import {filterDOMProps} from "@nextui-org/react-utils";
import {TreeState} from "@react-stately/tree";
import {clsx, dataAttr, objectToDeps, removeEvents} from "@nextui-org/shared-utils";
import {useMenuItem as useAriaMenuItem} from "@react-aria/menu";
import {chain, mergeProps} from "@react-aria/utils";
import {useHover, usePress} from "@react-aria/interactions";
import {useAriaMenuItem} from "@nextui-org/use-aria-menu";
import {mergeProps} from "@react-aria/utils";
import {useIsMobile} from "@nextui-org/use-is-mobile";
import {filterDOMProps} from "@nextui-org/react-utils";
interface Props<T extends object> extends MenuItemBaseProps<T> {
item: Node<T>;
@ -38,8 +37,12 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
classNames,
onAction,
autoFocus,
onPress,
onClick,
onPress,
onPressStart,
onPressUp,
onPressEnd,
onPressChange,
hideSelectedIcon = false,
isReadOnly = false,
closeOnSelect,
@ -61,21 +64,13 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
const isMobile = useIsMobile();
const {pressProps, isPressed} = usePress({
ref: domRef,
isDisabled: isDisabled,
onPress,
});
const {isHovered, hoverProps} = useHover({
isDisabled,
});
const {isFocusVisible, focusProps} = useFocusRing({
autoFocus,
});
const {
isHovered,
isPressed,
isFocused,
isSelected,
menuItemProps,
@ -87,6 +82,12 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
key,
onClose,
isDisabled,
onPress,
onClick,
onPressStart,
onPressUp,
onPressEnd,
onPressChange,
"aria-label": props["aria-label"],
closeOnSelect,
isVirtualized,
@ -117,12 +118,11 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
const getItemProps: PropGetter = (props = {}) => ({
ref: domRef,
...mergeProps(
itemProps,
isReadOnly ? {} : mergeProps(focusProps, pressProps),
hoverProps,
isReadOnly ? {} : focusProps,
filterDOMProps(otherProps, {
enabled: shouldFilterDOMProps,
}),
itemProps,
props,
),
"data-focus": dataAttr(isFocused),
@ -133,7 +133,6 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
"data-pressed": dataAttr(isPressed),
"data-focus-visible": dataAttr(isFocusVisible),
className: slots.base({class: clsx(baseStyles, props.className)}),
onClick: chain(pressProps.onClick, onClick),
});
const getLabelProps: PropGetter = (props = {}) => ({

View File

@ -1,7 +1,8 @@
import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system";
import type {AriaMenuProps} from "@react-types/menu";
import {AriaMenuOptions, useMenu as useAriaMenu} from "@react-aria/menu";
import {AriaMenuProps} from "@react-types/menu";
import {AriaMenuOptions} from "@react-aria/menu";
import {useAriaMenu} from "@nextui-org/use-aria-menu";
import {menu, MenuVariantProps, SlotsToClasses, MenuSlots} from "@nextui-org/theme";
import {TreeState, useTreeState} from "@react-stately/tree";
import {ReactRef, filterDOMProps, useDOMRef} from "@nextui-org/react-utils";

View File

@ -0,0 +1,24 @@
# @nextui-org/use-aria-menu-item
A Quick description of the component
> This is an internal utility, not intended for public usage.
## Installation
```sh
yarn add @nextui-org/use-aria-menu-item
# or
npm i @nextui-org/use-aria-menu-item
```
## Contribution
Yes please! See the
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md)
for details.
## License
This project is licensed under the terms of the
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE).

View File

@ -0,0 +1,63 @@
{
"name": "@nextui-org/use-aria-menu",
"version": "2.0.0",
"description": "React-aria useMenu hooks with custom implementations",
"keywords": [
"use-aria-menu"
],
"author": "Junior Garcia <jrgarciadev@gmail.com>",
"homepage": "https://nextui.org",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nextui-org/nextui.git",
"directory": "packages/hooks/use-aria-menu"
},
"bugs": {
"url": "https://github.com/nextui-org/nextui/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "pnpm build:fast --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18"
},
"dependencies": {
"@react-aria/utils": "^3.23.2",
"@react-types/shared": "^3.22.1",
"@react-aria/menu": "^3.13.1",
"@react-aria/interactions": "^3.21.1",
"@react-stately/tree": "^3.7.6",
"@react-aria/i18n": "^3.10.2",
"@react-aria/selection": "^3.17.5",
"@react-stately/collections": "^3.10.5",
"@react-types/menu": "^3.9.7"
},
"devDependencies": {
"clean-package": "2.2.0",
"react": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
"clean": true,
"target": "es2019",
"format": [
"cjs",
"esm"
]
}
}

View File

@ -0,0 +1,5 @@
export {menuData, useMenu as useAriaMenu} from "./use-menu";
export {useMenuItem as useAriaMenuItem} from "./use-menu-item";
export type {AriaMenuOptions, MenuAria} from "./use-menu";
export type {AriaMenuItemProps, MenuItemAria} from "./use-menu-item";

View File

@ -0,0 +1,341 @@
import {
DOMAttributes,
DOMProps,
FocusableElement,
FocusEvents,
HoverEvents,
Key,
KeyboardEvents,
PressEvent,
PressEvents,
} from "@react-types/shared";
import {chain, filterDOMProps, mergeProps, useRouter, useSlotId} from "@react-aria/utils";
import {getItemCount} from "@react-stately/collections";
import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from "@react-aria/interactions";
import {RefObject} from "react";
import {TreeState} from "@react-stately/tree";
import {useSelectableItem} from "@react-aria/selection";
import {menuData} from "./use-menu";
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 hovered. */
isHovered: boolean;
/** 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;
onClick?: DOMAttributes["onClick"];
/** 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,
onClick,
} = props;
let isTrigger = !!hasPopup;
// @ts-ignore
let isDisabled = props.isDisabled ?? state.disabledKeys.has(key);
// @ts-ignore
let isSelected = props.isSelected ?? state.selectionManager.isSelected(key);
let data = menuData.get(state);
// @ts-ignore
let onClose = props.onClose || data.onClose;
// @ts-ignore
let onAction = isTrigger ? () => {} : props.onAction || data.onAction;
let router = useRouter();
let performAction = (e: PressEvent) => {
if (onAction) {
// @ts-ignore
onAction(key);
}
if (e.target instanceof HTMLAnchorElement) {
router.open(e.target, e);
}
};
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) {
// @ts-ignore
ariaProps["aria-checked"] = isSelected;
}
// @ts-ignore
let item = state.collection.getItem(key);
if (isVirtualized) {
// @ts-ignore
ariaProps["aria-posinset"] = item?.index;
// @ts-ignore
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") {
setTimeout(() => {
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" ||
// @ts-ignore
state.selectionManager.isLink(key)))
) {
setTimeout(() => {
onClose?.();
});
}
}
pressUpProp?.(e);
};
let {itemProps, isFocused} = useSelectableItem({
selectionManager: state.selectionManager,
// @ts-ignore
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 {isHovered, hoverProps} = useHover({
isDisabled,
onHoverStart(e) {
if (!isFocusVisible()) {
state.selectionManager.setFocused(true);
// @ts-ignore
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});
// @ts-ignore
let domProps = filterDOMProps(item.props, {isLink: !!item?.props?.href});
delete domProps.id;
return {
menuItemProps: {
...ariaProps,
...mergeProps(
domProps,
isTrigger ? {onFocus: itemProps.onFocus} : itemProps,
pressProps,
hoverProps,
keyboardProps,
focusProps,
),
onClick: chain(onClick, pressProps.onClick),
tabIndex: itemProps.tabIndex != null ? -1 : undefined,
},
labelProps: {
id: labelId,
},
descriptionProps: {
id: descriptionId,
},
keyboardShortcutProps: {
id: keyboardId,
},
isHovered,
isFocused,
isSelected,
isPressed,
isDisabled,
};
}

View File

@ -0,0 +1,84 @@
/* eslint-disable no-console */
import {AriaMenuProps} from "@react-types/menu";
import {DOMAttributes, Key, KeyboardDelegate, KeyboardEvents} from "@react-types/shared";
import {filterDOMProps, mergeProps} from "@react-aria/utils";
import {RefObject} from "react";
import {TreeState} from "@react-stately/tree";
import {useSelectableList} from "@react-aria/selection";
export interface MenuAria {
/** Props for the menu element. */
menuProps: DOMAttributes;
}
export interface AriaMenuOptions<T> extends Omit<AriaMenuProps<T>, "children">, KeyboardEvents {
/** Whether the menu uses virtual scrolling. */
isVirtualized?: boolean;
/**
* An optional keyboard delegate implementation for type to select,
* to override the default.
*/
keyboardDelegate?: KeyboardDelegate;
}
interface MenuData {
onClose?: () => void;
onAction?: (key: Key) => void;
}
export const menuData = new WeakMap<TreeState<unknown>, MenuData>();
/**
* Provides the behavior and accessibility implementation for a menu component.
* A menu displays a list of actions or options that a user can choose.
* @param props - Props for the menu.
* @param state - State for the menu, as returned by `useListState`.
*/
export function useMenu<T>(
props: AriaMenuOptions<T>,
state: TreeState<T>,
ref: RefObject<HTMLElement>,
): MenuAria {
let {shouldFocusWrap = true, onKeyDown, onKeyUp, ...otherProps} = props;
if (!props["aria-label"] && !props["aria-labelledby"]) {
console.warn("An aria-label or aria-labelledby prop is required for accessibility.");
}
// @ts-ignore
let domProps = filterDOMProps(props, {labelable: true});
let {listProps} = useSelectableList({
...otherProps,
ref,
selectionManager: state.selectionManager,
collection: state.collection,
disabledKeys: state.disabledKeys,
shouldFocusWrap,
linkBehavior: "override",
});
menuData.set(state, {
onClose: props.onClose,
onAction: props.onAction,
});
return {
menuProps: mergeProps(
domProps,
{onKeyDown, onKeyUp},
{
role: "menu",
...listProps,
// @ts-ignore
onKeyDown: (e) => {
// don't clear the menu selected keys if the user is presses escape since escape closes the menu
if (e.key !== "Escape") {
// @ts-ignore
listProps.onKeyDown(e);
}
},
},
),
};
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.json",
"include": ["src", "index.ts"]
}

43
pnpm-lock.yaml generated
View File

@ -1716,6 +1716,9 @@ importers:
'@nextui-org/shared-utils':
specifier: workspace:*
version: link:../../utilities/shared-utils
'@nextui-org/use-aria-menu':
specifier: workspace:*
version: link:../../hooks/use-aria-menu
'@nextui-org/use-is-mobile':
specifier: workspace:*
version: link:../../hooks/use-is-mobile
@ -1750,6 +1753,9 @@ importers:
'@nextui-org/system':
specifier: workspace:*
version: link:../../core/system
'@nextui-org/test-utils':
specifier: workspace:*
version: link:../../utilities/test-utils
'@nextui-org/theme':
specifier: workspace:*
version: link:../../core/theme
@ -3167,6 +3173,43 @@ importers:
specifier: ^18.2.0
version: 18.2.0
packages/hooks/use-aria-menu:
dependencies:
'@react-aria/i18n':
specifier: ^3.10.2
version: 3.10.2(react@18.2.0)
'@react-aria/interactions':
specifier: ^3.21.1
version: 3.21.1(react@18.2.0)
'@react-aria/menu':
specifier: ^3.13.1
version: 3.13.1(react-dom@18.2.0)(react@18.2.0)
'@react-aria/selection':
specifier: ^3.17.5
version: 3.17.5(react-dom@18.2.0)(react@18.2.0)
'@react-aria/utils':
specifier: ^3.23.2
version: 3.23.2(react@18.2.0)
'@react-stately/collections':
specifier: ^3.10.5
version: 3.10.5(react@18.2.0)
'@react-stately/tree':
specifier: ^3.7.6
version: 3.7.6(react@18.2.0)
'@react-types/menu':
specifier: ^3.9.7
version: 3.9.7(react@18.2.0)
'@react-types/shared':
specifier: ^3.22.1
version: 3.22.1(react@18.2.0)
devDependencies:
clean-package:
specifier: 2.2.0
version: 2.2.0
react:
specifier: ^18.2.0
version: 18.2.0
packages/hooks/use-aria-modal-overlay:
dependencies:
'@react-aria/overlays':