diff --git a/apps/web/package.json b/apps/web/package.json index 68199e0..4260539 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,17 +15,19 @@ "build-storybook": "storybook build" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.80.7", "@tanstack/react-query-devtools": "^5.80.7", "date-fns": "^4.1.0", "next": "^15.3.0", "next-auth": "5.0.0-beta.28", + "overlay-kit": "^1.8.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "vaul": "^1.1.2", "zod": "^3.25.67" }, "devDependencies": { diff --git a/apps/web/src/shared/components/BottomSheet/BottomSheet.stories.tsx b/apps/web/src/shared/components/BottomSheet/BottomSheet.stories.tsx new file mode 100644 index 0000000..5600990 --- /dev/null +++ b/apps/web/src/shared/components/BottomSheet/BottomSheet.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { OverlayProvider, overlay } from "overlay-kit"; +import { BottomSheet } from "./BottomSheet"; + +/** + * **vaul docs** + * https://vaul.emilkowal.ski/api + */ +const meta: Meta = { + title: "V2/Components/BottomSheet", + component: BottomSheet, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + locked: { + control: "boolean", + description: "외부 요소와의 상호작용을 설정합니다.", + }, + radius: { + control: "select", + options: ["none", "small", "medium", "large"], + }, + theme: { + control: "select", + options: ["light", "dark"], + }, + }, + decorators: [ + Story => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const BottomSheetNoScroll = () => { + return ( +
+ BottomSheet +
+ ); +}; + +const BottomSheetScroll = () => { + return ( +
+ BottomSheet +
+ ); +}; + +export const BottomSheets: Story = { + args: { + showHandle: true, + radius: "medium", + }, + render: args => { + const openNoScrollBottomSheet = () => { + overlay.open(({ isOpen, close }) => ( + { + close(); + }} + content={} + /> + )); + }; + + const openScrollBottomSheet = () => { + overlay.open(({ isOpen, close }) => ( + { + close(); + }} + content={} + /> + )); + }; + + return ( +
+ + +
+ ); + }, +}; diff --git a/apps/web/src/shared/components/BottomSheet/BottomSheet.style.ts b/apps/web/src/shared/components/BottomSheet/BottomSheet.style.ts new file mode 100644 index 0000000..4302ad5 --- /dev/null +++ b/apps/web/src/shared/components/BottomSheet/BottomSheet.style.ts @@ -0,0 +1,71 @@ +import { css, cva } from "../../../../styled-system/css"; + +/* Color tokens */ +const SEED_PALETTE_COLOR_BASE_100 = "#ffffff"; +const SEED_PALETTE_COLOR_BASE_800 = "#494f54"; + +/* Radius tokens */ +const SEED_SPACING_100 = "0px"; +const SEED_SPACING_300 = "8px"; +const SEED_SPACING_600 = "20px"; +const SEED_SPACING_900 = "32px"; + +export const overlay = css({ + position: "fixed", + inset: 0, + backgroundColor: "rgba(0, 0, 0, 0.4)", +}); + +export const drawerContent = cva({ + base: { + position: "fixed", + bottom: 0, + left: 0, + right: 0, + height: "fit-content", + backgroundColor: "#fff", + outline: "none", + maxHeight: "calc(100dvh - 32px)", + borderBottomLeftRadius: "0 !important", + borderBottomRightRadius: "0 !important", + overflowY: "hidden", + }, + variants: { + radius: { + none: { + borderTop: SEED_SPACING_100, + }, + small: { + borderRadius: SEED_SPACING_300, + }, + medium: { + borderRadius: SEED_SPACING_600, + }, + large: { + borderRadius: SEED_SPACING_900, + }, + }, + theme: { + light: { + backgroundColor: SEED_PALETTE_COLOR_BASE_100, + }, + dark: { + backgroundColor: SEED_PALETTE_COLOR_BASE_800, + }, + }, + }, +}); + +export const drawerContentInner = css({ + maxHeight: "calc(100dvh - 68px)", + overflowY: "auto", + WebkitOverflowScrolling: "touch", +}); + +export const drawerHandle = css({ + height: "36px", + width: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", +}); diff --git a/apps/web/src/shared/components/BottomSheet/BottomSheet.tsx b/apps/web/src/shared/components/BottomSheet/BottomSheet.tsx new file mode 100644 index 0000000..7303cc0 --- /dev/null +++ b/apps/web/src/shared/components/BottomSheet/BottomSheet.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { SwitchCase } from "@/shared/utils/SwitchCase"; +import { Drawer } from "vaul"; +import * as styles from "./BottomSheet.style"; +import type { BottomSheetProps } from "./BottomSheet.type"; + +export const BottomSheet = ({ + open, + onClose, + locked = true, + showHandle = true, + handleOnly = false, + radius, + theme = "light", + zIndex = 1, + className, + content, +}: BottomSheetProps) => { + return ( + + + + + + + + ), + }} + /> +
{content}
+
+
+
+ ); +}; diff --git a/apps/web/src/shared/components/BottomSheet/BottomSheet.type.ts b/apps/web/src/shared/components/BottomSheet/BottomSheet.type.ts new file mode 100644 index 0000000..06dec1a --- /dev/null +++ b/apps/web/src/shared/components/BottomSheet/BottomSheet.type.ts @@ -0,0 +1,38 @@ +export const BottomSheetRadius = { + NONE: "none", + SMALL: "small", + MEDIUM: "medium", + LARGE: "large", +} as const; + +export const BottomSheetTheme = { + light: "light", + dark: "dark", +} as const; + +type ValueOf = T[keyof T]; + +export type BottomSheetRadius = ValueOf; +export type BottomSheetTheme = ValueOf; +export interface BottomSheetProps { + /** 바텀시트의 열림/닫힘 상태를 제어합니다. */ + open?: boolean; + /** 바텀시트가 닫힐 때 호출되는 콜백 함수입니다. */ + onClose?: () => void; + /** 바텀시트의 모달리티를 제어합니다. true일 경우 외부 요소와의 상호작용을 비활성화하고 스크린 리더에는 바텀시트 콘텐츠만 보입니다. */ + locked?: boolean; + /** 바텀시트 상단의 드래그 핸들을 표시할지 여부를 결정합니다. */ + showHandle?: boolean; + /** true일 경우 핸들을 통해서만 드래그가 가능합니다. */ + handleOnly?: boolean; + /** 바텀시트의 z-index 값을 설정합니다. */ + zIndex?: number; + /** 바텀시트의 모서리 둥글기 정도를 설정합니다. */ + radius?: BottomSheetRadius; + /** 바텀시트의 테마를 설정합니다. */ + theme?: BottomSheetTheme; + /** 추가 CSS 클래스명을 적용합니다. */ + className?: string; + /** 바텀시트 내부에 표시할 콘텐츠입니다. */ + content?: React.ReactNode; +} diff --git a/apps/web/src/shared/components/BottomSheet/index.ts b/apps/web/src/shared/components/BottomSheet/index.ts new file mode 100644 index 0000000..1a22951 --- /dev/null +++ b/apps/web/src/shared/components/BottomSheet/index.ts @@ -0,0 +1,2 @@ +export { BottomSheet } from "./BottomSheet"; +export type { BottomSheetProps } from "./BottomSheet.type"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0de8ff..b9f24e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,12 +159,18 @@ importers: next-auth: specifier: 5.0.0-beta.28 version: 5.0.0-beta.28(next@15.3.0(@babel/core@7.28.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.89.2))(react@19.1.0) + overlay-kit: + specifier: ^1.8.6 + version: 1.8.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) zod: specifier: ^3.25.67 version: 3.25.67 @@ -2355,6 +2361,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -6682,6 +6701,12 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + overlay-kit@1.8.6: + resolution: {integrity: sha512-0JeZ+QcoMCEgW4OndzCJger/eHZHGan7M9iQ6sHxrMPVwLyqSN8665CdciNrNjzorV3xGtqVeZUs2o4rt4ddYw==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 + react-dom: ^16.8 || ^17 || ^18 || ^19 + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -8369,6 +8394,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -11758,6 +11789,28 @@ snapshots: optionalDependencies: '@types/react': 19.1.0 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.0)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.0)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.0)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.0 + '@types/react-dom': 19.1.1(@types/react@19.1.0) + '@radix-ui/react-direction@1.1.1(@types/react@19.1.0)(react@19.1.0)': dependencies: react: 19.1.0 @@ -16854,6 +16907,11 @@ snapshots: outvariant@1.4.3: {} + overlay-kit@1.8.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -18678,6 +18736,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite-node@3.2.4(@types/node@22.15.3)(jiti@2.5.1)(lightningcss@1.25.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.0): dependencies: cac: 6.7.14