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 {
|
||||
ref: mergeRefs(_ref, menuRef),
|
||||
menuProps,
|
||||
...mergeProps(props, {onAction: () => onMenuAction(props?.closeOnSelect)}),
|
||||
closeOnSelect,
|
||||
...mergeProps(props, {
|
||||
onAction: () => onMenuAction(props?.closeOnSelect),
|
||||
onClose: state.close,
|
||||
}),
|
||||
} as MenuProps;
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = {}) => ({
|
||||
|
||||
@ -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";
|
||||
|
||||
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':
|
||||
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':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user