diff --git a/packages/wow-ui/src/components/DropDown/DropDown.test.tsx b/packages/wow-ui/src/components/DropDown/DropDown.test.tsx new file mode 100644 index 00000000..27ad15e9 --- /dev/null +++ b/packages/wow-ui/src/components/DropDown/DropDown.test.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +import DropDown from "@/components/DropDown"; +import DropDownOption from "@/components/DropDown/DropDownOption"; + +const dropdownId = "dropdown-id"; + +const options = [ + { value: "option1", text: "Option 1" }, + { value: "option2", text: "Option 2" }, + { value: "option3", text: "Option 3" }, +]; + +const renderDropDown = (props = {}) => + render( + + {options.map((option) => ( + + ))} + + ); + +describe("DropDown component", () => { + it("should render the placeholder", () => { + renderDropDown({ placeholder: "Please select" }); + expect(screen.getByText("Please select")).toBeInTheDocument(); + }); + + it("should allow selection of an option", async () => { + const dropdownId = "dropdown-id"; + renderDropDown({ id: dropdownId, placeholder: "Please select" }); + + await userEvent.click(screen.getByText("Please select")); + await userEvent.click(screen.getByText("Option 1")); + + await waitFor(() => { + const option1 = document.querySelector(`#${dropdownId}-option-option1`); + expect(option1).toHaveAttribute("aria-selected", "true"); + }); + }); + + it("should render with default value", () => { + renderDropDown({ + defaultValue: "option2", + placeholder: "Please select", + }); + + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveTextContent("Option 2"); + }); + + it("should render the trigger button", async () => { + renderDropDown({ trigger: }); + + userEvent.click(screen.getByText("Open Dropdown")); + + await waitFor(() => { + expect(screen.getByText("Option 1")).toBeInTheDocument(); + }); + }); + + it("closes dropdown when clicking outside", async () => { + renderDropDown({ placeholder: "Select an option" }); + await userEvent.click(screen.getByText("Select an option")); + await userEvent.click(document.body); + + await waitFor(() => { + const dropdown = screen.queryByRole("listbox"); + expect(dropdown).toBeNull(); + }); + }); +}); + +describe("external control and events", () => { + it("should fire external onChange event and update controlled state", async () => { + const ControlledDropDown = () => { + const [selectedValue, setSelectedValue] = useState(""); + + const handleChange = (value: { + selectedValue: string; + selectedText: ReactNode; + }) => { + setSelectedValue(value.selectedValue); + }; + + return ( + + {options.map((option) => ( + + ))} + + ); + }; + + render(); + + await userEvent.click(screen.getByText("Please select")); + await userEvent.click(screen.getByText("Option 2")); + + await waitFor(() => { + const option2 = document.querySelector(`#${dropdownId}-option-option2`); + expect(option2).toHaveAttribute("aria-selected", "true"); + }); + }); + + it("should navigate options using keyboard and apply focus correctly", async () => { + const ControlledDropDown = () => { + const [selectedValue, setSelectedValue] = useState(""); + + const handleChange = (value: { + selectedValue: string; + selectedText: ReactNode; + }) => { + setSelectedValue(value.selectedValue); + }; + + return ( + + {options.map((option) => ( + + ))} + + ); + }; + + render(); + await userEvent.click(screen.getByText("Please select")); + + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => { + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option1` + ); + }); + + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => { + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option2` + ); + }); + + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => { + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option3` + ); + }); + + await userEvent.keyboard("{ArrowUp}"); + await waitFor(() => { + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option2` + ); + }); + + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + const option2 = document.querySelector(`#${dropdownId}-option-option2`); + expect(option2).toHaveAttribute("aria-selected", "true"); + + const option1 = document.querySelector(`#${dropdownId}-option-option1`); + const option3 = document.querySelector(`#${dropdownId}-option-option3`); + expect(option1).toHaveAttribute("aria-selected", "false"); + expect(option3).toHaveAttribute("aria-selected", "false"); + }); + }); +}); diff --git a/packages/wow-ui/src/components/DropDown/DropDownOption.tsx b/packages/wow-ui/src/components/DropDown/DropDownOption.tsx index fda4348e..7fd348fb 100644 --- a/packages/wow-ui/src/components/DropDown/DropDownOption.tsx +++ b/packages/wow-ui/src/components/DropDown/DropDownOption.tsx @@ -52,6 +52,7 @@ const DropDownOption = forwardRef( return ( { - const { open, setFocusedValue, focusedValue, handleSelect } = + const { open, setFocusedValue, focusedValue, handleSelect, dropdownId } = useDropDownContext(); const itemMap = useCollection(); const listRef = useRef(null); @@ -64,11 +64,11 @@ export const DropDownOptionList = ({ return ( { const itemMap = useCollection(); - const { open, selectedValue, setOpen, setFocusedValue, dropdownId } = - useDropDownContext(); + const { + open, + selectedValue, + focusedValue, + setOpen, + setFocusedValue, + dropdownId, + } = useDropDownContext(); const selectedText = itemMap.get(selectedValue); @@ -42,13 +48,28 @@ const DropDownTrigger = ({ [toggleDropdown] ); + const commonProps: ButtonHTMLAttributes = { + "aria-expanded": open, + role: "combobox", + "aria-haspopup": "listbox", + id: `${dropdownId}-trigger`, + "aria-controls": `${dropdownId}-option-list`, + ...(focusedValue && { + "aria-activedescendant": `${dropdownId}-option-${focusedValue}`, + }), + ...(label + ? { + "aria-labelledby": `${dropdownId}-label`, + } + : { + "aria-label": `dropdown-open`, + }), + }; + if (trigger) { return cloneElement(trigger, { onClick: toggleDropdown, - "aria-expanded": open, - "aria-haspopup": "true", - id: `${dropdownId}-trigger`, - "aria-controls": `${dropdownId}`, + ...commonProps, }); } @@ -57,19 +78,17 @@ const DropDownTrigger = ({ {label && ( {label} )} ); }; + +export default DropDownWrapper; diff --git a/packages/wow-ui/src/components/DropDown/context/DropDownContext.ts b/packages/wow-ui/src/components/DropDown/context/DropDownContext.ts index 7757ad7f..b0e16dc4 100644 --- a/packages/wow-ui/src/components/DropDown/context/DropDownContext.ts +++ b/packages/wow-ui/src/components/DropDown/context/DropDownContext.ts @@ -1,7 +1,7 @@ import { createContext } from "react"; -import type useDropDownState from "../../../hooks/useDropDownState"; -import useSafeContext from "../../../hooks/useSafeContext"; +import type useDropDownState from "@/hooks/useDropDownState"; +import useSafeContext from "@/hooks/useSafeContext"; export const DropDownContext = createContext< (ReturnType & { dropdownId: string }) | null diff --git a/packages/wow-ui/src/components/DropDown/index.tsx b/packages/wow-ui/src/components/DropDown/index.tsx index 47cf5803..60f72a80 100644 --- a/packages/wow-ui/src/components/DropDown/index.tsx +++ b/packages/wow-ui/src/components/DropDown/index.tsx @@ -9,12 +9,12 @@ import type { import { useId } from "react"; import { DropDownContext } from "@/components/DropDown/context/DropDownContext"; -import { DropDownOptionList } from "@/components/DropDown/DropDownOptionList"; +import DropDownOptionList from "@/components/DropDown/DropDownOptionList"; import DropDownTrigger from "@/components/DropDown/DropDownTrigger"; +import DropDownWrapper from "@/components/DropDown/DropDownWrapper"; import useDropDownState from "@/hooks/useDropDownState"; import { CollectionProvider } from "./context/CollectionContext"; -import { DropDownWrapper } from "./DropDownWrapper"; export interface DropDownWithTriggerProps extends PropsWithChildren { /** * @description 드롭다운을 열기 위한 외부 트리거 요소입니다.