From 3530dbc69e10b657743b78c51cac265c60b442d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=84=9C=ED=98=84?= Date: Mon, 24 Jun 2024 00:38:40 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20MultiGroup=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EB=B0=9C=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Squashed commit of refactor/switch-component * chore: package.json 수정 * chore: Story 템플릿 수정 * feat: MultiGroup 컴포넌트 구현 * chore: Checkbox 스토리 및 테스트 코드 수정 * chore: Switch 컴포넌트 스토리 및 테스트 수정 * feat: MultiGroup 컴포넌트 빌드 설정 적용 * feat: docs에 MultiGroup 컴포넌트 적용 * comment: jsdoc 추가 * docs: 스토리 코드 문서화 추가 * test: 테스트 코드 작성 * chore: 자잘한 수정사항 반영 * chore: useCheckedState 훅에서 disabled 상태 고려할 수 있도록 로직 개선 * chore: group context 상태 구조 분해 할당으로 가져오도록 수정 * chore: MultiGroup 잘못된 variant 수정 * chore: 상태값 네이밍 변경 * chore: 개별 스위치, 체크박스에는 value default 값 지정 * comment: gap, position 관련 주석 추가 * fix: 머지 에러 해결 --- apps/wow-docs/app/page.tsx | 18 +- packages/codegen/templates/Story.tsx.hbs | 2 +- packages/wow-ui/package.json | 5 + packages/wow-ui/rollup.config.js | 1 + .../components/Checkbox/Checkbox.stories.tsx | 12 +- .../src/components/Checkbox/Checkbox.test.tsx | 6 +- .../wow-ui/src/components/Checkbox/index.tsx | 12 +- .../MultiGroup/MultiGroup.stories.tsx | 322 ++++++++++++++++++ .../components/MultiGroup/MultiGroup.test.tsx | 297 ++++++++++++++++ .../MultiGroup/MultiGroupContext.ts | 30 ++ .../src/components/MultiGroup/index.tsx | 105 ++++++ .../src/components/Switch/Switch.stories.tsx | 18 +- .../src/components/Switch/Switch.test.tsx | 16 +- .../wow-ui/src/components/Switch/index.tsx | 20 +- packages/wow-ui/src/hooks/useCheckedState.ts | 54 ++- 15 files changed, 878 insertions(+), 40 deletions(-) create mode 100644 packages/wow-ui/src/components/MultiGroup/MultiGroup.stories.tsx create mode 100644 packages/wow-ui/src/components/MultiGroup/MultiGroup.test.tsx create mode 100644 packages/wow-ui/src/components/MultiGroup/MultiGroupContext.ts create mode 100644 packages/wow-ui/src/components/MultiGroup/index.tsx diff --git a/apps/wow-docs/app/page.tsx b/apps/wow-docs/app/page.tsx index 6c4546bc..14f502cf 100644 --- a/apps/wow-docs/app/page.tsx +++ b/apps/wow-docs/app/page.tsx @@ -1,5 +1,6 @@ import Checkbox from "wowds-ui/Checkbox"; import Chip from "wowds-ui/Chip"; +import MultiGroup from "wowds-ui/MultiGroup"; import RadioButton from "wowds-ui/RadioButton"; import RadioGroup from "wowds-ui/RadioGroup"; import Switch from "wowds-ui/Switch"; @@ -7,14 +8,25 @@ import Switch from "wowds-ui/Switch"; const Home = () => { return ( <> - + - - + + + + + + + + + + + + + ); }; diff --git a/packages/codegen/templates/Story.tsx.hbs b/packages/codegen/templates/Story.tsx.hbs index abb27659..f299b128 100644 --- a/packages/codegen/templates/Story.tsx.hbs +++ b/packages/codegen/templates/Story.tsx.hbs @@ -19,7 +19,7 @@ const meta = { }, argTypes: { props1: { - description: "props1은 default 값이 있는 필수적인 속성입니다."", + description: "props1은 default 값이 있는 필수적인 속성입니다.", table: { // type, 필수가 아닐 경우 required 삭제 type: { summary: "string", required: true }, diff --git a/packages/wow-ui/package.json b/packages/wow-ui/package.json index 82005b36..7dbf7dce 100644 --- a/packages/wow-ui/package.json +++ b/packages/wow-ui/package.json @@ -40,6 +40,11 @@ "require": "./dist/RadioGroup.cjs", "import": "./dist/RadioGroup.js" }, + "./MultiGroup": { + "types": "./dist/components/MultiGroup/index.d.ts", + "require": "./dist/MultiGroup.cjs", + "import": "./dist/MultiGroup.js" + }, "./Chip": { "types": "./dist/components/Chip/index.d.ts", "require": "./dist/Chip.cjs", diff --git a/packages/wow-ui/rollup.config.js b/packages/wow-ui/rollup.config.js index 905d9cb6..e16bdb25 100644 --- a/packages/wow-ui/rollup.config.js +++ b/packages/wow-ui/rollup.config.js @@ -24,6 +24,7 @@ export default { Switch: "./src/components/Switch", RadioButton: "./src/components/RadioGroup/RadioButton", RadioGroup: "./src/components/RadioGroup/RadioGroup", + MultiGroup: "./src/components/MultiGroup", Chip: "./src/components/Chip", Checkbox: "./src/components/Checkbox", Button: "./src/components/Button", diff --git a/packages/wow-ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/wow-ui/src/components/Checkbox/Checkbox.stories.tsx index 00d829bd..b69cb803 100644 --- a/packages/wow-ui/src/components/Checkbox/Checkbox.stories.tsx +++ b/packages/wow-ui/src/components/Checkbox/Checkbox.stories.tsx @@ -148,18 +148,21 @@ type Story = StoryObj; export const Default: Story = { args: { defaultChecked: false, + value: "checkbox", }, }; export const Checked: Story = { args: { defaultChecked: true, + value: "checkbox", }, }; export const Disabled: Story = { args: { disabled: true, + value: "checkbox", }, }; @@ -168,6 +171,7 @@ export const Vertical: Story = { checked: true, children: "string", position: "vertical", + value: "checkbox", }, }; @@ -176,6 +180,7 @@ export const Horizontal: Story = { checked: true, children: "string", position: "horizontal", + value: "checkbox", }, }; @@ -186,9 +191,14 @@ const ControlledCheckBox = () => { setIsChecked((prev) => !prev); }; - return ; + return ( + + ); }; export const ControlledState: Story = { render: () => , + args: { + value: "checkbox", + }, }; diff --git a/packages/wow-ui/src/components/Checkbox/Checkbox.test.tsx b/packages/wow-ui/src/components/Checkbox/Checkbox.test.tsx index 40aff07c..0cd5c543 100644 --- a/packages/wow-ui/src/components/Checkbox/Checkbox.test.tsx +++ b/packages/wow-ui/src/components/Checkbox/Checkbox.test.tsx @@ -7,7 +7,11 @@ import Checkbox from "./index"; describe("Checkbox component", () => { const renderCheckbox = (props: Partial = {}): RenderResult => { - return render(Text); + return render( + + Text + + ); }; test("toggles checked state when clicked", async () => { diff --git a/packages/wow-ui/src/components/Checkbox/index.tsx b/packages/wow-ui/src/components/Checkbox/index.tsx index 1703c216..48d5bf1a 100644 --- a/packages/wow-ui/src/components/Checkbox/index.tsx +++ b/packages/wow-ui/src/components/Checkbox/index.tsx @@ -18,6 +18,7 @@ import { useCheckedState } from "@/hooks"; * @param {boolean} [defaultChecked=false] 체크박스가 처음에 활성화되어 있는지 여부. * @param {boolean} [disabled=false] 체크박스가 비활성화되어 있는지 여부. * @param {boolean} [checked] 외부에서 제어할 활성 상태. + * @param {string} value 체크박스 값. * @param {() => void} [onChange] 외부 활성 상태가 변경될 때 호출되는 함수. * @param {() => void} [onClick] 체크박스 클릭 시 호출되는 함수. * @param {() => void} [onKeyDown] 체크박스에 포커스 됐을 때 엔터 키 또는 스페이스 바를 눌렀을 때 호출되는 함수. @@ -35,6 +36,7 @@ export interface CheckboxProps extends PropsWithChildren { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; + value?: string; onChange?: () => void; onClick?: () => void; onKeyDown?: () => void; @@ -50,8 +52,9 @@ const Checkbox = forwardRef( ( { defaultChecked = false, - disabled = false, + disabled: disabledProp = false, checked: checkedProp, + value = "checkbox", onClick, onChange, children, @@ -68,6 +71,7 @@ const Checkbox = forwardRef( const { checked, pressed, + disabled, handleClick, handleKeyDown, handleKeyUp, @@ -76,7 +80,8 @@ const Checkbox = forwardRef( } = useCheckedState({ defaultChecked, checked: checkedProp, - disabled, + disabled: disabledProp, + value, onChange, onClick, onKeyDown, @@ -112,7 +117,8 @@ const Checkbox = forwardRef( type: disabled ? "disabled" : checked ? "checked" : "default", })} {...inputProps} - onClick={handleClick} + value={value} + onClick={() => handleClick(value)} /> {checked && ( void" }, + defaultValue: { summary: null }, + }, + control: false, + }, + disabled: { + description: + "그룹 내 모든 체크박스 또는 스위치가 비활성화되어 있는지 여부입니다.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: false }, + }, + control: { + type: "boolean", + }, + }, + className: { + description: "그룹에 전달하는 커스텀 클래스입니다.", + table: { + type: { summary: "string" }, + defaultValue: { summary: null }, + }, + control: { + type: "text", + }, + }, + style: { + description: "그룹의 커스텀 스타일입니다.", + table: { + type: { summary: "CSSProperties" }, + defaultValue: { summary: null }, + }, + control: false, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const TextWithVerticalCheckboxMultiGroup: Story = { + render: () => ( + + + + + + + ), + args: { + children: <>, + variant: "checkbox", + }, +}; + +export const TextWithHorizontalCheckboxMultiGroup: Story = { + render: () => ( + + + + + + + ), + args: { + children: <>, + variant: "checkbox", + }, +}; + +export const TextWithHorizontalWithGapCheckboxMultiGroup: Story = { + render: () => ( + + + + + + + ), + args: { + children: <>, + variant: "checkbox", + }, +}; + +export const DisabledCheckboxMultiGroup = { + render: () => ( + + + + + + + ), + args: { + children: <>, + variant: "checkbox", + }, +}; + +export const WithDefaultValueCheckboxMultiGroup = { + render: () => ( + + + + + + + ), + args: { + children: <>, + variant: "checkbox", + }, +}; + +const ControlledCheckboxState = () => { + const [checked, setChecked] = useState(["checkbox1", "checkbox3"]); + + const handleChange = (value: string) => { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + }; + + return ( + + + + + + + ); +}; + +export const ControlledCheckboxMultiGroup: Story = { + render: () => , + args: { + children: <>, + variant: "switch", + }, +}; + +export const SwitchMultiGroup: Story = { + render: () => ( + + + + + + ), + args: { + children: <>, + variant: "switch", + }, +}; + +export const DisabledSwitchMultiGroup: Story = { + render: () => ( + + + + + + ), + args: { + children: <>, + variant: "switch", + }, +}; + +export const TextWithSwitchMultiGroup: Story = { + render: () => ( + + + + + + ), + args: { + children: <>, + variant: "switch", + }, +}; + +export const WithDefaultValueSwitchMultiGroup: Story = { + render: () => ( + + + + + + ), + args: { + children: <>, + variant: "switch", + }, +}; + +const ControlledSwitchState = () => { + const [checked, setChecked] = useState(["switch1", "switch2"]); + + const handleChange = (value: string) => { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + }; + + return ( + + + + + + ); +}; + +export const ControlledSwitchMultiGroup: Story = { + render: () => , + args: { + children: <>, + variant: "switch", + }, +}; diff --git a/packages/wow-ui/src/components/MultiGroup/MultiGroup.test.tsx b/packages/wow-ui/src/components/MultiGroup/MultiGroup.test.tsx new file mode 100644 index 00000000..2bde8262 --- /dev/null +++ b/packages/wow-ui/src/components/MultiGroup/MultiGroup.test.tsx @@ -0,0 +1,297 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import fireEvent from "@testing-library/user-event"; +import { useState } from "react"; + +import Checkbox from "@/components/Checkbox"; +import MultiGroup from "@/components/MultiGroup"; +import Switch from "@/components/Switch"; + +describe("multi group with checkbox", () => { + it("should render checkbox components with children", () => { + render( + + + + + + ); + const checkbox1 = screen.getByText("checkbox1"); + const checkbox2 = screen.getByText("checkbox1"); + const checkbox3 = screen.getByText("checkbox1"); + + expect(checkbox1).toBeInTheDocument(); + expect(checkbox2).toBeInTheDocument(); + expect(checkbox3).toBeInTheDocument(); + }); + + it("should render checkbox components with its own state", () => { + const rendered = render( + + + + + + ); + const checkboxes = rendered.getAllByRole("checkbox"); + + expect(checkboxes[0]).toHaveAttribute("aria-disabled", "false"); + expect(checkboxes[1]).toHaveAttribute("aria-disabled", "true"); + expect(checkboxes[2]).toHaveAttribute("aria-disabled", "false"); + + expect(checkboxes[0]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[1]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[2]).toHaveAttribute("aria-checked", "false"); + }); + + it("should render checkbox components with external value provided", async () => { + const rendered = render( + + + + + + ); + const checkboxes = rendered.getAllByRole("checkbox"); + + expect(checkboxes[0]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[1]).toHaveAttribute("aria-checked", "true"); + expect(checkboxes[2]).toHaveAttribute("aria-checked", "true"); + }); + + it("should render checkbox components with default value provided", () => { + const rendered = render( + + + + + + ); + const checkboxes = rendered.getAllByRole("checkbox"); + + expect(checkboxes[0]).toHaveAttribute("aria-checked", "true"); + expect(checkboxes[1]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[2]).toHaveAttribute("aria-checked", "true"); + }); + + it("should toggle state when onChange event fired", async () => { + const Component = () => { + const [checked, setChecked] = useState(["checkbox3"]); + const handleChange = (value: string) => { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + }; + + return ( + + + + + + ); + }; + const rendered = render(); + const checkboxes = rendered.getAllByRole("checkbox"); + + fireEvent.click(checkboxes[0]!); + fireEvent.click(checkboxes[1]!); + fireEvent.click(checkboxes[2]!); + + await waitFor(() => { + expect(checkboxes[0]).toHaveAttribute("aria-checked", "true"); + expect(checkboxes[1]).toHaveAttribute("aria-checked", "true"); + expect(checkboxes[2]).toHaveAttribute("aria-checked", "false"); + }); + }); + + it("should not toggle state when toggle is disabled", async () => { + const Component = () => { + const [checked, setChecked] = useState(["checkbox3"]); + const handleChange = (value: string) => { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + }; + + return ( + + + + + + ); + }; + const rendered = render(); + const checkboxes = rendered.getAllByRole("checkbox"); + + fireEvent.click(checkboxes[0]!); + fireEvent.click(checkboxes[1]!); + fireEvent.click(checkboxes[2]!); + + await waitFor(() => { + expect(checkboxes[0]).toHaveAttribute("aria-disabled", "true"); + expect(checkboxes[1]).toHaveAttribute("aria-disabled", "true"); + expect(checkboxes[2]).toHaveAttribute("aria-disabled", "true"); + + expect(checkboxes[0]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[1]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[2]).toHaveAttribute("aria-checked", "true"); + }); + }); +}); + +describe("multi group with switch", () => { + it("should render switch components with children", () => { + render( + + + + + + ); + const switch1 = screen.getByText("switch1"); + const switch2 = screen.getByText("switch2"); + const switch3 = screen.getByText("switch3"); + + expect(switch1).toBeInTheDocument(); + expect(switch2).toBeInTheDocument(); + expect(switch3).toBeInTheDocument(); + }); + + it("should render switch components with its own state", () => { + const rendered = render( + + + + + + ); + const switches = rendered.getAllByRole("checkbox"); + + expect(switches[0]).toHaveAttribute("aria-disabled", "false"); + expect(switches[1]).toHaveAttribute("aria-disabled", "true"); + expect(switches[2]).toHaveAttribute("aria-disabled", "false"); + + expect(switches[0]).toHaveAttribute("aria-checked", "false"); + expect(switches[1]).toHaveAttribute("aria-checked", "false"); + expect(switches[2]).toHaveAttribute("aria-checked", "false"); + }); + + it("should render switch components with external value provided", async () => { + const rendered = render( + + + + + + ); + const switches = rendered.getAllByRole("checkbox"); + + expect(switches[0]).toHaveAttribute("aria-checked", "false"); + expect(switches[1]).toHaveAttribute("aria-checked", "true"); + expect(switches[2]).toHaveAttribute("aria-checked", "true"); + }); + + it("should render switch components with default value provided", () => { + const rendered = render( + + + + + + ); + const switches = rendered.getAllByRole("checkbox"); + + expect(switches[0]).toHaveAttribute("aria-checked", "true"); + expect(switches[1]).toHaveAttribute("aria-checked", "false"); + expect(switches[2]).toHaveAttribute("aria-checked", "true"); + }); + + it("should toggle state when onChange event fired", async () => { + const Component = () => { + const [checked, setChecked] = useState(["switch3"]); + const handleChange = (value: string) => { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + }; + + return ( + + + + + + ); + }; + const rendered = render(); + const switches = rendered.getAllByRole("checkbox"); + + fireEvent.click(switches[0]!); + fireEvent.click(switches[1]!); + fireEvent.click(switches[2]!); + + await waitFor(() => { + expect(switches[0]).toHaveAttribute("aria-checked", "true"); + expect(switches[1]).toHaveAttribute("aria-checked", "true"); + expect(switches[2]).toHaveAttribute("aria-checked", "false"); + }); + }); + + it("should not toggle state when toggle is disabled", async () => { + const Component = () => { + const [checked, setChecked] = useState(["switch3"]); + const handleChange = (value: string) => { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + }; + + return ( + + + + + + ); + }; + const rendered = render(); + const switches = rendered.getAllByRole("checkbox"); + + fireEvent.click(switches[0]!); + fireEvent.click(switches[1]!); + fireEvent.click(switches[2]!); + + await waitFor(() => { + expect(switches[0]).toHaveAttribute("aria-disabled", "true"); + expect(switches[1]).toHaveAttribute("aria-disabled", "true"); + expect(switches[2]).toHaveAttribute("aria-disabled", "true"); + + expect(switches[0]).toHaveAttribute("aria-checked", "false"); + expect(switches[1]).toHaveAttribute("aria-checked", "false"); + expect(switches[2]).toHaveAttribute("aria-checked", "true"); + }); + }); +}); diff --git a/packages/wow-ui/src/components/MultiGroup/MultiGroupContext.ts b/packages/wow-ui/src/components/MultiGroup/MultiGroupContext.ts new file mode 100644 index 00000000..e25325c2 --- /dev/null +++ b/packages/wow-ui/src/components/MultiGroup/MultiGroupContext.ts @@ -0,0 +1,30 @@ +import { createContext } from "react"; + +import type { MultiGroupProps, VariantType } from "@/components/MultiGroup"; + +/** + * @description 여러 체크박스 또는 스위치 컴포넌트 사이 공유되는 MultiGroupContext의 속성을 정의합니다. + * + * @template T 체크박스 또는 스위치 타입. + * + * @param {string} [name] 그룹명. + * @param {string[]} [checked] 외부에서 제어할 활성 상태. + * @param {(value: string) => void} [onChange] 외부 활성 상태가 변경될 때 호출되는 함수. + * @param {boolean} [disabled] 그룹 내 모든 체크박스 또는 스위치가 비활성화되어 있는지 여부. + */ +export type MultiGroupContextProps = Pick< + MultiGroupProps, + "name" | "checked" | "onChange" | "disabled" +>; + +const defaultContextValue: MultiGroupContextProps = { + name: "MultiGroupName", + checked: undefined, + onChange: undefined, + disabled: false, +}; + +const MultiGroupContext = + createContext>(defaultContextValue); + +export default MultiGroupContext; diff --git a/packages/wow-ui/src/components/MultiGroup/index.tsx b/packages/wow-ui/src/components/MultiGroup/index.tsx new file mode 100644 index 00000000..9d7ac130 --- /dev/null +++ b/packages/wow-ui/src/components/MultiGroup/index.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Flex } from "@styled-system/jsx"; +import type { CSSProperties, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import MultiGroupContext from "@/components/MultiGroup/MultiGroupContext"; + +export type VariantType = "checkbox" | "switch"; +type PositionType = "vertical" | "horizontal"; + +/** + * @description 여러 체크박스 또는 스위치 컴포넌트를 하나의 그룹으로 묶는 컴포넌트입니다. + * + * @template T 체크박스 또는 스위치 타입. + * + * @param {T} variant 체크박스 또는 스위치 타입. + * @param {T extends "checkbox" ? PositionType : undefined} [position] 체크박스 그룹의 방향. (가로 또는 세로). + * @throws {Error} position은 variant가 "switch"인 경우 사용할 수 없습니다. + * @param {T extends "checkbox" ? number : undefined} [gap] 체크박스 사이의 간격. + * @throws {Error} gap은 variant가 "switch"인 경우 사용할 수 없습니다. + * @param {ReactNode} children 그룹 내에 포함될 체크박스 또는 스위치 컴포넌트들. + * @param {string} [name] 그룹명. + * @param {string[]} [defaultValue] 기본으로 선택된 값들의 배열. + * @param {string[]} [checked] 외부에서 제어할 활성 상태. + * @param {(value: string) => void} [onChange] 외부 활성 상태가 변경될 때 호출되는 함수. + * @param {boolean} [disabled] 그룹 내 모든 체크박스 또는 스위치가 비활성화되어 있는지 여부. + * @param {string} [className] 그룹에 전달하는 커스텀 클래스. + * @param {CSSProperties} [style] 그룹의 커스텀 스타일. + */ +export interface MultiGroupProps { + variant: T; + position?: T extends "checkbox" ? PositionType : undefined; + gap?: T extends "checkbox" ? number : undefined; + children: ReactNode; + name?: string; + defaultValue?: string[]; + checked?: string[]; + onChange?: (value: string) => void; + disabled?: boolean; + className?: string; + style?: CSSProperties; +} + +const MultiGroup = ({ + position, + gap, + children, + name, + defaultValue, + checked: checkedProp, + onChange, + disabled, + ...rest +}: MultiGroupProps) => { + const [checked, setChecked] = useState( + checkedProp || defaultValue || [] + ); + + useEffect(() => { + if (checkedProp !== undefined) { + setChecked(checkedProp); + } + }, [checkedProp]); + + const handleChange = useCallback( + (value: string) => { + if (onChange) { + onChange(value); + } else { + if (!checked.includes(value)) { + setChecked((prev) => [...prev, value]); + } else { + setChecked((prev) => prev.filter((item) => item !== value)); + } + } + }, + [checked, onChange] + ); + + const contextValue = useMemo( + () => ({ + name, + checked, + onChange: handleChange, + disabled, + }), + [name, checked, handleChange, disabled] + ); + + return ( + + + {children} + + + ); +}; + +export default MultiGroup; diff --git a/packages/wow-ui/src/components/Switch/Switch.stories.tsx b/packages/wow-ui/src/components/Switch/Switch.stories.tsx index 4ca0a70f..f95014b9 100644 --- a/packages/wow-ui/src/components/Switch/Switch.stories.tsx +++ b/packages/wow-ui/src/components/Switch/Switch.stories.tsx @@ -41,14 +41,14 @@ const meta = { type: "boolean", }, }, - text: { + label: { description: "스위치 오른쪽에 들어갈 텍스트입니다.", table: { type: { summary: "string" }, defaultValue: { summary: null }, }, control: { - type: "text", + type: "label", }, }, onChange: { @@ -136,24 +136,29 @@ export default meta; type Story = StoryObj; export const Primary: Story = { - args: {}, + args: { + value: "switch", + }, }; export const DefaultChecked: Story = { args: { defaultChecked: true, + value: "switch", }, }; export const Disabled: Story = { args: { disabled: true, + value: "switch", }, }; export const WithText: Story = { args: { - text: "Text", + label: "Label", + value: "switch", }, }; @@ -164,9 +169,12 @@ const ControlledSwitch = () => { setChecked((prev) => !prev); }; - return ; + return ; }; export const ControlledState: Story = { render: () => , + args: { + value: "switch", + }, }; diff --git a/packages/wow-ui/src/components/Switch/Switch.test.tsx b/packages/wow-ui/src/components/Switch/Switch.test.tsx index 89682194..5504494f 100644 --- a/packages/wow-ui/src/components/Switch/Switch.test.tsx +++ b/packages/wow-ui/src/components/Switch/Switch.test.tsx @@ -7,7 +7,7 @@ describe("toggle", () => { let rendered: RenderResult; beforeEach(() => { - rendered = render(); + rendered = render(); }); it("should render with attributes aria-checked to be false, aria-disabled to be false by default", () => { @@ -17,8 +17,8 @@ describe("toggle", () => { expect(switchComponent).toHaveAttribute("aria-disabled", "false"); }); - it("should render text", () => { - expect(rendered.getByText("Text")).toBeInTheDocument(); + it("should render label", () => { + expect(rendered.getByText("Label")).toBeInTheDocument(); }); it("should toggle state when onClick event is fired", async () => { @@ -61,7 +61,7 @@ describe("when defaultChecked is true", () => { let rendered: RenderResult; beforeEach(() => { - rendered = render(); + rendered = render(); }); it("should render with attributes aria-checked to be true, aria-disabled to be false", () => { @@ -76,7 +76,7 @@ describe("disabled", () => { let rendered: RenderResult; beforeEach(() => { - rendered = render(); + rendered = render(); }); it("should render with attributes aria-disabled to be true", () => { @@ -129,7 +129,7 @@ describe("external control and events", () => { let rendered: RenderResult; it("should fire external onClick event", async () => { - rendered = render(); + rendered = render(); const switchComponent = rendered.getByRole("checkbox"); const onClickHandler = jest.fn(); switchComponent.onclick = onClickHandler; @@ -142,7 +142,7 @@ describe("external control and events", () => { }); it("should fire external onKeyDown event", async () => { - rendered = render(); + rendered = render(); const switchComponent = rendered.getByRole("checkbox"); const onKeyDownHandler = jest.fn(); switchComponent.onkeydown = onKeyDownHandler; @@ -159,7 +159,7 @@ describe("external control and events", () => { const handleChange = () => { checked = !checked; }; - const rendered = render(); + const rendered = render(); const switchComponent = rendered.getByRole("checkbox"); switchComponent.onchange = handleChange; diff --git a/packages/wow-ui/src/components/Switch/index.tsx b/packages/wow-ui/src/components/Switch/index.tsx index 04953a1c..e8be8ee1 100644 --- a/packages/wow-ui/src/components/Switch/index.tsx +++ b/packages/wow-ui/src/components/Switch/index.tsx @@ -13,7 +13,8 @@ import useCheckedState from "@/hooks/useCheckedState"; * @param {boolean} [defaultChecked=false] 스위치가 처음에 활성화되어 있는지 여부. * @param {boolean} [disabled=false] 스위치가 비활성화되어 있는지 여부. * @param {boolean} [checked] 외부에서 제어할 활성 상태. - * @param {ReactNode} [text] 스위치 오른쪽에 들어갈 텍스트. + * @param {ReactNode} [label] 스위치 오른쪽에 들어갈 텍스트. + * @param {string} value 스위치 컴포넌트 값. * @param {() => void} [onChange] 외부 활성 상태가 변경될 때 호출되는 함수. * @param {() => void} [onClick] 스위치를 클릭했을 때 호출되는 함수. * @param {() => void} [onKeyDown] 스위치가 포커스됐을 때 엔터 키 또는 스페이스 바를 눌렀을 때 호출되는 함수. @@ -29,7 +30,8 @@ export interface SwitchProps { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; - text?: ReactNode; + label?: ReactNode; + value?: string; onChange?: () => void; onClick?: () => void; onKeyDown?: () => void; @@ -44,9 +46,10 @@ const Switch = forwardRef( ( { defaultChecked = false, - disabled = false, + disabled: disabledProp = false, checked: checkedProp, - text = "", + label = "", + value = "switch", onChange, onClick, onKeyDown, @@ -63,6 +66,7 @@ const Switch = forwardRef( const { checked, pressed, + disabled, handleClick, handleKeyDown, handleKeyUp, @@ -71,7 +75,8 @@ const Switch = forwardRef( } = useCheckedState({ defaultChecked, checked: checkedProp, - disabled, + disabled: disabledProp, + value, onChange, onClick, onKeyDown, @@ -100,12 +105,13 @@ const Switch = forwardRef( id={id} ref={ref} type="checkbox" - onClick={handleClick} + value={value} + onClick={() => handleClick(value)} {...inputProps} /> - {!!text && {text}} + {!!label && {label}} ); } diff --git a/packages/wow-ui/src/hooks/useCheckedState.ts b/packages/wow-ui/src/hooks/useCheckedState.ts index 5e32d7de..d85a8f99 100644 --- a/packages/wow-ui/src/hooks/useCheckedState.ts +++ b/packages/wow-ui/src/hooks/useCheckedState.ts @@ -1,10 +1,13 @@ import type { KeyboardEvent } from "react"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; + +import MultiGroupContext from "@/components/MultiGroup/MultiGroupContext"; interface CheckedStateProps { defaultChecked?: boolean; checked?: boolean; disabled?: boolean; + value: string; onChange?: () => void; onClick?: () => void; onKeyDown?: () => void; @@ -13,21 +16,49 @@ interface CheckedStateProps { const useCheckedState = ({ defaultChecked = false, checked: checkedProp, - disabled, + disabled: disabledProp, + value, onChange, onClick, onKeyDown, }: CheckedStateProps) => { - const [checked, setChecked] = useState( - checkedProp ? checkedProp : defaultChecked + const { + onChange: groupOnChange, + checked: groupCheckedValues, + disabled: groupDisabled, + } = useContext(MultiGroupContext); + + const groupCheckedValue = groupCheckedValues?.includes(value); + const disabled = groupDisabled || disabledProp || false; + + const [checkedValue, setCheckedValue] = useState( + groupCheckedValue || checkedProp || defaultChecked ); const [pressed, setPressed] = useState(false); useEffect(() => { - if (checkedProp !== undefined) { - setChecked(checkedProp); + if (groupCheckedValue !== undefined) { + setCheckedValue(groupCheckedValue); + } + }, [groupCheckedValue]); + + useEffect(() => { + if (groupCheckedValue === undefined && checkedProp !== undefined) { + setCheckedValue(checkedProp); } - }, [checkedProp]); + }, [checkedProp, groupCheckedValue]); + + const toggleCheckedState = (value: string) => { + if (disabled) return; + + if (groupOnChange) { + groupOnChange(value); + } else if (onChange) { + onChange(); + } else { + setCheckedValue((prev) => !prev); + } + }; const handleMouseDown = () => { if (!disabled) setPressed(true); @@ -37,8 +68,8 @@ const useCheckedState = ({ if (!disabled) setPressed(false); }; - const handleClick = () => { - onChange ? onChange() : setChecked((prev) => !prev); + const handleClick = (value: string) => { + toggleCheckedState(value); onClick?.(); }; @@ -52,14 +83,15 @@ const useCheckedState = ({ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPressed(true); - onChange ? onChange() : setChecked((prev) => !prev); + toggleCheckedState(value); onKeyDown?.(); } }; return { - checked, + checked: checkedValue, pressed, + disabled, handleClick, handleKeyDown, handleMouseDown,