diff --git a/.changeset/chilly-pumas-search.md b/.changeset/chilly-pumas-search.md new file mode 100644 index 00000000..cbe67c1c --- /dev/null +++ b/.changeset/chilly-pumas-search.md @@ -0,0 +1,5 @@ +--- +"wowds-ui": patch +--- + +Tab 컴포넌트를 구현합니다. diff --git a/apps/wow-docs/app/page.tsx b/apps/wow-docs/app/page.tsx index d89460d2..a3e944cb 100644 --- a/apps/wow-docs/app/page.tsx +++ b/apps/wow-docs/app/page.tsx @@ -10,6 +10,10 @@ import RadioButton from "wowds-ui/RadioButton"; import RadioGroup from "wowds-ui/RadioGroup"; import SearchBar from "wowds-ui/SearchBar"; import Switch from "wowds-ui/Switch"; +import Tabs from "wowds-ui/Tabs"; +import TabsContent from "wowds-ui/TabsContent"; +import TabsItem from "wowds-ui/TabsItem"; +import TabsList from "wowds-ui/TabsList"; const Home = () => { return ( @@ -43,6 +47,22 @@ const Home = () => { + + + 첫번째첫번째첫번째첫번째 + 두 번째 + 세 번쨰 + + + 첫번째 탭 + + + 두번째 탭 + + + 세번째 탭 + + ); }; diff --git a/packages/wow-ui/package.json b/packages/wow-ui/package.json index 8d6e35a2..4d9a4f53 100644 --- a/packages/wow-ui/package.json +++ b/packages/wow-ui/package.json @@ -50,6 +50,26 @@ "require": "./dist/Tag.cjs", "import": "./dist/Tag.js" }, + "./Tabs": { + "types": "./dist/components/Tabs/index.d.ts", + "require": "./dist/Tabs.cjs", + "import": "./dist/Tabs.js" + }, + "./TabsContent": { + "types": "./dist/components/Tabs/TabsContent.d.ts", + "require": "./dist/TabsContent.cjs", + "import": "./dist/TabsContent.js" + }, + "./TabsItem": { + "types": "./dist/components/Tabs/TabsItem.d.ts", + "require": "./dist/TabsItem.cjs", + "import": "./dist/TabsItem.js" + }, + "./TabsList": { + "types": "./dist/components/Tabs/TabsList.d.ts", + "require": "./dist/TabsList.cjs", + "import": "./dist/TabsList.js" + }, "./Switch": { "types": "./dist/components/Switch/index.d.ts", "require": "./dist/Switch.cjs", diff --git a/packages/wow-ui/rollup.config.js b/packages/wow-ui/rollup.config.js index b0a3ef70..59f5108d 100644 --- a/packages/wow-ui/rollup.config.js +++ b/packages/wow-ui/rollup.config.js @@ -26,6 +26,10 @@ export default { TextField: "./src/components/TextField", TextButton: "./src/components/TextButton", Tag: "./src/components/Tag", + Tabs: "./src/components/Tabs", + TabsContent: "./src/components/Tabs/TabsContent", + TabsItem: "./src/components/Tabs/TabsItem", + TabsList: "./src/components/Tabs/TabsList", Switch: "./src/components/Switch", Stepper: "./src/components/Stepper", BlueSpinner: "./src/components/Spinner/BlueSpinner", diff --git a/packages/wow-ui/src/components/Checkbox/index.tsx b/packages/wow-ui/src/components/Checkbox/index.tsx index 6e8169b7..325cd822 100644 --- a/packages/wow-ui/src/components/Checkbox/index.tsx +++ b/packages/wow-ui/src/components/Checkbox/index.tsx @@ -116,7 +116,7 @@ const Checkbox = forwardRef( })} {...inputProps} value={value} - onClick={() => handleClick(value)} + onChange={() => handleClick(value)} /> {checked && ( ( ref={ref} type="checkbox" value={value} - onClick={() => handleClick(value)} + onChange={() => handleClick(value)} {...inputProps} /> diff --git a/packages/wow-ui/src/components/Tabs/Tabs.stories.tsx b/packages/wow-ui/src/components/Tabs/Tabs.stories.tsx new file mode 100644 index 00000000..23020448 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/Tabs.stories.tsx @@ -0,0 +1,196 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; + +import type { TabsProps } from "@/components/Tabs"; +import Tabs from "@/components/Tabs"; +import TabsContent from "@/components/Tabs/TabsContent"; +import TabsItem from "@/components/Tabs/TabsItem"; +import TabsList from "@/components/Tabs/TabsList"; + +const meta: Meta = { + title: "UI/Tabs", + component: Tabs, + tags: ["autodocs"], + parameters: { + componentSubtitle: "탭을 통해 콘텐츠를 선택할 수 있는 컴포넌트입니다.", + docs: { + description: { + component: + "TabsList 로 TabsItem을 감싸서 탭 트리거를 관리하고 TabsContent 로 탭 콘텐츠를 관리합니다.", + }, + }, + a11y: { + config: { + rules: [{ id: "color-contrast", enabled: false }], + }, + }, + }, + argTypes: { + children: { + description: "TabsList,TabsItem,TabsContent 를 children 으로 받습니다.", + table: { + type: { summary: "ReactNode" }, + }, + control: false, + }, + value: { + description: "현재 선택된 탭의 값을 나타냅니다.", + table: { + type: { summary: "string" }, + }, + control: "text", + }, + defaultValue: { + description: "초기 선택된 탭 값을 나타냅니다.", + table: { + type: { summary: "string" }, + }, + control: "text", + }, + onChange: { + description: "탭 값이 변경될 때 호출되는 함수입니다.", + table: { + type: { summary: "(value: string) => void" }, + }, + action: "changed", + }, + label: { + description: "각 탭을 구분할 수 있는 레이블입니다.", + table: { + type: { summary: "string" }, + }, + control: "text", + }, + style: { + description: "탭의 커스텀 스타일을 설정합니다.", + table: { + type: { summary: "CSSProperties" }, + defaultValue: { summary: "{}" }, + }, + control: false, + }, + className: { + description: "탭에 전달하는 커스텀 클래스를 설정합니다.", + table: { + type: { summary: "string" }, + }, + control: { + type: "text", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: { + children: ( + <> + + Tab 1 + Tab 2 + + Tab 1 Content + Tab 2 Content + + ), + defaultValue: "tab1", + }, + parameters: { + docs: { + description: { + story: "기본적인 탭 컴포넌트입니다. 탭 1과 탭 2가 제공됩니다.", + }, + }, + }, +}; + +export const WithDefaultValue: Story = { + args: { + children: ( + <> + + Tab 1 + Tab 2 + Tab 3 + + Tab 1 Content + Tab 2 Content + Tab 3 Content + + ), + defaultValue: "tab2", + }, + parameters: { + docs: { + description: { + story: + "초기 값으로 두 번째 탭이 선택된 상태로 시작하는 컴포넌트입니다.", + }, + }, + }, +}; + +const ControlledTabsComponent = () => { + const [selectedTab, setSelectedTab] = useState("tab1"); + + const handleChange = (value: string) => { + setSelectedTab(value); + }; + + return ( + + + Tab 1 + Tab 2 + Tab 3 + + Tab 1 Content + Tab 2 Content + Tab 3 Content + + ); +}; + +export const ControlledValue: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "외부 상태에 따라 제어되는 탭 컴포넌트입니다.", + }, + }, + }, +}; + +export const ManyTabs: Story = { + args: { + children: ( + <> + + {Array.from({ length: 10 }, (_, index) => ( + + Tab {index + 1} + + ))} + + {Array.from({ length: 10 }, (_, index) => ( + + Tab {index + 1} Content + + ))} + + ), + defaultValue: "tab1", + }, + parameters: { + docs: { + description: { + story: "여러 개의 탭을 가진 탭 컴포넌트입니다.", + }, + }, + }, +}; diff --git a/packages/wow-ui/src/components/Tabs/Tabs.test.tsx b/packages/wow-ui/src/components/Tabs/Tabs.test.tsx new file mode 100644 index 00000000..32b9c992 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/Tabs.test.tsx @@ -0,0 +1,87 @@ +import type { RenderResult } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; + +import type { TabsProps } from "@/components/Tabs"; +import Tabs from "@/components/Tabs"; +import TabsContent from "@/components/Tabs/TabsContent"; +import TabsItem from "@/components/Tabs/TabsItem"; +import TabsList from "@/components/Tabs/TabsList"; + +describe("Tabs component", () => { + const uncontrolledTabs = (props: Partial = {}): RenderResult => { + return render( + + + Tab 1 + Tab 2 + + Tab 1 Content + Tab 2 Content + + ); + }; + + const controlledTabs = (props: Partial = {}): RenderResult => { + return render( + + + Tab 1 + Tab 2 + + Tab 1 Content + Tab 2 Content + + ); + }; + test("renders correctly with default value", async () => { + const { getByText } = uncontrolledTabs(); + expect(getByText("Tab 1 Content")).toBeVisible(); + }); + + test("switches content when clicking on tab triggers", async () => { + const { getByText } = uncontrolledTabs(); + await userEvent.click(getByText("Tab 2")); + await waitFor(() => { + expect(getByText("Tab 2 Content")).toBeVisible(); + }); + }); + + test("calls onChange when the tab is changed", async () => { + const handleChange = jest.fn(); + const { getByText } = controlledTabs({ + value: "tab1", + onChange: handleChange, + }); + await userEvent.click(getByText("Tab 2")); + expect(handleChange).toHaveBeenCalledWith("tab2"); + }); + + test("can navigate between tabs using keyboard (ArrowRight)", async () => { + const { getByText } = uncontrolledTabs(); + const tab1 = getByText("Tab 1"); + const tab2 = getByText("Tab 2"); + + tab1.focus(); + await userEvent.keyboard("{ArrowRight}"); + expect(tab2).toHaveFocus(); + + await waitFor(() => { + expect(getByText("Tab 2 Content")).toBeVisible(); + }); + }); + + test("can navigate between tabs using keyboard (ArrowLeft)", async () => { + const { getByText } = uncontrolledTabs(); + const tab1 = getByText("Tab 1"); + const tab2 = getByText("Tab 2"); + + tab1.focus(); + await userEvent.keyboard("{ArrowLeft}"); + expect(tab2).toHaveFocus(); + + await waitFor(() => { + expect(getByText("Tab 2 Content")).toBeVisible(); + }); + }); +}); diff --git a/packages/wow-ui/src/components/Tabs/TabsContent.tsx b/packages/wow-ui/src/components/Tabs/TabsContent.tsx new file mode 100644 index 00000000..ce6234a5 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/TabsContent.tsx @@ -0,0 +1,43 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { forwardRef } from "react"; + +import type { DefaultProps } from "@/types/DefaultProps"; + +import { useTabsContext } from "./contexts/TabsContext"; + +/** + * @description TabsContent 컴포넌트는 각 Tab에 해당하는 콘텐츠입니다. + * @param {string} value - TabTrigger의 value와 일치하는 값입니다. + * @param {string} [className] - TabsContent에 전달할 커스텀 클래스. + * @param {CSSProperties} [style] - TabsContent에 전달할 커스텀 스타일. + * @param {ComponentPropsWithoutRef} rest 렌더링된 요소 또는 컴포넌트에 전달할 추가 props. + * @param {ComponentPropsWithRef["ref"]} ref 렌더링된 요소 또는 컴포넌트에 연결할 ref. + * @param {ReactNode} children - TabsContent의 자식 요소. + */ +interface TabsContentProps extends PropsWithChildren, DefaultProps { + value: string; +} + +const TabsContent = forwardRef( + ({ value: tabValue, children }: TabsContentProps, ref) => { + const { value, label } = useTabsContext(); + const selected = tabValue === value; + if (!selected) return null; + + return ( +
+ {children} +
+ ); + } +); + +export default TabsContent; diff --git a/packages/wow-ui/src/components/Tabs/TabsItem.tsx b/packages/wow-ui/src/components/Tabs/TabsItem.tsx new file mode 100644 index 00000000..92e15ca2 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/TabsItem.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { cva } from "@styled-system/css"; +import { clsx } from "clsx"; +import type { ButtonHTMLAttributes, PropsWithChildren } from "react"; +import { forwardRef, useEffect, useRef } from "react"; + +import { useMergeRefs } from "@/hooks/useMergeRefs"; +import type { DefaultProps } from "@/types/DefaultProps"; + +import { useCollectionContext } from "./contexts/CollectionContext"; +import { useTabsContext } from "./contexts/TabsContext"; + +/** + * @description TabsItem 컴포넌트는 각 Tab 컴포넌트입니다. + * @param {string} value - TabsContent의 value와 일치하는 값입니다. + * @param {ReactNode} children - TabsContent 자식 요소. + * @param {string} [className] - TabsItem에 전달할 커스텀 클래스. + * @param {CSSProperties} [style] - TabsItem에 전달할 커스텀 스타일. + * @param {ComponentPropsWithoutRef} rest 렌더링된 요소 또는 컴포넌트에 전달할 추가 props. + * @param {ComponentPropsWithRef["ref"]} ref 렌더링된 요소 또는 컴포넌트에 연결할 ref. + * @param {ReactNode} children - TabsItem의 자식 요소. + */ +interface TabsItemProps + extends PropsWithChildren, + DefaultProps, + ButtonHTMLAttributes { + value: string; +} + +const TabsItem = forwardRef( + ({ value, children, className, ...rest }: TabsItemProps, ref) => { + const { value: selectedValue, setSelectedValue, label } = useTabsContext(); + const selected = selectedValue === value; + + const handleClickTabTrigger = () => { + setSelectedValue(value); + }; + + const { values } = useCollectionContext(); + const internalButtonRef = useRef(null); + const buttonRef = useMergeRefs(ref, internalButtonRef); + + useEffect(() => { + values.add(value); + if (selected && internalButtonRef.current) { + internalButtonRef.current.focus(); + } + }, [values, selected, value]); + + return ( + + ); + } +); +export default TabsItem; + +const tabItemStyle = cva({ + base: { + textStyle: "label1", + paddingY: "sm", + paddingX: "14px", + borderBottom: "1px solid", + borderColor: "outline", + color: "sub", + outline: "none", + cursor: "pointer", + whiteSpace: "pre", + xsToSm: { + display: "flex", + flexGrow: 1, + justifyContent: "center", + alignItems: "center", + }, + }, + variants: { + type: { + selected: { + color: "primary", + borderColor: "primary", + }, + default: {}, + }, + }, +}); diff --git a/packages/wow-ui/src/components/Tabs/TabsList.tsx b/packages/wow-ui/src/components/Tabs/TabsList.tsx new file mode 100644 index 00000000..ed02fee9 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/TabsList.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { css } from "@styled-system/css"; +import { Flex } from "@styled-system/jsx"; +import { + type KeyboardEvent, + type PropsWithChildren, + useCallback, + useEffect, +} from "react"; + +import { useCollectionContext } from "./contexts/CollectionContext"; +import { useTabsContext } from "./contexts/TabsContext"; + +/** + * @description TabsList 컴포넌트는 TabsItem 컴포넌트들을 관리합니다. + */ +const TabsList = ({ children }: PropsWithChildren) => { + const { + label, + setSelectedValue, + value: selectedValue, + isControlled, + } = useTabsContext(); + + const { values } = useCollectionContext(); + + useEffect(() => { + if (!isControlled && !selectedValue && values.size > 0) { + setSelectedValue(values.values().next().value); + } + }, []); + + const updateFocusedValue = useCallback( + (direction: number) => { + const valuesArray = Array.from(values); + const currentIndex = valuesArray.indexOf(selectedValue ?? ""); + const nextIndex = + (currentIndex + direction + valuesArray.length) % valuesArray.length; + setSelectedValue(valuesArray[nextIndex] ?? ""); + }, + [setSelectedValue, selectedValue, values] + ); + + const handleArrowNavigation = useCallback( + (direction: number, event: KeyboardEvent) => { + updateFocusedValue(direction); + event.preventDefault(); + }, + [updateFocusedValue] + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const { key } = event; + + if (key === "ArrowRight") { + handleArrowNavigation(1, event); + } else if (key === "ArrowLeft") { + handleArrowNavigation(-1, event); + } + }, + [handleArrowNavigation] + ); + + return ( + + {children} + + ); +}; + +export default TabsList; + +const tabsListStyle = css({ + overflowX: "scroll", + scrollBehavior: "smooth", + _scrollbar: { + width: "65px", + height: "2px", + }, + _scrollbarThumb: { + width: "65px", + height: "2px", + borderRadius: "sm", + backgroundColor: "outline", + }, + _scrollbarTrack: { + marginTop: "2px", + marginBottom: "2px", + }, +}); diff --git a/packages/wow-ui/src/components/Tabs/contexts/CollectionContext.tsx b/packages/wow-ui/src/components/Tabs/contexts/CollectionContext.tsx new file mode 100644 index 00000000..f0ce0217 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/contexts/CollectionContext.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext } from "react"; + +import useSafeContext from "@/hooks/useSafeContext"; + +interface CollectionContextProps { + values: Set; +} + +const CollectionContext = createContext(null); + +export const useCollectionContext = () => { + const context = useSafeContext(CollectionContext); + return context; +}; + +export const CollectionProvider = ({ children }: PropsWithChildren) => { + return ( + () }}> + {children} + + ); +}; diff --git a/packages/wow-ui/src/components/Tabs/contexts/TabsContext.ts b/packages/wow-ui/src/components/Tabs/contexts/TabsContext.ts new file mode 100644 index 00000000..60259df4 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/contexts/TabsContext.ts @@ -0,0 +1,19 @@ +"use client"; + +import { createContext } from "react"; + +import useSafeContext from "@/hooks/useSafeContext"; + +interface TabsContextProps { + value: string; + setSelectedValue: (value: string) => void; + label?: string; + isControlled: boolean; +} + +export const TabsContext = createContext(null); + +export const useTabsContext = () => { + const context = useSafeContext(TabsContext); + return context; +}; diff --git a/packages/wow-ui/src/components/Tabs/index.tsx b/packages/wow-ui/src/components/Tabs/index.tsx new file mode 100644 index 00000000..50edce44 --- /dev/null +++ b/packages/wow-ui/src/components/Tabs/index.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { css } from "@styled-system/css"; +import { clsx } from "clsx"; +import type { PropsWithChildren } from "react"; +import { useRef, useState } from "react"; + +import { CollectionProvider } from "@/components/Tabs/contexts/CollectionContext"; +import { TabsContext } from "@/components/Tabs/contexts/TabsContext"; +import type { DefaultProps } from "@/types/DefaultProps"; + +/** + * @description Tabs 컴포넌트는 탭을 통해 콘텐츠를 선택할 수 있는 컴포넌트입니다. + * @param {string} [defaultValue] - 탭의 기본값입니다. + * @param {string} [value] - 현재 선택된 탭의 값입니다. + * @param {string} [label] - 각 탭을 구분할 수 있는 레이블입니다. + * @param {(value: string) => void} [onChange] - 탭이 변경될 때 호출되는 함수입니다. + * @param {CSSProperties} [style] - 탭 컴포넌트의 커스텀 스타일. + * @param {string} [className] - 탭 컴포넌트에 전달할 커스텀 클래스. + * @param {ReactNode} children - 탭의 자식 요소. + */ +export interface TabsProps extends PropsWithChildren, DefaultProps { + defaultValue?: string; + value?: string; + label?: string; + onChange?: (value: string) => void; +} +const Tabs = ({ + defaultValue, + value: valueProp, + label = "default-tab", + children, + onChange, + className, + style, +}: TabsProps) => { + const [selectedValue, setSelectedValue] = useState(defaultValue ?? ""); + const isControlled = useRef(valueProp !== undefined).current; + + const handleSelect = (selectedValue: string) => { + if (!isControlled) { + setSelectedValue(selectedValue); + return; + } + if (onChange) { + onChange(selectedValue); + } + }; + + return ( +
+ + {children} + +
+ ); +}; + +export default Tabs; + +const tabsContainerStyle = css({ + width: "100%", +}); diff --git a/packages/wow-ui/src/hooks/useMergeRefs.ts b/packages/wow-ui/src/hooks/useMergeRefs.ts new file mode 100644 index 00000000..11b535d8 --- /dev/null +++ b/packages/wow-ui/src/hooks/useMergeRefs.ts @@ -0,0 +1,13 @@ +import type { MutableRefObject, Ref } from "react"; + +export function useMergeRefs(...refs: (Ref | null)[]) { + return (value: T | null) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value); + } else if (ref !== null && typeof ref === "object") { + (ref as MutableRefObject).current = value; + } + }); + }; +} diff --git a/packages/wow-ui/src/types/DefaultProps.ts b/packages/wow-ui/src/types/DefaultProps.ts new file mode 100644 index 00000000..ace10e37 --- /dev/null +++ b/packages/wow-ui/src/types/DefaultProps.ts @@ -0,0 +1,11 @@ +import type { CSSProperties } from "react"; + +/** + * @description 컴포넌트에 전달한 기본적으로 전달한 props 입니다. + * @property {string} className - 컴포넌트에 전달할 커스텀 클래스명입니다. + * @property {CSSProperties} style - 컴포넌트에 전달할 커스텀 스타일입니다. + */ +export interface DefaultProps { + className?: string; + style?: CSSProperties; +}