diff --git a/packages/scripts/generateBuildConfig.ts b/packages/scripts/generateBuildConfig.ts index 55749eb9..ef21cba9 100644 --- a/packages/scripts/generateBuildConfig.ts +++ b/packages/scripts/generateBuildConfig.ts @@ -23,9 +23,10 @@ const generateExports = (files: string[]) => { for (const file of files) { const filePath = `./${file}`; const distPath = `./dist/${file}`; + const typePath = `./dist/components/${file}`; exportsObj[filePath] = { - types: `${distPath}/index.d.ts`, + types: `${typePath}/index.d.ts`, require: `${distPath}.cjs`, import: `${distPath}.js`, }; diff --git a/packages/wow-ui/package.json b/packages/wow-ui/package.json index 920f10dd..29c74df8 100644 --- a/packages/wow-ui/package.json +++ b/packages/wow-ui/package.json @@ -21,24 +21,24 @@ "exports": { "./styles.css": "./dist/styles.css", "./Box": { - "types": "./dist/Box/index.d.ts", + "types": "./dist/components/Box/index.d.ts", "require": "./dist/Box.cjs", "import": "./dist/Box.js" }, "./Button": { - "types": "./dist/Button/index.d.ts", + "types": "./dist/components/Button/index.d.ts", "require": "./dist/Button.cjs", "import": "./dist/Button.js" }, - "./Toggle": { - "types": "./dist/components/Toggle/index.d.ts", - "require": "./dist/Toggle.cjs", - "import": "./dist/Toggle.js" - }, "./Switch": { - "types": "./dist/Switch/index.d.ts", + "types": "./dist/components/Switch/index.d.ts", "require": "./dist/Switch.cjs", "import": "./dist/Switch.js" + }, + "./SwitchGroup": { + "types": "./dist/components/SwitchGroup/index.d.ts", + "require": "./dist/SwitchGroup.cjs", + "import": "./dist/SwitchGroup.js" } }, "keywords": [], diff --git a/packages/wow-ui/rollup.config.js b/packages/wow-ui/rollup.config.js index 04df3ac6..bfcf4ef3 100644 --- a/packages/wow-ui/rollup.config.js +++ b/packages/wow-ui/rollup.config.js @@ -22,6 +22,7 @@ export default { Box: "./src/components/Box", Button: "./src/components/Button", Switch: "./src/components/Switch", + SwitchGroup: "./src/components/SwitchGroup", }, output: [ { diff --git a/packages/wow-ui/src/components/Switch/Switch.stories.tsx b/packages/wow-ui/src/components/Switch/Switch.stories.tsx index 7aca484d..6a5a89dd 100644 --- a/packages/wow-ui/src/components/Switch/Switch.stories.tsx +++ b/packages/wow-ui/src/components/Switch/Switch.stories.tsx @@ -58,9 +58,9 @@ const meta = { table: { type: { summary: "() => void" }, defaultValue: { summary: null }, - control: { - type: "function", - }, + }, + control: { + type: "function", }, }, onClick: { @@ -68,9 +68,9 @@ const meta = { table: { type: { summary: "() => void" }, defaultValue: { summary: null }, - control: { - type: "function", - }, + }, + control: { + type: "function", }, }, onKeyDown: { @@ -79,9 +79,9 @@ const meta = { table: { type: { summary: "() => void" }, defaultValue: { summary: null }, - control: { - type: "function", - }, + }, + control: { + type: "function", }, }, }, diff --git a/packages/wow-ui/src/components/Switch/Switch.test.tsx b/packages/wow-ui/src/components/Switch/Switch.test.tsx index 35fdffca..013584ce 100644 --- a/packages/wow-ui/src/components/Switch/Switch.test.tsx +++ b/packages/wow-ui/src/components/Switch/Switch.test.tsx @@ -3,7 +3,7 @@ import fireEvent from "@testing-library/user-event"; import Switch from "@/components/Switch"; -describe("toggle", () => { +describe("switch", () => { let rendered: RenderResult; beforeEach(() => { diff --git a/packages/wow-ui/src/components/Switch/index.tsx b/packages/wow-ui/src/components/Switch/index.tsx index bd66913d..e7291ced 100644 --- a/packages/wow-ui/src/components/Switch/index.tsx +++ b/packages/wow-ui/src/components/Switch/index.tsx @@ -83,21 +83,20 @@ const Switch = forwardRef( }; return ( - + void" }, + defaultValue: { summary: null }, + }, + control: { + type: "function", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: { + children: ( + <> + + + + + ), + }, +}; + +const ControlledSwitchGroup = () => { + const [isCheckedState, setIsCheckedState] = useState([ + false, + true, + false, + ]); + + const handleChange = (index: number) => { + setIsCheckedState((prev) => + prev.map((prevState, i) => (index === i ? !prevState : prevState)) + ); + }; + + return ( + + + + + + ); +}; + +export const ControlledState: Story = { + render: () => , + args: { + children: null, + }, +}; diff --git a/packages/wow-ui/src/components/SwitchGroup/SwitchGroup.test.tsx b/packages/wow-ui/src/components/SwitchGroup/SwitchGroup.test.tsx new file mode 100644 index 00000000..52d4cbc8 --- /dev/null +++ b/packages/wow-ui/src/components/SwitchGroup/SwitchGroup.test.tsx @@ -0,0 +1,110 @@ +import type { RenderResult } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; +import fireEvent from "@testing-library/user-event"; + +import Switch from "@/components/Switch"; +import SwitchGroup from "@/components/SwitchGroup"; + +describe("switch group", () => { + let rendered: RenderResult; + + beforeEach(() => { + rendered = render( + + + + + + ); + }); + + it("should render switch components with its own state", () => { + const switchComponents = rendered.getAllByRole("checkbox"); + + expect(switchComponents[0]).toHaveAttribute("aria-checked", "false"); + expect(switchComponents[1]).toHaveAttribute("aria-disabled", "true"); + expect(switchComponents[2]).toHaveAttribute("aria-checked", "false"); + }); +}); + +describe("with external value provided", () => { + it("should render switch components with external value provided", () => { + const rendered = render( + + + + + + ); + const switchComponents = rendered.getAllByRole("checkbox"); + + expect(switchComponents[0]).toHaveAttribute("aria-checked", "true"); + expect(switchComponents[1]).toHaveAttribute("aria-checked", "false"); + expect(switchComponents[2]).toHaveAttribute("aria-checked", "true"); + }); + + it("should render switch components with default unchecked value provided", () => { + const rendered = render( + + + + + + ); + const switchComponents = rendered.getAllByRole("checkbox"); + + expect(switchComponents[0]).toHaveAttribute("aria-checked", "false"); + expect(switchComponents[1]).toHaveAttribute("aria-checked", "false"); + expect(switchComponents[2]).toHaveAttribute("aria-checked", "false"); + }); + + it("should toggle state when onChange event fired", async () => { + const checkedStates = [true, false, true]; + const handleChange = jest.fn((index: number) => { + checkedStates[index] = !checkedStates[index]; + }); + const rendered = render( + + + + + + ); + const switchComponents = rendered.getAllByRole("checkbox"); + + fireEvent.click(switchComponents[0]!); + fireEvent.click(switchComponents[1]!); + fireEvent.click(switchComponents[2]!); + + await waitFor(() => { + expect(checkedStates[0]).toBe(false); + expect(checkedStates[1]).toBe(true); + expect(checkedStates[2]).toBe(false); + }); + }); + + it("should not toggle state when toggle is disabled", async () => { + const checkedStates = [true, false, true]; + const handleChange = jest.fn((index: number) => { + checkedStates[index] = !checkedStates[index]; + }); + const rendered = render( + + + + + + ); + const switchComponents = rendered.getAllByRole("checkbox"); + + fireEvent.click(switchComponents[0]!); + fireEvent.click(switchComponents[1]!); + fireEvent.click(switchComponents[2]!); + + await waitFor(() => { + expect(checkedStates[0]).toBe(false); + expect(checkedStates[1]).toBe(false); + expect(checkedStates[2]).toBe(false); + }); + }); +}); diff --git a/packages/wow-ui/src/components/SwitchGroup/index.tsx b/packages/wow-ui/src/components/SwitchGroup/index.tsx new file mode 100644 index 00000000..e11ddd86 --- /dev/null +++ b/packages/wow-ui/src/components/SwitchGroup/index.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Flex } from "@styled-system/jsx"; +import type { ReactElement } from "react"; +import { cloneElement, memo, useMemo, useState } from "react"; + +import type { SwitchProps } from "@/components/Switch"; +import { extractChildrenArray } from "@/utils/extractChildrenArray"; + +/** + * @param {ReactElement[]} children 렌더링할 자식 요소. + * @param {boolean[]} [value] 외부에서 제어할 활성 상태. + * @param {(index: number) => void} [onChange] 외부 활성 상태가 변경될 때 호출될 콜백 함수. + */ +export interface SwitchGroupProps { + children: ReactElement[]; + value?: boolean[]; + onChange?: (index: number) => void; +} + +interface MemoizedSwitchProps extends SwitchProps { + children: ReactElement; + onChange: () => void; +} + +const MemoizedSwitch = memo(({ children, ...props }: MemoizedSwitchProps) => + cloneElement(children, { ...props }) +); + +const init = (value: boolean[], childrenArray: ReactElement[]): boolean[] => { + const initialValue = value.slice(0, childrenArray.length); + const remainingLength = childrenArray.length - initialValue.length; + + return [ + ...initialValue, + ...Array.from( + { length: remainingLength }, + (_, index) => + childrenArray[index]?.props.isChecked ?? + childrenArray[index]?.props.defaultChecked ?? + false + ), + ]; +}; + +const SwitchGroup = ({ children, value = [], onChange }: SwitchGroupProps) => { + const childrenArray = useMemo( + () => extractChildrenArray(children), + [children] + ); + + const [checkedStates, setCheckedStates] = useState(() => + init(value, childrenArray) + ); + const [disabledStates] = useState(() => + childrenArray.map((child) => !!child.props.isDisabled) + ); + + const handleChange = (index: number) => { + if (!disabledStates[index]) { + setCheckedStates((prevStates) => + prevStates.map((prevState, i) => (i === index ? !prevState : prevState)) + ); + onChange?.(index); + } + }; + + return ( + + {childrenArray.map((children, index) => ( + handleChange(index)} + /> + ))} + + ); +}; + +export default SwitchGroup; diff --git a/packages/wow-ui/src/utils/.gitkeep b/packages/wow-ui/src/utils/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/wow-ui/src/utils/extractChildrenArray.ts b/packages/wow-ui/src/utils/extractChildrenArray.ts new file mode 100644 index 00000000..879cb402 --- /dev/null +++ b/packages/wow-ui/src/utils/extractChildrenArray.ts @@ -0,0 +1,13 @@ +import type { ReactElement, ReactNode } from "react"; +import { Children, Fragment, isValidElement } from "react"; + +export const extractChildrenArray = (children: ReactNode): ReactElement[] => { + const childrenArray = Children.toArray(children); + const firstElement = childrenArray[0]; + + if (isValidElement(firstElement) && firstElement.type === Fragment) { + return Children.toArray(firstElement.props.children) as ReactElement[]; + } else { + return Children.toArray(childrenArray) as ReactElement[]; + } +};