From 081b086a919ae0b0023ac4cef9040c58ce1c36c2 Mon Sep 17 00:00:00 2001 From: SeieunYoo Date: Sat, 21 Sep 2024 16:51:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20dropdown=20aria=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80,=20test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DropDown/DropDown.test.tsx | 207 ++++++++++++++++++ .../components/DropDown/DropDownOption.tsx | 1 + .../DropDown/DropDownOptionList.tsx | 6 +- 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 packages/wow-ui/src/components/DropDown/DropDown.test.tsx 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..97a1bffe --- /dev/null +++ b/packages/wow-ui/src/components/DropDown/DropDown.test.tsx @@ -0,0 +1,207 @@ +"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 dropdownButton = screen.getByRole("button", { + name: "Option 2 down-arrow icon", + }); + expect(dropdownButton).toBeInTheDocument(); + expect(dropdownButton).toHaveAttribute("id", `${dropdownId}-trigger`); + expect(dropdownButton).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 dropdown = screen.getByRole("listbox"); + expect(dropdown).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option1` + ); + }); + + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => { + const dropdown = screen.getByRole("listbox"); + expect(dropdown).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option2` + ); + }); + + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => { + const dropdown = screen.getByRole("listbox"); + expect(dropdown).toHaveAttribute( + "aria-activedescendant", + `${dropdownId}-option-option3` + ); + }); + + await userEvent.keyboard("{ArrowUp}"); + await waitFor(() => { + const dropdown = screen.getByRole("listbox"); + expect(dropdown).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,6 +64,10 @@ export const DropDownOptionList = ({ return ( Date: Sat, 21 Sep 2024 21:15:30 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20default=20export=20=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wow-ui/src/components/DropDown/DropDownOptionList.tsx | 4 +++- packages/wow-ui/src/components/DropDown/DropDownWrapper.tsx | 4 +++- .../wow-ui/src/components/DropDown/context/DropDownContext.ts | 4 ++-- packages/wow-ui/src/components/DropDown/index.tsx | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx b/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx index 8af45ec0..2886e3fc 100644 --- a/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx +++ b/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx @@ -14,7 +14,7 @@ import { useDropDownContext } from "./context/DropDownContext"; interface DropDownWrapperProps extends PropsWithChildren { hasCustomTrigger?: boolean; } -export const DropDownOptionList = ({ +const DropDownOptionList = ({ children, hasCustomTrigger, }: DropDownWrapperProps) => { @@ -85,6 +85,8 @@ export const DropDownOptionList = ({ ); }; +export default DropDownOptionList; + const dropdownContentStyle = cva({ base: { position: "absolute", diff --git a/packages/wow-ui/src/components/DropDown/DropDownWrapper.tsx b/packages/wow-ui/src/components/DropDown/DropDownWrapper.tsx index 9a124160..291cbcdf 100644 --- a/packages/wow-ui/src/components/DropDown/DropDownWrapper.tsx +++ b/packages/wow-ui/src/components/DropDown/DropDownWrapper.tsx @@ -11,7 +11,7 @@ interface DropDownWrapperProps extends PropsWithChildren { className?: DropDownProps["className"]; hasCustomTrigger?: boolean; } -export const DropDownWrapper = ({ +const DropDownWrapper = ({ children, hasCustomTrigger, ...rest @@ -43,3 +43,5 @@ export const DropDownWrapper = ({ ); }; + +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 드롭다운을 열기 위한 외부 트리거 요소입니다. From d9f3b8946d4101b41c7a19f637044154de7fbbe9 Mon Sep 17 00:00:00 2001 From: SeieunYoo Date: Sat, 21 Sep 2024 22:34:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20aria=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=86=8D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EB=B9=BC?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DropDown/DropDownOptionList.tsx | 10 +++--- .../components/DropDown/DropDownTrigger.tsx | 36 +++++++++++++------ .../components/DropDown/DropDownWrapper.tsx | 1 - 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx b/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx index 2886e3fc..170a0181 100644 --- a/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx +++ b/packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx @@ -64,15 +64,11 @@ 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,24 @@ 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-labeledby": `${dropdownId}-label`, + }), + }; + if (trigger) { return cloneElement(trigger, { onClick: toggleDropdown, - "aria-expanded": open, - "aria-haspopup": "true", - id: `${dropdownId}-trigger`, - "aria-controls": `${dropdownId}`, + ...commonProps, }); } @@ -57,16 +74,15 @@ const DropDownTrigger = ({ {label && ( {label} )} Date: Sun, 22 Sep 2024 15:43:59 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20aria=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DropDown/DropDown.test.tsx | 24 ++++++++----------- .../components/DropDown/DropDownTrigger.tsx | 11 +++++---- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/wow-ui/src/components/DropDown/DropDown.test.tsx b/packages/wow-ui/src/components/DropDown/DropDown.test.tsx index 97a1bffe..27ad15e9 100644 --- a/packages/wow-ui/src/components/DropDown/DropDown.test.tsx +++ b/packages/wow-ui/src/components/DropDown/DropDown.test.tsx @@ -54,12 +54,8 @@ describe("DropDown component", () => { placeholder: "Please select", }); - const dropdownButton = screen.getByRole("button", { - name: "Option 2 down-arrow icon", - }); - expect(dropdownButton).toBeInTheDocument(); - expect(dropdownButton).toHaveAttribute("id", `${dropdownId}-trigger`); - expect(dropdownButton).toHaveTextContent("Option 2"); + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveTextContent("Option 2"); }); it("should render the trigger button", async () => { @@ -159,8 +155,8 @@ describe("external control and events", () => { await userEvent.keyboard("{ArrowDown}"); await waitFor(() => { - const dropdown = screen.getByRole("listbox"); - expect(dropdown).toHaveAttribute( + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( "aria-activedescendant", `${dropdownId}-option-option1` ); @@ -168,8 +164,8 @@ describe("external control and events", () => { await userEvent.keyboard("{ArrowDown}"); await waitFor(() => { - const dropdown = screen.getByRole("listbox"); - expect(dropdown).toHaveAttribute( + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( "aria-activedescendant", `${dropdownId}-option-option2` ); @@ -177,8 +173,8 @@ describe("external control and events", () => { await userEvent.keyboard("{ArrowDown}"); await waitFor(() => { - const dropdown = screen.getByRole("listbox"); - expect(dropdown).toHaveAttribute( + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( "aria-activedescendant", `${dropdownId}-option-option3` ); @@ -186,8 +182,8 @@ describe("external control and events", () => { await userEvent.keyboard("{ArrowUp}"); await waitFor(() => { - const dropdown = screen.getByRole("listbox"); - expect(dropdown).toHaveAttribute( + const dropdownTrigger = screen.getByRole("combobox"); + expect(dropdownTrigger).toHaveAttribute( "aria-activedescendant", `${dropdownId}-option-option2` ); diff --git a/packages/wow-ui/src/components/DropDown/DropDownTrigger.tsx b/packages/wow-ui/src/components/DropDown/DropDownTrigger.tsx index d0974dad..85e75ad8 100644 --- a/packages/wow-ui/src/components/DropDown/DropDownTrigger.tsx +++ b/packages/wow-ui/src/components/DropDown/DropDownTrigger.tsx @@ -57,9 +57,13 @@ const DropDownTrigger = ({ ...(focusedValue && { "aria-activedescendant": `${dropdownId}-option-${focusedValue}`, }), - ...(label && { - "aria-labeledby": `${dropdownId}-label`, - }), + ...(label + ? { + "aria-labelledby": `${dropdownId}-label`, + } + : { + "aria-label": `dropdown-open`, + }), }; if (trigger) { @@ -85,7 +89,6 @@ const DropDownTrigger = ({ alignItems="center" cursor="pointer" display="flex" - id={`${dropdownId}-trigger`} justifyContent="space-between" outline="none" type="button"