feat(dropdown): tests added

This commit is contained in:
Junior Garcia 2023-04-08 23:18:07 -03:00
parent c66e3d7585
commit 548c4b824c
10 changed files with 463 additions and 87 deletions

View File

@ -1,19 +1,389 @@
import * as React from "react";
import {render} from "@testing-library/react";
import {act, render} from "@testing-library/react";
import {Button} from "@nextui-org/button";
import userEvent from "@testing-library/user-event";
import {Dropdown} from "../src";
import {Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSection} from "../src";
describe("Dropdown", () => {
it("should render correctly", () => {
const wrapper = render(<Dropdown />);
it("should render correctly (static)", () => {
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button>Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" onAction={alert}>
<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>,
);
expect(() => wrapper.unmount()).not.toThrow();
});
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
it("should render correctly (dynamic)", () => {
const menuItems = [
{key: "new", name: "New File"},
{key: "copy", name: "Copy Link"},
{key: "edit", name: "Edit File"},
{key: "delete", name: "Delete File"},
];
render(<Dropdown ref={ref} />);
expect(ref.current).not.toBeNull();
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button>Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" items={menuItems}>
{(item: any) => <DropdownItem key={item.key}>{item.name}</DropdownItem>}
</DropdownMenu>
</Dropdown>,
);
expect(() => wrapper.unmount()).not.toThrow();
});
it("should render correctly with section (static)", () => {
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button>Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" onAction={alert}>
<DropdownSection title="Actions">
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger Zone">
<DropdownItem key="edit">Edit file</DropdownItem>
<DropdownItem key="delete" color="danger">
Delete file
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>,
);
expect(() => wrapper.unmount()).not.toThrow();
});
it("should render correctly with section (dynamic)", () => {
const menuItems = [
{
title: "Actions",
children: [
{key: "new", name: "New File"},
{key: "copy", name: "Copy Link"},
{key: "edit", name: "Edit File"},
],
},
{
title: "Danger Zone",
children: [{key: "delete", name: "Delete File"}],
},
];
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button>Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" items={menuItems}>
{(section: any) => (
<DropdownSection
aria-label={section.title}
items={section.children}
title={section.title}
>
{(item: any) => <DropdownItem key={item.key}>{item.name}</DropdownItem>}
</DropdownSection>
)}
</DropdownMenu>
</Dropdown>,
);
expect(() => wrapper.unmount()).not.toThrow();
});
it("should work with single selection (controlled)", async () => {
let onOpenChange = jest.fn();
let onSelectionChange = jest.fn();
const wrapper = render(
<Dropdown onOpenChange={onOpenChange}>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu
disallowEmptySelection
aria-label="Actions"
selectionMode="single"
onSelectionChange={onSelectionChange}
>
<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");
expect(onOpenChange).toBeCalledTimes(0);
act(() => {
triggerButton.click();
});
expect(onOpenChange).toBeCalledTimes(1);
let menu = wrapper.getByRole("menu");
expect(menu).toBeTruthy();
// validates if the menu has the triggerButton id as aria-labelledby
expect(menu.getAttribute("aria-labelledby")).toBe(triggerButton.id);
let menuItems = wrapper.getAllByRole("menuitemradio");
expect(menuItems.length).toBe(4);
await act(async () => {
await userEvent.click(menuItems[1]);
expect(onSelectionChange).toBeCalledTimes(1);
expect(onOpenChange).toBeCalled();
});
});
it("should work with multiple selection (controlled)", async () => {
let onOpenChange = jest.fn();
let onSelectionChange = jest.fn();
const wrapper = render(
<Dropdown onOpenChange={onOpenChange}>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu
disallowEmptySelection
aria-label="Actions"
selectionMode="multiple"
onSelectionChange={onSelectionChange}
>
<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");
expect(onOpenChange).toBeCalledTimes(0);
act(() => {
triggerButton.click();
});
expect(onOpenChange).toBeCalledTimes(1);
let menu = wrapper.getByRole("menu");
expect(menu).toBeTruthy();
// validates if the menu has the triggerButton id as aria-labelledby
expect(menu.getAttribute("aria-labelledby")).toBe(triggerButton.id);
let menuItems = wrapper.getAllByRole("menuitemcheckbox");
expect(menuItems.length).toBe(4);
await act(async () => {
await userEvent.click(menuItems[0]);
expect(onSelectionChange).toBeCalledTimes(1);
expect(onOpenChange).toBeCalled();
});
});
it("should show checkmarks if selectionMode is single and has a selected item", () => {
const wrapper = render(
<Dropdown isOpen>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" selectedKeys={["new"]} 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 menuItems = wrapper.getAllByRole("menuitemradio");
expect(menuItems.length).toBe(4);
expect(menuItems[0].getAttribute("aria-checked")).toBe("true");
expect(menuItems[1].getAttribute("aria-checked")).toBe("false");
expect(menuItems[2].getAttribute("aria-checked")).toBe("false");
expect(menuItems[3].getAttribute("aria-checked")).toBe("false");
let svg = menuItems[0].querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("data-selected")).toBe("true");
});
it("should show multiple checkmarks if selectionMode is multiple and has selected items", () => {
const wrapper = render(
<Dropdown isOpen>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" selectedKeys={["new", "copy"]} selectionMode="multiple">
<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 menuItems = wrapper.getAllByRole("menuitemcheckbox");
expect(menuItems.length).toBe(4);
expect(menuItems[0].getAttribute("aria-checked")).toBe("true");
expect(menuItems[1].getAttribute("aria-checked")).toBe("true");
expect(menuItems[2].getAttribute("aria-checked")).toBe("false");
expect(menuItems[3].getAttribute("aria-checked")).toBe("false");
let checkmark1 = menuItems[0].querySelector("svg");
expect(checkmark1).toBeTruthy();
expect(checkmark1?.getAttribute("data-selected")).toBe("true");
let checkmark2 = menuItems[1].querySelector("svg");
expect(checkmark2).toBeTruthy();
expect(checkmark2?.getAttribute("data-selected")).toBe("true");
});
it("should not show checkmarks if selectionMode not defined", () => {
const wrapper = render(
<Dropdown isOpen>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions" selectedKeys={["new", "copy"]}>
<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 menuItems = wrapper.getAllByRole("menuitem");
expect(menuItems.length).toBe(4);
expect(menuItems[0].getAttribute("aria-checked")).toBeFalsy();
expect(menuItems[1].getAttribute("aria-checked")).toBeFalsy();
expect(menuItems[2].getAttribute("aria-checked")).toBeFalsy();
expect(menuItems[3].getAttribute("aria-checked")).toBeFalsy();
let checkmark1 = menuItems[0].querySelector("svg");
expect(checkmark1).toBeFalsy();
});
it("should not open on disabled button", () => {
const wrapper = render(
<Dropdown>
<DropdownTrigger>
<Button isDisabled data-testid="trigger-test">
Trigger
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Actions">
<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");
expect(triggerButton).toBeTruthy();
act(() => {
triggerButton.click();
});
let menu = wrapper.queryByRole("menu");
expect(menu).toBeFalsy();
});
it("should not select on disabled item", () => {
const onSelectionChange = jest.fn();
const wrapper = render(
<Dropdown isOpen>
<DropdownTrigger>
<Button data-testid="trigger-test">Trigger</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Actions"
disabledKeys={["copy"]}
selectionMode="single"
onSelectionChange={onSelectionChange}
>
<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 menuItems = wrapper.getAllByRole("menuitemradio");
expect(menuItems.length).toBe(4);
act(() => {
menuItems[1].click();
});
expect(onSelectionChange).toBeCalledTimes(0);
});
});

View File

@ -5,6 +5,8 @@ import {useMenu} from "@react-aria/menu";
import {useDOMRef} from "@nextui-org/dom-utils";
import {AriaMenuProps} from "@react-types/menu";
import {useTreeState} from "@react-stately/tree";
import {dropdownMenu} from "@nextui-org/theme";
import {useMemo} from "react";
import DropdownSection from "./dropdown-section";
import DropdownItem, {DropdownItemProps} from "./dropdown-item";
@ -35,7 +37,7 @@ export interface DropdownMenuProps<T = object>
/**
* The dropdown items styles.
*/
styles?: DropdownItemProps["styles"];
itemStyles?: DropdownItemProps["styles"];
}
const DropdownMenu = forwardRef<DropdownMenuProps, "ul">(
@ -48,7 +50,7 @@ const DropdownMenu = forwardRef<DropdownMenuProps, "ul">(
onAction,
closeOnSelect,
className,
styles,
itemStyles,
...otherProps
},
ref,
@ -62,9 +64,11 @@ const DropdownMenu = forwardRef<DropdownMenuProps, "ul">(
const state = useTreeState(otherProps);
const {menuProps} = useMenu(otherProps, state, domRef);
const styles = useMemo(() => dropdownMenu({className}), [className]);
return (
<PopoverContent>
<Component {...getMenuProps({...menuProps, className}, domRef)}>
<Component {...getMenuProps({...menuProps}, domRef)} className={styles}>
{[...state.collection].map((item) => {
const itemProps = {
key: item.key,
@ -73,7 +77,7 @@ const DropdownMenu = forwardRef<DropdownMenuProps, "ul">(
disableAnimation,
item,
state,
styles,
styles: itemStyles,
variant,
onAction,
...item.props,

View File

@ -5,7 +5,7 @@ import {dropdownSection} from "@nextui-org/theme";
import {Node} from "@react-types/shared";
import {TreeState} from "@react-stately/tree";
import {useMenuSection} from "@react-aria/menu";
import {useMemo, Key} from "react";
import {useMemo, Key, useId} from "react";
import {mergeProps} from "@react-aria/utils";
import {clsx} from "@nextui-org/shared-utils";
@ -67,6 +67,8 @@ const DropdownSection = forwardRef<DropdownSectionProps, "li">(
) => {
const {section, heading, ...styles} = stylesProp;
const headingId = useId();
const Component = as || "li";
const isFirstKey = item.key === state.collection.getFirstKey();
@ -88,11 +90,11 @@ const DropdownSection = forwardRef<DropdownSectionProps, "li">(
className={slots.section({class: baseStyles})}
>
{item.rendered && (
<span {...headingProps} className={slots.heading({class: heading})}>
<span {...headingProps} className={slots.heading({class: heading})} id={headingId}>
{item.rendered}
</span>
)}
<ul {...groupProps}>
<ul {...groupProps} aria-labelledby={headingId}>
{[...item.childNodes].map((node) => {
let dropdownItem = (
<DropdownItem

View File

@ -7,7 +7,13 @@ export function DropdownSelectedIcon(props: DropdownSelectedIconProps) {
const {isSelected, disableAnimation, ...otherProps} = props;
return (
<svg aria-hidden="true" role="presentation" viewBox="0 0 17 18" {...otherProps}>
<svg
aria-hidden="true"
data-selected={isSelected}
role="presentation"
viewBox="0 0 17 18"
{...otherProps}
>
<polyline
fill="none"
points="1 9 7 14 15 4"

View File

@ -1,6 +1,6 @@
import type {DropdownItemBaseProps} from "./base/dropdown-item-base";
import {useMemo, useRef, useCallback} from "react";
import {useMemo, useRef, useCallback, useId} from "react";
import {dropdownItem} from "@nextui-org/theme";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {useFocusRing} from "@react-aria/focus";
@ -58,11 +58,13 @@ export function useDropdownItem<T extends object>(originalProps: UseDropdownItem
const {rendered, key} = item;
const isSelected = state.selectionManager.isSelected(key);
const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.disabledKeys.has(key) || originalProps.isDisabled;
const isSelectable = state.selectionManager.selectionMode !== "none";
const labelId = useId();
const descriptionId = useId();
const keyboardId = useId();
const {pressProps} = usePress({
ref: domRef,
isDisabled,
@ -73,11 +75,17 @@ export function useDropdownItem<T extends object>(originalProps: UseDropdownItem
autoFocus,
});
const {menuItemProps, labelProps, descriptionProps, keyboardShortcutProps} = useMenuItem(
const {
isFocused,
isSelected,
menuItemProps,
labelProps,
descriptionProps,
keyboardShortcutProps,
} = useMenuItem(
{
key,
onClose,
isSelected,
isDisabled,
"aria-label": props["aria-label"],
closeOnSelect,
@ -111,22 +119,27 @@ export function useDropdownItem<T extends object>(originalProps: UseDropdownItem
props,
),
"data-focused": dataAttr(isFocused),
"aria-labelledby": labelId,
"aria-describedby": [descriptionId, keyboardId].filter(Boolean).join(" ") || undefined,
className: slots.base({class: clsx(baseStyles, props.className)}),
onClick: chain(pressProps.onClick, onClick),
});
const getLabelProps: PropGetter = (props = {}) => ({
...mergeProps(labelProps, props),
id: labelId,
className: slots.title({class: styles?.title}),
});
const getDescriptionProps: PropGetter = (props = {}) => ({
...mergeProps(descriptionProps, props),
id: descriptionId,
className: slots.description({class: styles?.description}),
});
const getKeyboardShortcutProps: PropGetter = (props = {}) => ({
...mergeProps(keyboardShortcutProps, props),
id: keyboardId,
className: slots.shortcut({class: styles?.shortcut}),
});

View File

@ -1,7 +1,5 @@
import type {DropdownVariantProps, SlotsToClasses, DropdownSlots} from "@nextui-org/theme";
import {Ref, useId} from "react";
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {HTMLNextUIProps, PropGetter} from "@nextui-org/system";
import {useMenuTriggerState} from "@react-stately/menu";
import {MenuTriggerType} from "@react-types/menu";
import {useMenuTrigger} from "@react-aria/menu";
@ -11,7 +9,7 @@ import {PopoverProps} from "@nextui-org/popover";
import {useMemo, useRef} from "react";
import {mergeProps} from "@react-aria/utils";
interface Props
export interface UseDropdownProps
extends HTMLNextUIProps<"div", Omit<PopoverProps, "children" | "color" | "variant">> {
/**
* Type of overlay that is opened by the trigger.
@ -36,33 +34,12 @@ interface Props
* @default true
*/
closeOnSelect?: boolean;
/**
* Classname or List of classes to change the styles of the element.
* if `className` is passed, it will be added to the base slot.
*
* @example
* ```ts
* <Dropdown styles={{
* trigger: "trigger-classes",
* base: "base-classes", // popover wrapper
* menu: "menu-classes",// items wrapper
* section: "section-classes",
* sectionHeading: "sectionHeading-classes",
* }} />
* ```
*/
styles?: SlotsToClasses<DropdownSlots>;
}
export type UseDropdownProps = Props & DropdownVariantProps;
export function useDropdown(originalProps: UseDropdownProps) {
const [props, variantProps] = mapPropsVariants(originalProps, dropdown.variantKeys);
export function useDropdown(props: UseDropdownProps) {
const {
as,
triggerRef: triggerRefProp,
className,
isOpen,
defaultOpen,
onOpenChange,
@ -71,7 +48,9 @@ export function useDropdown(originalProps: UseDropdownProps) {
placement = "bottom",
isDisabled = false,
closeOnSelect = true,
styles,
styles: stylesProp,
disableAnimation = false,
className,
...otherProps
} = props;
@ -83,8 +62,7 @@ export function useDropdown(originalProps: UseDropdownProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const triggerId = useId();
const disableAnimation = originalProps.disableAnimation ?? false;
const menuId = useId();
const state = useMenuTriggerState({trigger, isOpen, defaultOpen, onOpenChange});
@ -94,16 +72,14 @@ export function useDropdown(originalProps: UseDropdownProps) {
menuTriggerRef,
);
const slots = useMemo(
const styles = useMemo(
() =>
dropdown({
...variantProps,
className,
}),
[...Object.values(variantProps)],
[className],
);
const baseStyles = clsx(styles?.base, className);
const getPopoverProps: PropGetter = (props = {}) => ({
state,
placement,
@ -112,7 +88,12 @@ export function useDropdown(originalProps: UseDropdownProps) {
scrollRef: menuRef,
triggerRef: menuTriggerRef,
...mergeProps(otherProps, props),
className: slots.base({class: clsx(baseStyles, props.className)}),
styles: {
...stylesProp,
...props.styles,
base: clsx(styles, stylesProp?.base, props.className),
arrow: clsx("border border-neutral-100", stylesProp?.arrow),
},
});
const getMenuTriggerProps: PropGetter = (
@ -127,13 +108,15 @@ export function useDropdown(originalProps: UseDropdownProps) {
...mergeProps(otherMenuTriggerProps, props),
id: triggerId,
ref: mergeRefs(_ref, triggerRef),
"aria-controls": menuId,
};
};
const getMenuProps: PropGetter = (props = {}, _ref: Ref<any> | null | undefined = null) => ({
...mergeProps(menuProps, props),
id: menuId,
ref: mergeRefs(_ref, menuRef),
className: slots.menu({class: clsx(styles?.menu, props.className)}),
"aria-labelledby": triggerId,
});
return {

View File

@ -456,6 +456,7 @@ const CustomTriggerTemplate: ComponentStory<any> = ({variant, ...args}) => {
<Avatar
isBordered
as="button"
className="transition-transform"
color="secondary"
size="lg"
src="https://i.pravatar.cc/150?u=a042581f4e29026704d"
@ -493,6 +494,7 @@ const CustomTriggerTemplate: ComponentStory<any> = ({variant, ...args}) => {
size: "lg",
src: "https://i.pravatar.cc/150?u=a042581f4e29026024d",
}}
className="transition-transform"
description="@tonyreichert"
name="Tony Reichert"
/>

View File

@ -0,0 +1,13 @@
import {tv} from "tailwind-variants";
/**
* Dropdown Menu wrapper **Tailwind Variants** component
*
* const styles = dropdownMenu({...})
*/
const dropdownMenu = tv({
base: "w-full flex flex-col p-1",
});
export {dropdownMenu};

View File

@ -1,11 +1,9 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* Dropdown wrapper **Tailwind Variants** component
*
* const { base, trigger, arrow } = dropdown({...})
* const { base, menu } = dropdown({...})
*
* @example
* <div>
@ -17,33 +15,17 @@ import {tv} from "tailwind-variants";
* </div>
*/
const dropdown = tv({
slots: {
base: [
"w-full",
"p-1",
"min-w-[200px]",
"shadow",
"shadow-lg",
"bg-white",
"dark:bg-content1",
"border",
"border-neutral-100",
],
menu: "w-full flex flex-col p-1",
trigger: [],
},
variants: {
disableAnimation: {
true: "transition-none",
},
},
defaultVariants: {
disableAnimation: false,
},
base: [
"w-full",
"p-1",
"min-w-[200px]",
"shadow",
"shadow-lg",
"bg-white",
"dark:bg-content1",
"border",
"border-neutral-100",
],
});
export type DropdownVariantProps = VariantProps<typeof dropdown>;
export type DropdownSlots = keyof ReturnType<typeof dropdown>;
export {dropdown};

View File

@ -26,3 +26,4 @@ export * from "./input";
export * from "./dropdown";
export * from "./dropdown-item";
export * from "./dropdown-section";
export * from "./dropdown-menu";