From 3682ddd00b39dafe40bed1596dcdf1e56999e41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=84=9C=ED=98=84?= Date: Thu, 10 Oct 2024 15:53:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Feature]=20Avatar=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 아바타 이미지 wowds-icons에 추가 * rename: 스피너 관련 폴더 네이밍 변경 * fix: svg 이미지 관련 스토리북 이슈 해결 * feat: Avatar 컴포넌트 구현 * feat: Avatar 컴포넌트 스토리 작성 * chore: git 캐시된 부분 삭제 * refactor: Polymorphic 타입 리팩토링 * docs: jsdoc 및 스토리 문서화 추가 * chore: tsconfig 속성 변경 * fix: Avatar 컴포넌트 타입 추론 안 되는 문제 해결 * chore: orientation props default 값 right로 지정 * feat: ImageComponent도 props로 받을 수 있게 수정 * chore: Avatar 컴포넌트 changeset 작성 --- .changeset/clever-lizards-tell.md | 6 + .../wow-icons/src/component/BlueAvatar.tsx | 83 +++++++++ .../wow-icons/src/component/GreenAvatar.tsx | 87 ++++++++++ .../wow-icons/src/component/RedAvatar.tsx | 84 +++++++++ .../wow-icons/src/component/YellowAvatar.tsx | 91 ++++++++++ packages/wow-icons/src/component/index.ts | 4 + packages/wow-icons/src/svg/blue-avatar.svg | 18 ++ packages/wow-icons/src/svg/green-avatar.svg | 18 ++ packages/wow-icons/src/svg/red-avatar.svg | 20 +++ packages/wow-icons/src/svg/yellow-avatar.svg | 19 ++ packages/wow-ui/.storybook/main.ts | 22 +++ packages/wow-ui/package.json | 5 + packages/wow-ui/rollup.config.js | 1 + .../{lottie => lotties}/blueSpinner.json | 0 .../{lottie => lotties}/rainbowSpinner.json | 0 .../src/components/Avatar/Avatar.stories.tsx | 119 +++++++++++++ .../wow-ui/src/components/Avatar/index.tsx | 163 ++++++++++++++++++ .../src/components/Spinner/BlueSpinner.tsx | 2 +- .../src/components/Spinner/RainbowSpinner.tsx | 2 +- packages/wow-ui/src/types/index.ts | 4 +- .../types/{Polymorphic.ts => polymorphic.ts} | 8 +- 21 files changed, 748 insertions(+), 8 deletions(-) create mode 100644 .changeset/clever-lizards-tell.md create mode 100644 packages/wow-icons/src/component/BlueAvatar.tsx create mode 100644 packages/wow-icons/src/component/GreenAvatar.tsx create mode 100644 packages/wow-icons/src/component/RedAvatar.tsx create mode 100644 packages/wow-icons/src/component/YellowAvatar.tsx create mode 100644 packages/wow-icons/src/svg/blue-avatar.svg create mode 100644 packages/wow-icons/src/svg/green-avatar.svg create mode 100644 packages/wow-icons/src/svg/red-avatar.svg create mode 100644 packages/wow-icons/src/svg/yellow-avatar.svg rename packages/wow-ui/src/assets/{lottie => lotties}/blueSpinner.json (100%) rename packages/wow-ui/src/assets/{lottie => lotties}/rainbowSpinner.json (100%) create mode 100644 packages/wow-ui/src/components/Avatar/Avatar.stories.tsx create mode 100644 packages/wow-ui/src/components/Avatar/index.tsx rename packages/wow-ui/src/types/{Polymorphic.ts => polymorphic.ts} (74%) diff --git a/.changeset/clever-lizards-tell.md b/.changeset/clever-lizards-tell.md new file mode 100644 index 00000000..ca856a46 --- /dev/null +++ b/.changeset/clever-lizards-tell.md @@ -0,0 +1,6 @@ +--- +"wowds-icons": patch +"wowds-ui": patch +--- + +Avatar 컴포넌트를 추가합니다. diff --git a/packages/wow-icons/src/component/BlueAvatar.tsx b/packages/wow-icons/src/component/BlueAvatar.tsx new file mode 100644 index 00000000..6aee0b4d --- /dev/null +++ b/packages/wow-icons/src/component/BlueAvatar.tsx @@ -0,0 +1,83 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "@/types/Icon.ts"; + +const BlueAvatar = forwardRef( + ( + { + className, + width = "100", + height = "100", + viewBox = "0 0 100 100", + ...rest + }, + ref + ) => { + return ( + + + + + + + + + + + + + + + + + + + ); + } +); + +BlueAvatar.displayName = "BlueAvatar"; +export default BlueAvatar; diff --git a/packages/wow-icons/src/component/GreenAvatar.tsx b/packages/wow-icons/src/component/GreenAvatar.tsx new file mode 100644 index 00000000..b3a465b5 --- /dev/null +++ b/packages/wow-icons/src/component/GreenAvatar.tsx @@ -0,0 +1,87 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "@/types/Icon.ts"; + +const GreenAvatar = forwardRef( + ( + { + className, + width = "100", + height = "100", + viewBox = "0 0 100 100", + ...rest + }, + ref + ) => { + return ( + + + + + + + + + + + + + + + + + + + ); + } +); + +GreenAvatar.displayName = "GreenAvatar"; +export default GreenAvatar; diff --git a/packages/wow-icons/src/component/RedAvatar.tsx b/packages/wow-icons/src/component/RedAvatar.tsx new file mode 100644 index 00000000..542a5ffd --- /dev/null +++ b/packages/wow-icons/src/component/RedAvatar.tsx @@ -0,0 +1,84 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "@/types/Icon.ts"; + +const RedAvatar = forwardRef( + ( + { + className, + width = "100", + height = "100", + viewBox = "0 0 100 100", + ...rest + }, + ref + ) => { + return ( + + + + + + + + + + + + + + + + + + + + + ); + } +); + +RedAvatar.displayName = "RedAvatar"; +export default RedAvatar; diff --git a/packages/wow-icons/src/component/YellowAvatar.tsx b/packages/wow-icons/src/component/YellowAvatar.tsx new file mode 100644 index 00000000..606be0bb --- /dev/null +++ b/packages/wow-icons/src/component/YellowAvatar.tsx @@ -0,0 +1,91 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "@/types/Icon.ts"; + +const YellowAvatar = forwardRef( + ( + { + className, + width = "100", + height = "100", + viewBox = "0 0 100 100", + ...rest + }, + ref + ) => { + return ( + + + + + + + + + + + + + + + + + + + + ); + } +); + +YellowAvatar.displayName = "YellowAvatar"; +export default YellowAvatar; diff --git a/packages/wow-icons/src/component/index.ts b/packages/wow-icons/src/component/index.ts index 11ab8e7f..6e0a57b6 100644 --- a/packages/wow-icons/src/component/index.ts +++ b/packages/wow-icons/src/component/index.ts @@ -1,14 +1,18 @@ +export { default as BlueAvatar } from "./BlueAvatar.tsx"; export { default as Calendar } from "./Calendar.tsx"; export { default as Check } from "./Check.tsx"; export { default as Close } from "./Close.tsx"; export { default as DownArrow } from "./DownArrow.tsx"; export { default as Edit } from "./Edit.tsx"; +export { default as GreenAvatar } from "./GreenAvatar.tsx"; export { default as Help } from "./Help.tsx"; export { default as LeftArrow } from "./LeftArrow.tsx"; export { default as Link } from "./Link.tsx"; export { default as Plus } from "./Plus.tsx"; +export { default as RedAvatar } from "./RedAvatar.tsx"; export { default as Reload } from "./Reload.tsx"; export { default as RightArrow } from "./RightArrow.tsx"; export { default as Search } from "./Search.tsx"; export { default as Trash } from "./Trash.tsx"; export { default as Warn } from "./Warn.tsx"; +export { default as YellowAvatar } from "./YellowAvatar.tsx"; diff --git a/packages/wow-icons/src/svg/blue-avatar.svg b/packages/wow-icons/src/svg/blue-avatar.svg new file mode 100644 index 00000000..49658c71 --- /dev/null +++ b/packages/wow-icons/src/svg/blue-avatar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/wow-icons/src/svg/green-avatar.svg b/packages/wow-icons/src/svg/green-avatar.svg new file mode 100644 index 00000000..5e0cfaf4 --- /dev/null +++ b/packages/wow-icons/src/svg/green-avatar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/wow-icons/src/svg/red-avatar.svg b/packages/wow-icons/src/svg/red-avatar.svg new file mode 100644 index 00000000..26bf9bb3 --- /dev/null +++ b/packages/wow-icons/src/svg/red-avatar.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/wow-icons/src/svg/yellow-avatar.svg b/packages/wow-icons/src/svg/yellow-avatar.svg new file mode 100644 index 00000000..0dd1a239 --- /dev/null +++ b/packages/wow-icons/src/svg/yellow-avatar.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/wow-ui/.storybook/main.ts b/packages/wow-ui/.storybook/main.ts index 35682223..e6c80f20 100644 --- a/packages/wow-ui/.storybook/main.ts +++ b/packages/wow-ui/.storybook/main.ts @@ -36,6 +36,28 @@ const config: StorybookConfig = { "@styled-system": path.resolve(__dirname, "../styled-system"), }; } + + if (!config.module || !config.module.rules) { + return config; + } + + config.module.rules = [ + ...config.module.rules.map((rule) => { + if (!rule || rule === "...") { + return rule; + } + + if (rule.test && /svg/.test(String(rule.test))) { + return { ...rule, exclude: /\.svg$/i }; + } + return rule; + }), + { + test: /\.svg$/, + use: ["@svgr/webpack"], + }, + ]; + return config; }, }; diff --git a/packages/wow-ui/package.json b/packages/wow-ui/package.json index 488d13fc..a1278468 100644 --- a/packages/wow-ui/package.json +++ b/packages/wow-ui/package.json @@ -134,6 +134,11 @@ "types": "./dist/components/Box/index.d.ts", "require": "./dist/Box.cjs", "import": "./dist/Box.js" + }, + "./Avatar": { + "types": "./dist/components/Avatar/index.d.ts", + "require": "./dist/Avatar.cjs", + "import": "./dist/Avatar.js" } }, "keywords": [], diff --git a/packages/wow-ui/rollup.config.js b/packages/wow-ui/rollup.config.js index 2113035a..28c0c666 100644 --- a/packages/wow-ui/rollup.config.js +++ b/packages/wow-ui/rollup.config.js @@ -43,6 +43,7 @@ export default { Checkbox: "./src/components/Checkbox", Button: "./src/components/Button", Box: "./src/components/Box", + Avatar: "./src/components/Avatar", }, output: [ { diff --git a/packages/wow-ui/src/assets/lottie/blueSpinner.json b/packages/wow-ui/src/assets/lotties/blueSpinner.json similarity index 100% rename from packages/wow-ui/src/assets/lottie/blueSpinner.json rename to packages/wow-ui/src/assets/lotties/blueSpinner.json diff --git a/packages/wow-ui/src/assets/lottie/rainbowSpinner.json b/packages/wow-ui/src/assets/lotties/rainbowSpinner.json similarity index 100% rename from packages/wow-ui/src/assets/lottie/rainbowSpinner.json rename to packages/wow-ui/src/assets/lotties/rainbowSpinner.json diff --git a/packages/wow-ui/src/components/Avatar/Avatar.stories.tsx b/packages/wow-ui/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 00000000..bcedd31c --- /dev/null +++ b/packages/wow-ui/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import Avatar from "@/components/Avatar/index.tsx"; + +const meta = { + title: "UI/Avatar", + component: Avatar, + parameters: { + componentSubtitle: "아바타 컴포넌트", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "radio" }, + options: ["sm", "lg"], + description: "아바타의 크기입니다.", + table: { + defaultValue: { summary: "lg" }, + type: { + summary: "sm | lg", + }, + }, + }, + variant: { + control: { type: "radio" }, + options: ["blue", "green", "yellow", "red"], + description: "아바타의 색상입니다.", + table: { + defaultValue: { summary: "blue" }, + type: { + summary: "blue | green | yellow | red", + }, + }, + }, + ImageComponent: { + control: false, + description: "아바타에 표시할 이미지 컴포넌트입니다.", + table: { + type: { summary: "ElementType" }, + }, + }, + imageUrl: { + control: { type: "text" }, + description: "아바타에 표시할 이미지의 URL입니다.", + table: { + type: { summary: "string" }, + }, + }, + username: { + control: { type: "text" }, + description: "아바타 옆에 표시할 사용자 이름입니다.", + table: { + type: { summary: "string" }, + }, + }, + orientation: { + control: { type: "radio" }, + options: ["left", "right"], + description: + "사용자 이름 레이블의 방향입니다. size가 'sm'인 경우 지정할 수 있습니다.", + table: { + defaultValue: { summary: "right" }, + type: { summary: "left | right" }, + }, + }, + asProp: { + control: false, + description: "렌더링할 HTML 요소나 React 컴포넌트입니다.", + table: { + defaultValue: { summary: "div" }, + type: { summary: "React.ElementType" }, + }, + }, + ref: { + description: "'ref'를 사용하여 컴포넌트에 직접 접근할 수 있습니다.", + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Small: Story = { + args: { + size: "sm", + }, +}; + +export const WithImage: Story = { + args: { + imageUrl: "https://i.ibb.co/2hz6hNP/2024-06-06-11-52-22.png", + }, +}; + +export const LargeWithUsername: Story = { + args: { + username: "김홍익 님", + }, +}; + +export const SmallWithUsernameOrientationLeft: Story = { + args: { + username: "김홍익 님", + size: "sm", + orientation: "left", + }, +}; + +export const SmallWithUsernameOrientationRight: Story = { + args: { + username: "김홍익 님", + size: "sm", + }, +}; diff --git a/packages/wow-ui/src/components/Avatar/index.tsx b/packages/wow-ui/src/components/Avatar/index.tsx new file mode 100644 index 00000000..018c6134 --- /dev/null +++ b/packages/wow-ui/src/components/Avatar/index.tsx @@ -0,0 +1,163 @@ +import { cva } from "@styled-system/css"; +import type { ElementType, PropsWithChildren, ReactNode } from "react"; +import { forwardRef } from "react"; +import { BlueAvatar, GreenAvatar, RedAvatar, YellowAvatar } from "wowds-icons"; + +import type { + PolymorphicComponentProps, + PolymorphicComponentPropsWithRef, + PolymorphicRef, +} from "@/types/polymorphic.ts"; + +/** + * @description Avatar 컴포넌트는 사용자 프로필을 나타내는 컴포넌트입니다. + * + * @param {("sm" | "lg")} [size="lg"] - 아바타의 크기. + * @param {("blue" | "green" | "yellow" | "red")} [variant="blue"] - 아바타의 색상. + * @param {ElementType} [ImageComponent] - 아바타에 표시할 이미지 컴포넌트. + * @param {string} [imageUrl] - 아바타에 표시할 이미지의 URL. + * @param {string} [username] - 아바타 옆에 표시할 사용자 이름. 사용자 이름이 제공되면 레이블로 표시됨. + * @param {("left" | "right")} [orientation="right"] - 사용자 이름 레이블의 방향. size가 'sm'인 경우 지정 가능. + * @param {React.ElementType} [asProp="div"] - 렌더링할 HTML 요소나 React 컴포넌트. 기본값은 "div". + */ + +type AvatarSizeType = "sm" | "lg"; + +export interface _AvatarProps { + size?: AvatarSizeType; + variant?: "blue" | "green" | "yellow" | "red"; + ImageComponent?: ElementType; + imageUrl?: string; + username?: string; + orientation?: "left" | "right"; +} + +type AvatarProps = PolymorphicComponentProps< + T, + _AvatarProps +> & + PropsWithChildren; + +type AvatarComponent = ( + props: PolymorphicComponentPropsWithRef> +) => ReactNode; + +const Avatar: AvatarComponent & { displayName?: string } = forwardRef( + ( + { + asProp, + size = "lg", + variant = "blue", + ImageComponent, + imageUrl, + username, + orientation = "right", + ...rest + }: AvatarProps, + ref?: PolymorphicRef + ) => { + const Component = asProp || "div"; + const AvatarComponent = avatarMap[variant]; + + return ( + + {ImageComponent ? ( + + ) : imageUrl ? ( + avatar + ) : ( + + )} + + + ); + } +); + +Avatar.displayName = "Avatar"; +export default Avatar; + +const avatarMap = { + blue: BlueAvatar, + green: GreenAvatar, + red: RedAvatar, + yellow: YellowAvatar, +}; + +const avatarContainerStyle = cva({ + base: { + height: "fit-content", + width: "fit-content", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, + variants: { + size: { + sm: { + gap: 8, + }, + lg: { + gap: 12, + }, + }, + orientation: { + default: { + flexDirection: "column", + }, + left: { + flexDirection: "row-reverse", + }, + right: { + flexDirection: "row", + }, + }, + }, +}); + +const avatarSizeStyle = cva({ + base: { + borderRadius: "50%", + }, + variants: { + size: { + sm: { + width: 32, + height: 32, + }, + lg: { + width: 100, + height: 100, + }, + }, + }, +}); + +const avatarLabelStyle = cva({ + variants: { + size: { + sm: { + textStyle: "label1", + }, + lg: { + textStyle: "h1", + }, + }, + }, +}); diff --git a/packages/wow-ui/src/components/Spinner/BlueSpinner.tsx b/packages/wow-ui/src/components/Spinner/BlueSpinner.tsx index 354d26b6..c2be21f5 100644 --- a/packages/wow-ui/src/components/Spinner/BlueSpinner.tsx +++ b/packages/wow-ui/src/components/Spinner/BlueSpinner.tsx @@ -2,7 +2,7 @@ import type { LottieComponentProps } from "lottie-react"; import Lottie from "lottie-react"; import type { CSSProperties } from "react"; -import blueSpinner from "@/assets/lottie/blueSpinner.json"; +import blueSpinner from "@/assets/lotties/blueSpinner.json"; /** * @description 블루 스피너 컴포넌트입니다. diff --git a/packages/wow-ui/src/components/Spinner/RainbowSpinner.tsx b/packages/wow-ui/src/components/Spinner/RainbowSpinner.tsx index 5020d788..e1731fca 100644 --- a/packages/wow-ui/src/components/Spinner/RainbowSpinner.tsx +++ b/packages/wow-ui/src/components/Spinner/RainbowSpinner.tsx @@ -2,7 +2,7 @@ import type { LottieComponentProps } from "lottie-react"; import Lottie from "lottie-react"; import type { CSSProperties } from "react"; -import rainbowSpinner from "@/assets/lottie/rainbowSpinner.json"; +import rainbowSpinner from "@/assets/lotties/rainbowSpinner.json"; /** * @description 레인보우 스피너 컴포넌트입니다. Lottie 애니메이션을 사용하여 스피너를 표시합니다. diff --git a/packages/wow-ui/src/types/index.ts b/packages/wow-ui/src/types/index.ts index 2d701f74..e904c893 100644 --- a/packages/wow-ui/src/types/index.ts +++ b/packages/wow-ui/src/types/index.ts @@ -3,9 +3,9 @@ export type { ButtonElementType, MenuButtonProps, ToggleButtonProps, -} from "./Button"; +} from "./button.ts"; export type { PolymorphicComponentProps, PolymorphicComponentPropsWithRef, PolymorphicRef, -} from "./Polymorphic"; +} from "./polymorphic.ts"; diff --git a/packages/wow-ui/src/types/Polymorphic.ts b/packages/wow-ui/src/types/polymorphic.ts similarity index 74% rename from packages/wow-ui/src/types/Polymorphic.ts rename to packages/wow-ui/src/types/polymorphic.ts index 72c7681c..f9dedd89 100644 --- a/packages/wow-ui/src/types/Polymorphic.ts +++ b/packages/wow-ui/src/types/polymorphic.ts @@ -13,12 +13,12 @@ export type PolymorphicRef = export type PolymorphicComponentPropsWithRef< C extends ElementType, - Props = {}, -> = Props & { ref?: PolymorphicRef }; + ComponentProps = {}, +> = ComponentProps & { ref?: PolymorphicRef }; export type PolymorphicComponentProps< T extends ElementType, - Props = {}, + ComponentProps = {}, > = AsProps & ComponentPropsWithoutRef & - PolymorphicComponentPropsWithRef; + PolymorphicComponentPropsWithRef; From 185475a11b787a419d3b70a98f6a7eedea46112f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=98=81?= <89445100+hamo-o@users.noreply.github.com> Date: Sat, 12 Oct 2024 23:28:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[Feature]=20Toast=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 토스트 컴포넌트 구현 * feat: 토스트 컴포넌트 스토리북 작성 * chore: package.json 및 rollup 추가 * chore: displayName 추가 및 export 형식 변경 * design: 두 줄인 경우 줄바꿈 * fix: type 옵셔널로 처리 * feat: 토스트 컴포넌트를 사라지게 하는 효과 추가 * feat: 아이콘 클릭 시 호출할 함수 추가, 배경 추가 * feat: 아이콘 클릭 함수 스토리 추가, state 활용 추가 * feat: 토스트 위치조정 및 배경 스타일 prop 추가 * feat: toast 함수로 렌더링 가능하도록 로직 추가 * fix: 토스트가 여러개일 때 레이아웃 조정 * chore: useToast 빌드 포함 * fix: color contrast 검사 통과 * feat: 토스트 duration 받을 수 있도록 추가 * feat: overlay zIndex 토큰 추가 * refactor: zIndex 토큰 사용하도록 변경 * feat: 공통 타입 파일 내보내도록 스크립트 수정 * refactor: TypeIconComponent 네이밍 변경 및 공통 prop 빼기 * fix: 토스트 트리거 존재하는 경우 스토리 추가, 토스트가 없을 때 오버레이 공간 차지하지 않도록 조건부 렌더링 * docs: ToastProvider 내용 안내 * docs: 스토리북 radio 옵션 추가 및 기본 컴포넌트 변경 * feat: 토스트 컴포넌트 제거 이후 실행되는 함수 추가 * feat: docs에도overlay 토큰 추가 * fix: type -> rightIcon 네이밍 변경 * docs: 스토리북 설명 및 타이틀 변경 * fix: 스크립트 content 변수 네이밍 변경 * fix: icon prop 불리언으로 수정 * refactor: 현재 시각 대신 uuid로 토스트 id 지정 * feat: 정규식 입력 시 해당 정규식을 만족하는 아이디를 가진 토스트만 보여주기 * chore: changeset 작성 --- .changeset/green-feet-teach.md | 6 + .changeset/pretty-cooks-join.md | 5 + .changeset/seven-eggs-wait.md | 5 + apps/wow-docs/styled-system/tokens/index.js | 4 + .../wow-docs/styled-system/tokens/tokens.d.ts | 3 +- packages/scripts/generateBuildConfig.ts | 13 +- .../scripts/generateReactComponentFromSvg.ts | 4 +- packages/wow-icons/package.json | 1 - packages/wow-icons/src/component/index.ts | 1 + packages/wow-theme/src/tokens/zIndex.ts | 3 + packages/wow-tokens/src/zIndex.ts | 1 + packages/wow-ui/package.json | 19 +- packages/wow-ui/rollup.config.js | 3 + .../src/components/Toast/Toast.stories.tsx | 173 +++++++++++ .../src/components/Toast/ToastContext.ts | 22 ++ .../src/components/Toast/ToastProvider.tsx | 63 ++++ .../wow-ui/src/components/Toast/index.tsx | 131 +++++++++ .../wow-ui/src/components/Toast/useToast.ts | 12 + packages/wow-ui/styled-system/styles.css | 273 +----------------- packages/wow-ui/styled-system/tokens/index.js | 4 + .../wow-ui/styled-system/tokens/tokens.d.ts | 3 +- pnpm-lock.yaml | 15 + 22 files changed, 483 insertions(+), 281 deletions(-) create mode 100644 .changeset/green-feet-teach.md create mode 100644 .changeset/pretty-cooks-join.md create mode 100644 .changeset/seven-eggs-wait.md create mode 100644 packages/wow-ui/src/components/Toast/Toast.stories.tsx create mode 100644 packages/wow-ui/src/components/Toast/ToastContext.ts create mode 100644 packages/wow-ui/src/components/Toast/ToastProvider.tsx create mode 100644 packages/wow-ui/src/components/Toast/index.tsx create mode 100644 packages/wow-ui/src/components/Toast/useToast.ts diff --git a/.changeset/green-feet-teach.md b/.changeset/green-feet-teach.md new file mode 100644 index 00000000..c0095ff2 --- /dev/null +++ b/.changeset/green-feet-teach.md @@ -0,0 +1,6 @@ +--- +"wowds-tokens": patch +"wowds-theme": patch +--- + +zIndex 토큰을 추가합니다. diff --git a/.changeset/pretty-cooks-join.md b/.changeset/pretty-cooks-join.md new file mode 100644 index 00000000..06c59008 --- /dev/null +++ b/.changeset/pretty-cooks-join.md @@ -0,0 +1,5 @@ +--- +"wowds-icons": patch +--- + +Icon 공통 타입을 내보내기합니다. diff --git a/.changeset/seven-eggs-wait.md b/.changeset/seven-eggs-wait.md new file mode 100644 index 00000000..55c9b204 --- /dev/null +++ b/.changeset/seven-eggs-wait.md @@ -0,0 +1,5 @@ +--- +"wowds-ui": patch +--- + +Toast 컴포넌트를 추가합니다. diff --git a/apps/wow-docs/styled-system/tokens/index.js b/apps/wow-docs/styled-system/tokens/index.js index 930662c7..a6ec6779 100644 --- a/apps/wow-docs/styled-system/tokens/index.js +++ b/apps/wow-docs/styled-system/tokens/index.js @@ -343,6 +343,10 @@ const tokens = { value: 10, variable: "var(--z-index-dropdown)", }, + "zIndex.overlay": { + value: 9999, + variable: "var(--z-index-overlay)", + }, "shadows.blue": { value: "0px 4px 8px 0px rgba(16, 43, 74, 0.2)", variable: "var(--shadows-blue)", diff --git a/apps/wow-docs/styled-system/tokens/tokens.d.ts b/apps/wow-docs/styled-system/tokens/tokens.d.ts index 24b9c593..45319136 100644 --- a/apps/wow-docs/styled-system/tokens/tokens.d.ts +++ b/apps/wow-docs/styled-system/tokens/tokens.d.ts @@ -86,6 +86,7 @@ export type Token = | "borderWidths.button" | "borderWidths.arrow" | "zIndex.dropdown" + | "zIndex.overlay" | "shadows.blue" | "shadows.mono" | "breakpoints.xs" @@ -323,7 +324,7 @@ export type RadiusToken = "sm" | "md" | "full"; export type BorderWidthToken = "button" | "arrow"; -export type ZIndexToken = "dropdown"; +export type ZIndexToken = "dropdown" | "overlay"; export type ShadowToken = "blue" | "mono"; diff --git a/packages/scripts/generateBuildConfig.ts b/packages/scripts/generateBuildConfig.ts index c9bed9e1..613353d5 100644 --- a/packages/scripts/generateBuildConfig.ts +++ b/packages/scripts/generateBuildConfig.ts @@ -26,17 +26,22 @@ const excludedComponents = [ "CollectionContext", "DropDownOptionList", "pickerComponents", + "ToastContext", ]; +// 추가할 컴포넌트 목록 +const includedComponents = ["useToast"]; + const getFilteredComponentFiles = async (directoryPath: string) => { const files = await fs.readdir(directoryPath, { recursive: true }); return files.filter( (file) => - file.endsWith(".tsx") && - !file.includes("test") && - !file.includes("stories") && - !excludedComponents.some((excluded) => file.includes(excluded)) + (file.endsWith(".tsx") && + !file.includes("test") && + !file.includes("stories") && + !excludedComponents.some((excluded) => file.includes(excluded))) || + includedComponents.some((included) => file.includes(included)) ); }; diff --git a/packages/scripts/generateReactComponentFromSvg.ts b/packages/scripts/generateReactComponentFromSvg.ts index a08f184c..ad7ec5db 100644 --- a/packages/scripts/generateReactComponentFromSvg.ts +++ b/packages/scripts/generateReactComponentFromSvg.ts @@ -138,7 +138,9 @@ const generateExportFile = async (components: string[]) => { ) .join("\n"); - await fs.writeFile(EXPORT_FILE_PATH, exportFileContent); + const resolvedExportFileContent = `export * from "../types/Icon.ts";\n${exportFileContent}`; + + await fs.writeFile(EXPORT_FILE_PATH, resolvedExportFileContent); }; (async () => { diff --git a/packages/wow-icons/package.json b/packages/wow-icons/package.json index 447ce8c2..d01d72fd 100644 --- a/packages/wow-icons/package.json +++ b/packages/wow-icons/package.json @@ -16,7 +16,6 @@ "package.json" ], "type": "module", - "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/component/index.d.ts", diff --git a/packages/wow-icons/src/component/index.ts b/packages/wow-icons/src/component/index.ts index 6e0a57b6..80f5a998 100644 --- a/packages/wow-icons/src/component/index.ts +++ b/packages/wow-icons/src/component/index.ts @@ -1,3 +1,4 @@ +export * from "../types/Icon.ts"; export { default as BlueAvatar } from "./BlueAvatar.tsx"; export { default as Calendar } from "./Calendar.tsx"; export { default as Check } from "./Check.tsx"; diff --git a/packages/wow-theme/src/tokens/zIndex.ts b/packages/wow-theme/src/tokens/zIndex.ts index 879fc4ab..628e6787 100644 --- a/packages/wow-theme/src/tokens/zIndex.ts +++ b/packages/wow-theme/src/tokens/zIndex.ts @@ -5,4 +5,7 @@ export const zIndex = defineTokens.zIndex({ dropdown: { value: wowZIndex.dropdown, }, + overlay: { + value: wowZIndex.overlay, + }, }); diff --git a/packages/wow-tokens/src/zIndex.ts b/packages/wow-tokens/src/zIndex.ts index fb2a7f3f..79e30bc3 100644 --- a/packages/wow-tokens/src/zIndex.ts +++ b/packages/wow-tokens/src/zIndex.ts @@ -1 +1,2 @@ export const dropdown = 10; +export const overlay = 9999; diff --git a/packages/wow-ui/package.json b/packages/wow-ui/package.json index a1278468..8d6e35a2 100644 --- a/packages/wow-ui/package.json +++ b/packages/wow-ui/package.json @@ -20,6 +20,21 @@ "type": "module", "exports": { "./styles.css": "./dist/styles.css", + "./ToastProvider": { + "types": "./dist/components/Toast/ToastProvider.d.ts", + "require": "./dist/ToastProvider.cjs", + "import": "./dist/ToastProvider.js" + }, + "./Toast": { + "types": "./dist/components/Toast/index.d.ts", + "require": "./dist/Toast.cjs", + "import": "./dist/Toast.js" + }, + "./useToast": { + "types": "./dist/components/Toast/useToast.d.ts", + "require": "./dist/useToast.cjs", + "import": "./dist/useToast.js" + }, "./TextField": { "types": "./dist/components/TextField/index.d.ts", "require": "./dist/TextField.cjs", @@ -179,12 +194,14 @@ "plop": "^4.0.1", "rollup-plugin-peer-deps-external": "^2.2.4", "storybook": "^8.1.9", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "@types/uuid": "^10.0.0" }, "dependencies": { "clsx": "^2.1.1", "lottie-react": "^2.4.0", "react-day-picker": "^9.0.8", + "uuid": "^10.0.0", "wowds-icons": "workspace:^" }, "peerDependencies": { diff --git a/packages/wow-ui/rollup.config.js b/packages/wow-ui/rollup.config.js index 28c0c666..b0a3ef70 100644 --- a/packages/wow-ui/rollup.config.js +++ b/packages/wow-ui/rollup.config.js @@ -20,6 +20,9 @@ process.env.BABEL_ENV = "production"; export default { input: { + ToastProvider: "./src/components/Toast/ToastProvider", + Toast: "./src/components/Toast", + useToast: "./src/components/Toast/useToast", TextField: "./src/components/TextField", TextButton: "./src/components/TextButton", Tag: "./src/components/Tag", diff --git a/packages/wow-ui/src/components/Toast/Toast.stories.tsx b/packages/wow-ui/src/components/Toast/Toast.stories.tsx new file mode 100644 index 00000000..11451a3f --- /dev/null +++ b/packages/wow-ui/src/components/Toast/Toast.stories.tsx @@ -0,0 +1,173 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect } from "react"; +import { Warn } from "wowds-icons"; + +import Button from "@/components/Button"; + +import Toast from "."; +import ToastProvider from "./ToastProvider"; +import useToast from "./useToast"; + +const meta: Meta = { + title: "UI/Toast", + component: Toast, + tags: ["autodocs"], + parameters: { + componentSubtitle: "Toast 컴포넌트", + a11y: { + config: { + rules: [{ id: "color-contrast", enabled: false }], + }, + }, + docs: { + description: { + component: + "토스트가 필요한 레이아웃에서 children을 ToastProvider로 감싸 사용합니다.", + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + argTypes: { + text: { + description: "Toast에 들어갈 메인 텍스트를 나타냅니다.", + control: { type: "text" }, + }, + subText: { + description: "Toast에 들어갈 보조 텍스트를 나타냅니다.", + control: { type: "text" }, + }, + rightIcon: { + description: "Toast의 우측에 들어갈 아이콘을 나타냅니다.", + table: { + type: { summary: "none | close | arrow" }, + defaultValue: { summary: "none" }, + }, + control: "radio", + options: ["none", "close", "arrow"], + }, + id: { + description: "Toast 컴포넌트의 id를 나타냅니다.", + control: false, + }, + onClickArrowIcon: { + description: + "Toast 컴포넌트의 화살표 아이콘을 클릭했을 때 호출되는 함수를 나타냅니다.", + control: false, + }, + onRemove: { + description: "Toast 컴포넌트가 닫힌 이후 호출되는 함수를 나타냅니다.", + control: false, + }, + showLeftIcon: { + description: "Toast 좌측에 들어갈 아이콘의 노출 여부를 나타냅니다.", + control: "boolean", + }, + toastDuration: { + description: "Toast가 보여지는 시간(ms)을 나타냅니다.", + control: { type: "number" }, + }, + style: { + description: "Toast에 커스텀 스타일을 적용하기 위한 객체를 나타냅니다.", + control: false, + }, + className: { + description: "Toast에 커스텀 클래스를 적용하기 위한 문자열을 나타냅니다.", + control: false, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + id: "1", + text: "Text", + subText: "subtext", + toastDuration: 60 * 60 * 1000, + }, +}; + +export const WithTrigger = () => { + const { toast } = useToast(); + + return ( + + ); +}; + +export const WithCloseIcon = () => { + const { toast } = useToast(); + useEffect(() => { + toast({ + text: "Text", + subText: "subtext", + rightIcon: "close", + }); + }, []); +}; + +export const WithArrowIcon = () => { + const { toast } = useToast(); + useEffect(() => { + toast({ + text: "Text", + subText: "subtext", + rightIcon: "arrow", + }); + }, []); +}; + +export const WithLeftIcon = () => { + const { toast } = useToast(); + useEffect(() => { + toast({ + text: "Text", + subText: "subtext", + showLeftIcon: true, + }); + }, []); +}; + +export const WithLeftAndArrowIcons = () => { + const { toast } = useToast(); + useEffect(() => { + toast({ + text: "Text", + subText: "subtext", + showLeftIcon: true, + rightIcon: "arrow", + }); + }, []); +}; + +export const TwoLines = () => { + const { toast } = useToast(); + useEffect(() => { + toast({ + showLeftIcon: true, + text: "TextTextTextTextTextTextTextTextTextTextTextTextTextTextTextText", + }); + }, []); +}; + +export const Slow = () => { + const { toast } = useToast(); + useEffect(() => { + toast({ + text: "Text", + subText: "subtext", + toastDuration: 5000, + }); + }, []); +}; diff --git a/packages/wow-ui/src/components/Toast/ToastContext.ts b/packages/wow-ui/src/components/Toast/ToastContext.ts new file mode 100644 index 00000000..aff6f0f2 --- /dev/null +++ b/packages/wow-ui/src/components/Toast/ToastContext.ts @@ -0,0 +1,22 @@ +import { createContext } from "react"; + +import useSafeContext from "@/hooks/useSafeContext"; + +import type { ToastProps } from "."; + +interface ToastContextProps { + toasts: ToastProps[]; + addToast: ( + toast: Omit & Partial> + ) => void; + removeToast: (id: string) => void; +} + +export const ToastContext = createContext( + undefined +); + +export const useToastContext = () => { + const context = useSafeContext(ToastContext); + return context; +}; diff --git a/packages/wow-ui/src/components/Toast/ToastProvider.tsx b/packages/wow-ui/src/components/Toast/ToastProvider.tsx new file mode 100644 index 00000000..aa766870 --- /dev/null +++ b/packages/wow-ui/src/components/Toast/ToastProvider.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Flex } from "@styled-system/jsx"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { v4 as uuidv4 } from "uuid"; + +import type { ToastProps } from "."; +import Toast from "."; +import { ToastContext } from "./ToastContext"; + +interface ToastProviderProps { + children: ReactNode; + idPattern?: RegExp; +} + +const ToastProvider = ({ children, idPattern }: ToastProviderProps) => { + const [toasts, setToasts] = useState([]); + + const addToast = ( + props: Omit & Partial> + ) => { + const newToast = { + ...props, + id: props.id || uuidv4(), + }; + + setToasts((prev) => [...prev, newToast]); + }; + + const removeToast = (id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }; + + const filteredToasts = idPattern + ? toasts.filter((toast) => idPattern.test(toast.id)) + : toasts; + + return ( + + {toasts.length > 0 && ( + + {filteredToasts?.map((toast: ToastProps) => ( + + ))} + + )} + {children} + + ); +}; + +export default ToastProvider; diff --git a/packages/wow-ui/src/components/Toast/index.tsx b/packages/wow-ui/src/components/Toast/index.tsx new file mode 100644 index 00000000..efd10216 --- /dev/null +++ b/packages/wow-ui/src/components/Toast/index.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { css } from "@styled-system/css"; +import type { FlexProps } from "@styled-system/jsx"; +import { Flex, styled } from "@styled-system/jsx"; +import type { CSSProperties } from "react"; +import { forwardRef, useEffect, useState } from "react"; +import type { IconProps } from "wowds-icons"; +import { Close, RightArrow, Warn } from "wowds-icons"; + +import useToast from "./useToast"; + +/** + * @description 토스트 컴포넌트입니다. + * + * @param {string} id - 토스트 컴포넌트의 id. + * @param {"default"|"close"|"arrow"} [type] - 토스트 컴포넌트의 타입. + * @param {string} text - 토스트 컴포넌트의 메인 텍스트. + * @param {ReactNode} icon - 토스트 컴포넌트의 좌측에 들어갈 아이콘. + * @param {()=>void} [onClickArrowIcon] - 화살표 아이콘을 클릭했을 때 호출되는 함수. + * @param {()=>void} [onRemove] - 토스트 컴포넌트가 닫히고 나서 호출되는 함수. + * @param {string} [subText] - 토스트 컴포넌트의 보조 텍스트. + * @param {string} [toastDuration] - 토스트 컴포넌트의 보여지는 시간. + * @param {CSSProperties} [style] - 커스텀 스타일을 적용하기 위한 객체. + * @param {string} [className] - 커스텀 클래스를 적용하기 위한 문자열. + */ + +export interface ToastProps extends FlexProps { + id: string; + showLeftIcon?: boolean; + rightIcon?: "none" | "close" | "arrow"; + text: string; + onClickArrowIcon?: () => void; + onRemove?: () => void; + subText?: string; + toastDuration?: number; + style?: CSSProperties; + className?: string; +} + +const Toast = forwardRef( + ({ + id, + text, + subText, + onClickArrowIcon, + onRemove, + rightIcon = "none", + showLeftIcon = false, + toastDuration, + ...rest + }: ToastProps) => { + const TOAST_DURATION = toastDuration || 2000; + const ANIMATION_DURATION = 200; + const { removeToast } = useToast(); + + const RightIcon = (props: IconProps) => { + if (rightIcon === "close") + return removeToast(id)} {...props} />; + else if (rightIcon === "arrow") + return ; + return null; + }; + + const [opacity, setOpacity] = useState(0.2); + + useEffect(() => { + setOpacity(1); + const timeoutForRemove = setTimeout(() => { + removeToast(id); + onRemove?.(); + }, TOAST_DURATION); + + const timeoutForVisible = setTimeout(() => { + setOpacity(0); + }, TOAST_DURATION - ANIMATION_DURATION); + + return () => { + clearTimeout(timeoutForRemove); + clearTimeout(timeoutForVisible); + }; + }, [id, removeToast]); + + return ( + + + {showLeftIcon && } + + + {text} + + {subText && ( + + {subText} + + )} + + + + + ); + } +); + +const toastContainerStyle = css({ + width: "22.375rem", + height: "fit-content", + padding: "0.75rem 1rem", + + borderRadius: "md", + + background: "backgroundDimmer", + backdropFilter: "blur(30px)", + boxShadow: "mono", +}); + +Toast.displayName = "Toast"; +export default Toast; diff --git a/packages/wow-ui/src/components/Toast/useToast.ts b/packages/wow-ui/src/components/Toast/useToast.ts new file mode 100644 index 00000000..31c618d7 --- /dev/null +++ b/packages/wow-ui/src/components/Toast/useToast.ts @@ -0,0 +1,12 @@ +import { useToastContext } from "./ToastContext"; + +const useToast = () => { + const { addToast, removeToast } = useToastContext(); + + return { + toast: addToast, + removeToast, + }; +}; + +export default useToast; diff --git a/packages/wow-ui/styled-system/styles.css b/packages/wow-ui/styled-system/styles.css index 49dc1210..47d2f26a 100644 --- a/packages/wow-ui/styled-system/styles.css +++ b/packages/wow-ui/styled-system/styles.css @@ -1,272 +1 @@ -:host, -html { - --font-fallback: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - -webkit-text-size-adjust: 100%; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -moz-tab-size: 4; - tab-size: 4; - -webkit-tap-highlight-color: transparent; - line-height: 1.5; - font-family: var(--global-font-body, var(--font-fallback)); -} -*, -::backdrop, -::file-selector-button, -:after, -:before { - margin: 0px; - padding: 0px; - box-sizing: border-box; - border-width: 0px; - border-style: solid; - border-color: var(--global-color-border, currentColor); -} -hr { - height: 0px; - color: inherit; - border-top-width: 1px; -} -body { - height: 100%; - line-height: inherit; -} -img { - border-style: none; -} -audio, -canvas, -embed, -iframe, -img, -object, -svg, -video { - display: block; - vertical-align: middle; -} -img, -video { - max-width: 100%; - height: auto; -} -h1, -h2, -h3, -h4, -h5, -h6 { - text-wrap: balance; - font-size: inherit; - font-weight: inherit; -} -h1, -h2, -h3, -h4, -h5, -h6, -p { - overflow-wrap: break-word; -} -menu, -ol, -ul { - list-style: none; -} -::file-selector-button, -button, -input:where([type="button"], [type="reset"], [type="submit"]) { - appearance: button; - -webkit-appearance: button; -} -::file-selector-button, -button, -input, -optgroup, -select, -textarea { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - background: transparent; -} -::placeholder { - opacity: 1; - --placeholder-fallback: color-mix(in srgb, currentColor 50%, transparent); - color: var(--global-color-placeholder, var(--placeholder-fallback)); -} -textarea { - resize: vertical; -} -table { - text-indent: 0px; - border-collapse: collapse; - border-color: inherit; -} -summary { - display: list-item; -} -small { - font-size: 80%; -} -sub, -sup { - position: relative; - vertical-align: baseline; - font-size: 75%; - line-height: 0; -} -sub { - bottom: -0.25em; -} -sup { - top: -0.5em; -} -dialog { - padding: 0px; -} -a { - color: inherit; - text-decoration: inherit; -} -abbr:where([title]) { - text-decoration: underline dotted; -} -b, -strong { - font-weight: bolder; -} -code, -kbd, -pre, -samp { - --font-mono-fallback: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - "Liberation Mono", "Courier New"; - font-feature-settings: normal; - font-variation-settings: normal; - font-family: var(--global-font-mono, var(--font-mono-fallback)); - font-size: 1em; -} -progress { - vertical-align: baseline; -} -::-webkit-search-cancel-button, -::-webkit-search-decoration { - -webkit-appearance: none; -} -::-webkit-inner-spin-button, -::-webkit-outer-spin-button { - height: auto; -} -:-moz-ui-invalid { - box-shadow: none; -} -:-moz-focusring { - outline: auto; -} -[hidden]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - display: none !important; -} -:where(:root, :host):not(#\#):not(#\#) { - --colors-red-400: #ee695d; - --colors-red-500: #ea4335; - --colors-blue-100: #d7e9fd; - --colors-blue-400: #5ea5f9; - --colors-primary: #368ff7; - --colors-dark-disabled: #c2c2c2; - --colors-blue-pressed: #5ea5f9; - --colors-blue-background-pressed: #ebf4fe; -} -.textStyle_body2:not(#\#):not(#\#):not(#\#):not(#\#) { - letter-spacing: -0.00875rem; - font-size: 0.875rem; - line-height: 160%; - font-weight: 500; -} -.bg_blue\.100:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - background: var(--colors-blue-100); -} -.px_4:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - padding-inline: 4px; -} -.py_3:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - padding-block: 3px; -} -.rounded_md:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - border-radius: md; -} -.bg_red\.400:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - background: var(--colors-red-400); -} -.w_20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - width: 20px; -} -.h_20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - height: 20px; -} -.rounded_9999:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - border-radius: 9999px; -} -.d_flex:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - display: flex; -} -.bg_blueBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - background: var(--colors-blue-background-pressed); -} -.w_10:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - width: 10px; -} -.h_10:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - height: 10px; -} -.bg_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - background: var(--colors-primary); -} -.gap_8:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - gap: 8px; -} -.d_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - display: none; -} -.gap_0\.5rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - gap: 0.5rem; -} -.font_Inter:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - font-family: Inter; -} -.border-w_1:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - border-width: 1px; -} -.items_center:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - align-items: center; -} -.justify_center:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - justify-content: center; -} -.border_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - border-color: var(--colors-primary); -} -.border_darkDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - border-color: var(--colors-dark-disabled); -} -.border_bluePressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - border-color: var(--colors-blue-pressed); -} -.flex_column:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#) { - flex-direction: column; -} -.hover\:bg_blue\.400:is(:hover, [data-hover]):not(#\#):not(#\#):not(#\#):not( - #\# - ):not(#\#) { - background: var(--colors-blue-400); -} -.hover\:bg_red\.500:is(:hover, [data-hover]):not(#\#):not(#\#):not(#\#):not( - #\# - ):not(#\#) { - background: var(--colors-red-500); -} +:host,html{--font-fallback:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji';-webkit-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent;line-height:1.5;font-family:var(--global-font-body,var(--font-fallback))}*,::backdrop,::file-selector-button,:after,:before{margin:0px;padding:0px;box-sizing:border-box;border-width:0px;border-style:solid;border-color:var(--global-color-border,currentColor)}hr{height:0px;color:inherit;border-top-width:1px}body{height:100%;line-height:inherit}img{border-style:none}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}h1,h2,h3,h4,h5,h6{text-wrap:balance;font-size:inherit;font-weight:inherit}h1,h2,h3,h4,h5,h6,p{overflow-wrap:break-word}menu,ol,ul{list-style:none}::file-selector-button,button,input:where([type=button],[type=reset],[type=submit]){appearance:button;-webkit-appearance:button}::file-selector-button,button,input,optgroup,select,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;background:transparent}::placeholder{opacity:1;--placeholder-fallback:color-mix(in srgb,currentColor 50%,transparent);color:var(--global-color-placeholder,var(--placeholder-fallback))}textarea{resize:vertical}table{text-indent:0px;border-collapse:collapse;border-color:inherit}summary{display:list-item}small{font-size:80%}sub,sup{position:relative;vertical-align:baseline;font-size:75%;line-height:0}sub{bottom:-0.25em}sup{top:-0.5em}dialog{padding:0px}a{color:inherit;text-decoration:inherit}abbr:where([title]){text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{--font-mono-fallback:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New';font-feature-settings:normal;font-variation-settings:normal;font-family:var(--global-font-mono,var(--font-mono-fallback));font-size:1em}progress{vertical-align:baseline}::-webkit-search-cancel-button,::-webkit-search-decoration{-webkit-appearance:none}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}:-moz-ui-invalid{box-shadow:none}:-moz-focusring{outline:auto}[hidden]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){display:none!important}body:not(#\#){div:border-box;button:border-box}:where(:root,:host):not(#\#):not(#\#){--colors-red-50:#FDECEB;--colors-red-100:#FBD9D7;--colors-red-150:#F9C7C2;--colors-red-200:#F7B4AE;--colors-red-300:#F28E86;--colors-red-400:#EE695D;--colors-red-500:#EA4335;--colors-red-600:#BB362A;--colors-red-700:#8C2820;--colors-red-800:#5E1B15;--colors-red-850:#461410;--colors-red-900:#2F0D0B;--colors-red-950:#170705;--colors-blue-50:#EBF4FE;--colors-blue-100:#D7E9FD;--colors-blue-150:#C3DDFD;--colors-blue-200:#AFD2FC;--colors-blue-300:#86BCFA;--colors-blue-400:#5EA5F9;--colors-blue-500:#368FF7;--colors-blue-600:#2B72C6;--colors-blue-700:#205694;--colors-blue-800:#163963;--colors-blue-850:#102B4A;--colors-blue-900:#0B1D31;--colors-blue-950:#050E19;--colors-yellow-50:#FEF7E6;--colors-yellow-100:#FEEECC;--colors-yellow-150:#FDE6B3;--colors-yellow-200:#FDDD99;--colors-yellow-300:#FBCD66;--colors-yellow-400:#FABC33;--colors-yellow-500:#F9AB00;--colors-yellow-600:#C78900;--colors-yellow-700:#956700;--colors-yellow-800:#644400;--colors-yellow-850:#4B3300;--colors-yellow-900:#322200;--colors-yellow-950:#191100;--colors-green-50:#EBF6EE;--colors-green-100:#D6EEDD;--colors-green-150:#C2E5CB;--colors-green-200:#AEDCBA;--colors-green-300:#85CB98;--colors-green-400:#5DB975;--colors-green-500:#34A853;--colors-green-600:#2A8642;--colors-green-700:#1F6532;--colors-green-800:#154321;--colors-green-850:#103219;--colors-green-900:#0A2211;--colors-green-950:#051108;--colors-mono-50:#F7F7F7;--colors-mono-100:#F0F0F0;--colors-mono-150:#E8E8E8;--colors-mono-200:#E1E1E1;--colors-mono-300:#D1D1D1;--colors-mono-400:#C2C2C2;--colors-mono-500:#B3B3B3;--colors-mono-600:#8F8F8F;--colors-mono-700:#6B6B6B;--colors-mono-800:#484848;--colors-mono-900:#242424;--colors-mono-950:#121212;--colors-white:#FFFFFF;--colors-black:#000000;--spacing-xxs:0.25rem;--spacing-xs:0.5rem;--spacing-sm:0.75rem;--spacing-lg:1.25rem;--spacing-xl:1.5rem;--radii-sm:0.25rem;--radii-md:0.5rem;--radii-full:2.5rem;--border-widths-button:1px;--z-index-dropdown:10;--shadows-blue:0px 4px 8px 0px rgba(16,43,74,0.2);--shadows-mono:0px 4px 8px 0px rgba(0,0,0,0.2);--colors-primary:#368FF7;--colors-success:#2A8642;--colors-error:#BB362A;--colors-background-normal:#FFFFFF;--colors-background-alternative:#F7F7F7;--colors-background-dimmer:rgba(0,0,0,0.8);--colors-sub:#6B6B6B;--colors-outline:#C2C2C2;--colors-text-black:#121212;--colors-text-white:#FFFFFF;--colors-dark-disabled:#C2C2C2;--colors-light-disabled:#E1E1E1;--colors-blue-hover:#2B72C6;--colors-mono-hover:#121212;--colors-elevated-hover:rgba(16,43,74,0.2);--colors-blue-pressed:#5EA5F9;--colors-blue-background-pressed:#EBF4FE;--colors-mono-background-pressed:#F7F7F7;--colors-shadow-small:rgba(0,0,0,0.1);--colors-shadow-medium:rgba(0,0,0,0.2);--colors-blue-shadow:rgba(16,43,74,0.2);--colors-discord:#5566FB;--colors-github:#000000;--colors-secondary-yellow:#F9AB00;--colors-secondary-green:#34A853;--colors-secondary-red:#EA4335;--colors-error-background:#FBD9D7;--colors-blue-disabled:#D7E9FD;--colors-text-blue-disabled:#AFD2FC}.textStyle_h3:not(#\#):not(#\#):not(#\#):not(#\#){letter-spacing:-0.01rem;font-size:1rem;line-height:130%;font-weight:600}.textStyle_body1:not(#\#):not(#\#):not(#\#):not(#\#){letter-spacing:-0.01rem;font-size:1rem;line-height:160%;font-weight:500}.textStyle_label3:not(#\#):not(#\#):not(#\#):not(#\#){font-size:0.75rem;line-height:100%;font-weight:600}.textStyle_label1:not(#\#):not(#\#):not(#\#):not(#\#){letter-spacing:-0.01rem;font-size:1rem;line-height:100%;font-weight:600}.textStyle_label2:not(#\#):not(#\#):not(#\#):not(#\#){letter-spacing:-0.01rem;font-size:0.875rem;line-height:100%;font-weight:600}.textStyle_h2:not(#\#):not(#\#):not(#\#):not(#\#){letter-spacing:-0.01125rem;font-size:1.125rem;line-height:130%;font-weight:600}.textStyle_body2:not(#\#):not(#\#):not(#\#):not(#\#){letter-spacing:-0.00875rem;font-size:0.875rem;line-height:160%;font-weight:500}.textStyle_body3:not(#\#):not(#\#):not(#\#):not(#\#){font-size:0.75rem;line-height:140%;font-weight:500}.text_blue\.50:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-50)}.text_blue\.100:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-100)}.text_blue\.150:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-150)}.text_blue\.200:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-200)}.text_blue\.300:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-300)}.text_blue\.400:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-400)}.text_blue\.500:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-500)}.text_blue\.600:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-600)}.text_blue\.700:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-700)}.text_blue\.800:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-800)}.text_blue\.850:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-850)}.text_blue\.900:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-900)}.text_blue\.950:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-950)}.text_yellow\.50:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-50)}.text_yellow\.100:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-100)}.text_yellow\.150:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-150)}.text_yellow\.200:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-200)}.text_yellow\.300:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-300)}.text_yellow\.400:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-400)}.text_yellow\.500:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-500)}.text_yellow\.600:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-600)}.text_yellow\.700:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-700)}.text_yellow\.800:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-800)}.text_yellow\.850:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-850)}.text_yellow\.900:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-900)}.text_yellow\.950:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-yellow-950)}.text_green\.50:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-50)}.text_green\.100:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-100)}.text_green\.150:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-150)}.text_green\.200:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-200)}.text_green\.300:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-300)}.text_green\.400:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-400)}.text_green\.500:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-500)}.text_green\.600:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-600)}.text_green\.700:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-700)}.text_green\.800:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-800)}.text_green\.850:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-850)}.text_green\.900:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-900)}.text_green\.950:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-green-950)}.text_red\.50:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-50)}.text_red\.100:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-100)}.text_red\.150:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-150)}.text_red\.200:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-200)}.text_red\.300:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-300)}.text_red\.400:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-400)}.text_red\.500:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-500)}.text_red\.600:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-600)}.text_red\.700:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-700)}.text_red\.800:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-800)}.text_red\.850:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-850)}.text_red\.900:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-900)}.text_red\.950:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-red-950)}.text_mono\.50:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-50)}.text_mono\.100:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-100)}.text_mono\.150:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-150)}.text_mono\.200:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-200)}.text_mono\.300:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-300)}.text_mono\.400:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-400)}.text_mono\.500:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-500)}.text_mono\.600:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-600)}.text_mono\.700:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-700)}.text_mono\.800:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-800)}.text_mono\.850:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:mono.850}.text_mono\.900:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-900)}.text_mono\.950:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-950)}.text_white:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-white)}.text_black:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-black)}.text_whiteOpacity\.20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:whiteOpacity.20}.text_whiteOpacity\.40:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:whiteOpacity.40}.text_whiteOpacity\.60:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:whiteOpacity.60}.text_whiteOpacity\.80:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:whiteOpacity.80}.text_blackOpacity\.20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:blackOpacity.20}.text_blackOpacity\.40:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:blackOpacity.40}.text_blackOpacity\.60:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:blackOpacity.60}.text_blackOpacity\.80:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:blackOpacity.80}.text_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-primary)}.text_success:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-success)}.text_error:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-error)}.text_backgroundNormal:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-background-normal)}.text_backgroundAlternative:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-background-alternative)}.text_backgroundDimmer:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-background-dimmer)}.text_errorBackground:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-error-background)}.text_sub:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-sub)}.text_outline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-outline)}.text_textBlack:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-text-black)}.text_textWhite:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-text-white)}.text_darkDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-dark-disabled)}.text_lightDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-light-disabled)}.text_blueDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-disabled)}.text_textBlueDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-text-blue-disabled)}.text_blueHover:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-hover)}.text_monoHover:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-hover)}.text_elevatedHover:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-elevated-hover)}.text_bluePressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-pressed)}.text_blueBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-background-pressed)}.text_monoBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-mono-background-pressed)}.text_shadowSmall:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-shadow-small)}.text_shadowMedium:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-shadow-medium)}.text_blueShadow:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-shadow)}.text_discord:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-discord)}.text_github:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-github)}.text_secondaryYellow:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-secondary-yellow)}.text_secondaryGreen:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-secondary-green)}.text_secondaryRed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-secondary-red)}.text_blueGradientDark:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:blueGradientDark}.text_blueGradientLight:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:blueGradientLight}.text_redGradientDark:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:redGradientDark}.text_redGradientLight:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:redGradientLight}.text_greenGradientDark:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:greenGradientDark}.text_greenGradientLight:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:greenGradientLight}.text_yellowGradientDark:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:yellowGradientDark}.text_yellowGradientLight:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:yellowGradientLight}.d_flex:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){display:flex}.gap_sm:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:var(--spacing-sm)}.gap_xs:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:var(--spacing-xs)}.text_GrayText:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:GrayText}.gap_lg:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:var(--spacing-lg)}.w_100\%:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:100%}.gap_xxs:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:var(--spacing-xxs)}.h_20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:20px}.w_20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:20px}.h_24:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:24px}.w_24:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:24px}.px_xl:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-inline:var(--spacing-xl)}.rounded_md:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:var(--radii-md)}.border_1px_solid:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border:1px solid}.cursor_pointer:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:pointer}.cursor_default:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:default}.p_1rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding:1rem}.p_0\.75rem_1\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding:0.75rem 1.25rem}.rounded_full:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:var(--radii-full)}.bg_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-primary)}.bg_background:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:background}.bg_blueBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-background-pressed)}.cursor_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:none}.gap_0px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:0px}.pointer-events_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){pointer-events:none}.pointer-events_auto:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){pointer-events:auto}.w_fit-content:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:fit-content}.pos_relative:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){position:relative}.pos_absolute:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){position:absolute}.transform_translate\(-50\%\,_-50\%\):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transform:translate(-50%,-50%)}.stroke_darkDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){stroke:var(--colors-dark-disabled)}.stroke_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){stroke:var(--colors-primary)}.appearance_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){appearance:none;-webkit-appearance:none}.w_1\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:1.25rem}.h_1\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:1.25rem}.rounded_sm:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:var(--radii-sm)}.ring_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){outline:2px solid transparent;outline-offset:2px}.cursor_inherit:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:inherit}.bg_white:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-white)}.bg_lightDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-light-disabled)}.d_inline-block:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){display:inline-block}.min-w_3\.5rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){min-width:3.5rem}.h_1\.875rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:1.875rem}.rounded_1\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:1.25rem}.py_xs:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-block:var(--spacing-xs)}.px_sm:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-inline:var(--spacing-sm)}.h_0\.075rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:0.075rem}.rounded_100\%:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:100%}.vis_visible:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){visibility:visible}.vis_hidden:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){visibility:hidden}.z_dropdown:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){z-index:var(--z-index-dropdown)}.max-h_18\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){max-height:18.75rem}.overflow_auto:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){overflow:auto}.stroke_sub:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){stroke:var(--colors-sub)}.stroke_outline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){stroke:var(--colors-outline)}.transition_transform_1s_ease:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transition:transform 1s ease}.transform_rotate\(180deg\):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transform:rotate(180deg)}.transform_rotate\(0deg\):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transform:rotate(0deg)}.w_auto:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:auto}.pos_horizontal:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){position:horizontal}.gap_20:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:20px}.gap_0\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:0.75rem}.gap_2\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:2.25rem}.w_19\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:19.75rem}.w_2\.3125rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:2.3125rem}.px_0\.4rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-inline:0.4rem}.text-align_center:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){text-align:center}.bg_backgroundAlternative:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-background-alternative)}.rounded_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:0}.bg_monoBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-mono-background-pressed)}.px_1rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-inline:1rem}.py_1\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-block:1.25rem}.shadow_mono:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){box-shadow:var(--shadows-mono)}.w_17\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:17.75rem}.bg_Background:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:Background}.cursor_not-allowed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:not-allowed}.h_2\.625rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:2.625rem}.max-h_7\.5rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){max-height:7.5rem}.h_1\.2px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:1.2px}.min-w_17\.375rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){min-width:17.375rem}.select_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){-webkit-user-select:none;user-select:none}.transform_translateX\(-50\%\):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transform:translateX(-50%)}.h_1\.5rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:1.5rem}.w_1\.5rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:1.5rem}.d_inline-flex:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){display:inline-flex}.w_3\.25rem\!:not(#\#):not(#\#){width:3.25rem!important}.h_1\.75rem\!:not(#\#):not(#\#){height:1.75rem!important}.opacity_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){opacity:0}.w_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:0}.h_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:0}.overflow_hidden:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){overflow:hidden}.rounded_50\%:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-radius:50%}.bg_backgroundNormal:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-background-normal)}.bg_darkDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-dark-disabled)}.px_xs:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-inline:var(--spacing-xs)}.py_xxs:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-block:var(--spacing-xxs)}.border_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border:none}.text-decor_underline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){text-decoration:underline}.resize_none:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){resize:none}.h_100vh:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:100vh}.pos_fixed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){position:fixed}.w_100vw:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:100vw}.z_9999:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){z-index:9999}.w_14:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:14px}.transition_opacity:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transition-property:var(--transition-prop,opacity);transition-timing-function:var(--transition-easing,cubic-bezier(0.4,0,0.2,1));transition-duration:var(--transition-duration,150ms)}.gap_0\.25rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){gap:0.25rem}.break_break-all:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){word-break:break-all}.w_22\.375rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:22.375rem}.h_fit-content:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){height:fit-content}.p_0\.75rem_1rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding:0.75rem 1rem}.bg_backgroundDimmer:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-background-dimmer)}.backdrop_blur\(30px\):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){backdrop-filter:blur(30px);-webkit-backdrop-filter:blur(30px)}.flex_column:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){flex-direction:column}.flex_row:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){flex-direction:row}.items_center:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){align-items:center}.justify_space-between:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){justify-content:space-between}.pt_xl:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-top:var(--spacing-xl)}.pb_lg:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:var(--spacing-lg)}.bg_white:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-white)}.border_outline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-outline)}.border_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-primary)}.border_error:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-error)}.justify_center:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){justify-content:center}.border-w_1:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:1px}.border-style_solid:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-style:solid}.flex_column-reverse:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){flex-direction:column-reverse}.left_50\%:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){left:50%}.top_50\%:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){top:50%}.border_darkDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-dark-disabled)}.bg_lightDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-light-disabled)}.border-w_0\.0625rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:0.0625rem}.bg_darkDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-dark-disabled)}.bg_blueBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-background-pressed)}.top_calc\(100\%_\+_0\.5rem\):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){top:calc(100% + 0.5rem)}.left_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){left:0}.bg_backgroundNormal:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-background-normal)}.border_sub:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-sub)}.bg_background:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:background}.border_textBlack:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-text-black)}.border-w_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:0}.border-w_button:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:var(--border-widths-button)}.bg_backgroundAlternative:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-background-alternative)}.overflow-y_hidden:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){overflow-y:hidden}.bg_outline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-outline)}.bg_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-primary)}.mt_14px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){margin-top:14px}.border-w_1px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:1px}.top_0\.125rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){top:0.125rem}.left_0\.125rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){left:0.125rem}.left_1\.625rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){left:1.625rem}.bg_monoBackgroundPressed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-mono-background-pressed)}.border_secondaryRed:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-secondary-red)}.border_secondaryGreen:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-secondary-green)}.border_secondaryYellow:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-secondary-yellow)}.bg_blueDisabled:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-disabled)}.bg_errorBackground:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-error-background)}.items_flex-end:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){align-items:flex-end}.border_success:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-success)}.top_1\.5rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){top:1.5rem}.delay_0\.5:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){transition-delay:0.5px}.ease_ease-in-out:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){--transition-easing:ease-in-out;transition-timing-function:ease-in-out}.shrink_0:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){flex-shrink:0}.disabled\:bg_monoBackgroundPressed:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-mono-background-pressed)}.disabled\:text_outline:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-outline)}.disabled\:pointer-events_none:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){pointer-events:none}.disabled\:text_darkDisabled:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-dark-disabled)}.disabled\:text_blueDisabled:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-disabled)}.pressed\:bg_blueBackgroundPressed:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-background-pressed)}.scrollbarThumb\:w_2px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar-thumb,.scrollbar\:w_2px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar{width:2px}.scrollbarThumb\:h_65px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar-thumb{height:65px}.scrollbarThumb\:rounded_sm:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar-thumb{border-radius:var(--radii-sm)}.before\:w_0\.625rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{width:0.625rem}.before\:h_0\.625rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{height:0.625rem}.before\:rounded_full:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{border-radius:var(--radii-full)}.before\:bg_primary:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{background:var(--colors-primary)}.\[\&\[data-readonly\=true\]\]\:bg_lightDisabled[data-readonly=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-light-disabled)}.\[\&\[data-readonly\=true\]\]\:cursor_not-allowed[data-readonly=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:not-allowed}.\[\&\[data-pressed\=true\]\]\:bg_blueBackgroundPressed[data-pressed=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-background-pressed)}.disabled\:bg_lightDisabled:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-light-disabled)}.disabled\:cursor_not-allowed:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){cursor:not-allowed}.placeholder\:text_outline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::placeholder,.placeholder\:text_outline[data-placeholder]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-outline)}.pressed\:bg_monoBackgroundPressed:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-mono-background-pressed)}.disabled\:text_lightDisabled:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-light-disabled)}.disabled\:border_darkDisabled:is(:disabled,[disabled],[data-disabled]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-dark-disabled)}.pressed\:border_bluePressed:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-blue-pressed)}.\[\&\[data-selected\=true\]\]\:bg_blue\.500[data-selected=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-500)}.\[\&\[data-selected\=false\]\]\:bg_white[data-selected=false]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-white)}.\[\&\[data-selected\=false\]\]\:border-w_0\.0625rem[data-selected=false]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:0.0625rem}.\[\&\[data-selected\=false\]\]\:border_mono\.600[data-selected=false]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-mono-600)}.scrollbarThumb\:bg_outline:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar-thumb{background-color:var(--colors-outline)}.scrollbarTrack\:mt_2px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar-track{margin-top:2px}.scrollbarTrack\:mb_2px:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#)::-webkit-scrollbar-track{margin-bottom:2px}.pressed\:bg_monoBackgroundPressed:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-mono-background-pressed)}.before\:content_\"\":not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{content:""}.\[\&\[data-readonly\=true\]\]\:border_darkDisabled[data-readonly=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-dark-disabled)}.\[\&\[data-pressed\=true\]\]\:border_bluePressed[data-pressed=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-blue-pressed)}.pressed\:bg_bluePressed:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-pressed)}.focus\:ring_none:is(:focus,[data-focus]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){outline:2px solid transparent;outline-offset:2px}.hover\:shadow_blue:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){box-shadow:var(--shadows-blue)}.hover\:text_blueHover:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-hover)}.hover\:text_textBlack:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-text-black)}.hover\:text_sub:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-sub)}.hover\:border_blueHover:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-blue-hover)}.hover\:border_textBlack:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-text-black)}.hover\:border_sub:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-sub)}.hover\:bg_blueHover:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-hover)}.hover\:bg_sub:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-sub)}.active\:bg_bluePressed:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-pressed)}.active\:bg_blueBackgroundPressed:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-background-pressed)}.active\:text_bluePressed:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-blue-pressed)}.active\:bg_blueDisabled:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-disabled)}.active\:bg_monoBackgroundPressed:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-mono-background-pressed)}.active\:text_textBlack:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-text-black)}.active\:text_sub:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){color:var(--colors-sub)}.active\:border_bluePressed:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-blue-pressed)}.active\:border_outline:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-outline)}.active\:border-w_1:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-width:1px}.active\:border_sub:is(:active,[data-active]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-sub)}.\[\&\[data-selected\=true\]\]\:hover\:shadow_0px_0\.25rem_0\.5rem_0px_rgba\(16\,_43\,_74\,_0\.20\)[data-selected=true]:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){box-shadow:0px 0.25rem 0.5rem 0px rgba(16,43,74,0.20)}.\[\&\[data-readonly\=true\]\]\:before\:bg_darkDisabled[data-readonly=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{background:var(--colors-dark-disabled)}.\[\&\[data-pressed\=true\]\]\:before\:bg_bluePressed[data-pressed=true]:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before{background:var(--colors-blue-pressed)}.\[\&\[data-selected\=true\]\]\:hover\:bg_blue\.500[data-selected=true]:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-500)}.\[\&\[data-selected\=true\]\]\:pressed\:bg_blue\.400[data-selected=true]:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-400)}.\[\&\[data-selected\=false\]\]\:hover\:border_mono\.950[data-selected=false]:is(:hover,[data-hover]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-mono-950)}.\[\&\[data-selected\=false\]\]\:pressed\:border_mono\.400[data-selected=false]:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){border-color:var(--colors-mono-400)}.\[\&\[data-selected\=false\]\]\:pressed\:bg_mono\.50[data-selected=false]:is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-mono-50)}.hover\:pressed\:bg_blueBackgroundPressed:is(:hover,[data-hover]):is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-blue-background-pressed)}.hover\:pressed\:bg_monoBackgroundPressed:is(:hover,[data-hover]):is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background:var(--colors-mono-background-pressed)}.hover\:pressed\:bg_bluePressed:is(:hover,[data-hover]):is([aria-pressed=true],[data-pressed]):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){background-color:var(--colors-blue-pressed)}@media screen and (min-width: 56.25rem){.md\:max-w_40\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){max-width:40.75rem}.md\:min-w_19\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){min-width:19.75rem}}@media screen and (min-width: 80rem){.lgOnly\:max-w_316:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){max-width:316px}.lg\:max-w_22\.375rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){max-width:22.375rem}.lg\:min-w_19\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){min-width:19.75rem}.lg\:max-w_40\.75rem:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){max-width:40.75rem}}@media screen and (max-width: 37.4975rem){.smDown\:w_100\%:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#){width:100%}} \ No newline at end of file diff --git a/packages/wow-ui/styled-system/tokens/index.js b/packages/wow-ui/styled-system/tokens/index.js index 930662c7..a6ec6779 100644 --- a/packages/wow-ui/styled-system/tokens/index.js +++ b/packages/wow-ui/styled-system/tokens/index.js @@ -343,6 +343,10 @@ const tokens = { value: 10, variable: "var(--z-index-dropdown)", }, + "zIndex.overlay": { + value: 9999, + variable: "var(--z-index-overlay)", + }, "shadows.blue": { value: "0px 4px 8px 0px rgba(16, 43, 74, 0.2)", variable: "var(--shadows-blue)", diff --git a/packages/wow-ui/styled-system/tokens/tokens.d.ts b/packages/wow-ui/styled-system/tokens/tokens.d.ts index 24b9c593..45319136 100644 --- a/packages/wow-ui/styled-system/tokens/tokens.d.ts +++ b/packages/wow-ui/styled-system/tokens/tokens.d.ts @@ -86,6 +86,7 @@ export type Token = | "borderWidths.button" | "borderWidths.arrow" | "zIndex.dropdown" + | "zIndex.overlay" | "shadows.blue" | "shadows.mono" | "breakpoints.xs" @@ -323,7 +324,7 @@ export type RadiusToken = "sm" | "md" | "full"; export type BorderWidthToken = "button" | "arrow"; -export type ZIndexToken = "dropdown"; +export type ZIndexToken = "dropdown" | "overlay"; export type ShadowToken = "blue" | "mono"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f85f6f4..2118e5d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: react-day-picker: specifier: ^9.0.8 version: 9.0.8(react@18.2.0) + uuid: + specifier: ^10.0.0 + version: 10.0.0 wowds-icons: specifier: workspace:^ version: link:../wow-icons @@ -323,6 +326,9 @@ importers: '@types/react-dom': specifier: ^18.2.19 version: 18.2.19 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 axe-playwright: specifier: ^2.0.1 version: 2.0.1(playwright@1.45.0) @@ -5445,6 +5451,10 @@ packages: resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} dev: true + /@types/uuid@10.0.0: + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + dev: true + /@types/uuid@9.0.8: resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} dev: true @@ -15658,6 +15668,11 @@ packages: engines: {node: '>= 0.4.0'} dev: true + /uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + dev: false + /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true From 7b19f0532c3c7203f6b329068ae17512746547b4 Mon Sep 17 00:00:00 2001 From: SeieunYoo <101736358+SeieunYoo@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:34:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[Feature]=20Tab=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/chilly-pumas-search.md | 5 + apps/wow-docs/app/page.tsx | 20 ++ packages/wow-ui/package.json | 20 ++ packages/wow-ui/rollup.config.js | 4 + .../wow-ui/src/components/Checkbox/index.tsx | 2 +- .../wow-ui/src/components/Switch/index.tsx | 2 +- .../src/components/Tabs/Tabs.stories.tsx | 196 ++++++++++++++++++ .../wow-ui/src/components/Tabs/Tabs.test.tsx | 87 ++++++++ .../src/components/Tabs/TabsContent.tsx | 43 ++++ .../wow-ui/src/components/Tabs/TabsItem.tsx | 100 +++++++++ .../wow-ui/src/components/Tabs/TabsList.tsx | 98 +++++++++ .../Tabs/contexts/CollectionContext.tsx | 25 +++ .../components/Tabs/contexts/TabsContext.ts | 19 ++ packages/wow-ui/src/components/Tabs/index.tsx | 70 +++++++ packages/wow-ui/src/hooks/useMergeRefs.ts | 13 ++ packages/wow-ui/src/types/DefaultProps.ts | 11 + 16 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 .changeset/chilly-pumas-search.md create mode 100644 packages/wow-ui/src/components/Tabs/Tabs.stories.tsx create mode 100644 packages/wow-ui/src/components/Tabs/Tabs.test.tsx create mode 100644 packages/wow-ui/src/components/Tabs/TabsContent.tsx create mode 100644 packages/wow-ui/src/components/Tabs/TabsItem.tsx create mode 100644 packages/wow-ui/src/components/Tabs/TabsList.tsx create mode 100644 packages/wow-ui/src/components/Tabs/contexts/CollectionContext.tsx create mode 100644 packages/wow-ui/src/components/Tabs/contexts/TabsContext.ts create mode 100644 packages/wow-ui/src/components/Tabs/index.tsx create mode 100644 packages/wow-ui/src/hooks/useMergeRefs.ts create mode 100644 packages/wow-ui/src/types/DefaultProps.ts 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; +}