Skip to content

Commit

Permalink
[Feature] DropDown 테스트 코드 작성 & 접근성 aria 속성 추가 (#166)
Browse files Browse the repository at this point in the history
* feat: dropdown aria 속성 추가, test 코드 추가

* chore: default export  하는 것으로 수정

* chore: aria 속성 위치 수정 및 공통 속성으로 빼기

* chore: aria 속성 변경에 따른 테스트 코드 수정
  • Loading branch information
SeieunYoo authored Oct 21, 2024
1 parent 772a6d8 commit a3ab726
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 22 deletions.
203 changes: 203 additions & 0 deletions packages/wow-ui/src/components/DropDown/DropDown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DropDown {...props} id={dropdownId}>
{options.map((option) => (
<DropDownOption
key={option.value}
text={option.text}
value={option.value}
/>
))}
</DropDown>
);

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: <button>Open Dropdown</button> });

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 (
<DropDown
id={dropdownId}
placeholder="Please select"
value={selectedValue}
onChange={handleChange}
>
{options.map((option) => (
<DropDownOption
key={option.value}
text={option.text}
value={option.value}
/>
))}
</DropDown>
);
};

render(<ControlledDropDown />);

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 (
<DropDown
id={dropdownId}
placeholder="Please select"
value={selectedValue}
onChange={handleChange}
>
{options.map((option) => (
<DropDownOption
key={option.value}
text={option.text}
value={option.value}
/>
))}
</DropDown>
);
};

render(<ControlledDropDown />);
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");
});
});
});
1 change: 1 addition & 0 deletions packages/wow-ui/src/components/DropDown/DropDownOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const DropDownOption = forwardRef<HTMLLIElement, DropDownOptionProps>(

return (
<styled.li
aria-selected={isSelected}
id={`${dropdownId}-option-${value}`}
ref={ref}
role="option"
Expand Down
14 changes: 9 additions & 5 deletions packages/wow-ui/src/components/DropDown/DropDownOptionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import { useDropDownContext } from "./context/DropDownContext";
interface DropDownWrapperProps extends PropsWithChildren {
hasCustomTrigger?: boolean;
}
export const DropDownOptionList = ({
const DropDownOptionList = ({
children,
hasCustomTrigger,
}: DropDownWrapperProps) => {
const { open, setFocusedValue, focusedValue, handleSelect } =
const { open, setFocusedValue, focusedValue, handleSelect, dropdownId } =
useDropDownContext();
const itemMap = useCollection();
const listRef = useRef<HTMLUListElement>(null);
Expand Down Expand Up @@ -64,11 +64,11 @@ export const DropDownOptionList = ({

return (
<styled.ul
display="flex"
flexDirection="column"
aria-hidden={!open}
aria-labelledby={`${dropdownId}-trigger`}
id={`${dropdownId}-option-list`}
ref={listRef}
role="listbox"
style={{ visibility: open ? "visible" : "hidden" }}
tabIndex={0}
visibility={open ? "visible" : "hidden"}
className={dropdownContentStyle({
Expand All @@ -81,8 +81,12 @@ export const DropDownOptionList = ({
);
};

export default DropDownOptionList;

const dropdownContentStyle = cva({
base: {
display: "flex",
flexDirection: "column",
position: "absolute",
outline: "none",
top: "calc(100% + 0.5rem)",
Expand Down
41 changes: 30 additions & 11 deletions packages/wow-ui/src/components/DropDown/DropDownTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { cva } from "@styled-system/css";
import { styled } from "@styled-system/jsx";
import type { KeyboardEvent } from "react";
import type { ButtonHTMLAttributes, KeyboardEvent } from "react";
import { cloneElement, useCallback } from "react";
import { DownArrow } from "wowds-icons";

Expand All @@ -23,8 +23,14 @@ const DropDownTrigger = ({
trigger,
}: DropDownTriggerProps) => {
const itemMap = useCollection();
const { open, selectedValue, setOpen, setFocusedValue, dropdownId } =
useDropDownContext();
const {
open,
selectedValue,
focusedValue,
setOpen,
setFocusedValue,
dropdownId,
} = useDropDownContext();

const selectedText = itemMap.get(selectedValue);

Expand All @@ -42,13 +48,28 @@ const DropDownTrigger = ({
[toggleDropdown]
);

const commonProps: ButtonHTMLAttributes<HTMLButtonElement> = {
"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,
});
}

Expand All @@ -57,19 +78,17 @@ const DropDownTrigger = ({
{label && (
<styled.span
color={open ? "primary" : selectedValue ? "textBlack" : "sub"}
id={`${dropdownId}-label`}
textStyle="label2"
>
{label}
</styled.span>
)}
<styled.button
{...commonProps}
alignItems="center"
aria-controls={dropdownId}
aria-expanded={open}
aria-haspopup={true}
cursor="pointer"
display="flex"
id={`${dropdownId}-trigger`}
justifyContent="space-between"
outline="none"
type="button"
Expand Down
5 changes: 3 additions & 2 deletions packages/wow-ui/src/components/DropDown/DropDownWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface DropDownWrapperProps extends PropsWithChildren {
className?: DropDownProps["className"];
hasCustomTrigger?: boolean;
}
export const DropDownWrapper = ({
const DropDownWrapper = ({
children,
hasCustomTrigger,
...rest
Expand All @@ -27,7 +27,6 @@ export const DropDownWrapper = ({

return (
<Flex
aria-labelledby={`${dropdownId}-trigger`}
cursor="pointer"
direction="column"
gap="xs"
Expand All @@ -43,3 +42,5 @@ export const DropDownWrapper = ({
</Flex>
);
};

export default DropDownWrapper;
Original file line number Diff line number Diff line change
@@ -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<typeof useDropDownState> & { dropdownId: string }) | null
Expand Down
4 changes: 2 additions & 2 deletions packages/wow-ui/src/components/DropDown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 드롭다운을 열기 위한 외부 트리거 요소입니다.
Expand Down

0 comments on commit a3ab726

Please sign in to comment.