Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] DropDown 테스트 코드 작성 & 접근성 aria 속성 추가 #166

Merged
merged 4 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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");
});
});
});
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
Loading