fix(dropdown): focus behaviour on press / enter keydown (#2970)

* fix(dropdown): set focus on the first item

* feat(dropdown): add keyboard interactions tests

* feat(changeset): add changeset

* fix(dropdown): use fireEvent.keyDown instead

* chore(deps): add @nextui-org/test-utils to dropdown

* refactor(dropdown): pass onKeyDown to menu trigger and don't hardcode autoFocus

* chore(dropdown): remove autoFocus

* fix(menu): pass userMenuProps to useTreeState and useAriaMenu and remove from getListProps

* chore(changeset): add menu package
This commit is contained in:
աӄա 2024-05-20 03:40:28 +08:00 committed by GitHub
parent 1109baea6a
commit 7df2c71ecc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 93 additions and 13 deletions

View File

@ -0,0 +1,6 @@
---
"@nextui-org/dropdown": patch
"@nextui-org/menu": patch
---
Focus on the first item when pressing Space / Enter key on dropdown menu open (#2863)

View File

@ -1,7 +1,8 @@
import * as React from "react";
import {act, render} from "@testing-library/react";
import {act, render, fireEvent} from "@testing-library/react";
import {Button} from "@nextui-org/button";
import userEvent from "@testing-library/user-event";
import {keyCodes} from "@nextui-org/test-utils";
import {User} from "@nextui-org/user";
import {Image} from "@nextui-org/image";
import {Avatar} from "@nextui-org/avatar";
@ -538,3 +539,81 @@ describe("Dropdown", () => {
spy.mockRestore();
});
});
describe("Keyboard interactions", () => {
it("should focus on the first item on keyDown (Enter)", async () => {
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu disallowEmptySelection aria-label="Actions" selectionMode="single">
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
<DropdownItem key="edit">Edit file</DropdownItem>
<DropdownItem key="delete" color="danger">
Delete file
</DropdownItem>
</DropdownMenu>
</Dropdown>,
);
let triggerButton = wrapper.getByTestId("trigger-test");
act(() => {
triggerButton.focus();
});
expect(triggerButton).toHaveFocus();
fireEvent.keyDown(triggerButton, {key: "Enter", charCode: keyCodes.Enter});
let menu = wrapper.queryByRole("menu");
expect(menu).toBeTruthy();
let menuItems = wrapper.getAllByRole("menuitemradio");
expect(menuItems.length).toBe(4);
expect(menuItems[0]).toHaveFocus();
});
it("should focus on the first item on keyDown (Space)", async () => {
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu disallowEmptySelection aria-label="Actions" selectionMode="single">
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
<DropdownItem key="edit">Edit file</DropdownItem>
<DropdownItem key="delete" color="danger">
Delete file
</DropdownItem>
</DropdownMenu>
</Dropdown>,
);
let triggerButton = wrapper.getByTestId("trigger-test");
act(() => {
triggerButton.focus();
});
expect(triggerButton).toHaveFocus();
fireEvent.keyDown(triggerButton, {key: " ", charCode: keyCodes.Space});
let menu = wrapper.queryByRole("menu");
expect(menu).toBeTruthy();
let menuItems = wrapper.getAllByRole("menuitemradio");
expect(menuItems.length).toBe(4);
expect(menuItems[0]).toHaveFocus();
});
});

View File

@ -59,6 +59,7 @@
"@nextui-org/user": "workspace:*",
"@nextui-org/image": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/test-utils": "workspace:*",
"framer-motion": "^11.0.22",
"clean-package": "2.2.0",
"react": "^18.0.0",

View File

@ -126,7 +126,7 @@ export function useDropdown(props: UseDropdownProps) {
) => {
// These props are not needed for the menu trigger since it is handled by the popover trigger.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {onKeyDown, onPress, onPressStart, ...otherMenuTriggerProps} = menuTriggerProps;
const {onPress, onPressStart, ...otherMenuTriggerProps} = menuTriggerProps;
return {
...mergeProps(otherMenuTriggerProps, {isDisabled}, originalProps),

View File

@ -119,11 +119,11 @@ export function useMenu<T extends object>(props: UseMenuProps<T>) {
const domRef = useDOMRef(ref);
const shouldFilterDOMProps = typeof Component === "string";
const innerState = useTreeState({...otherProps, children});
const innerState = useTreeState({...otherProps, ...userMenuProps, children});
const state = propState || innerState;
const {menuProps} = useAriaMenu(otherProps, state, domRef);
const {menuProps} = useAriaMenu({...otherProps, ...userMenuProps}, state, domRef);
const slots = useMemo(() => menu({className}), [className]);
const baseStyles = clsx(classNames?.base, className);
@ -144,9 +144,7 @@ export function useMenu<T extends object>(props: UseMenuProps<T>) {
return {
"data-slot": "list",
className: slots.list({class: classNames?.list}),
...userMenuProps,
...menuProps,
...props,
};
};

10
pnpm-lock.yaml generated
View File

@ -1468,6 +1468,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
@ -5967,10 +5970,6 @@ packages:
peerDependencies:
'@effect-ts/otel-node': '*'
peerDependenciesMeta:
'@effect-ts/core':
optional: true
'@effect-ts/otel':
optional: true
'@effect-ts/otel-node':
optional: true
dependencies:
@ -22464,9 +22463,6 @@ packages:
resolution: {integrity: sha512-W+gxAq7aQ9dJIg/XLKGcRT0cvnStFAQHPaI0pvD0U2l6IVLueUAm3nwN7lkY62zZNmlvNx6jNtE4wlbS+CyqSg==}
engines: {node: '>= 12.0.0'}
hasBin: true
peerDependenciesMeta:
'@parcel/core':
optional: true
dependencies:
'@parcel/config-default': 2.12.0(@parcel/core@2.12.0)(typescript@4.9.5)
'@parcel/core': 2.12.0