mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
Fix DropdownItem onPress (#2746)
* chore(dropdown): missing events added * fix(menu): item selection events * chore(menu): changeset
This commit is contained in:
parent
4ca6e2c1f6
commit
6b56e43a35
7
.changeset/healthy-parents-brake.md
Normal file
7
.changeset/healthy-parents-brake.md
Normal 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
|
||||||
@ -139,7 +139,11 @@ export function useDropdown(props: UseDropdownProps) {
|
|||||||
return {
|
return {
|
||||||
ref: mergeRefs(_ref, menuRef),
|
ref: mergeRefs(_ref, menuRef),
|
||||||
menuProps,
|
menuProps,
|
||||||
...mergeProps(props, {onAction: () => onMenuAction(props?.closeOnSelect)}),
|
closeOnSelect,
|
||||||
|
...mergeProps(props, {
|
||||||
|
onAction: () => onMenuAction(props?.closeOnSelect),
|
||||||
|
onClose: state.close,
|
||||||
|
}),
|
||||||
} as MenuProps;
|
} as MenuProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -137,7 +137,7 @@ const Template = ({color, variant, ...args}: DropdownProps & DropdownMenuProps)
|
|||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button>Trigger</Button>
|
<Button>Trigger</Button>
|
||||||
</DropdownTrigger>
|
</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="new">New file</DropdownItem>
|
||||||
<DropdownItem key="copy">Copy link</DropdownItem>
|
<DropdownItem key="copy">Copy link</DropdownItem>
|
||||||
<DropdownItem key="edit">Edit file</DropdownItem>
|
<DropdownItem key="edit">Edit file</DropdownItem>
|
||||||
|
|||||||
@ -109,6 +109,7 @@ describe("Menu", () => {
|
|||||||
<Menu aria-label="Actions" items={menuItems}>
|
<Menu aria-label="Actions" items={menuItems}>
|
||||||
{(section: any) => (
|
{(section: any) => (
|
||||||
<MenuSection aria-label={section.title} items={section.children} title={section.title}>
|
<MenuSection aria-label={section.title} items={section.children} title={section.title}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
{(item: any) => <MenuItem key={item.key}>{item.name}</MenuItem>}
|
{(item: any) => <MenuItem key={item.key}>{item.name}</MenuItem>}
|
||||||
</MenuSection>
|
</MenuSection>
|
||||||
)}
|
)}
|
||||||
@ -273,4 +274,81 @@ describe("Menu", () => {
|
|||||||
|
|
||||||
expect(checkmark1).toBeFalsy();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
"@nextui-org/use-is-mobile": "workspace:*",
|
"@nextui-org/use-is-mobile": "workspace:*",
|
||||||
"@nextui-org/shared-utils": "workspace:*",
|
"@nextui-org/shared-utils": "workspace:*",
|
||||||
"@nextui-org/react-utils": "workspace:*",
|
"@nextui-org/react-utils": "workspace:*",
|
||||||
|
"@nextui-org/use-aria-menu": "workspace:*",
|
||||||
"@react-aria/focus": "^3.16.2",
|
"@react-aria/focus": "^3.16.2",
|
||||||
"@react-aria/interactions": "^3.21.1",
|
"@react-aria/interactions": "^3.21.1",
|
||||||
"@react-aria/menu": "^3.13.1",
|
"@react-aria/menu": "^3.13.1",
|
||||||
@ -57,6 +58,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nextui-org/theme": "workspace:*",
|
"@nextui-org/theme": "workspace:*",
|
||||||
"@nextui-org/system": "workspace:*",
|
"@nextui-org/system": "workspace:*",
|
||||||
|
"@nextui-org/test-utils": "workspace:*",
|
||||||
"clean-package": "2.2.0",
|
"clean-package": "2.2.0",
|
||||||
"@nextui-org/shared-icons": "workspace:*",
|
"@nextui-org/shared-icons": "workspace:*",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import type {MenuItemBaseProps} from "./base/menu-item-base";
|
import type {MenuItemBaseProps} from "./base/menu-item-base";
|
||||||
|
import type {Node} from "@react-types/shared";
|
||||||
|
|
||||||
import {useMemo, useRef, useCallback} from "react";
|
import {useMemo, useRef, useCallback} from "react";
|
||||||
import {menuItem} from "@nextui-org/theme";
|
import {menuItem} from "@nextui-org/theme";
|
||||||
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
|
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
|
||||||
import {useFocusRing} from "@react-aria/focus";
|
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 {TreeState} from "@react-stately/tree";
|
||||||
import {clsx, dataAttr, objectToDeps, removeEvents} from "@nextui-org/shared-utils";
|
import {clsx, dataAttr, objectToDeps, removeEvents} from "@nextui-org/shared-utils";
|
||||||
import {useMenuItem as useAriaMenuItem} from "@react-aria/menu";
|
import {useAriaMenuItem} from "@nextui-org/use-aria-menu";
|
||||||
import {chain, mergeProps} from "@react-aria/utils";
|
import {mergeProps} from "@react-aria/utils";
|
||||||
import {useHover, usePress} from "@react-aria/interactions";
|
|
||||||
import {useIsMobile} from "@nextui-org/use-is-mobile";
|
import {useIsMobile} from "@nextui-org/use-is-mobile";
|
||||||
|
import {filterDOMProps} from "@nextui-org/react-utils";
|
||||||
|
|
||||||
interface Props<T extends object> extends MenuItemBaseProps<T> {
|
interface Props<T extends object> extends MenuItemBaseProps<T> {
|
||||||
item: Node<T>;
|
item: Node<T>;
|
||||||
@ -38,8 +37,12 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
|
|||||||
classNames,
|
classNames,
|
||||||
onAction,
|
onAction,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
onPress,
|
|
||||||
onClick,
|
onClick,
|
||||||
|
onPress,
|
||||||
|
onPressStart,
|
||||||
|
onPressUp,
|
||||||
|
onPressEnd,
|
||||||
|
onPressChange,
|
||||||
hideSelectedIcon = false,
|
hideSelectedIcon = false,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
closeOnSelect,
|
closeOnSelect,
|
||||||
@ -61,21 +64,13 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
|
|||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const {pressProps, isPressed} = usePress({
|
|
||||||
ref: domRef,
|
|
||||||
isDisabled: isDisabled,
|
|
||||||
onPress,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {isHovered, hoverProps} = useHover({
|
|
||||||
isDisabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {isFocusVisible, focusProps} = useFocusRing({
|
const {isFocusVisible, focusProps} = useFocusRing({
|
||||||
autoFocus,
|
autoFocus,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
isHovered,
|
||||||
|
isPressed,
|
||||||
isFocused,
|
isFocused,
|
||||||
isSelected,
|
isSelected,
|
||||||
menuItemProps,
|
menuItemProps,
|
||||||
@ -87,6 +82,12 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
|
|||||||
key,
|
key,
|
||||||
onClose,
|
onClose,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
onPress,
|
||||||
|
onClick,
|
||||||
|
onPressStart,
|
||||||
|
onPressUp,
|
||||||
|
onPressEnd,
|
||||||
|
onPressChange,
|
||||||
"aria-label": props["aria-label"],
|
"aria-label": props["aria-label"],
|
||||||
closeOnSelect,
|
closeOnSelect,
|
||||||
isVirtualized,
|
isVirtualized,
|
||||||
@ -117,12 +118,11 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
|
|||||||
const getItemProps: PropGetter = (props = {}) => ({
|
const getItemProps: PropGetter = (props = {}) => ({
|
||||||
ref: domRef,
|
ref: domRef,
|
||||||
...mergeProps(
|
...mergeProps(
|
||||||
itemProps,
|
isReadOnly ? {} : focusProps,
|
||||||
isReadOnly ? {} : mergeProps(focusProps, pressProps),
|
|
||||||
hoverProps,
|
|
||||||
filterDOMProps(otherProps, {
|
filterDOMProps(otherProps, {
|
||||||
enabled: shouldFilterDOMProps,
|
enabled: shouldFilterDOMProps,
|
||||||
}),
|
}),
|
||||||
|
itemProps,
|
||||||
props,
|
props,
|
||||||
),
|
),
|
||||||
"data-focus": dataAttr(isFocused),
|
"data-focus": dataAttr(isFocused),
|
||||||
@ -133,7 +133,6 @@ export function useMenuItem<T extends object>(originalProps: UseMenuItemProps<T>
|
|||||||
"data-pressed": dataAttr(isPressed),
|
"data-pressed": dataAttr(isPressed),
|
||||||
"data-focus-visible": dataAttr(isFocusVisible),
|
"data-focus-visible": dataAttr(isFocusVisible),
|
||||||
className: slots.base({class: clsx(baseStyles, props.className)}),
|
className: slots.base({class: clsx(baseStyles, props.className)}),
|
||||||
onClick: chain(pressProps.onClick, onClick),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getLabelProps: PropGetter = (props = {}) => ({
|
const getLabelProps: PropGetter = (props = {}) => ({
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system";
|
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 {menu, MenuVariantProps, SlotsToClasses, MenuSlots} from "@nextui-org/theme";
|
||||||
import {TreeState, useTreeState} from "@react-stately/tree";
|
import {TreeState, useTreeState} from "@react-stately/tree";
|
||||||
import {ReactRef, filterDOMProps, useDOMRef} from "@nextui-org/react-utils";
|
import {ReactRef, filterDOMProps, useDOMRef} from "@nextui-org/react-utils";
|
||||||
|
|||||||
24
packages/hooks/use-aria-menu/README.md
Normal file
24
packages/hooks/use-aria-menu/README.md
Normal 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).
|
||||||
63
packages/hooks/use-aria-menu/package.json
Normal file
63
packages/hooks/use-aria-menu/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/hooks/use-aria-menu/src/index.ts
Normal file
5
packages/hooks/use-aria-menu/src/index.ts
Normal 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";
|
||||||
341
packages/hooks/use-aria-menu/src/use-menu-item.ts
Normal file
341
packages/hooks/use-aria-menu/src/use-menu-item.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
84
packages/hooks/use-aria-menu/src/use-menu.ts
Normal file
84
packages/hooks/use-aria-menu/src/use-menu.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
4
packages/hooks/use-aria-menu/tsconfig.json
Normal file
4
packages/hooks/use-aria-menu/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"include": ["src", "index.ts"]
|
||||||
|
}
|
||||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@ -1716,6 +1716,9 @@ importers:
|
|||||||
'@nextui-org/shared-utils':
|
'@nextui-org/shared-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../utilities/shared-utils
|
version: link:../../utilities/shared-utils
|
||||||
|
'@nextui-org/use-aria-menu':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../hooks/use-aria-menu
|
||||||
'@nextui-org/use-is-mobile':
|
'@nextui-org/use-is-mobile':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../hooks/use-is-mobile
|
version: link:../../hooks/use-is-mobile
|
||||||
@ -1750,6 +1753,9 @@ importers:
|
|||||||
'@nextui-org/system':
|
'@nextui-org/system':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../core/system
|
version: link:../../core/system
|
||||||
|
'@nextui-org/test-utils':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../utilities/test-utils
|
||||||
'@nextui-org/theme':
|
'@nextui-org/theme':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../core/theme
|
version: link:../../core/theme
|
||||||
@ -3167,6 +3173,43 @@ importers:
|
|||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 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:
|
packages/hooks/use-aria-modal-overlay:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@react-aria/overlays':
|
'@react-aria/overlays':
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user