From e9561a0f22f971accc412ed143c4e56877951896 Mon Sep 17 00:00:00 2001 From: YuGilJong <101111364+XionWCFM@users.noreply.github.com> Date: Sun, 22 Dec 2024 23:00:01 +0900 Subject: [PATCH] feat: Progress Development UI Packages (#27) --- packages/ui/README.md | 3 + packages/ui/package.json | 57 + packages/ui/postcss.config.mjs | 10 + packages/ui/src/box.test.tsx | 90 + packages/ui/src/box.tsx | 19 + packages/ui/src/center-stack.tsx | 18 + packages/ui/src/center.test.tsx | 16 + packages/ui/src/center.tsx | 13 + packages/ui/src/flex.tsx | 11 + packages/ui/src/index.ts | 12 + packages/ui/src/internal/is-string.tsx | 1 + packages/ui/src/internal/separated.tsx | 21 + packages/ui/src/justify-between.tsx | 11 + packages/ui/src/justify-end.tsx | 11 + packages/ui/src/list.tsx | 28 + packages/ui/src/polymorphics.ts | 16 + packages/ui/src/row.test.tsx | 68 + packages/ui/src/row.tsx | 29 + packages/ui/src/skeleton.test.tsx | 91 + packages/ui/src/skeleton.tsx | 25 + packages/ui/src/spacing.tsx | 11 + packages/ui/src/stack.test.tsx | 93 + packages/ui/src/stack.tsx | 11 + packages/ui/src/toggle-button-group.test.tsx | 86 + packages/ui/src/toggle-button-group.tsx | 66 + .../src/toggle-button-multiple-group.test.tsx | 122 ++ .../ui/src/toggle-button-multiple-group.tsx | 71 + packages/ui/src/toggle-button.test.tsx | 107 ++ packages/ui/src/toggle-button.tsx | 26 + packages/ui/style.css | 22 + packages/ui/tailwind.config.ts | 7 + packages/ui/tsconfig.json | 9 + packages/ui/tsconfig.lint.json | 8 + packages/ui/tsup.config.ts | 13 + packages/ui/vitest.config.mts | 14 + packages/ui/vitest.setup.tsx | 7 + packages/xds/package.json | 20 +- packages/xds/src/toggle-button-group.tsx | 15 - packages/xds/src/toggle-button.tsx | 20 - packages/xds/tsup.config.ts | 3 +- packages/xds/vitest.config.mts | 14 + packages/xds/vitest.setup.tsx | 7 + pnpm-lock.yaml | 1651 +++++++++-------- 43 files changed, 2179 insertions(+), 774 deletions(-) create mode 100644 packages/ui/README.md create mode 100644 packages/ui/package.json create mode 100644 packages/ui/postcss.config.mjs create mode 100644 packages/ui/src/box.test.tsx create mode 100644 packages/ui/src/box.tsx create mode 100644 packages/ui/src/center-stack.tsx create mode 100644 packages/ui/src/center.test.tsx create mode 100644 packages/ui/src/center.tsx create mode 100644 packages/ui/src/flex.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/internal/is-string.tsx create mode 100644 packages/ui/src/internal/separated.tsx create mode 100644 packages/ui/src/justify-between.tsx create mode 100644 packages/ui/src/justify-end.tsx create mode 100644 packages/ui/src/list.tsx create mode 100644 packages/ui/src/polymorphics.ts create mode 100644 packages/ui/src/row.test.tsx create mode 100644 packages/ui/src/row.tsx create mode 100644 packages/ui/src/skeleton.test.tsx create mode 100644 packages/ui/src/skeleton.tsx create mode 100644 packages/ui/src/spacing.tsx create mode 100644 packages/ui/src/stack.test.tsx create mode 100644 packages/ui/src/stack.tsx create mode 100644 packages/ui/src/toggle-button-group.test.tsx create mode 100644 packages/ui/src/toggle-button-group.tsx create mode 100644 packages/ui/src/toggle-button-multiple-group.test.tsx create mode 100644 packages/ui/src/toggle-button-multiple-group.tsx create mode 100644 packages/ui/src/toggle-button.test.tsx create mode 100644 packages/ui/src/toggle-button.tsx create mode 100644 packages/ui/style.css create mode 100644 packages/ui/tailwind.config.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/tsconfig.lint.json create mode 100644 packages/ui/tsup.config.ts create mode 100644 packages/ui/vitest.config.mts create mode 100644 packages/ui/vitest.setup.tsx delete mode 100644 packages/xds/src/toggle-button-group.tsx delete mode 100644 packages/xds/src/toggle-button.tsx create mode 100644 packages/xds/vitest.config.mts create mode 100644 packages/xds/vitest.setup.tsx diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..cae8127 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,3 @@ +# Xion Design System + +.. \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..1262d01 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,57 @@ +{ + "name": "@xionwcfm/ui", + "version": "0.0.1", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "style": "./dist/style.css", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup && pnpm run build:css", + "build:css": "tailwindcss -i ./style.css -o dist/style.css --minify --postcss", + "build:no": "tailwindcss -i ./style.css -o dist/style.css --postcss", + "test": "vitest", + "test:ci": "vitest run" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "catalog:", + "@types/react": "catalog:react18", + "@types/react-dom": "catalog:react18", + "@vitejs/plugin-react": "^4.3.4", + "@xionwcfm/typescript-config": "workspace:*", + "autoprefixer": "catalog:", + "happy-dom": "^15.11.7", + "postcss": "catalog:", + "react": "catalog:react18", + "tailwindcss": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vite": "^6.0.5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } +} diff --git a/packages/ui/postcss.config.mjs b/packages/ui/postcss.config.mjs new file mode 100644 index 0000000..1e2282a --- /dev/null +++ b/packages/ui/postcss.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + // "postcss-variable-compress": {}, + }, +}; + +export default config; diff --git a/packages/ui/src/box.test.tsx b/packages/ui/src/box.test.tsx new file mode 100644 index 0000000..4e67e03 --- /dev/null +++ b/packages/ui/src/box.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import "@testing-library/jest-dom"; +import { Box } from "./box"; + +describe("Box", () => { + it("should render as default div element", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element.tagName).toBe("DIV"); + }); + + it("should render as specified element", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element.tagName).toBe("SECTION"); + }); + + it("should apply custom className", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element).toHaveClass("custom-class"); + }); + + it("should forward ref correctly", () => { + function TestComponent() { + const ref = useRef(null); + return Content; + } + + render(); + const element = screen.getByText("Content"); + expect(element).toBeInTheDocument(); + expect(element.tagName).toBe("DIV"); + }); + + it("should handle click events", async () => { + const handleClick = vi.fn(); + render(Clickable); + + await userEvent.click(screen.getByText("Clickable")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should handle keyboard events", async () => { + const handleKeyDown = vi.fn(); + render( + + Interactive + , + ); + + const element = screen.getByText("Interactive"); + await userEvent.tab(); + expect(element).toHaveFocus(); + + await userEvent.keyboard("[Enter]"); + expect(handleKeyDown).toHaveBeenCalledTimes(1); + }); + + it("should handle aria attributes", () => { + render( + + Accessible Content + , + ); + + const element = screen.getByLabelText("Test label"); + expect(element).toHaveAttribute("aria-describedby", "description"); + }); + + it("should handle data attributes", () => { + render( + + Content + , + ); + + const element = screen.getByTestId("test-box"); + expect(element).toHaveAttribute("data-custom", "value"); + }); + + it("should handle style prop", () => { + render(Styled Content); + + const element = screen.getByText("Styled Content"); + expect(element).toHaveStyle({ backgroundColor: "red" }); + }); +}); diff --git a/packages/ui/src/box.tsx b/packages/ui/src/box.tsx new file mode 100644 index 0000000..7c28566 --- /dev/null +++ b/packages/ui/src/box.tsx @@ -0,0 +1,19 @@ +import { type ElementType, type ReactNode, forwardRef } from "react"; +import { type PolymorphicComponentProps, type PolymorphicRef } from "./polymorphics"; +export type BoxProps = PolymorphicComponentProps< + C, + { + className?: string; + children?: ReactNode; + } +>; + +export type BoxRef = PolymorphicRef; + +export const Box = forwardRef(function Box( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const Component = as ?? "div"; + return ; +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/center-stack.tsx b/packages/ui/src/center-stack.tsx new file mode 100644 index 0000000..c1dfb46 --- /dev/null +++ b/packages/ui/src/center-stack.tsx @@ -0,0 +1,18 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const CenterStack = forwardRef(function CenterStack( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ( + + ); +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/center.test.tsx b/packages/ui/src/center.test.tsx new file mode 100644 index 0000000..9be457f --- /dev/null +++ b/packages/ui/src/center.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { Center } from "./center"; + +describe("Center", () => { + it("should handle aria attributes", () => { + render( +
+ Accessible Content +
, + ); + + const element = screen.getByLabelText("Test label"); + expect(element).toHaveAttribute("aria-describedby", "description"); + }); +}); diff --git a/packages/ui/src/center.tsx b/packages/ui/src/center.tsx new file mode 100644 index 0000000..2bf2264 --- /dev/null +++ b/packages/ui/src/center.tsx @@ -0,0 +1,13 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const Center = forwardRef(function Center( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ( + + ); +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/flex.tsx b/packages/ui/src/flex.tsx new file mode 100644 index 0000000..2f13de6 --- /dev/null +++ b/packages/ui/src/flex.tsx @@ -0,0 +1,11 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const Flex = forwardRef(function Flex( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ; +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..f567627 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,12 @@ +import { Box } from "./box"; +import { Center } from "./center"; +import { CenterStack } from "./center-stack"; +import { Flex } from "./flex"; +import { JustifyBetween } from "./justify-between"; +import { JustifyEnd } from "./justify-end"; +import { List } from "./list"; +import { Row } from "./row"; +import { Skeleton } from "./skeleton"; +import { Spacing } from "./spacing"; +import { Stack } from "./stack"; +import { ToggleButton } from "./toggle-button"; diff --git a/packages/ui/src/internal/is-string.tsx b/packages/ui/src/internal/is-string.tsx new file mode 100644 index 0000000..ee860f5 --- /dev/null +++ b/packages/ui/src/internal/is-string.tsx @@ -0,0 +1 @@ +export const isString = (value: unknown): value is string => typeof value === "string"; diff --git a/packages/ui/src/internal/separated.tsx b/packages/ui/src/internal/separated.tsx new file mode 100644 index 0000000..645f567 --- /dev/null +++ b/packages/ui/src/internal/separated.tsx @@ -0,0 +1,21 @@ +import { Children, Fragment, type PropsWithChildren, type ReactNode, isValidElement } from "react"; + +interface Props extends PropsWithChildren { + with: ReactNode; +} + +export function Separated({ children, with: separator }: Props) { + const childrenArray = Children.toArray(children).filter(isValidElement); + const childrenLength = childrenArray.length; + + return ( + <> + {childrenArray.map((child, i) => ( + + {child} + {i + 1 !== childrenLength ? separator : null} + + ))} + + ); +} diff --git a/packages/ui/src/justify-between.tsx b/packages/ui/src/justify-between.tsx new file mode 100644 index 0000000..7738384 --- /dev/null +++ b/packages/ui/src/justify-between.tsx @@ -0,0 +1,11 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const JustifyBetween = forwardRef(function JustifyBetween( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ; +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/justify-end.tsx b/packages/ui/src/justify-end.tsx new file mode 100644 index 0000000..d418a6e --- /dev/null +++ b/packages/ui/src/justify-end.tsx @@ -0,0 +1,11 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const JustifyEnd = forwardRef(function JustifyEnd( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ; +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/list.tsx b/packages/ui/src/list.tsx new file mode 100644 index 0000000..2a519b6 --- /dev/null +++ b/packages/ui/src/list.tsx @@ -0,0 +1,28 @@ +import { Children, type ElementType, type ReactNode, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; +import { Separated } from "./internal/separated"; + +type ListProps = { + fallback?: ReactNode; + with?: ReactNode; +}; + +export const List = forwardRef(function List( + { as, className, children, fallback, ...rest }: BoxProps & ListProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + const child = Children.toArray(children); + const isEmpty = child.length === 0; + + if (isEmpty) { + return <>{fallback}; + } + + return ( + + {rest.with ? {children} : children} + + ); +}) as (props: BoxProps & ListProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/polymorphics.ts b/packages/ui/src/polymorphics.ts new file mode 100644 index 0000000..6d3f35a --- /dev/null +++ b/packages/ui/src/polymorphics.ts @@ -0,0 +1,16 @@ +import type { ComponentPropsWithoutRef, ElementType } from "react"; + +export type AsProp = { + as?: C; +}; + +export type KeyWithAs = keyof (AsProp & Props); + +export type PolymorphicRef = ComponentPropsWithoutRef["ref"]; + +export type PolymorphicComponentProps = (Props & AsProp) & + Omit, KeyWithAs>; + +export type PolymorphicComponentPropsWithRef = Props & { + ref?: PolymorphicRef; +}; diff --git a/packages/ui/src/row.test.tsx b/packages/ui/src/row.test.tsx new file mode 100644 index 0000000..2094c49 --- /dev/null +++ b/packages/ui/src/row.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Row } from "./row"; + +describe("Row", () => { + const user = userEvent.setup(); + + it("renders children correctly", () => { + render(Content); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + + it("renders left and right content correctly", () => { + render( + + Middle Content + , + ); + expect(screen.getByText("Left Content")).toBeInTheDocument(); + expect(screen.getByText("Middle Content")).toBeInTheDocument(); + expect(screen.getByText("Right Content")).toBeInTheDocument(); + }); + + it("renders with custom element type", () => { + render( + + Content + , + ); + const row = screen.getByTestId("custom-row"); + expect(row.tagName).toBe("DIV"); + }); + + it("renders with custom className", () => { + render(Content); + const content = screen.getByText("Content"); + expect(content.parentElement).toHaveClass("custom-class"); + }); + + it("renders ReactNode elements correctly", () => { + const LeftComponent = () =>
Left Node
; + const RightComponent = () =>
Right Node
; + + render( + } right={}> +
Child Node
+
, + ); + + expect(screen.getByText("Left Node")).toBeInTheDocument(); + expect(screen.getByText("Child Node")).toBeInTheDocument(); + expect(screen.getByText("Right Node")).toBeInTheDocument(); + }); + + it("maintains proper layout structure", () => { + render( + + Center + , + ); + + const container = screen.getByText("Center").parentElement; + expect(container).toHaveClass("@xui-flex"); + expect(container).toHaveClass("@xui-w-full"); + expect(container).toHaveClass("@xui-justify-between"); + expect(container).toHaveClass("@xui-items-center"); + }); +}); diff --git a/packages/ui/src/row.tsx b/packages/ui/src/row.tsx new file mode 100644 index 0000000..0ca5fd3 --- /dev/null +++ b/packages/ui/src/row.tsx @@ -0,0 +1,29 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; +import { isString } from "./internal/is-string"; + +type RowProps = { + left?: React.ReactNode; + children?: React.ReactNode; + right?: React.ReactNode; +}; + +export const Row = forwardRef(function Row( + { as, className, left, children, right, ...rest }: BoxProps & RowProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ( + + {isString(left) ?
{left}
: left} + {isString(children) ?
{children}
: children} + {isString(right) ?
{right}
: right} +
+ ); +}) as (props: BoxProps & RowProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/skeleton.test.tsx b/packages/ui/src/skeleton.test.tsx new file mode 100644 index 0000000..dbcb1b3 --- /dev/null +++ b/packages/ui/src/skeleton.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import "@testing-library/jest-dom"; +import type { BoxRef } from "./box"; +import { Skeleton } from "./skeleton"; + +describe("Skeleton", () => { + it("should render as default div element with skeleton classes", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element.tagName).toBe("DIV"); + expect(element).toHaveClass("xui-skeleton-color", "@xui-animate-pulse"); + }); + + it("should render with custom width and height", () => { + render( + + Content + , + ); + const element = screen.getByText("Content"); + expect(element).toHaveStyle({ width: "100px", height: "50px" }); + }); + + it("should render as specified element", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element.tagName).toBe("SECTION"); + }); + + it("should apply custom className while preserving default classes", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element).toHaveClass("custom-class", "xui-skeleton-color", "@xui-animate-pulse"); + }); + + it("should forward ref correctly", () => { + function TestComponent() { + const ref = useRef(null); + return Content; + } + + render(); + const element = screen.getByText("Content"); + expect(element).toBeInTheDocument(); + expect(element.tagName).toBe("DIV"); + }); + + it("should handle click events", async () => { + const handleClick = vi.fn(); + render(Clickable); + + await userEvent.click(screen.getByText("Clickable")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should handle keyboard events", async () => { + const handleKeyDown = vi.fn(); + render( + + Interactive + , + ); + + const element = screen.getByText("Interactive"); + await userEvent.tab(); + expect(element).toHaveFocus(); + + await userEvent.keyboard("[Enter]"); + expect(handleKeyDown).toHaveBeenCalledTimes(1); + }); + + it("should handle custom styles", () => { + render(Styled Content); + + const element = screen.getByText("Styled Content"); + expect(element).toHaveStyle({ backgroundColor: "red" }); + }); + + it("should handle aria attributes", () => { + render( + + Loading + , + ); + + const element = screen.getByLabelText("Loading content"); + expect(element).toHaveAttribute("aria-busy", "true"); + }); +}); diff --git a/packages/ui/src/skeleton.tsx b/packages/ui/src/skeleton.tsx new file mode 100644 index 0000000..46bd52e --- /dev/null +++ b/packages/ui/src/skeleton.tsx @@ -0,0 +1,25 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +type Props = { + w?: string; + h?: string; +}; + +export const Skeleton = forwardRef(function Skeleton( + { as, className, w, h, style, variant, ...rest }: BoxProps & Props, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + + return ( + + ); +}) as (props: BoxProps & Props & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/spacing.tsx b/packages/ui/src/spacing.tsx new file mode 100644 index 0000000..b153918 --- /dev/null +++ b/packages/ui/src/spacing.tsx @@ -0,0 +1,11 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const Spacing = forwardRef(function Spacing( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ; +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/stack.test.tsx b/packages/ui/src/stack.test.tsx new file mode 100644 index 0000000..a246807 --- /dev/null +++ b/packages/ui/src/stack.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ElementType, useRef } from "react"; +import "@testing-library/jest-dom"; +import type { BoxProps, BoxRef } from "./box"; +import { Stack } from "./stack"; + +describe("Stack", () => { + it("should render as default div element with flex column", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element.tagName).toBe("DIV"); + expect(element).toHaveClass("@xui-flex", "@xui-flex-col"); + }); + + it("should render as specified element", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element.tagName).toBe("SECTION"); + expect(element).toHaveClass("@xui-flex", "@xui-flex-col"); + }); + + it("should apply custom className while preserving default classes", () => { + render(Content); + const element = screen.getByText("Content"); + expect(element).toHaveClass("custom-class", "@xui-flex", "@xui-flex-col"); + }); + + it("should forward ref correctly", () => { + function TestComponent() { + const ref = useRef(null); + return ( + ref={ref} as="div"> + Content + + ); + } + + render(); + const element = screen.getByText("Content"); + expect(element).toBeInTheDocument(); + expect(element.tagName).toBe("DIV"); + }); + + it("should handle click events", async () => { + const handleClick = vi.fn(); + render(Clickable); + + await userEvent.click(screen.getByText("Clickable")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should handle keyboard events", async () => { + const handleKeyDown = vi.fn(); + render( + + Interactive + , + ); + + const element = screen.getByText("Interactive"); + await userEvent.tab(); + expect(element).toHaveFocus(); + + await userEvent.keyboard("[Enter]"); + expect(handleKeyDown).toHaveBeenCalledTimes(1); + }); + + it("should handle aria attributes", () => { + render( + + Accessible Content + , + ); + + const element = screen.getByLabelText("Test stack"); + expect(element).toHaveAttribute("aria-describedby", "description"); + }); + + it("should handle nested elements", () => { + render( + +
Child 1
+
Child 2
+
Child 3
+
, + ); + + expect(screen.getByText("Child 1")).toBeInTheDocument(); + expect(screen.getByText("Child 2")).toBeInTheDocument(); + expect(screen.getByText("Child 3")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/stack.tsx b/packages/ui/src/stack.tsx new file mode 100644 index 0000000..5c083ba --- /dev/null +++ b/packages/ui/src/stack.tsx @@ -0,0 +1,11 @@ +import { type ElementType, forwardRef } from "react"; +import { Box, type BoxRef } from "./box"; +import type { BoxProps } from "./box"; + +export const Stack = forwardRef(function Stack( + { as, className, ...rest }: BoxProps, + ref?: BoxRef, +) { + const typesRest = rest as BoxProps; + return ; +}) as (props: BoxProps & { ref?: BoxRef }) => JSX.Element; diff --git a/packages/ui/src/toggle-button-group.test.tsx b/packages/ui/src/toggle-button-group.test.tsx new file mode 100644 index 0000000..b22c5a9 --- /dev/null +++ b/packages/ui/src/toggle-button-group.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { ToggleButton } from "./toggle-button"; +import { ToggleButtonGroup } from "./toggle-button-group"; +import "@testing-library/jest-dom"; + +describe("ToggleButtonGroup", () => { + const TestComponent = ({ allowToggle }: { allowToggle: boolean }) => { + const [value, setValue] = useState(null); + return ( + + 1 + 2 + + ); + }; + + it("should render buttons correctly", () => { + render(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + describe("when allowToggle is true", () => { + it("should toggle selected state on click", async () => { + render(); + const button1 = screen.getByText("1"); + + // 초기 상태 확인 + expect(button1).toHaveAttribute("data-state", "unselected"); + + // 첫 번째 클릭 + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + + // 두 번째 클릭 (토글 해제) + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "unselected"); + }); + }); + + describe("when allowToggle is false", () => { + it("should not toggle off selected button on click", async () => { + render(); + const button1 = screen.getByText("1"); + + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + }); + + it("should only allow one button to be selected at a time", async () => { + render(); + const button1 = screen.getByText("1"); + const button2 = screen.getByText("2"); + + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + expect(button2).toHaveAttribute("data-state", "unselected"); + + await userEvent.click(button2); + expect(button1).toHaveAttribute("data-state", "unselected"); + expect(button2).toHaveAttribute("data-state", "selected"); + }); + }); + + it("should handle keyboard interactions", async () => { + render(); + const button1 = screen.getByText("1"); + + // Tab으로 포커스 + await userEvent.tab(); + expect(button1).toHaveFocus(); + + // Space로 선택 + await userEvent.keyboard(" "); + expect(button1).toHaveAttribute("data-state", "selected"); + + // Enter로 선택 해제 + await userEvent.keyboard("[Enter]"); + expect(button1).toHaveAttribute("data-state", "unselected"); + }); +}); diff --git a/packages/ui/src/toggle-button-group.tsx b/packages/ui/src/toggle-button-group.tsx new file mode 100644 index 0000000..32b2419 --- /dev/null +++ b/packages/ui/src/toggle-button-group.tsx @@ -0,0 +1,66 @@ +import { Children, KeyboardEvent, MouseEvent, ReactElement, cloneElement, createContext } from "react"; +import { ToggleButtonProps } from "./toggle-button"; + +export interface ToggleButtonGroupProps { + children: ReactElement | ReactElement[]; + value: string | null; + onChange: (value: string | null) => void; + allowToggle?: boolean; +} + +export const ToggleButtonGroupContext = createContext(null); + +export function ToggleButtonGroup(props: ToggleButtonGroupProps) { + const { allowToggle = true, value, children, onChange } = props; + const childs = Children.toArray(children) as ReactElement[]; + + return ( + + + {(context) => { + const handleClick = (newValue: string | null) => { + if (newValue === null) { + return onChange(null); + } + + if (newValue === context && allowToggle) { + return onChange(null); + } + + if (newValue === context && !allowToggle) { + return; + } + + return onChange(newValue); + }; + + const handleKeyDown = (event: KeyboardEvent, newValue: string | null) => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + handleClick(newValue); + } + }; + return ( + <> + {childs.map((child) => { + const childElement = child as ReactElement; + return cloneElement(childElement, { + selected: context === childElement.props.value, + onClick: (event: MouseEvent) => { + handleClick(childElement.props.value ?? null); + childElement.props.onClick?.(event); + }, + onKeyDown: (event: KeyboardEvent) => { + handleKeyDown(event, childElement.props.value ?? null); + childElement.props.onKeyDown?.(event); + }, + role: "button", + }); + })} + + ); + }} + + + ); +} diff --git a/packages/ui/src/toggle-button-multiple-group.test.tsx b/packages/ui/src/toggle-button-multiple-group.test.tsx new file mode 100644 index 0000000..65d1481 --- /dev/null +++ b/packages/ui/src/toggle-button-multiple-group.test.tsx @@ -0,0 +1,122 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { ToggleButton } from "./toggle-button"; +import { ToggleButtonMultipleGroup } from "./toggle-button-multiple-group"; +import "@testing-library/jest-dom"; + +describe("ToggleButtonMultipleGroup", () => { + const TestComponent = ({ allowToggle }: { allowToggle: boolean }) => { + const [value, setValue] = useState([]); + return ( + + 1 + 2 + 3 + + ); + }; + + it("should render buttons correctly", () => { + render(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + describe("when allowToggle is true", () => { + it("should toggle selected state on click", async () => { + render(); + const button1 = screen.getByText("1"); + + // 초기 상태 확인 + expect(button1).toHaveAttribute("data-state", "unselected"); + + // 첫 번째 클릭 (선택) + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + + // 두 번째 클릭 (토글 해제) + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "unselected"); + }); + + it("should allow multiple buttons to be selected", async () => { + render(); + const button1 = screen.getByText("1"); + const button2 = screen.getByText("2"); + const button3 = screen.getByText("3"); + + await userEvent.click(button1); + await userEvent.click(button2); + await userEvent.click(button3); + + expect(button1).toHaveAttribute("data-state", "selected"); + expect(button2).toHaveAttribute("data-state", "selected"); + expect(button3).toHaveAttribute("data-state", "selected"); + }); + + it("should allow deselecting individual buttons", async () => { + render(); + const button1 = screen.getByText("1"); + const button2 = screen.getByText("2"); + + // 두 버튼 선택 + await userEvent.click(button1); + await userEvent.click(button2); + + // button1만 선택 해제 + await userEvent.click(button1); + + expect(button1).toHaveAttribute("data-state", "unselected"); + expect(button2).toHaveAttribute("data-state", "selected"); + }); + }); + + describe("when allowToggle is false", () => { + it("should not allow deselecting buttons", async () => { + render(); + const button1 = screen.getByText("1"); + + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + + // 다시 클릭해도 선택 해제되지 않음 + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + }); + + it("should allow selecting multiple buttons without deselection", async () => { + render(); + const button1 = screen.getByText("1"); + const button2 = screen.getByText("2"); + + await userEvent.click(button1); + await userEvent.click(button2); + + expect(button1).toHaveAttribute("data-state", "selected"); + expect(button2).toHaveAttribute("data-state", "selected"); + + // 다시 클릭해도 선택 해제되��� 않음 + await userEvent.click(button1); + expect(button1).toHaveAttribute("data-state", "selected"); + }); + }); + + it("should handle keyboard interactions", async () => { + render(); + const button1 = screen.getByText("1"); + + // Tab으로 포커스 + await userEvent.tab(); + expect(button1).toHaveFocus(); + + // Space로 선택 + await userEvent.keyboard(" "); + expect(button1).toHaveAttribute("data-state", "selected"); + + // Enter로 선택 해제 + await userEvent.keyboard("[Enter]"); + expect(button1).toHaveAttribute("data-state", "unselected"); + }); +}); diff --git a/packages/ui/src/toggle-button-multiple-group.tsx b/packages/ui/src/toggle-button-multiple-group.tsx new file mode 100644 index 0000000..5c654be --- /dev/null +++ b/packages/ui/src/toggle-button-multiple-group.tsx @@ -0,0 +1,71 @@ +import { Children, KeyboardEvent, MouseEvent, ReactElement, cloneElement, createContext } from "react"; +import { ToggleButtonProps } from "./toggle-button"; + +export interface ToggleButtonMultipleGroupProps { + children: ReactElement | ReactElement[]; + value: string[]; + onChange: (value: string[]) => void; + allowToggle?: boolean; +} + +export const ToggleButtonMultipleGroupContext = createContext([]); + +export function ToggleButtonMultipleGroup(props: ToggleButtonMultipleGroupProps) { + const { allowToggle = true, value, children, onChange } = props; + const childs = Children.toArray(children) as ReactElement[]; + + return ( + + + {(context) => { + const isSelected = (newValue: string | undefined) => { + if (!newValue) { + return false; + } + return context.includes(newValue); + }; + + const handleClick = (newValue: string | null) => { + if (newValue === null) { + return; + } + + const isIncludes = context.includes(newValue); + if (isIncludes && allowToggle) { + return onChange(context.filter((v) => v !== newValue)); + } + + return onChange([...context, newValue]); + }; + + const handleKeyDown = (event: KeyboardEvent, newValue: string | null) => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + handleClick(newValue); + } + }; + + return ( + <> + {childs.map((child) => { + const childElement = child as ReactElement; + return cloneElement(childElement, { + selected: isSelected(childElement.props.value), + onClick: (event: MouseEvent) => { + handleClick(childElement.props.value ?? null); + childElement.props.onClick?.(event); + }, + onKeyDown: (event: KeyboardEvent) => { + handleKeyDown(event, childElement.props.value ?? null); + childElement.props.onKeyDown?.(event); + }, + role: "button", + }); + })} + + ); + }} + + + ); +} diff --git a/packages/ui/src/toggle-button.test.tsx b/packages/ui/src/toggle-button.test.tsx new file mode 100644 index 0000000..1a41788 --- /dev/null +++ b/packages/ui/src/toggle-button.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ToggleButton } from "./toggle-button"; +import "@testing-library/jest-dom"; + +describe("ToggleButton", () => { + it("should render correctly with default props", () => { + render(Test Button); + const button = screen.getByText("Test Button"); + + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("data-state", "unselected"); + expect(button).toHaveAttribute("role", "button"); + expect(button).toHaveAttribute("tabIndex", "0"); + }); + + it("should render with selected state", () => { + render( + + Test Button + , + ); + const button = screen.getByText("Test Button"); + + expect(button).toHaveAttribute("data-state", "selected"); + }); + + it("should handle click events", async () => { + const handleClick = vi.fn(); + render( + + Test Button + , + ); + const button = screen.getByText("Test Button"); + + await userEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should handle keyboard events", async () => { + const handleKeyDown = vi.fn(); + render( + + Test Button + , + ); + const button = screen.getByText("Test Button"); + + // Space 키 테스트 + await userEvent.tab(); + expect(button).toHaveFocus(); + await userEvent.keyboard(" "); + expect(handleKeyDown).toHaveBeenCalledTimes(1); + + // Enter 키 테스트 + await userEvent.keyboard("[Enter]"); + expect(handleKeyDown).toHaveBeenCalledTimes(2); + }); + + it("should apply custom className", () => { + render( + + Test Button + , + ); + const button = screen.getByText("Test Button"); + + expect(button).toHaveClass("custom-class"); + }); + + it("should be disabled when disabled prop is true", () => { + render( + + Test Button + , + ); + const button = screen.getByText("Test Button"); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-disabled", "true"); + }); + + it("should not trigger click handler when disabled", async () => { + const handleClick = vi.fn(); + render( + + Test Button + , + ); + const button = screen.getByText("Test Button"); + + await userEvent.click(button); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it("should handle aria-label prop", () => { + render( + + Test Button + , + ); + const button = screen.getByLabelText("Test Label"); + + expect(button).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/toggle-button.tsx b/packages/ui/src/toggle-button.tsx new file mode 100644 index 0000000..68f9e7e --- /dev/null +++ b/packages/ui/src/toggle-button.tsx @@ -0,0 +1,26 @@ +import { ComponentPropsWithoutRef, Ref, forwardRef } from "react"; + +export interface ToggleButtonProps extends ComponentPropsWithoutRef<"button"> { + selected?: boolean; + value: string; +} + +export const ToggleButton = forwardRef(function ToggleButton(props: ToggleButtonProps, ref: Ref) { + const { className, selected, value, disabled, role = "button", tabIndex = 0, ...rest } = props; + const typesRest = rest; + const selectedState = selected ? "selected" : "unselected"; + + return ( +