diff --git a/docs/conventions/component-convention.md b/docs/conventions/component-convention.md index b9eedfe..6d048bf 100644 --- a/docs/conventions/component-convention.md +++ b/docs/conventions/component-convention.md @@ -40,6 +40,7 @@ interface RentalItemCardProps { - 예: Figma `Chip/Chip` → `import { Chip } from '@wanteddev/wds'` - 아이콘은 `@wanteddev/wds-icon`에서 가져온다. - **WDS 컴포넌트 내부를 임의로 오버라이드하지 않는다.** 간격·배치 같은 레이아웃 조정은 감싸는 wrapper에서 한다. +- **화면 헤더는 화면에서 `TopNavigation`을 직접 새로 조립하지 않고 `src/components/ui/ScreenHeader.tsx`를 거친다.** Figma의 Top Navigation 패턴(타이틀 정렬, leading/trailing 조합, 게시판류의 토글형 타이틀 등)을 `ScreenHeader`의 props(`variant`/`title`/`leading`/`trailing`)로 고르게 돼 있다 — 화면마다 손으로 다시 조립하면 컨벤션이 흩어진다. 새 헤더 패턴이 필요하면 `ScreenHeader`부터 확장한다(`docs/plans/unified-screen-header.md` 참고). ## 4. Stream 고유 UI (신규 컴포넌트) @@ -50,6 +51,26 @@ interface RentalItemCardProps { - 색은 `text-[#171719]`처럼 hex를 직접 박지 않는다. `src/index.css`의 `@theme` 블록에 있는 시맨틱 토큰(`text-label-normal`, `bg-background-alternative`, `border-line-solid-neutral`, `text-primary`, `bg-primary-subtle` 등)을 쓴다. 이 토큰들은 `@wanteddev/wds/global.css`가 심어둔 `--semantic-*`/`--atomic-*` CSS 변수를 그대로 별칭 연결한 것이라 다크 테마 전환도 자동으로 따라간다. - 필요한 색이 아직 토큰으로 없으면, hex를 추측해서 쓰지 말고 `get_variable_defs(fileKey, nodeId)`로 해당 노드의 실제 Figma 변수명·값을 확인한 뒤 `index.css`의 `@theme`에 새 토큰을 추가한다. `Line/Normal/Neutral`(반투명 `#70737c29`)과 `Line/Solid/Neutral`(불투명 `#eaebec`)처럼 이름이 비슷해도 값이 다른 토큰이 있으니 이름만 보고 넘겨짚지 않는다. - 컴포넌트 인스턴스가 없는 화면 배경/외곽선처럼 Figma 값이 실제로는 안 보이는 경우(예: Bottom Nav 상단 border가 바로 위 배경과 같은 색이라 안 보였던 사례)도 있다 — 이럴 땐 왜 다른 토큰으로 바꿨는지 주석으로 남긴다. +### 타이포그래피 + +- 글자 크기·굵기를 `text-xs`/`text-[17px]`/`font-semibold`처럼 Tailwind로 직접 짓지 않는다. `@wanteddev/wds`의 `Typography` 컴포넌트를 쓴다 — Figma의 이름 있는 타입 스타일(Headline 2/Bold 등)과 `variant`+`weight` 조합이 1:1로 대응한다(`node_modules/@wanteddev/wds/dist/components/typography/style.js`에서 실측 확인 가능). +- Tailwind에는 11px(Caption 2) 같은 값이 기본 스케일에 없어서, 대충 가까운 `text-xs`(12px)를 썼다가 실제로 다른 크기가 되는 사고가 실제로 있었다(Bottom Nav 탭 라벨). `Typography`를 쓰면 이 스케일 자체가 WDS 값이라 이런 어긋남이 구조적으로 없어진다. +- 색은 `className`의 Tailwind 색상 토큰이 아니라 `Typography`의 `color` prop(예: `color="semantic.label.normal"`)으로 준다. `Typography`가 `color` 미지정 시 `color: inherit`을 자체 CSS-in-JS로 주입하는데, 이 스타일이 Tailwind 유틸리티 레이어보다 우선순위가 높아서(`@wanteddev/wds/global.css`를 `layer(base)`로 감싸야 했던 것과 같은 이유) `className="text-label-normal"`을 같이 줘도 씹힐 수 있다. +- `variant`/`weight` → Figma 이름 대응표(자주 쓰는 것만): + + | `variant` | `weight` | 실제 크기 | Figma 이름 | + |---|---|---|---| + | `headline1` | `bold` | 18px / SemiBold | Headline 1/Bold | + | `headline2` | `bold` | 17px / SemiBold | Headline 2/Bold | + | `headline2` | `medium` | 17px / Medium | Headline 2/Medium | + | `label1` | `bold` | 14px / SemiBold | Label 1/Normal - Bold | + | `caption1` | `bold` | 12px / SemiBold | Caption 1/Bold | + | `caption1` | `medium` | 12px / Medium | Caption 1/Medium | + | `caption1` | `regular` | 12px / Regular | Caption 1/Regular | + | `caption2` | `medium` | 11px / Medium | Caption 2/Medium | + +- **예외**: 서드파티 라이브러리가 `className` 문자열만 받아서 자기 DOM에 그대로 꽂는 자리(예: `@ncdai/react-wheel-picker`의 `classNames` prop)는 `Typography`로 감쌀 수 없다 — 이럴 땐 Tailwind `text-[17px]` 같은 값을 그대로 쓰되, 어느 Figma 타입 스타일을 옮긴 값인지 주석을 남긴다(`BililgeRentalSheet.tsx`의 `WHEEL_CLASS_NAMES` 참고). + - Stream 자체 이미지·아이콘(일러스트, 물품 아이콘 등)은 `download_assets`로 받아 `src/assets/`에 커밋한다. Figma asset URL은 **7일 후 만료**되므로 절대 코드에 그대로 참조하지 않는다. - `data-node-id` 같은 Figma 추적용 속성은 컴포넌트 마크업에 남기지 않는다. 대신 파일 최상단에 원본 Figma 노드를 알 수 있는 주석 한 줄만 남긴다 — 나중에 디자인이 바뀌었을 때 다시 대조할 수 있도록: @@ -60,6 +81,7 @@ interface RentalItemCardProps { ## 5. 완료 기준 체크리스트 - [ ] WDS로 확인된 요소는 전부 import로 대체했다 (raw JSX 없음) +- [ ] 텍스트는 `Typography`(`variant`+`weight`)로 썼다 — 서드파티가 className만 받는 자리가 아닌 이상 `text-xs`/`text-[Npx]` 직접 사용 없음 - [ ] Stream 고유 요소만 새 컴포넌트로 작성했다 - [ ] Props가 Figma variant를 유니온 타입으로 반영한다 - [ ] 이미지/아이콘 asset을 다운로드해 커밋했다 (만료되는 Figma URL 미참조) diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index fd28c06..a76a9bd 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -66,6 +66,12 @@ WDS 컴포넌트만 합산하면 파일 안에서 **약 350회 이상**의 인 코드에서는 `@wanteddev/wds-icon`의 `IconSearch`/`IconBell`/`IconHome`/`IconTicket`/`IconList`로 대응된다(각각 default export를 `index.d.ts`에서 named export로 재노출). `Segmented Control`은 `@wanteddev/wds`의 `SegmentedControl`/`SegmentedControlItem`으로 대응된다. +### `Typography` — 텍스트 스타일은 Figma 인스턴스 스캔에 안 잡혀서 뒤늦게 확인됨 + +위 표들은 Figma의 인스턴스(컴포넌트) 이름을 스캔해서 만든 거라, Figma에서 텍스트 스타일(Text Style)로만 적용되고 별도 컴포넌트 인스턴스가 아닌 타이포그래피는 이 방식으로는 안 잡힌다. 그래서 지금까지 `text-xs`/`text-[17px]` 같은 Tailwind 값을 화면마다 손으로 맞춰왔는데, `@wanteddev/wds`에 Figma의 이름 있는 타입 스타일(Headline 2/Bold 등)과 정확히 대응하는 `Typography` 컴포넌트가 있다는 걸 뒤늦게 확인했다(`node_modules/@wanteddev/wds/dist/components/typography/style.js`). Bottom Nav 탭 라벨을 `text-xs`(12px)로 잘못 만들었던 것도 실제 Figma 값(Caption 2/Medium, 11px)과 어긋난 채로 남아있다가 이번에 확인됐다. + +`variant`+`weight` 조합과 색상 `color` prop 사용법은 `docs/conventions/component-convention.md`의 "타이포그래피" 절 참고. 이후 새 화면을 만들 때는 텍스트 크기를 짐작하지 말고 이 컴포넌트부터 확인한다. + ### 반례 — 빌릴게 필터 Chip은 WDS `Chip/Chip`이 아니었다 위 "WDS 컴포넌트로 확인됨" 표에 `Chip/Chip`이 파일 전체 기준 24개 인스턴스로 확정돼 있다고 해서, **다른 화면의 비슷하게 생긴 칩도 자동으로 WDS라고 가정하면 안 된다.** 빌릴게 화면의 카테고리 필터(전체/전자기기/생활잡화/상비약/위생용품)를 처음 구현할 때 이 표만 보고 재조사 없이 WDS `Chip`을 그대로 썼는데, 실제 Figma 스타일(활성 = 연한 파랑 배경 + 파랑 outline, 비활성 = 회색 outline)이 WDS Chip의 기본 활성 스타일(검정 배경)과 달랐다 — Stream이 로컬로 새로 만든 칩이었다. **스타일이 눈에 띄게 다르면, 이름이 같아 보여도 그 인스턴스는 따로 `get_design_context`로 열어 확인한다.** 코드는 `src/features/rental/components/RentalCategoryFilter.tsx` 참고 (plain ` ``` -hex를 하드코딩하지 않고 `index.css`의 색상 토큰을 그대로 참조했고, `Button` 자체(접근성 속성, `disabled`/`loading` 상태 처리 등)는 그대로 재사용한다. `src/features/rental/components/RentalItemCard.tsx` 참고. +hex를 하드코딩하지 않고 `index.css`의 색상 토큰을 그대로 참조했고, `Button` 자체(접근성 속성, `disabled`/`loading` 상태 처리 등)는 그대로 재사용한다. `src/features/bililge/components/BililgeItemCard.tsx` 참고. + +### 빌릴게 대여 바텀시트 — `Action Area/Action Area`, `Icon/Normal/Circle Info`, 그리고 Time Picker는 의도적으로 WDS를 안 씀 + +`/component`로 빌릴게 대여 바텀시트(Figma nodeId `1422:57155`, 실제 시트 콘텐츠는 `1422:57176`)를 구현하며 확인된 내용. + +- **`Action Area/Action Area` + `ActionAreaButton`**: 버튼 하나(대여 신청하기)만 있는 액션 영역도 `@wanteddev/wds`의 `ActionArea`(기본 `variant="strong"`) + `ActionAreaButton`(기본 `variant="main"`)으로 그대로 재현된다. `ActionAreaButton`의 `main` variant가 내부적으로 `Button`을 `size="large"` `fullWidth` `variant="solid"` `color="primary"`로 렌더링해서 Figma의 파란 통 너비 버튼과 정확히 일치했다(`node_modules/@wanteddev/wds/dist/components/action-area/index.js` 확인). `src/features/bililge/components/BililgeRentalSheet.tsx` 참고. +- **`Icon/Normal/Circle Info`**: 안내 문구("대여 시작 시간은 최소 5분 뒤부터...") 앞 아이콘. 위 표에서 이미 확정된 매핑 재사용(`@wanteddev/wds-icon`의 `IconCircleInfo`). +- **Time Picker(휠 피커)는 예외적으로 WDS를 쓰지 않기로 결정했다.** `@wanteddev/wds`에 `time-picker` 컴포넌트가 실제로 존재하지만(`node_modules/@wanteddev/wds/dist/components/time-picker/`), 이건 `` 기반 텍스트 필드형 컴포넌트라 Figma가 그리는 iOS 스타일 휠 스크롤 피커(오전/오후·시·분 3열, 가운데 값만 진하게)와는 UI 패턴 자체가 다르다. 사용자가 명시적으로 지정한 [`@ncdai/react-wheel-picker`](https://react-wheel-picker.chanhdai.com)(unstyled core, `WheelPicker`/`WheelPickerWrapper`)로 구현했다: + - `optionItem`/`highlightItem`/`highlightWrapper` classNames로 텍스트 스타일만 입히고(선택 안 됨: `text-label-disable` 17px medium, 선택됨: `text-label-normal` 18px semibold), Figma의 "Selection Highlight"(3열을 가로지르는 pill 배경, `bg-background-alternative`)는 라이브러리 밖에서 별도 `absolute` div로 얹었다 — 각 컬럼마다 하이라이트 배경을 따로 안 그리기 위해서다. + - 이 라이브러리의 CSS(`@ncdai/react-wheel-picker/style.css`)도 `@wanteddev/wds/global.css`와 같은 이유로 **반드시 `layer(base)`로 import해야 한다**(`src/index.css`) — 안 그러면 Tailwind 유틸리티가 라이브러리의 unlayered CSS한테 밀려서 `justify-center` 같은 오버라이드가 안 먹는다. + - 새 토큰 `--color-label-disable`(`--semantic-label-disable` 별칭)을 이때 추가했다. `get_variable_defs`로 확인한 실제 값은 `rgba(55,56,60,0.16)`. ## 제외됨 — Stream 자체 로컬 컴포넌트 (WDS 아님) diff --git a/package.json b/package.json index 93b6b61..9feac36 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "check:fix": "biome check --write ." }, "dependencies": { + "@ncdai/react-wheel-picker": "^1.2.3", "@wanteddev/wds": "^3.12.0", "@wanteddev/wds-icon": "^3.12.0", "react": "^19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6eb849c..0e18b92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ncdai/react-wheel-picker': + specifier: ^1.2.3 + version: 1.2.3(react@19.2.8) '@wanteddev/wds': specifier: ^3.12.0 version: 3.12.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/serialize@1.3.3)(@emotion/utils@1.4.2)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -234,6 +237,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@ncdai/react-wheel-picker@1.2.3': + resolution: {integrity: sha512-TQjDfuDH9Ss7sgacSRPnakqWfkR8E1w6g7Pkt7bTYX35SfdHbGSzC8g/lXKFeMyNIikOWD9wC3uFPLy+Ui/R0g==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + '@oxc-project/types@0.147.0': resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} @@ -1485,6 +1493,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@ncdai/react-wheel-picker@1.2.3(react@19.2.8)': + dependencies: + react: 19.2.8 + '@oxc-project/types@0.147.0': {} '@radix-ui/number@1.1.0': {} diff --git a/src/app/App.tsx b/src/app/App.tsx index 8dd6739..616d406 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,15 +1,18 @@ import { Route, Routes } from "react-router-dom"; +import ScreenLayout from "@/components/ui/ScreenLayout"; +import BililgeListScreen from "@/features/bililge/BililgeListScreen"; import HomeScreen from "@/features/home/HomeScreen"; -import RentalListScreen from "@/features/rental/RentalListScreen"; // 데스크톱에서 보기 좋게 아이폰 화면 크기로 가운데 정렬만 해준다. function App() { return (
- } path="/" /> - } path="/rental" /> + }> + } path="/" /> + } path="/bililge" /> +
); diff --git a/src/assets/icons/rental-items/8pin-charger.svg b/src/assets/icons/bililge-items/8pin-charger.svg similarity index 100% rename from src/assets/icons/rental-items/8pin-charger.svg rename to src/assets/icons/bililge-items/8pin-charger.svg diff --git a/src/assets/icons/rental-items/alcohol-swab.svg b/src/assets/icons/bililge-items/alcohol-swab.svg similarity index 100% rename from src/assets/icons/rental-items/alcohol-swab.svg rename to src/assets/icons/bililge-items/alcohol-swab.svg diff --git a/src/assets/icons/rental-items/band-aid.svg b/src/assets/icons/bililge-items/band-aid.svg similarity index 100% rename from src/assets/icons/rental-items/band-aid.svg rename to src/assets/icons/bililge-items/band-aid.svg diff --git a/src/assets/icons/rental-items/curling-iron.svg b/src/assets/icons/bililge-items/curling-iron.svg similarity index 100% rename from src/assets/icons/rental-items/curling-iron.svg rename to src/assets/icons/bililge-items/curling-iron.svg diff --git a/src/assets/icons/rental-items/default.svg b/src/assets/icons/bililge-items/default.svg similarity index 100% rename from src/assets/icons/rental-items/default.svg rename to src/assets/icons/bililge-items/default.svg diff --git a/src/assets/icons/rental-items/eye-drops.svg b/src/assets/icons/bililge-items/eye-drops.svg similarity index 100% rename from src/assets/icons/rental-items/eye-drops.svg rename to src/assets/icons/bililge-items/eye-drops.svg diff --git a/src/assets/icons/rental-items/hair-dryer.svg b/src/assets/icons/bililge-items/hair-dryer.svg similarity index 100% rename from src/assets/icons/rental-items/hair-dryer.svg rename to src/assets/icons/bililge-items/hair-dryer.svg diff --git a/src/assets/icons/rental-items/laptop-charger.svg b/src/assets/icons/bililge-items/laptop-charger.svg similarity index 100% rename from src/assets/icons/rental-items/laptop-charger.svg rename to src/assets/icons/bililge-items/laptop-charger.svg diff --git a/src/assets/icons/rental-items/mask.svg b/src/assets/icons/bililge-items/mask.svg similarity index 100% rename from src/assets/icons/rental-items/mask.svg rename to src/assets/icons/bililge-items/mask.svg diff --git a/src/assets/icons/rental-items/ointment.svg b/src/assets/icons/bililge-items/ointment.svg similarity index 100% rename from src/assets/icons/rental-items/ointment.svg rename to src/assets/icons/bililge-items/ointment.svg diff --git a/src/assets/icons/rental-items/pain-relief-patch.svg b/src/assets/icons/bililge-items/pain-relief-patch.svg similarity index 100% rename from src/assets/icons/rental-items/pain-relief-patch.svg rename to src/assets/icons/bililge-items/pain-relief-patch.svg diff --git a/src/assets/icons/rental-items/pill.svg b/src/assets/icons/bililge-items/pill.svg similarity index 100% rename from src/assets/icons/rental-items/pill.svg rename to src/assets/icons/bililge-items/pill.svg diff --git a/src/assets/icons/rental-items/power-bank.svg b/src/assets/icons/bililge-items/power-bank.svg similarity index 100% rename from src/assets/icons/rental-items/power-bank.svg rename to src/assets/icons/bililge-items/power-bank.svg diff --git a/src/assets/icons/rental-items/sanitary-pad.svg b/src/assets/icons/bililge-items/sanitary-pad.svg similarity index 100% rename from src/assets/icons/rental-items/sanitary-pad.svg rename to src/assets/icons/bililge-items/sanitary-pad.svg diff --git a/src/assets/icons/rental-items/umbrella.svg b/src/assets/icons/bililge-items/umbrella.svg similarity index 100% rename from src/assets/icons/rental-items/umbrella.svg rename to src/assets/icons/bililge-items/umbrella.svg diff --git a/src/assets/icons/rental-items/usb-c-charger.svg b/src/assets/icons/bililge-items/usb-c-charger.svg similarity index 100% rename from src/assets/icons/rental-items/usb-c-charger.svg rename to src/assets/icons/bililge-items/usb-c-charger.svg diff --git a/src/assets/icons/bottom-nav/rental-selected.svg b/src/assets/icons/bottom-nav/bililge-selected.svg similarity index 100% rename from src/assets/icons/bottom-nav/rental-selected.svg rename to src/assets/icons/bottom-nav/bililge-selected.svg diff --git a/src/components/ui/BottomNav.tsx b/src/components/ui/BottomNav.tsx index 8bfe241..4696735 100644 --- a/src/components/ui/BottomNav.tsx +++ b/src/components/ui/BottomNav.tsx @@ -1,3 +1,4 @@ +import { Typography } from "@wanteddev/wds"; import { IconHome, IconList, @@ -5,12 +6,12 @@ import { IconTicket, } from "@wanteddev/wds-icon"; +import bililgeSelected from "@/assets/icons/bottom-nav/bililge-selected.svg"; import boardSelected from "@/assets/icons/bottom-nav/board-selected.svg"; import eventSelected from "@/assets/icons/bottom-nav/event-selected.svg"; import homeSelected from "@/assets/icons/bottom-nav/home-selected.svg"; -import rentalSelected from "@/assets/icons/bottom-nav/rental-selected.svg"; -export type BottomNavValue = "home" | "event" | "board" | "rental"; +export type BottomNavValue = "home" | "event" | "board" | "bililge"; interface BottomNavProps { value: BottomNavValue; @@ -47,8 +48,8 @@ const TABS: BottomNavTab[] = [ { NormalIcon: IconStorage, label: "빌릴게", - selectedIcon: rentalSelected, - value: "rental", + selectedIcon: bililgeSelected, + value: "bililge", }, ]; @@ -75,18 +76,22 @@ function BottomNav({ value, onValueChange }: BottomNavProps) { ) : ( )} - {label} - + ); })} -
-
-
+
); } diff --git a/src/components/ui/BottomSheet.tsx b/src/components/ui/BottomSheet.tsx new file mode 100644 index 0000000..9b3f31c --- /dev/null +++ b/src/components/ui/BottomSheet.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal"; + +interface BottomSheetProps { + open: boolean; + onClose: () => void; + children: ReactNode; +} + +// Figma: Views / Bottom Sheets (nodeId 1422:57176) — WDS에는 대응하는 코드 컴포넌트가 없다 +// (component-convention.md 참고: Native / Bottom Sheet Indicator는 Stream/iOS 목업 전용 로컬 요소). +// 딤+시트를 ScreenLayout의 포털 슬롯(useScreenSheetPortal)에 그려서 375×812 프레임 전체를 덮는다. +function BottomSheet({ open, onClose, children }: BottomSheetProps) { + const portalEl = useScreenSheetPortal(); + + if (!portalEl) { + return null; + } + + return createPortal( +
+ + ); + })} +
+ ); +} + +type ScreenHeaderProps = + | { + variant?: "display"; + title?: ScreenHeaderTitle; + trailing?: ReactNode; + } + | { + variant: "normal"; + title?: ScreenHeaderTitle; + leading?: ReactNode; + trailing?: ReactNode; + }; + +// variant="display"(기본값, 빌릴게/홈)는 더 이상 WDS `Top Navigation/Resource/Contents`가 아니다 — +// Figma가 별도 Stream 로컬 컴포넌트(nodeId 1765:71193 "Top Navigation")로 바뀌었다: 세로 패딩 +// 12px(기존 WDS display variant는 16px 고정이라 오버라이드 불가) + Title 3/Bold(32px)가 정확히 +// 들어가서 총 56px. leading은 이 패턴에서 쓴 적이 없어 그대로 받지 않는다. +// +// variant="normal"(모달형 닫기 버튼 등, leading 필요)은 아직 WDS `TopNavigation`을 그대로 쓴다 — +// Figma 쪽 해당 패턴은 안 바뀌었다(component-convention.md "WDS 컴포넌트 내부를 임의로 +// 오버라이드하지 않는다" 원칙 유지). +// +// title이 문자열이면 그대로 렌더링하고, { options, activeIndex } 형태(활성 상태가 있는 경우)면 +// 게시판류의 토글형 2단 타이틀로 렌더링한다. +// +// search variant(타이틀 자리가 검색 필드로 바뀌는 패턴)는 이번 범위에서 뺐다 — +// docs/plans/unified-screen-header.md 참고. 화면이 실제로 생기면 그때 추가한다. +function ScreenHeader(props: ScreenHeaderProps) { + const { title, trailing } = props; + + if (props.variant === "normal") { + return ( + + {title !== undefined && + (isToggleTitle(title) ? ( + + ) : ( + title + ))} + + ); + } + + return ( +
+
+ {title !== undefined && + (isToggleTitle(title) ? ( + + ) : ( + + {title} + + ))} +
+ {trailing !== undefined && ( +
{trailing}
+ )} +
+ ); +} + +export default ScreenHeader; diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 8fc52b6..c3b6e30 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -1,34 +1,70 @@ import type { ReactNode } from "react"; +import { useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; import BottomNav, { type BottomNavValue } from "@/components/ui/BottomNav"; +import { ScreenHeaderContext } from "@/components/ui/screenHeaderContext"; +import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalContext"; -interface ScreenLayoutProps { - header: ReactNode; - children: ReactNode; - bottomNavValue: BottomNavValue; - onBottomNavValueChange: (value: BottomNavValue) => void; +// Bottom Nav 탭 ↔ 라우트 경로 매핑. 화면이 늘어나면 여기에 추가한다. +const BOTTOM_NAV_PATHS: Record = { + bililge: "/bililge", + board: "/board", + event: "/event", + home: "/", +}; + +function getBottomNavValueFromPath(pathname: string): BottomNavValue { + const matched = ( + Object.entries(BOTTOM_NAV_PATHS) as [BottomNavValue, string][] + ).find(([, path]) => path === pathname); + return matched?.[0] ?? "home"; } -// 홈/행사/게시판/빌릴게 등 모든 화면이 공유하는 뼈대 — 고정 크기(375x812) 프레임 안에서 -// header·Bottom Nav는 고정, 본문만 스크롤된다. Top Navigation·필터·리스트 등 화면마다 -// 다른 내용은 header/children으로 각 화면이 채운다(공통인 건 프레임 비율과 Bottom Nav뿐). -function ScreenLayout({ - header, - children, - bottomNavValue, - onBottomNavValueChange, -}: ScreenLayoutProps) { +// 홈/행사/게시판/빌릴게 등 Bottom Nav가 있는 화면 전용 라우트 레이아웃 — App.tsx에서 부모 +// route로 두고 화면들을 자식 route(Outlet)로 넣는다. 화면이 직접 이 컴포넌트를 임포트해서 +// 감쌀 필요가 없어서, "일부 화면만 감싸는 걸 깜빡"하는 불일치가 구조적으로 불가능해진다. +// 화면마다 다른 헤더(Top Navigation 등)는 useScreenHeader 훅으로 이 레이아웃에 등록한다. +// Bottom Nav의 활성 탭도 화면 state가 아니라 현재 라우트에서 파생시킨다. +function ScreenLayout() { + const [header, setHeader] = useState(null); + const [sheetPortalEl, setSheetPortalEl] = useState( + null, + ); + const location = useLocation(); + const navigate = useNavigate(); + const bottomNavValue = getBottomNavValueFromPath(location.pathname); + return ( -
-
{header}
-
{children}
-
- -
-
+ + +
+
{header}
+ {/* 스크롤 처리는 각 화면이 스스로 결정한다(예: 상단 토글/필터는 고정하고 목록만 스크롤). + overflow-y-auto가 동작하려면 자식 높이가 명확해야 해서, 화면마다 h-full을 직접 + 챙기지 않아도 되도록 여기서 기본으로 보장한다. */} +
+
+ +
+
+
+ navigate(BOTTOM_NAV_PATHS[value])} + value={bottomNavValue} + /> +
+ {/* BottomSheet 포털 대상 — 헤더/본문/Bottom Nav보다 위(z-50)에 겹쳐서, 화면 하나가 + 열어도 375×812 프레임 전체를 딤 처리할 수 있다. 시트가 닫혀있을 때는 빈 오버레이가 + 클릭을 가로채지 않도록 pointer-events-none — BottomSheet가 열릴 때 자기 자신에만 + pointer-events-auto를 되돌려준다. */} +
+
+ + ); } diff --git a/src/components/ui/screenHeaderContext.ts b/src/components/ui/screenHeaderContext.ts new file mode 100644 index 0000000..737c2f6 --- /dev/null +++ b/src/components/ui/screenHeaderContext.ts @@ -0,0 +1,10 @@ +import { + createContext, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; + +export type SetScreenHeader = Dispatch>; + +export const ScreenHeaderContext = createContext(null); diff --git a/src/components/ui/screenSheetPortalContext.ts b/src/components/ui/screenSheetPortalContext.ts new file mode 100644 index 0000000..23232b6 --- /dev/null +++ b/src/components/ui/screenSheetPortalContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; + +// BottomSheet가 포털로 렌더링될 위치. ScreenLayout의 375×812 프레임 최상단에 고정된 빈 div를 +// 가리켜서, 헤더/본문/Bottom Nav보다 위에 겹쳐 그려지면서도 브라우저 전체가 아니라 그 프레임 +// 안에서만 딤 처리된다. +export const ScreenSheetPortalContext = createContext( + null, +); diff --git a/src/components/ui/useScreenHeader.ts b/src/components/ui/useScreenHeader.ts new file mode 100644 index 0000000..16a5cb1 --- /dev/null +++ b/src/components/ui/useScreenHeader.ts @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { useContext, useEffect } from "react"; + +import { ScreenHeaderContext } from "@/components/ui/screenHeaderContext"; + +// 화면(라우트)이 자신의 헤더(Top Navigation 등)를 ScreenLayout의 header 슬롯에 등록한다. +// ScreenLayout이 라우터에서 부모 route로 화면을 감싸기 때문에, 화면은 을 +// 직접 감쌀 필요 없이 이 훅만 호출하면 된다 — 감싸는 걸 깜빡하는 불일치가 구조적으로 없어진다. +export function useScreenHeader(header: ReactNode) { + const setHeader = useContext(ScreenHeaderContext); + + if (!setHeader) { + throw new Error( + "useScreenHeader는 ScreenLayout의 하위 라우트에서만 쓸 수 있다", + ); + } + + useEffect(() => { + setHeader(header); + return () => setHeader(null); + }, [header, setHeader]); +} diff --git a/src/components/ui/useScreenSheetPortal.ts b/src/components/ui/useScreenSheetPortal.ts new file mode 100644 index 0000000..7e6bb20 --- /dev/null +++ b/src/components/ui/useScreenSheetPortal.ts @@ -0,0 +1,9 @@ +import { useContext } from "react"; + +import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalContext"; + +// BottomSheet가 포털로 그려질 DOM 노드를 가져온다. 마운트 첫 렌더에는 ref가 아직 안 붙어서 +// null일 수 있으므로(프로그래밍 오류가 아니라 정상적인 과도 상태), 호출부에서 null을 직접 처리한다. +export function useScreenSheetPortal() { + return useContext(ScreenSheetPortalContext); +} diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx new file mode 100644 index 0000000..a6725e6 --- /dev/null +++ b/src/features/bililge/BililgeListScreen.tsx @@ -0,0 +1,95 @@ +import { + SegmentedControl, + SegmentedControlItem, + TopNavigationButton, +} from "@wanteddev/wds"; +import { IconBell, IconSearch } from "@wanteddev/wds-icon"; +import { startTransition, useState } from "react"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import BililgeCategoryFilter from "@/features/bililge/components/BililgeCategoryFilter"; +import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; +import BililgeRentalSheet from "@/features/bililge/components/BililgeRentalSheet"; +import { + BILILGE_ITEMS, + type BililgeItem, +} from "@/features/bililge/constants/bililgeItems"; + +// Figma: 빌릴게 (nodeId 1243:73331) +function BililgeListScreen() { + const [tab, setTab] = useState("rent"); + const [category, setCategory] = useState("전체"); + const [rentalItem, setRentalItem] = useState(null); + const [rentalSheetOpen, setRentalSheetOpen] = useState(false); + + useScreenHeader( + + + + + + + + + } + />, + ); + + return ( +
+ {/* 대여/반납 토글 + 카테고리 필터는 화면마다 값·동작이 달라 헤더가 아니라 화면이 직접 그린다. + 목록만 스크롤되도록 여기는 고정(shrink-0)한다. */} +
+ + 대여 + 반납 + +
+ +
+ +
+ +
+ {tab === "rent" ? ( +
+ {BILILGE_ITEMS.map((item) => ( + { + // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 + // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 + // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, + // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. + setRentalSheetOpen(true); + startTransition(() => { + setRentalItem(item); + }); + }} + quantity={item.quantity} + /> + ))} +
+ ) : ( +
+ 반납 화면은 아직 준비 중이에요 +
+ )} +
+ + setRentalSheetOpen(false)} + open={rentalSheetOpen} + /> +
+ ); +} + +export default BililgeListScreen; diff --git a/src/features/bililge/components/BililgeCategoryFilter.tsx b/src/features/bililge/components/BililgeCategoryFilter.tsx new file mode 100644 index 0000000..774c542 --- /dev/null +++ b/src/features/bililge/components/BililgeCategoryFilter.tsx @@ -0,0 +1,55 @@ +import { Typography } from "@wanteddev/wds"; + +const BILILGE_CATEGORIES = [ + "전체", + "전자기기", + "생활잡화", + "상비약", + "위생용품", +]; + +interface BililgeCategoryFilterProps { + value: string; + onChange: (category: string) => void; +} + +// Figma: 빌릴게 Filter Row (nodeId 1243:73337) — WDS Chip이 아니라 Stream 로컬 커스텀 칩. +// 카테고리·물품 매핑 API가 아직 없어 필터링 없이 선택 상태만 표시한다. +function BililgeCategoryFilter({ + value, + onChange, +}: BililgeCategoryFilterProps) { + return ( +
+ {BILILGE_CATEGORIES.map((category) => { + const active = category === value; + return ( + + ); + })} +
+ ); +} + +export default BililgeCategoryFilter; diff --git a/src/features/rental/components/RentalItemCard.tsx b/src/features/bililge/components/BililgeItemCard.tsx similarity index 74% rename from src/features/rental/components/RentalItemCard.tsx rename to src/features/bililge/components/BililgeItemCard.tsx index 3e8a65b..5462401 100644 --- a/src/features/rental/components/RentalItemCard.tsx +++ b/src/features/bililge/components/BililgeItemCard.tsx @@ -1,10 +1,10 @@ // Figma: Rental Item Card (nodeId 1041:61407) -import { Button } from "@wanteddev/wds"; +import { Button, Typography } from "@wanteddev/wds"; import circleMinusFill from "@/assets/icons/circle-minus-fill.svg"; import circlePlusFill from "@/assets/icons/circle-plus-fill.svg"; -interface RentalItemCardProps { +interface BililgeItemCardProps { icon: string; itemName: string; quantity: number; @@ -19,7 +19,7 @@ interface RentalItemCardProps { // 그 이름과 1:1 대응하는 코드 export는 없다 — 대신 WDS `Button`(size="small")이 padding(7px/14px)· // radius(8px)·타이포(label2)까지 정확히 일치해서 그걸 쓰고, Button 공개 variant엔 없는 "연한 파랑 배경 // + 파랑 텍스트" 조합만 sx로 보정했다. 스테퍼 +/- 아이콘은 여전히 WDS 미확정이라 하드코딩 — docs/plans 참고. -function RentalItemCard({ +function BililgeItemCard({ icon, itemName, quantity, @@ -28,7 +28,7 @@ function RentalItemCard({ stepperValue = 1, onStepperDecrease, onStepperIncrease, -}: RentalItemCardProps) { +}: BililgeItemCardProps) { const isStepper = trailingControl === "stepper"; return ( @@ -39,10 +39,22 @@ function RentalItemCard({
-

+ {itemName} -

-

수량 {quantity}

+ + + 수량 {quantity} +
@@ -70,9 +82,16 @@ function RentalItemCard({ > - + {stepperValue} - +
- } - trailingContent={ - <> - - - - - - - - } - variant="display" - > - 빌릴게 - - } - onBottomNavValueChange={handleBottomNavValueChange} - > -
- -
- - {tab === "rent" ? ( -
- {RENTAL_ITEMS.map((item) => ( - - ))} -
- ) : ( -
- 반납 화면은 아직 준비 중이에요 -
- )} - - ); -} - -export default RentalListScreen; diff --git a/src/features/rental/components/RentalCategoryFilter.tsx b/src/features/rental/components/RentalCategoryFilter.tsx deleted file mode 100644 index d34d04b..0000000 --- a/src/features/rental/components/RentalCategoryFilter.tsx +++ /dev/null @@ -1,40 +0,0 @@ -const RENTAL_CATEGORIES = [ - "전체", - "전자기기", - "생활잡화", - "상비약", - "위생용품", -]; - -interface RentalCategoryFilterProps { - value: string; - onChange: (category: string) => void; -} - -// Figma: 빌릴게 Filter Row (nodeId 1243:73337) — WDS Chip이 아니라 Stream 로컬 커스텀 칩. -// 카테고리·물품 매핑 API가 아직 없어 필터링 없이 선택 상태만 표시한다. -function RentalCategoryFilter({ value, onChange }: RentalCategoryFilterProps) { - return ( -
- {RENTAL_CATEGORIES.map((category) => { - const active = category === value; - return ( - - ); - })} -
- ); -} - -export default RentalCategoryFilter; diff --git a/src/index.css b/src/index.css index 509d881..decd1c7 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "@wanteddev/wds/global.css" layer(base); +@import "@ncdai/react-wheel-picker/style.css" layer(base); @theme { /* WDS(@wanteddev/wds/global.css)의 semantic/atomic CSS 변수를 Tailwind 색상 토큰으로 별칭 연결. */ @@ -9,6 +10,7 @@ --color-label-normal: var(--semantic-label-normal); --color-label-alternative: var(--semantic-label-alternative); --color-label-assistive: var(--semantic-label-assistive); + --color-label-disable: var(--semantic-label-disable); --color-background-normal: var(--semantic-background-normal-normal); --color-background-alternative: var(--semantic-background-normal-alternative); @@ -19,6 +21,16 @@ --color-primary: var(--semantic-primary-normal); --color-primary-subtle: var(--atomic-blue-95); - /* WDS 시맨틱 세트에 없는 값(Figma "Icons/Primary") — 홈 인디케이터 전용, 값 자체는 Figma에서 확인됨 */ - --color-icons-primary: #0f172a; + /* WDS 시맨틱 세트에 없는 값(Figma "Sky/Base") — Native / Bottom Sheet Indicator(드래그 핸들) 전용 */ + --color-sheet-indicator: #cdcfd0; +} + +/* 무한 스크롤 목록 등 스크롤은 동작하되 스크롤바는 안 보이게 할 때 쓰는 재사용 유틸리티 */ +@utility scrollbar-hidden { + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } }