diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index af5b03dd..8492e364 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,7 @@ +## 노션 + +- + ## 변경 내용 - @@ -8,4 +12,5 @@ - [ ] `npm run lint` - [ ] `npm test` - [ ] `npm run build` +- [ ] 화면 확인: - [ ] 그 외 수동 검증: diff --git a/.gitignore b/.gitignore index c89c0031..9e6dce6f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ SERVICE_CONTEXT.md # Local git worktrees used for isolated feature development .worktrees/ +# Claude Code personal settings +.claude/settings.local.json + # misc .DS_Store .idea/ diff --git a/AGENTS.md b/AGENTS.md index 65117af0..90f240c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,11 @@ 브랜치 생성, PR 작성, 병합 작업은 반드시 [CONTRIBUTING.md](./CONTRIBUTING.md)를 따른다. +## 프론트엔드 코드 작성 원칙 + +- 프론트엔드·퍼블리싱 코드를 쓰기 전에 [핵심 원칙](docs/code-conventions/principles.md)을 읽는다. +- 마치기 전에 변경한 코드를 [안티패턴](docs/code-conventions/anti-patterns.md)에 대어 보고 고친다. + ## 로딩 UI 원칙 - 화면 전환과 데이터 조회 중에 `불러오는 중…`, `확인 중…` 같은 문장형 로딩 안내를 화면에 노출하지 않는다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2136336..8bfa6353 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,22 @@ docs: 개발자 온보딩 보완 - `main`은 배포 기준 브랜치입니다. 직접 push하지 않고 모든 변경을 PR로 병합합니다. - `dev`는 기능을 통합하고 릴리스 전에 함께 검증하는 브랜치입니다. -- 일반 작업은 최신 `dev`에서 `feature/` 브랜치를 만들고 `dev`를 대상으로 PR을 엽니다. 브랜치는 하나의 작업 단위로 짧게 유지하며, squash 병합이 끝난 feature 브랜치는 다시 사용하지 않습니다. -- 긴급 수정은 최신 `main`에서 `hotfix/` 브랜치를 만들고 `main`을 대상으로 PR을 엽니다. +- 작업 브랜치는 아래 `브랜치 종류` 표의 기준 브랜치 최신 상태에서 만들고, 같은 표의 PR 대상으로 PR을 엽니다. +- 브랜치는 하나의 작업 단위로 짧게 유지하며, squash 병합이 끝난 브랜치는 다시 사용하지 않습니다. + +## 브랜치 종류 + +| 브랜치 | 용도 | 기준 브랜치 | PR 대상 | 커밋·PR 타입 | +| :-- | :-- | :-- | :-- | :-- | +| `feature/` | 새 기능, 화면·디자인 변경 | `dev` | `dev` | `feat` | +| `fix/` | 다음 배포에 함께 나가도 되는 버그 수정 | `dev` | `dev` | `fix` | +| `refactor/` | 동작이 바뀌지 않는 구조 개선 | `dev` | `dev` | `refactor` | +| `chore/` | 빌드·설정·의존성·개발 도구 | `dev` | `dev` | `chore` | +| `docs/` | 문서만 변경 | `dev` | `dev` | `docs` | +| `hotfix/` | 운영 장애처럼 바로 배포해야 하는 수정 | `main` | `main` | `fix` | + +- ``은 영어 소문자 kebab-case로 짧게 씁니다. 예: `feature/calendar-design`, `fix/logout-session` +- 표에 없는 접두사(`feat/`, `codex/` 등)로 새 브랜치를 만들지 않습니다. ## 공통 브랜치 흐름 @@ -23,7 +37,7 @@ docs: 개발자 온보딩 보완 | 병합 방향 | 방식 | | :-- | :-- | -| `feature/*` → `dev` | Squash and merge | +| `feature/*`, `fix/*`, `refactor/*`, `chore/*`, `docs/*` → `dev` | Squash and merge | | `dev` → `main` | Create a merge commit | | `hotfix/*` → `main` | Squash and merge | | `main` → `dev` 백머지 | Create a merge commit | @@ -39,3 +53,21 @@ docs: 개발자 온보딩 보완 - main으로 직접 merge 전 최소 1명의 리뷰가 필요하다. - PR 본문은 .github/pull_request_template.md 형식을 그대로 따른다. - PR 본문에 변경 이유와 테스트 방법을 간략히 적는다. +- PR을 열기 전에 대상 브랜치의 최신 상태를 작업 브랜치에 merge로 반영한다. 이미 push한 작업 브랜치는 rebase하거나 force-push하지 않는다. +- 병합은 리뷰어가 확인한 뒤 위 `공통 브랜치 흐름`의 방식으로 한다. + +## PR 전 검증 + +- 작업 중에는 전체 테스트를 돌리지 않는다. 필요한 확인만 한다. +- PR을 열기 전에 `npm run lint` → `npm test` → `npm run build`를 한 번 실행하고, 결과를 PR 본문 테스트 칸에 체크한다. +- 문서(`*.md`)·`.claude/`·`.github/`만 바뀐 PR은 생략할 수 있다. 생략하면 테스트 칸에 생략했다고 적는다. + +## 작업 공유 + +| 위치 | 내용 | +| :-- | :-- | +| 노션 작업 페이지 | 작업 배경(왜 시작했는지), 논의 내용, 전후 스크린샷, 개발 메모 | +| PR | 변경 내용, 짧은 변경 이유, 테스트 결과, 노션 페이지 링크 | +| 디스코드 | PR을 열면 GitHub Actions가 자동으로 알린다. 작업 내역을 따로 보내지 않는다. | + +- 노션 페이지가 없는 작은 작업은 PR 본문의 `노션` 칸을 지운다. diff --git a/Design.md b/Design.md index 2f0bf543..5bc7bfc8 100644 --- a/Design.md +++ b/Design.md @@ -5,8 +5,8 @@ ## 1. 공통 원칙 ### 1-1. 토큰 우선순위 -1. 시맨틱 토큰 — `primary`, `surface`, `border`, `danger` … -2. 원시 토큰 — `blue-100` 등. 1에 마땅한 게 없을 때만 +1. 시맨틱 토큰 — `primary`, `text`, `fill`, `border`, `status-negative` … +2. 원시 토큰 — `--blue-100` 같은 CSS 변수. 1에 마땅한 게 없을 때만 `bg-[var(--blue-100)]`처럼 쓴다(`bg-blue-100`은 Tailwind 기본 팔레트라 색이 다르다) 3. 임의값 — `text-[17px]`, `#3B82F6` - 값의 정의는 전부 `tailwind.config.js`(+ `src/app/globals.css`)에 있다. 이 문서에 값을 적지 않는다. @@ -17,9 +17,10 @@ 예를들어서 피그마 페이지에 폰트가 17px이고 시멘틱 토큰과 css변수가 없다면 임의값을 사용한다. ### 1-3. 컴포넌트 -- 공통 컴포넌트는 `src/components/<도메인>/`(`layout`, `place`, `map`, `settings` …)에 있다. 있으면 쓴다 -- 특정 페이지 전용 컴포넌트는 `src/app/**/_components/`에 둔다 -- 없으면 Tailwind로 직접 작업한다 +목표는 shadcn이다. 아직 설치 전이라 기존 공용 컴포넌트와 병행하고, 무엇을 쓸지는 [핵심 원칙 1-1](docs/code-conventions/principles.md)을 따른다. +- 공용: `src/components/<도메인>/`(`layout`, `place`, `map`, `settings` …) +- shadcn: 설치 후 `src/components/ui/` +- 특정 페이지 전용: `src/app/**/_components/` ### 1-4. Figma 디자인 시스템 [Design System](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System) 파일은 확정된 내용만 모아 둔 곳이다. 퍼블리싱할 때 해당 페이지의 컴포넌트·토큰을 기준으로 맞춘다. @@ -102,19 +103,16 @@ import { MapPinIcon } from "@/assets/icons"; ### 3-1. 상태 - 서버 상태 → TanStack Query -- 클라이언트 상태 → Zustand -- 서버에서 온 데이터를 Zustand에 복사해두지 않는다 +- 클라이언트 상태 → 둘 곳은 [핵심 원칙 2-2](docs/code-conventions/principles.md)의 표를 따른다 +- 서버에서 온 데이터를 `useState`·Zustand·Context에 복사해두지 않는다 ### 3-2. 필수 상태 처리 -데이터를 다루는 화면은 다음을 빠짐없이: -- loading -- error -- empty +데이터를 다루는 화면은 로딩·오류·빈 상태·재조회를 빠짐없이 그린다. 판정 순서와 상태별 UI는 [핵심 원칙 2-4](docs/code-conventions/principles.md) 표를 따른다(`data`부터 판정). ### 3-3. 이벤트 피드백 - 사용자 액션에는 결과 피드백이 있어야 한다 -- 현재 alert 는 toast를 사용하고, confirm은 시스템confirm 과 dialog가 혼용되고 있는 상태이다. 새로 작성하는 코드는 dialog를 우선시하되 요구조건에 맞춰서 해라 -- 비동기 동작 중 중복 실행 방지 +- alert는 toast, confirm은 `ConfirmDialog`를 쓰고 요구조건에 맞춘다(shadcn 설치 후 기준은 [핵심 원칙 1-1](docs/code-conventions/principles.md)) +- 비동기 동작 중 중복 실행 방지(핵심 원칙 2-4) --- diff --git a/docs/code-conventions/anti-patterns.md b/docs/code-conventions/anti-patterns.md new file mode 100644 index 00000000..5206acc2 --- /dev/null +++ b/docs/code-conventions/anti-patterns.md @@ -0,0 +1,116 @@ +# AI slop 안티패턴 + +> `원칙 N-N`은 [핵심 원칙](./principles.md)의 절 번호다. 옆 파일이 이 목록에 걸리면 본보기로 삼지 않는다. + +## 구조 + +### 1. props drilling +- 신호: 중간 컴포넌트가 쓰지 않는 값을 받아 그대로 넘긴다(개수와 상관없이). 이미 정해진 방 ID를 시트·모달까지 내린다. 반대로 drilling을 피하려고 한 화면의 UI 상태를 Context·스토어로 올린다. +- ✗ `` → ``(MemberSheet는 roomId를 쓰지 않는다) +- ✓ 행은 `onKick(userId)`만 올리고, 방 ID는 화면 훅(없으면 컨테이너)이 `useCurrentRoomId()`로 읽는다. 같은 파일 안 비공개 하위 컴포넌트로 넘기는 것은 걸리지 않는다(원칙 1-2·2-2). + +### 2. 얕은 층 쌓기(depth) +- 신호: 받은 것을 그대로 넘기는 컴포넌트·훅·파일, 레이아웃 역할이 없는 래퍼 `div`, 파일 하나씩 든 폴더가 겹쳐 depth만 깊어진다. +- ✗ `function MemberSection({ roomId }) { return ; }`, 조회 훅을 그대로 돌려주는 화면 훅(`const useRoomSummary = (id) => useRoomDetail(id)`), `member/list/item/MemberItem.tsx` +- ✓ 그 층을 지우고 쓰는 쪽이 바로 부른다(`useRoomDetail(roomId)`를 컴포넌트에서 직접). 지워도 복잡도가 다른 곳에 생기지 않으면 넘기기만 하던 층이다(원칙 1-3). + +### 3. 책임이 섞인 컴포넌트 +- 신호: 비즈니스 로직(변경 호출·핸들러·도메인 계산)이 컴포넌트 본문에 있다. 한 컴포넌트에 조회·변경·폼 상태·분석 전송·모달·행 마크업이 함께 있어 책임을 한 문장으로 말하지 못한다. 화면 훅이 열림·입력 중 값 같은 UI 상태까지 갖는다. 한 훅 파일에 여러 도메인의 조회·변경 훅이 쌓인다. PC·모바일이 같은 저장 흐름을 따로 구현해 한쪽에만 중복 제출 막기가 있다. +- ✗ 훅이 JSX를 반환한다(`useMemberActions`의 `dialogs`). +- ✓ 비즈니스 로직은 화면 훅(`useMemberPanel`), 분기와 UI 상태는 컨테이너(`MemberPanel`), 그리기는 표시 컴포넌트(`MemberRows`)가 맡는다(원칙 1-2·2-3). + +### 4. 무분별한 변수 선언 +- 신호: 한 번만 쓰는 별칭·중간 변수, 이름만 바꾼 복사, 같은 값을 두 이름으로 들고 있다. +- ✗ + ```tsx + const title = room.title; + const hasTitle = !!title; + const members = data.members; + const memberCount = members.length; + ``` +- ✓ 원래 식을 그대로 쓰고(`{room.title}`, `{data.members.length}명`), 뜻을 더하는 이름만 남긴다(`const isOverBudget = total > budget`). +- 계산되는 값을 state로 들고 effect로 맞추는 모양은 9번이다. +- 출처: Martin Fowler [Inline Variable](https://refactoring.com/catalog/inlineVariable.html)·[Extract Variable](https://refactoring.com/catalog/extractVariable.html) + +### 5. props 늘리기 +- 신호: 작업할 때마다 Props 타입에 prop이 하나씩 는다. 모드를 바꾸는 `isCompact`·`showFooter`·`hideIcon`, prop이 있느냐 없느냐로 구현을 갈아 끼우기, 같은 값을 다른 이름으로 또 받기(`member`와 `memberName`), 받은 값으로 계산할 수 있는 prop, 쿼리 상태를 `isPending`·`isError`로 쪼개 넘기기. Props가 6개를 넘었는데 또 더한다(이미 넘은 기존 컴포넌트를 줄이는 일은 따로 한다). +- ✓ prop을 더하기 전에 계산 → slot → `variant` → 화면 훅 순서로 본다. `} />`(원칙 2-2·1-4) + +### 6. 있는 부품 다시 만들기 +- 신호: 새 `fixed inset-0` 모달·바텀시트·로딩 표시, `SettingsActionButton`·`alertDialogButtonClass`가 맞는 자리에 복붙한 버튼 클래스, 새로 만든 범용 Button, `lucide-react`·새 아이콘 패키지 +- ✓ `ConfirmDialog`·`SettingsDialog`·`MobileBottomSheet`·`LoadingIndicator`를 먼저 쓴다. 버튼은 `SettingsActionButton`·`alertDialogButtonClass`가 맞으면 쓰고, 아니면 그 자리 ` + … + +``` + +### 1-2. 로직은 화면 훅, 그리기는 컴포넌트 (Custom Hook · Container / Presentational) + +로직과 그리기를 나눈다. 컴포넌트를 열면 무엇을 그리는지만, 화면 훅을 열면 무엇을 하는지만 보이게 해서 한쪽을 바꿀 때 다른 쪽을 건드리지 않게 한다. + +| 역할 | 맡는 일 | 하지 않는 일 | +| :-- | :-- | :-- | +| 화면 조립(page·layout) | 라우트를 읽고 영역을 배치한다. 서버 컴포넌트로 둔다 | 상호작용 상태 | +| 화면 훅(`use<컴포넌트>`) | 그 화면의 비즈니스 로직. 조회 조합, 변경 호출과 핸들러, 도메인 계산. 상태는 동작마다 따로 내보낸다 | JSX 반환, UI 상태, 여러 변경의 상태 합치기 | +| 기능 컨테이너 | 화면 훅(로직이 없으면 조회 훅)을 부르고 상태별로 분기해 표시 컴포넌트에 데이터와 콜백을 잇는다. UI 상태(열림·hover·입력 중 값)를 가진다 | 비즈니스 로직, 반복되는 행·카드 마크업 | +| 표시 | props와 자기 UI 상태로 그리고, 사용자 행동은 `onX`로 알린다 | 조회·변경 훅, 스토어 구독 | + +- 비즈니스 로직이 붙으면 화면 훅으로 뺀다. 조회 훅 하나를 그대로 쓰는 컴포넌트는 화면 훅 없이 부른다(넘기기만 하는 층이 된다). +- 화면 훅도 `src/hooks/`에 둔다. 이름은 컴포넌트 이름에 `use`를 붙이고(`MemberPanel` → `useMemberPanel`), 핸들러는 `handleX`로 짓는다. 조회 훅은 `use`, 변경 훅은 `use`, 컴포넌트의 동작 하나만 맡는 훅은 `use<대상><동작>`(`usePlanItineraryReorder`)이다. 기존 훅 이름은 기능 작업 중에 바꾸지 않는다. +- 화면 훅은 상태를 합치지 않는다. 여러 변경의 `isPending`·`error`를 합쳐 내보내는 파사드(`useMemberActions`)와 다르다(2-4). +- 성공·실패 뒤 UI 상태를 바꾸는 반응(닫기·입력 초기화)은 컨테이너가 핸들러에 `onSuccess`로 넘기고, 화면 훅이 그것을 `mutate` 호출부 콜백에 잇는다. +- 행마다 따로 진행되는 동작(행별 승인·거절)이 있으면 행 하나가 컨테이너가 된다. 행이 화면 훅을 부르고, 그리기는 같은 파일의 비공개 표시 컴포넌트가 맡는다. +- 훅은 JSX를 반환하지 않는다. 도메인 계산은 `src/lib/<도메인>/`의 순수 함수로 두고 화면 훅이 부른다. 서버가 계산한 값은 화면에서 다시 계산하지 않는다. +- PC·모바일이 같은 흐름이면 같은 화면 훅을 쓰고 레이아웃만 나눈다(나누는 방법은 3-4). + +```tsx +// src/hooks/useMemberPanel.ts — 화면 훅: 비즈니스 로직 +export function useMemberPanel() { + const { roomId } = useCurrentRoomId(); + const membersQuery = useRoomMembers(roomId); + const { mutate: kick, isPending: isKicking } = useKickMember(); + const handleKick = useCallback( + (userId: number, { onSuccess }: { onSuccess?: () => void } = {}) => { + if (!roomId) return; + kick({ roomId, userId }, { onSuccess, onError: () => toast.error("내보내지 못했어요.") }); + }, + [roomId, kick], + ); + return { roomId, membersQuery, isKicking, handleKick }; +} + +// MemberPanel.tsx — 컨테이너: 상태별 분기와 UI 상태 +export function MemberPanel() { + const { roomId, membersQuery, isKicking, handleKick } = useMemberPanel(); + const [kickTargetId, setKickTargetId] = useState(null); // UI 상태는 컴포넌트에 + + if (!roomId) return null; + if (membersQuery.data) { + return ( + <> + + {kickTargetId !== null && ( + handleKick(kickTargetId, { onSuccess: () => setKickTargetId(null) })} + onCancel={() => setKickTargetId(null)} + /> + )} + + ); + } + if (membersQuery.error) return ; + return ; +} + +// MemberRows.tsx — 표시: props로만 그린다. 강퇴 뒤의 캐시·토스트는 모른다 +``` + +### 1-3. 나누는 기준은 바뀌는 이유 (Thinking in React) + +같이 바뀌는 것은 모으고 따로 바뀌는 것은 나눈다. 그래야 변경 하나가 파일 하나에서 끝나고, 나눈 층마다 제 몫이 있다. + +- 경계는 Figma 컴포넌트·레이어 이름을 먼저 따른다. 나누는 기준은 줄 수가 아니라 바뀌는 이유다. +- 파일 하나에 export 컴포넌트 하나를 둔다. 그 컴포넌트만 쓰는 하위 컴포넌트·상수는 같은 파일에 비공개로 둔다. +- 컴포넌트는 모듈 최상위에 정의한다. 컴포넌트 안에서 정의하면 렌더마다 새 타입이 되어 하위 상태와 입력 포커스가 사라진다. +- 두 라우트 이상에서 쓰면 `src/components/<도메인>/`으로 올린다. `src/app/**/_components/`는 그 라우트 전용이라 공용 코드와 다른 라우트가 import하지 않는다. +- 추상화는 두 번째 사용처가 생길 때 만든다(화면 훅은 재사용이 아니라 로직을 나누는 것이라 사용처가 하나여도 만든다, 1-2). 넘기기만 하는 층(컴포넌트·훅·파일·폴더)은 만들지 않는다. 판별은 삭제 테스트다. 지웠을 때 복잡도가 다른 곳에 다시 생기지 않으면 넘기기만 하던 층이다. + +```tsx +// ✗ 부모가 렌더될 때마다 Row가 새 컴포넌트가 된다 +function PlaceList({ places }: PlaceListProps) { + const Row = ({ place }: { place: Place }) =>
  • {place.name}
  • ; + return
      {places.map((p) => )}
    ; +} + +// ✓ 최상위에 두고 필요한 값은 props로 넘긴다 +function PlaceRow({ place }: { place: Place }) { + return
  • {place.name}
  • ; +} +``` + +### 1-4. 변형은 합성으로 (Composition over Boolean Props) + +boolean prop은 하나 늘 때마다 가능한 조합이 두 배가 되고, 그중 일부는 있을 수 없는 상태다. 변형은 이름 있는 값이나 합성으로 드러낸다. + +- 구조·동작을 바꾸는 boolean prop 대신 children·slot이나 변형별 컴포넌트를 쓴다. 표시 변형은 boolean 여럿 대신 유니온 prop(`variant`·`size`)으로 받는다(`MobileBottomSheet`의 `variant: "menu" | "form"`). +- renderX prop 대신 children으로 넘긴다. +- 쿼리 상태를 `isPending`·`isError` prop으로 쪼개 넘기지 않는다. 분기는 컨테이너에서 끝낸다(1-2). + +```tsx +// ✗ boolean 하나마다 조합이 두 배로 는다 + + +// ✓ 표시 변형은 variant, 바뀌는 내용은 slot +} /> +``` + +### 1-5. 서버·클라이언트 경계 (Server Components) + +클라이언트 컴포넌트도 서버에서 한 번 먼저 그려진다. 클라이언트 경계를 좁게 둬야 번들이 작고, 서버와 브라우저의 첫 결과가 같아야 하이드레이션이 깨지지 않는다. + +- page·layout은 서버 컴포넌트로 두고 상호작용은 하위 클라이언트 컴포넌트에 맡긴다. 예: `src/app/(main)/packing/[roomId]/page.tsx`는 지시어 없이 ``만 렌더하고, `"use client"`는 `PackingPage.tsx`에만 있다. +- `"use client"`는 서버 컴포넌트가 직접 렌더하는 클라이언트 진입 파일에만 붙인다. 훅·`src/lib` 파일에는 붙이지 않는다. +- `useSearchParams`를 쓰는 컴포넌트는 가까이에서 ``로 감싼다. 없으면 개발 서버는 통과하고 프로덕션 빌드만 실패한다. +- 렌더 중 `typeof window`·`localStorage`·`new Date()`로 마크업을 바꾸지 않는다(하이드레이션 불일치). 브라우저에서만 정해지는 값은 `useClientReady()`가 true일 때 그린다. + +## 2. 상태·데이터 + +### 2-1. 최소 상태, 파생은 렌더 중에 (Derived State) + +상태에는 계산할 수 없는 값만 둔다. 같은 값을 두 곳에 두면 한쪽만 바뀌어 어긋난다. + +- 필터 결과·합계·개수는 렌더 중에 계산한다. +- props를 `useState`에 복사하지 않는다. 초깃값이면 `initialX`로 이름 짓는다(리셋은 2-8). +- 선택·편집 대상은 ID로 저장하고 객체는 최신 목록에서 찾는다. +- 동시에 참일 수 없는 boolean 여럿 대신 유니온 하나를 둔다. 바뀌는 경로가 여러 핸들러에 흩어지면 `useReducer`로 한 곳에 모으고, 액션은 이벤트 이름(`openEdit`·`close`)으로 짓는다. +- 편집 중인 값은 바꾼 것만 상태로 두고 표시값은 `draft ?? serverValue`로 계산한다. + +```tsx +// ✗ 서버 값을 복사하고 effect로 따라간다(이 모양은 lint를 통과한다) +const [memo, setMemo] = useState(serverMemo); +useEffect(() => { setMemo(serverMemo); }, [serverMemo]); + +// ✓ 바꾼 값만 들고 표시값은 계산한다 +const [draft, setDraft] = useState(null); // null = 변경 없음 +const memo = draft ?? serverMemo; + +// ✗ 동시에 참일 수 없는 상태를 따로 든다 +const [isEditOpen, setIsEditOpen] = useState(false); +const [isKickConfirmOpen, setIsKickConfirmOpen] = useState(false); +const [targetId, setTargetId] = useState(null); + +// ✓ 유니온 하나로 둔다. 전이가 많아지면 useReducer로 옮긴다 +type Sheet = { mode: "closed" } | { mode: "edit" | "kick"; memberId: number }; +const [sheet, setSheet] = useState({ mode: "closed" }); +``` + +### 2-2. 데이터는 내리고 이벤트는 올린다 (Lifting State Up · Colocation) + +상태는 그것을 쓰는 곳 가까이 둔다. 가까울수록 데이터가 어디서 와서 어디로 가는지 코드에 보이고, 바뀔 때 다시 그리는 범위도 좁다. + +| 상태 | 둘 곳 | +| :-- | :-- | +| 서버 데이터 | TanStack Query 캐시(2-3) | +| 새로고침·공유에 남아야 하는 선택 | URL(경로·`searchParams`) | +| 한 영역 안에서 끝나는 UI 상태(열림·입력값) | 그 상태를 쓰는 가장 가까운 공통 부모의 `useState`(엮이면 유니온, 2-1) | +| 멀리 떨어진 영역(지도와 일정 목록)이나 여러 화면이 함께 쓰는 클라이언트 상태 | Zustand selector(기본). 방·일정 범위면 `Record`로 나눈다 | +| 같은 때 하위 트리마다 값이 달라야 하는 상태(compound 내부, 요청마다 다른 초기값) | Context + Provider | + +중간 컴포넌트는 자기가 쓰는 값만 받는다. 중간이 쓰지 않는 값을 받아 넘기면 개수와 상관없이 아래 순서로 푼다. 같은 파일 안의 비공개 하위 컴포넌트로 넘기는 것은 둔다(함께 바뀐다). + +1. 상태를 쓰는 곳 가까이로 내린다. +2. children·slot으로 중간을 비운다. +3. 서버 데이터와 방 ID는 그것이 필요한 컨테이너가 직접 읽는다(화면 훅이 있으면 그 안에서). 표시 컴포넌트는 행동을 `onX`로 올리기만 한다. +4. 그래도 멀리 떨어진 영역이 함께 쓰면 Zustand selector, 하위 트리마다 값이 달라야 하면 Context로 읽는다. + +prop은 그리는 데 필요한 값과 사용자 행동을 알리는 `onX`만 받는다. 돌아가게 하려고 prop을 하나씩 더하지 않는다. 새 prop을 더하기 전에 순서대로 본다. + +1. 이미 받은 값으로 계산할 수 있으면 계산한다(2-1). +2. 넘기기만 할 값이면 위 순서로 푼다. +3. 모드를 바꾸는 boolean이면 `variant`나 변형 컴포넌트로 바꾼다(1-4). +4. 조회·변경·계산이 따라오면 화면 훅으로 옮긴다(1-2). + +Props가 6개를 넘으면 이 순서로 다시 본다. + +```tsx +// ✗ DaySection은 roomId·scheduleId를 쓰지 않고 안쪽에 넘기기만 한다 + + +// ✓ 조립하는 쪽이 안을 채운다(PlanDaySection의 summaryAction·children) +}> + + +``` + +- `(main)` 아래에서 방 ID는 중간을 거쳐 받지 않고, 필요한 화면 훅·컨테이너가 `const { roomId } = useCurrentRoomId()`로 읽는다(`string | null`). +- drilling을 피하려고 한 영역의 UI 상태(열림·입력값)를 Context·스토어로 올리지 않는다. +- Zustand는 selector로 필요한 조각만 구독한다(`useChatPanelStore((s) => s.minimized)`). 객체·배열을 새로 만들어 반환하는 selector는 `useShallow`로 감싼다. +- 렌더 중에 스토어를 채우지 않는다. 서버 렌더에서 요청마다 초기값이 달라야 하는 상태는 모듈 전역 스토어 대신 Provider가 만든 스토어(Context)에 둔다. 전역 스토어는 서버에서 요청끼리 공유된다. +- Provider가 꼭 있어야 하는 Context는 `createContext(null)`로 만들고, Provider 밖에서 쓰면 throw하는 `useXxx()`를 export한다(value 메모는 2-7). + +### 2-3. 서버 데이터는 api → 훅 → 컨테이너 (Query Hooks) + +서버 데이터는 요청 → 캐시 → 화면의 층으로 흐른다. 같은 데이터는 캐시 한 곳에만 두고, 언제 다시 가져오고 어떻게 고칠지는 훅 안에 숨긴다. 화면은 결과만 받는다. + +```text +src/lib/api/<리소스>.ts 요청·응답 보정·에러. 반환 타입을 여기서 정한다(방 리소스는 src/lib/api/rooms/) +src/lib/query-keys.ts 등 키 팩토리. 도메인 키 파일(expenseKeys 등)이 있으면 거기에 더한다 +src/hooks/use*.ts 조회 훅(useQuery)·변경 훅(useMutation)·화면 훅(1-2). 조회·변경 훅은 파일 하나에 도메인 하나 +기능 컨테이너 훅을 부르고 상태별로 분기한다(2-4) +``` + +- 서버 데이터는 캐시에만 둔다. `useState`·스토어·Context로 복사하지 않는다. 여러 컴포넌트가 쓰면 각자 같은 조회 훅을 부른다. 같은 키는 요청 하나로 합쳐진다. +- 요청은 `src/lib/api`의 함수로만 보낸다(`apiFetch`가 401 토큰 갱신과 세션 만료를 처리한다). 컴포넌트는 `fetch`도 API 함수도 직접 부르지 않고 훅을 쓴다. +- 실패는 `HttpError`로 던진다. `requestJson`·`requestVoid`에는 `useHttpError: true`를 넘긴다(없으면 `status`가 없는 `Error`가 된다). 화면은 `status`를 직접 비교하지 않고 `src/lib/api/errors.ts`의 판정·문구 함수를 쓴다. +- 키는 팩토리로만 만들고 queryFn이 쓰는 값을 모두 넣는다. 방 데이터 키는 `[세그먼트, roomId, …]` 순서다(강퇴·방 삭제 때 두 번째 자리로 캐시를 지운다). 개인 데이터 키에는 사용자 ID를 넣는다. +- 조회 훅은 키·queryFn·`enabled`와 필요한 `select`·`placeholderData`·`staleTime`만 둔다. 전역 기본값(`createQueryClient`)과 같은 값은 다시 쓰지 않고, 다른 값에는 갱신 사유를 주석으로 단다. +- 브로드캐스트와 본인 수정으로 갱신되는 방 데이터는 `staleTime: Infinity`로 둔다(전역은 60초). 무효화로 갱신받는 쿼리에는 `refetchOnMount: false`를 두지 않는다. 화면 밖에서 무효화된 캐시가 다시 마운트돼도 갱신되지 않는다. +- 변경 훅 하나는 동작 하나다. 캐시 반영과 쓰기 분석 이벤트는 훅에, 토스트·닫기·이동은 호출부 `mutate(vars, { onSuccess, onError })`에 둔다. +- 캐시는 응답으로 `setQueryData`하는 것이 기본이다. 응답으로 확정할 수 없을 때만 필요한 범위를 무효화하고, 그 promise를 반환해 재조회가 끝날 때까지 `isPending`을 유지한다. +- 응답·이벤트로 캐시를 고치는 updater는 캐시가 없으면 `undefined`를 돌려 건너뛴다. `old ?? []`로 한 건짜리 목록을 만들지 않는다. +- 방 ID 같은 범위 값은 mutation 변수로 넘기고 `onSuccess`는 그 변수를 쓴다. 완료 시점에 현재 방을 다시 읽지 않는다. 쓰기 분석 이벤트는 요청 중에 사용자·방·권한이 바뀌었으면 보내지 않는다(방 쓰기는 `trackRoomMutation`이 mutationFn을 감싸 확인한다). +- STOMP는 화면에서 구독하지 않는다. 새 토픽은 `src/lib/stomp`의 파서 → `*-dispatch(queryClient, …)` → `subscribeRoomStompTopics` 등록 순서로 더한다. React 안에서는 `useQueryClient()`를 쓰고, `getQueryClient()`는 인자로 받을 수 없는 모듈 코드에서만 쓴다. + +```tsx +// 조회 훅: data 타입은 API 함수의 반환 타입에서 추론된다 +export function useRoomDetail(roomId: string | null) { + const id = typeof roomId === "string" ? roomId.trim() : ""; + return useQuery({ + queryKey: roomDetailQueryKey(id), + queryFn: () => getRoomDetail(id), + enabled: id.length > 0, + }); +} + +// 변경 훅: 캐시는 훅이 맡는다 +export function useKickMember() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ roomId, userId }: { roomId: string; userId: number }) => kickMember(roomId, userId), + // 응답이 void라 그 방 멤버 목록만 무효화하고, 재조회가 끝날 때까지 기다린다 + onSuccess: (_, { roomId }) => queryClient.invalidateQueries({ queryKey: roomMembersQueryKey(roomId) }), + }); +} + +// 호출부(화면 훅의 핸들러): 화면 반응만 둔다 +kick({ roomId, userId }, { onError: () => toast.error("내보내지 못했어요.") }); +``` + +### 2-4. 상태 하나에 UI 하나 (State → UI) + +상태마다 보여 줄 UI를 하나로 정해 둔다. 그래야 같은 상태가 화면마다 다르게 보이지 않고, 재조회가 실패해도 보이던 내용이 사라지지 않는다. + +조회 상태는 `data`가 있으면 1~4행을 함께 적용하고(내용 유지 + 표시 추가), 없으면 5~7행을 위에서부터 판정한다. + +| 상태 | 판정 | UI | +| :-- | :-- | :-- | +| 데이터 있음 | `data` | 내용. 결과가 비었으면 빈 상태 | +| 재조회 중 | `data` + `isFetching` | 내용 유지. 필요하면 `aria-busy` | +| 재조회 실패 | `data` + `error` | 내용 유지 + 인라인 알림이나 토스트 | +| 임시 결과 | `data` + `isPlaceholderData` | 이전 결과 유지. 확정 결과가 필요한 동작만 막는다 | +| 최초 실패 | `error` | 그 영역의 오류 안내 + 재시도 | +| 조회 안 함 | `fetchStatus === "idle"`(`enabled: false`) | 곧 채워질 조건(STOMP 연결 등)을 기다리면 스켈레톤. 채워지지 않을 조건(방 미선택)이면 그 이유에 맞는 자리 | +| 최초 로딩 | 그 밖 | 완료 화면과 같은 배치의 스켈레톤. 작은 영역은 `LoadingIndicator` | + +- `isPending`·`isLoading` 하나로 로딩을 판정하지 않는다. `enabled: false`면 `isPending`이 계속 true이고, `isLoading`은 오프라인으로 멈춘 첫 조회(`fetchStatus === "paused"`)를 놓친다. +- 조회가 여럿이면 영역마다 따로 분기한다. 로딩을 합쳐(`a.isLoading || b.isLoading`) 화면 전체를 바꾸지 않는다. + +```tsx +export function RoomSummary() { + const { roomId } = useCurrentRoomId(); + const { data: room, error, fetchStatus, isFetching, refetch } = useRoomDetail(roomId); + if (room) { + return ( +
    + {error ? void refetch()} /> : null} + +
    + ); + } + if (error) return void refetch()} />; + if (fetchStatus === "idle") return ; + return ; +} +``` + +변경의 진행·실패 표시는 그 동작의 자리에만 둔다. + +| 상태 | 판정 | UI | +| :-- | :-- | :-- | +| 진행 중 | `isPending` | 그 버튼만 비활성 + 버튼 안 진행 표시 | +| 행별 진행 중 | `isPending` + `variables`의 행 ID | 그 행에 진행 표시. 한 훅을 여러 행이 함께 쓰면 진행 중에는 모든 행을 막는다 | +| 실패 | 호출부 `onError` | 토스트 하나 또는 그 자리의 문구 하나 | +| 성공 | 호출부 `onSuccess` | 닫기·이동·입력 초기화 | + +- 결과는 동작 이름으로 구조분해한다: `const { mutate: approve, isPending: isApproving } = useApproveJoinRequest()`. +- 여러 변경의 `isPending`·`error`를 합치지 않는다(`useMemberActions` 같은 파사드 포함). 합치면 어느 동작이 진행 중이고 어느 동작이 실패했는지 사라진다. +- 서로 배타적인 동작만 이유가 드러나는 이름으로 합쳐 비활성화에만 쓴다(`const isDeciding = isApproving || isRejecting`). 진행 표시는 누른 버튼에만 둔다. +- 요청 둘이 함께 성공해야 하는 동작은 변경 훅 하나의 `mutationFn`에서 차례로 보낸다. 여러 변경을 잇는 순서는 이벤트 핸들러에서 정한다. +- 호출부 콜백·`isPending`·`variables`는 그 훅의 마지막 호출만 따르고, 호출부 콜백은 컴포넌트가 마운트돼 있을 때만 실행된다. 여러 행을 동시에 실행하려면 행 단위 컨테이너가 각자 화면 훅을 부르거나(1-2), `mutationKey`와 `useMutationState`로 진행 중인 행을 읽는다. 응답 전에 사라지는 컴포넌트는 남는 상위에서 `mutate`하거나 `mutateAsync`를 `try/catch`로 감싼다. +- 연타로 두 번 실행되면 안 되는 동작은 변경 훅의 `useRef` 락으로 막고 `onSettled`에서 푼다. `isPending`은 다음 렌더에야 바뀐다. + +### 2-5. 낙관적 갱신은 되돌릴 수 있을 때만 (Optimistic Updates) + +낙관적 갱신은 응답 전에 결과를 보여 준다. 실패하면 보였던 값이 거짓이 되므로, 실패가 드물고 되돌릴 수 있는 곳에만 쓴다. + +- 실패 가능성이 낮고 즉시 반응이 필요한 곳(체크·순서 바꾸기)에만 쓴다. 그 밖에는 진행 중임을 보이고 응답으로 반영한다. +- 결과가 한 곳에만 보이면 캐시를 고치지 않고 `variables`·`isPending`으로 그 자리만 그린다. +- 여러 곳에 보이면 `onMutate`에서 `cancelQueries` → 스냅샷 → `setQueryData` 순서로 고친다. 실패하면 스냅샷으로 되돌리고 그 범위를 다시 조회한다(그사이 들어온 다른 사람의 변경도 스냅샷이 지운다). +- 연속 입력은 디바운스해 한 요청으로 보낸다. 스냅샷은 첫 입력 때 잡는다(보낼 때 잡으면 이미 고친 값이 잡힌다). 언마운트 때 대기분을 입력 당시의 방 ID로 보내고, 사용자가 바뀌었으면 버린다. +- 준비물·가계부는 자체 저장 방식(직렬 저장·충돌 복구)이 있어 이 패턴을 쓰지 않는다. + +```ts +onMutate: async (vars) => { + await queryClient.cancelQueries({ queryKey: key }); + const prev = queryClient.getQueryData(key); + queryClient.setQueryData(key, (old) => applyChange(old, vars)); + return { prev }; +}, +onError: (_error, _vars, context) => { + queryClient.setQueryData(key, context?.prev); + void queryClient.invalidateQueries({ queryKey: key }); +}, +``` + +### 2-6. effect는 외부 시스템과 맞출 때만 (Synchronizing with Effects) + +effect는 렌더가 끝난 뒤 실행돼 화면을 한 번 더 그리게 하고, 원인(사용자 행동)과 결과를 떨어뜨린다. 그래서 React 밖의 시스템과 맞출 때만 쓴다. + +| 하고 싶은 것 | effect 대신 | +| :-- | :-- | +| props·state로 계산되는 값 | 렌더 중 계산 | +| 엔터티가 바뀔 때 상태 초기화 | `key`(2-8) | +| 값이 바뀔 때 다른 상태도 함께 | 그 변경을 일으킨 이벤트 핸들러 | +| 제출·토스트·이동·부모에 알리기 | 이벤트 핸들러·mutation 콜백 | +| 서버 데이터 조회 | 조회 훅(2-3) | +| 브라우저 값·외부 저장소 구독 | `useSyncExternalStore` | + +- effect는 지도 SDK·DOM 측정·타이머·브라우저 이벤트·데이터가 표시된 시점의 조회 분석 이벤트에만 쓴다(STOMP는 2-3). +- 조회 분석 이벤트는 마지막으로 보낸 대상 ID를 ref에 두고 같으면 건너뛴다(`PlanPageView`의 `lastTrackedPlanIdRef`). 재조회·브로드캐스트로 `data`가 바뀌어도 다시 보내지 않는다. +- effect 하나에 동기화 하나를 두고, 구독·리스너·타이머는 cleanup에서 푼다. +- 두 번 실행돼도 결과가 같게 쓴다. `reactStrictMode: false`라 개발 중에 드러나지 않을 뿐 재마운트는 일어난다. +- `exhaustive-deps` 경고는 끄지 않고 구조를 바꾼다. 정적 값은 컴포넌트 밖, effect에서만 쓰는 값은 effect 안으로 옮기고 이전 값 기반 갱신은 updater 함수로 쓴다. + +```tsx +// ✗ 플래그를 지켜보는 effect에서 토스트·이동을 한다(이 모양은 lint를 통과한다) +useEffect(() => { + if (saved) { toast.success("저장했어요."); router.push("/plan"); } +}, [saved, router]); + +// ✓ 그 동작의 호출부 콜백에서 처리한다 +save(vars, { onSuccess: () => { toast.success("저장했어요."); router.push("/plan"); } }); +``` + +### 2-7. 메모는 이유가 있을 때만 (When to Memo) + +메모는 의존성 배열을 늘리고, 막아 주는 다시 그리기가 없으면 비용만 남는다. React Compiler를 쓰지 않으므로 직접 판단한다. 메모보다 먼저 바뀌는 상태를 쓰는 곳으로 내리고 바뀌지 않는 부분은 children으로 받는다. `useMemo`·`useCallback`은 아래에만 쓴다. + +- 커스텀 훅·Context가 내보내는 값과 함수(Context value는 항상) +- effect·다른 훅의 의존성으로 쓰는 값 +- `memo()` 컴포넌트에 넘기는 props +- 측정해서 비싼 계산 + +```tsx +// ✗ 원시값을 돌려주는 단순 계산까지 감싼다 +const selectedCount = useMemo(() => places.filter((p) => p.selected).length, [places]); + +// ✓ 렌더 중에 그대로 계산한다 +const selectedCount = places.filter((p) => p.selected).length; +``` + +### 2-8. 렌더는 순수하게, key는 안정 ID (Pure Render) + +렌더는 같은 입력에 같은 결과를 내는 계산이다. React는 렌더를 다시 하거나 버리기도 하므로, 렌더 중의 부수효과와 값 변경은 화면을 어긋나게 한다. + +- 렌더 중에 요청·분석 전송·`ref.current` 읽기를 하지 않는다. +- props·state·쿼리 `data`를 제자리에서 바꾸지 않는다. 정렬·추가는 복사본으로 하고 `setQueryData` updater도 새 객체를 반환한다. +- 목록 `key`는 데이터의 안정 ID다. 편집·재정렬·삽입되는 목록에 index를 쓰지 않는다. +- 같은 자리의 컴포넌트가 다른 엔터티를 보여주면 엔터티 ID를 `key`로 줘서 리셋한다(`ChatPanel`의 ``). +- 에러 경계(`error.tsx`)가 없어 렌더 중 예외 하나가 화면 전체를 깨뜨린다. 배열 인덱스·Record 접근은 타입에 `undefined`가 없으니 `items[0]?.id`처럼 읽는다. + +```tsx +// ✗ 쿼리 data를 제자리에서 정렬한다. 같은 키를 쓰는 모든 화면이 함께 바뀐다 +const sorted = data.places.sort(byOrder); + +// ✓ 복사본을 정렬한다 +const sorted = [...data.places].sort(byOrder); +``` + +## 3. 퍼블리싱 + +### 3-1. Auto Layout을 그대로 옮긴다 (Layout First) + +시안의 Auto Layout이 곧 레이아웃 구조다. 구조를 그대로 옮기면 숫자로 맞출 일이 없다. 순서는 영역과 시맨틱 태그 → 레이아웃 방식 → 늘어나는 곳과 고정인 곳 → 간격 → 토큰 값이다. + +- Figma Auto Layout은 방향 = flex 축, 간격 = 부모 `gap`, 안쪽 여백 = padding, Hug = 폭 없이 내용 크기, Fill = 부모 방향 축이면 `flex-1 min-w-0`·교차 축이면 `self-stretch`, Fixed = 3-2대로 옮긴다. 가로 행의 Fixed·Hug 형제에는 `shrink-0`을 준다. Hug·Fill 레이어에 Dev Mode가 보여 주는 W·H 수치와 절대 좌표는 옮기지 않는다. +- 한 축 나열은 flex, 행·열을 함께 맞추면 grid, 문서 본문은 flow, 겹침만 `absolute`(기준 부모에 `relative`)로 둔다. +- `space-y-*`와 재사용 컴포넌트 루트의 바깥 margin을 쓰지 않는다. +- margin은 auto 정렬(`mx-auto`·`ms-auto`), 의도한 블리드·겹침, 누르는 영역을 넓히고 되돌리는 음수 margin(3-7), flow 본문(약관·Markdown의 요소별 margin)에만 쓴다. +- 상단 헤더는 `fixed`와 보정 padding 대신 `sticky`로 둔다. + +```tsx +// ✗ 제목 컴포넌트가 바깥 간격을 들고 다닌다 +function SectionTitle({ title }: { title: string }) { + return

    {title}

    ; +} + +// ✓ 컴포넌트는 간격 없이 두고, 배치하는 부모가 gap으로 정한다 +
    + + +
    +``` + +### 3-2. 시안 크기는 지키고, 커져도 깨지지 않게 (Defensive CSS) + +크기를 다루는 목적은 둘이다. 디자이너가 정한 크기를 그대로 맞추고, 사용자가 글자를 키우거나 화면이 좁아져도 내용이 잘리거나 넘치지 않게 한다(접근성). 그래서 시안 값은 평소 크기로 그대로 쓰고, 늘어나거나 줄어들어야 하는 쪽만 막지 않는다. + +- 글자를 담는 요소의 높이는 시안 값을 `min-h-*`로 쓴다. 평소엔 시안과 같고, 글자가 커지거나 두 줄이 될 때만 늘어난다. +- 고정 폭은 시안 값을 쓰되 부모보다 넓어지지 않게 `w-[<폭>] max-w-full`로 쓴다. `%`는 화면이 아니라 부모 기준이라, 띄운 패널(absolute·fixed)은 `w-[min(<폭>,calc(100vw-<여백>))]`로 화면에 상한을 건다. 최소 폭은 부모 폭이 정해진 곳에서만 `min-w-[min(<폭>,100%)]`로 쓴다. +- 채우되 최대 폭이 있으면(Fill) 가로 행에서는 `flex-1 min-w-0 max-w-[<폭>]`, 세로로 쌓을 때는 `w-full max-w-[<폭>]`로 쓴다. +- 카드처럼 글자와 함께 커져야 하는 크기는 rem으로 쓴다(3-3). 아이콘·아바타·미디어는 고정 크기 그대로 둔다. +- 좁아질 때 줄어들 수 있게 flex·grid 자식에는 `min-w-0`(grid 트랙은 `minmax(0,1fr)`), 스크롤 영역까지 이어지는 래퍼에는 `min-h-0`을 준다. +- 화면 높이는 `h-dvh`·`min-h-svh`로 쓴다. `100vh`는 모바일에서 주소창 밑으로 내용이 빠진다. + +```tsx +// ✗ 라벨이 길어지거나 글자가 커지면 잘린다 + + +// ✓ 평소엔 같고 내용이 커질 때만 늘어난다 + +``` + +### 3-3. 글자를 따라 커져야 하면 rem (px vs rem) + +목적은 3-2와 같다. rem은 기본 글자 크기(16px)에서 px와 같아서 시안과 똑같이 보이고, 사용자가 기본 글자 크기를 키우면 함께 커진다. 그래서 글자를 따라 커져야 하는 값은 rem, 아니면 px로 쓴다. 줌은 px까지 키우지만 기본 글자 크기 설정은 rem만 키운다. + +| 값 | 쓸 것 | +| :-- | :-- | +| 글자 크기·줄 높이 | 타이포 토큰(3-6) | +| 간격·gap·padding | Tailwind 간격 스케일(rem). Figma px ÷ 4: 16px → `p-4`, 18px → `p-4.5`. 제목↔본문 간격도 글자를 따라 커져야 읽힌다 | +| 테두리·구분선 | px(`border`) | +| 카드 최소 폭 | rem과 상한: `grid-cols-[repeat(auto-fill,minmax(min(18rem,100%),1fr))]` | + +- 타이포 토큰은 아직 px다. 그래도 버튼·헤더 padding은 간격 스케일을 쓰고, 타이포를 rem으로 옮길 때 단위를 다시 정한다. + +### 3-4. 반응형은 세 축으로 나눈다 (Device · Viewport · Container) + +화면이 바뀌는 이유가 기기인지, 화면 폭인지, 놓인 영역의 폭인지로 축을 고른다. 이유에 맞는 축 하나만 써야 어느 값이 정본인지 분명하다. + +| 무엇이 다른가 | 쓸 것 | +| :-- | :-- | +| 기기(터치·바텀시트·하단 탭바) | `mobile:` variant. 구조가 다르면 `useMobileView()`로 컴포넌트를 고른다 | +| 뷰포트 폭(랜딩·홈 목록 열 수) | 테마 브레이크포인트(`sm:`·`lg:`·`landing-sm:`) | +| 놓인 영역의 폭(크기가 바뀌는 패널) | 이름 있는 컨테이너 쿼리(`@container/plan`) | + +- `mobile:`은 UA로 판별해 `html`에 붙인 클래스를 본다. 창을 줄여도 나오지 않으므로 기기 에뮬레이션이나 실기기로 확인한다. +- 같은 속성을 두 축으로 중복하지 않는다. 새 브레이크포인트를 더하지 않는다. +- px 기준(`s1`·`s2`·`landing-*`)과 rem 기준(`sm`·`md`·`lg`)을 한 요소에 섞지 않는다. px 기준이 먼저 출력돼 더 넓은 `landing-lg:`가 `sm:`에 진다. 글자 크기 설정을 따르는 것은 rem 기준뿐이다. +- 쿼리보다 내재적 방식(`flex-wrap`, `auto-fill`과 `minmax`, `min()`·`clamp()`)이 먼저다. 쿼리는 구조가 바뀌는 지점에만 쓴다. +- 카드 그리드는 시안의 열 수와 카드 폭을 맞추고, 화면이 넓어져 남는 폭은 여백으로 둔다(`lg:grid-cols-[repeat(3,minmax(0,20rem))] justify-center`). 글자가 커질 때 열이 줄어야 하면 `auto-fill`과 rem 최소 폭을 쓴다(3-3). +- 문서형 화면은 `mx-auto w-full max-w-[…] px-…`로 만든다. 가운데는 최대 폭만, 좌우는 최소 여백만 정한다. +- `hover:`는 터치 기기에서 적용되지 않는다. hover로만 드러나는 정보를 만들지 않는다. + +```tsx +// ✗ 같은 값을 두 축에 겹쳐 준다 +
    …
    + +// ✓ 차이를 만드는 축 하나만 쓴다(기기 차이면 mobile:) +
    …
    +``` + +### 3-5. 겹침은 스태킹 컨텍스트부터 (Stacking Context) + +z-index는 같은 스태킹 컨텍스트 안에서만 비교된다. 그래서 숫자를 올리기 전에 그 요소가 어느 컨텍스트에 있는지부터 본다. + +1. 컨텍스트를 만드는 것을 찾는다. z가 있는 `relative`·`absolute`·flex·grid 자식, `fixed`·`sticky`, `@container`, `opacity` 1 미만, `transform`·`translate-*`, `filter`·`backdrop-blur-*`가 컨텍스트를 만든다. +2. 컴포넌트 안 겹침은 루트에 `isolate`를 주고, 위치를 준 자식에 `z-0`·`z-10` 같은 작은 값을 쓴다. +3. 부모 밖으로 떠야 하면 portal로 렌더한다. +4. 화면 전체를 덮으면 공용 셸을 쓴다(1-1). + +새 z값으로 다른 레이어를 이기려 하지 않는다. + +```tsx +// ✗ 가려지니 숫자를 올린다. 부모가 컨텍스트를 만들었으면 올려도 나오지 못한다 +
    …
    + +// ✓ 컴포넌트 안 겹침은 루트에서 가둔다 +
    + + +
    +``` + +### 3-6. 값은 토큰, 클래스는 완성된 문자열 (Design Tokens) + +값은 디자인 시스템 토큰에서 가져온다. 그래야 디자인이 바뀔 때 한 곳만 고치면 된다. 클래스는 Tailwind가 소스에서 읽을 수 있는 완성된 문자열로 쓴다. + +- 토큰 이름은 `tailwind.config.js`에서 고른다. 색 우선순위와 원시 색 쓰는 법은 Design.md 1-1을 따른다. 레거시 별칭(`dark-gray`·`gray-border` 등)은 쓰지 않는다. +- 타이포는 토큰 하나로 쓴다. display·heading·title은 `text-<역할>-<크기>`(`text-title-m`), body·label·caption은 굵기까지 붙인다(`text-body-s-regular`). `text-body-s`처럼 없는 이름은 CSS가 생기지 않는다. +- 타이포 토큰에 `font-*`·`leading-*`·`tracking-*`·`/N`을 덧붙이지 않는다(토큰 값을 덮거나 굵기·자간이 빠진다). 맞는 토큰이 없으면 Figma 값대로 크기·줄 높이·굵기를 함께 임의값으로 쓰고(`text-[17px]/[24px] font-semibold`) 근거 주석을 단다(Design.md 1-2). +- radius는 재정의된 토큰을 쓴다(`rounded-md` = 8px, Tailwind 기본과 다르다). 그림자 토큰은 아직 없다. `shadow-sm` 같은 Tailwind 기본값 대신 Figma 값을 임의값과 근거 주석으로 쓴다. 같은 임의값이 반복되면 토큰 추가를 요청한다. +- 클래스는 `cn()`으로만 합친다. 템플릿 리터럴은 겹친 클래스(`px-2`와 바깥 `px-4`)를 정리하지 못하고, 조립한 이름(`` `bg-${tone}` ``)은 Tailwind가 소스에서 찾지 못한다. 변형은 `as const` 클래스 맵에 모은다. +- 상태 스타일은 aria 속성을 variant로 읽는다(`aria-expanded:bg-fill`, `aria-current="page"`는 `aria-[current=page]:`). aria가 없는 상태만 `data-*`로 둔다. 여러 하위가 함께 따르는 루트 상태(채팅 최소화·밀도)는 하위마다 prop으로 내리지 않고, 루트의 `data-minimized`를 하위가 `group-data-minimized/chat:`처럼 읽는다. +- 전역 요소 스타일은 `@layer base`에 둔다. 레이어 밖 CSS는 모든 유틸리티를 이긴다. + +```tsx +// ✗ 문자열 결합과 조립한 클래스 이름 +{label} + +// ✓ 완성된 클래스 맵과 cn +const TONE_CLASS = { + neutral: "bg-fill text-text", + brand: "bg-primary-subtle text-primary", +} as const; +{label} +``` + +### 3-7. 시맨틱 마크업과 접근성 (Semantic HTML) + +키보드와 스크린리더로도 같은 일을 할 수 있어야 한다. 브라우저 기본 요소(`button`·`Link`·`label`)를 쓰면 그 동작이 대부분 따라온다. + +- 동작은 `button`(form 제출이 아니면 `type="button"`), 이동은 `Link`로 만든다. `div`·`li`에 `onClick`을 달지 않는다. +- 아이콘만 있는 버튼에는 `aria-label`을 단다. 입력은 `label`과 연결한다(placeholder는 라벨이 아니다). 필드 오류는 `aria-invalid`와 `aria-describedby`로 알린다. +- 포커스 표시는 `focus-visible:outline-2 focus-visible:outline-primary`로 둔다. 같은 요소에 `outline-none`·`outline-hidden`을 함께 두지 않는다(v4에서 윤곽선이 사라진다). 입력은 `focus:outline-hidden`과 감싸는 필드의 `focus-within:border-primary`로 표시한다. +- 모바일에서 누르는 영역은 44×44px 이상으로 둔다. 시각 크기는 Figma대로 두고 영역만 `size-*`·padding으로 키운 뒤 음수 margin으로 되돌린다. +- 모바일 입력 글자는 16px 이상으로 둔다(iOS가 확대한다). viewport 설정으로 확대를 막지 않는다. 움직이는 애니메이션은 `motion-safe:`·`motion-reduce:`로 감싼다. + +```tsx +// ✗ 키보드로 닿지 않고 스크린리더가 버튼으로 읽지 않는다 +
    + +// ✓ 시각 크기는 24px, 누르는 영역은 44px + +``` + +## 출처 + +| 원칙 | 출처 | +| :-- | :-- | +| 0 | React [Thinking in React](https://react.dev/learn/thinking-in-react)·[Keeping Components Pure](https://react.dev/learn/keeping-components-pure), Josh Comeau [Understanding Layout Algorithms](https://www.joshwcomeau.com/css/understanding-layout-algorithms/) | +| 1-1 | [shadcn/ui](https://ui.shadcn.com/docs) | +| 1-3 | React [Thinking in React](https://react.dev/learn/thinking-in-react), Matt Pocock [codebase-design](https://github.com/mattpocock/skills)(삭제 테스트) | +| 1-4 | Vercel [composition-patterns](https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns) `architecture-avoid-boolean-props`·`patterns-explicit-variants`·`patterns-children-over-render-props` | +| 2-1 | React [Choosing the State Structure](https://react.dev/learn/choosing-the-state-structure), Vercel [react-best-practices](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices) `rerender-derived-state-no-effect` | +| 2-2 | React [Sharing State Between Components](https://react.dev/learn/sharing-state-between-components)·[Passing Data Deeply with Context](https://react.dev/learn/passing-data-deeply-with-context), Kent C. Dodds [Colocation](https://kentcdodds.com/blog/colocation) | +| 2-3 | TkDodo [Effective React Query Keys](https://tkdodo.eu/blog/effective-react-query-keys)·[Mastering Mutations in React Query](https://tkdodo.eu/blog/mastering-mutations-in-react-query) | +| 2-4 | TkDodo [Status Checks in React Query](https://tkdodo.eu/blog/status-checks-in-react-query) | +| 2-5 | TanStack Query [Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates) | +| 2-6 | React [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)·[Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects), Vercel react-best-practices `rerender-move-effect-to-event` | +| 2-7 | Vercel react-best-practices `rerender-simple-expression-in-memo`, React [useMemo](https://react.dev/reference/react/useMemo), Dan Abramov [Before You memo()](https://overreacted.io/before-you-memo/) | +| 2-8 | React [Keeping Components Pure](https://react.dev/learn/keeping-components-pure)·[Rendering Lists](https://react.dev/learn/rendering-lists) | +| 3-1 | Max Stoiber [Margin considered harmful](https://mxstbr.com/thoughts/margin) | +| 3-2 | Josh Comeau [The Height Enigma](https://www.joshwcomeau.com/css/height-enigma/), [Defensive CSS](https://defensivecss.dev/), web.dev [Viewport units](https://web.dev/blog/viewport-units) | +| 3-3 | Josh Comeau [The Surprising Truth About Pixels and Accessibility](https://www.joshwcomeau.com/css/surprising-truth-about-pixels-and-accessibility/) | +| 3-5 | Josh Comeau [What The Heck, z-index??](https://www.joshwcomeau.com/css/stacking-contexts/) | +| 3-6 | Tailwind [Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files) | diff --git a/public/map-search/arrow-right.svg b/public/map-search/arrow-right.svg new file mode 100644 index 00000000..e03a7f79 --- /dev/null +++ b/public/map-search/arrow-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/map-search/attraction.svg b/public/map-search/attraction.svg new file mode 100644 index 00000000..30038055 --- /dev/null +++ b/public/map-search/attraction.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/map-search/chevron-down.svg b/public/map-search/chevron-down.svg new file mode 100644 index 00000000..688e5216 --- /dev/null +++ b/public/map-search/chevron-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map-search/clear.svg b/public/map-search/clear.svg new file mode 100644 index 00000000..129f6879 --- /dev/null +++ b/public/map-search/clear.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/close.svg b/public/map-search/close.svg new file mode 100644 index 00000000..6712c055 --- /dev/null +++ b/public/map-search/close.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/hotel.svg b/public/map-search/hotel.svg new file mode 100644 index 00000000..e3fad22b --- /dev/null +++ b/public/map-search/hotel.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/map-search/location.svg b/public/map-search/location.svg new file mode 100644 index 00000000..c0bdec3e --- /dev/null +++ b/public/map-search/location.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/museum.svg b/public/map-search/museum.svg new file mode 100644 index 00000000..50eb214f --- /dev/null +++ b/public/map-search/museum.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/map-search/pointer.svg b/public/map-search/pointer.svg new file mode 100644 index 00000000..a6416b71 --- /dev/null +++ b/public/map-search/pointer.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/map-search/reset.svg b/public/map-search/reset.svg new file mode 100644 index 00000000..f0c45e54 --- /dev/null +++ b/public/map-search/reset.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/restaurant.svg b/public/map-search/restaurant.svg new file mode 100644 index 00000000..09b7a658 --- /dev/null +++ b/public/map-search/restaurant.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/map-search/search-white.svg b/public/map-search/search-white.svg new file mode 100644 index 00000000..97f48bc0 --- /dev/null +++ b/public/map-search/search-white.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/search.svg b/public/map-search/search.svg new file mode 100644 index 00000000..11b446ba --- /dev/null +++ b/public/map-search/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map-search/star.svg b/public/map-search/star.svg new file mode 100644 index 00000000..357f0547 --- /dev/null +++ b/public/map-search/star.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map-search/transit.svg b/public/map-search/transit.svg new file mode 100644 index 00000000..721fffd2 --- /dev/null +++ b/public/map-search/transit.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx index 842af452..e8a1e9b6 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx @@ -27,7 +27,11 @@ import { import type { PlanPlace } from "@/lib/plan/types"; import { cn } from "@/lib/utils"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; -import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; +import { + PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE, + planPlaceFocusTarget, + usePlanMapFocusStore, +} from "@/stores/plan-map-focus-store"; import { PlanItemMemoEditor, PlanItemMemoReadOnly } from "./PlanItemMemoForm"; import { PlanItemTimeEditor } from "./PlanItemTimeForm"; @@ -80,9 +84,10 @@ export function PlanPlaceCard({ s.focus.scheduleId === focusScheduleId && s.focus.itemId === place.itemId, ); - /** 지도 핀으로 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */ - const focusSeqFromMap = usePlanMapFocusStore((s) => - s.focus?.source === "map" && + /** 카드가 아닌 곳(지도 핀·장소 이동 스테퍼)에서 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */ + const focusSeqToReveal = usePlanMapFocusStore((s) => + s.focus !== null && + s.focus.source !== "card" && s.focus.roomId === focusRoomId && s.focus.scheduleId === focusScheduleId && s.focus.itemId === place.itemId @@ -91,10 +96,10 @@ export function PlanPlaceCard({ ); const articleRef = useRef(null); useEffect(() => { - if (focusSeqFromMap === null) return; + if (focusSeqToReveal === null) return; const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; articleRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" }); - }, [focusSeqFromMap]); + }, [focusSeqToReveal]); const pointerDownRef = useRef<{ x: number; y: number } | null>(null); const blockCardDragRef = useRef(false); const [thumbnailRef, thumbnailInViewport] = useInViewport(); @@ -202,24 +207,12 @@ export function PlanPlaceCard({ setSelectedPlace(null); return; } - const loc = place.location; - const hasLocation = - loc != null && - typeof loc.lat === "number" && - typeof loc.lng === "number" && - Number.isFinite(loc.lat) && - Number.isFinite(loc.lng); + const target = planPlaceFocusTarget(place, focusRoomId, focusScheduleId, "card"); + if (!target) return; // 일정에 넣은 장소는 상세를 보려는 게 아니라 위치를 보려는 것 — 상세(조회 포함) 대신 카드·지도 핀만 강조한다 setSelectedPlace(null); - focusPlace({ - roomId: focusRoomId, - scheduleId: focusScheduleId, - itemId: place.itemId, - googlePlaceId: place.googlePlaceId?.trim() || null, - location: hasLocation ? { lat: loc.lat, lng: loc.lng } : null, - source: "card", - }); - if (!hasLocation) toast.info("지도에 표시할 위치 정보가 없어요."); + focusPlace(target); + if (!target.location) toast.info(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE); }, [ clearFocus, @@ -227,9 +220,7 @@ export function PlanPlaceCard({ focusRoomId, focusScheduleId, focused, - place.googlePlaceId, - place.itemId, - place.location, + place, setSelectedPlace, ], ); @@ -293,7 +284,7 @@ export function PlanPlaceCard({ aria-label={hasTime ? `시간 ${timeRange} 수정` : "시간 설정"} className={cn(PLAN_PLACE_CARD_TW.actionButton, "tabular-nums")} > - + {timeRange || "시간"} ) : null; @@ -429,7 +420,8 @@ export function PlanPlaceCard({ label="비용" // 금액이 길면 메모 버튼 앞에서 말줄임되도록 공통 버튼의 shrink-0를 푼다 className={cn(PLAN_PLACE_CARD_TW.actionButton, "min-w-0 shrink")} - icon={} + // 금액이 있을 때만 파랑 — 버튼의 `data-filled`로 바꾼다 + icon={} /> )} diff --git a/src/app/globals.css b/src/app/globals.css index a676dc5a..ea5ef06f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,8 +13,11 @@ */ @custom-variant mobile (&:where(.is-mobile-device, .is-mobile-device *)); -/* 검색 말풍선은 Google 기본 여백·스크롤바 대신 본문에서 사방 12px을 확보한다. */ +/* 검색 말풍선의 여백·표면·그림자는 Figma 6156:14635 기준으로 맞춘다. */ .gm-style-iw-c:has([data-search-place-preview]) { + border-radius: 8px; + background: var(--color-fill-subtle); + box-shadow: 0 2px 10px #0000001a; padding: 0 !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 padding을 검색 말풍선에서만 재정의한다. */ } @@ -22,6 +25,33 @@ overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */ } +/* 검색 추천은 기존 상세 패널(z-20) 위에서 선택할 수 있어야 한다. */ +.map-discover-toolbar:has(input[aria-expanded="true"]) { + z-index: 25; +} + +/* 원본 말풍선 꼬리(14×12)를 Google 기본 삼각형 대신 사용한다. */ +.gm-style-iw-t:has([data-search-place-preview]) > .gm-style-iw-tc { + width: 14px; + height: 12px; + filter: drop-shadow(0 2px 5px #0000001a); +} + +.gm-style-iw-t:has([data-search-place-preview]) > .gm-style-iw-tc::after { + inset: 0; + width: 14px; + height: 12px; + clip-path: none; + background: url("/map-search/pointer.svg") center / contain no-repeat; + transform: rotate(180deg); + box-shadow: none; +} + +/* PC 지도 버튼은 지도 끝에서 아래·좌우 모두 24px — 확대·축소(+/−)의 아래는 저작권 줄과 기본 여백으로 이미 24px이라 오른쪽만 넓힌다. */ +[data-map-controls-inset] .gm-bundled-control { + margin-right: 24px !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 margin(10px)을 재정의한다. */ +} + @layer base { html { @apply font-sans; @@ -90,11 +120,12 @@ html:has([data-mobile-main-tabs]) { /* ============================================================ * 디자인 토큰 — Figma "🐰디자인-디자인 시스템"(node 161:9) 기반 - * 정본: docs/design/tokens/tokens.css (손으로 관리 중) + * 값의 정본은 이 파일과 tailwind.config.js다. 쓰는 순서는 Design.md 1-1을 따른다. * * primitive(--gray-*, --blue-* …)는 semantic이 참조하는 원천 값이다. - * 컴포넌트에서 직접 쓰지 말고 semantic(--color-*)만 사용할 것. - * tailwind.config.js 에는 semantic만 등록되어 있다. + * 컴포넌트는 semantic(--color-*)을 쓰고, 맞는 semantic이 없을 때만 + * primitive를 bg-[var(--gray-800)]처럼 직접 쓴다. + * tailwind.config.js 에는 semantic과 레거시 별칭(신규 사용 금지)이 등록되어 있다. * ============================================================ */ :root { /* ---------- 1. PRIMITIVE ---------- */ diff --git a/src/app/home/new/page.tsx b/src/app/home/new/page.tsx index 6b821300..c00f9392 100644 --- a/src/app/home/new/page.tsx +++ b/src/app/home/new/page.tsx @@ -18,7 +18,6 @@ import { isTripDateRangeInvalid, isTripDestinationsValid, ROOM_TRIP_TITLE_MAX_LENGTH, - tripEndDateMinYmd, } from "@/lib/rooms/trip-form"; import { roomDetailQueryKey } from "@/lib/query-keys"; import { useSessionStore } from "@/stores/session-store"; @@ -35,7 +34,6 @@ export default function NewTripPage() { const { mutate: createRoom, isPending, error } = useCreateRoom(); const todayYmd = formatDateYmd(new Date()); - const endDateMin = tripEndDateMinYmd(startDate, todayYmd); const dateRangeInvalid = isTripDateRangeInvalid(startDate, endDate); const canSubmit = @@ -96,7 +94,6 @@ export default function NewTripPage() { values={{ title, destinations, startDate, endDate }} autoFocusTitle startDateMin={todayYmd} - endDateMin={endDateMin} onTitleChange={setTitle} onDestinationsChange={setDestinations} onStartDateChange={setStartDate} diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx index d5df5b70..c5a407ef 100644 --- a/src/app/login/page.test.tsx +++ b/src/app/login/page.test.tsx @@ -16,11 +16,18 @@ vi.mock("@/lib/google-oauth", () => ({ saveOAuthPendingSession: vi.fn(), })); -import { LoginFallback } from "@/app/login/page"; +vi.mock("next/navigation", () => ({ + useSearchParams: () => { + // Exercise the page's real Suspense fallback while navigation data is pending. + throw new Promise(() => {}); + }, +})); + +import LoginPage from "@/app/login/page"; describe("LoginFallback", () => { it("renders meaningful non-interactive login HTML", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain("로그인하고 여행 계획을 이어가세요"); expect(html).toContain("Google로 계속하기"); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 0e01c355..b165cc8d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -108,7 +108,7 @@ function LoginPageContent() { ); } -export function LoginFallback() { +function LoginFallback() { return ( + + ); +} + +type DatePickerProps = Readonly<{ + /** `range`: 한 달력에서 시작·종료를 고른다(고른 쪽만 바뀐다). `single`은 `value.start`만 쓴다 */ + mode?: "single" | "range"; + value: DateRange; + /** 이보다 이른 날짜는 고를 수 없다 (`YYYY-MM-DD`) */ + min?: string; + onSelect: (ymd: string) => void; + onCancel: () => void; + onConfirm: () => void; +}>; + +/** Figma Design System `09 Date Picker` — Header / Weekday / Date(또는 연월 휠) / Footer */ +export function DatePicker({ mode = "range", value, min, onSelect, onCancel, onConfirm }: DatePickerProps) { + const [todayYmd] = useState(() => formatDateYmd(new Date())); + const initialFocus = value.start || (min && todayYmd < min ? min : todayYmd); + const [view, setView] = useState(() => toYearMonth(initialFocus)); + const [focusYmd, setFocusYmd] = useState(initialFocus); + /** 연월 휠을 연 시점의 달 — 휠에서 취소하면 되돌린다 */ + const [wheelOrigin, setWheelOrigin] = useState(null); + const gridRef = useRef(null); + const moveFocusRef = useRef(true); + + const minMonth = min ? toYearMonth(min) : undefined; + const canGoPrev = !minMonth || monthIndex(view) > monthIndex(minMonth); + const weeks = buildMonthWeeks(view); + const days = weeks.flat().filter((ymd) => ymd != null); + // 방향키 이동용 roving tabindex — 다른 달을 보고 있으면 그 달 1일 + const tabbableYmd = days.includes(focusYmd) ? focusYmd : days[0]!; + const thisYear = Number(todayYmd.slice(0, 4)); + const summary = formatSelectionSummary(mode === "single" ? { start: value.start, end: "" } : value); + + // 처음 열릴 때와 방향키로 이동했을 때만 날짜 칸으로 포커스를 옮긴다 + useEffect(() => { + if (!moveFocusRef.current) return; + moveFocusRef.current = false; + gridRef.current?.querySelector(`[data-ymd="${tabbableYmd}"]`)?.focus(); + }, [tabbableYmd]); + + function goToMonth(next: YearMonth) { + if (minMonth && monthIndex(next) < monthIndex(minMonth)) return; + setView(next); + } + + function handleDayKeyDown(event: KeyboardEvent, ymd: string) { + const delta = ARROW_DELTAS[event.key]; + if (delta == null) return; + event.preventDefault(); + const next = addDays(ymd, delta); + const nextMonth = toYearMonth(next); + if (minMonth && monthIndex(nextMonth) < monthIndex(minMonth)) return; + moveFocusRef.current = true; + setFocusYmd(next); + setView(nextMonth); + } + + return ( +
    +
    +
    + +

    + {summary} +

    +
    +
    + + +
    +
    + + {wheelOrigin ? ( + + ) : ( + <> +
    + {WEEKDAYS_KO.map((weekday) => ( + + {weekday} + + ))} +
    +
    + {weeks.map((week) => ( +
    + {week.map((ymd, index) => + ymd ? ( + { + setFocusYmd(selected); + onSelect(selected); + }} + onKeyDown={handleDayKeyDown} + /> + ) : ( +
    + ), + )} +
    + ))} +
    + + )} + +
    + + +
    +
    + ); +} diff --git a/src/components/date-picker/DatePickerLayer.tsx b/src/components/date-picker/DatePickerLayer.tsx new file mode 100644 index 00000000..a90889b3 --- /dev/null +++ b/src/components/date-picker/DatePickerLayer.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; + +import { DATE_PICKER_WIDTH } from "./DatePicker"; + +export type DatePickerPresentation = "anchored" | "popup"; + +/** + * 달력 표시 방식 — `anchored`는 `relative` 입력 칸 아래에 붙고, `popup`은 화면 가운데에 띄운다. + * 모바일과 스크롤되는 모달 안에서는 `popup`을 쓴다 (Figma Date Picker Usage). + * `anchored`는 입력 칸 폭에 맞춰 달력 전체를 같은 비율로 줄이거나 늘린다. + * `popup`은 가로 최소 300px이고, 화면보다 높으면(6주짜리 달·가로 모드) 달력 안에서 세로로 스크롤한다. + */ +export function DatePickerLayer({ + presentation, + boundaryRef, + label, + onDismiss, + onEscape, + children, +}: Readonly<{ + presentation: DatePickerPresentation; + /** `anchored`에서 바깥 클릭 판정 범위 — 입력 칸과 레이어를 함께 감싼 요소 */ + boundaryRef?: RefObject; + label: string; + /** 바깥 클릭·키보드로 레이어를 벗어날 때 */ + onDismiss: () => void; + onEscape: () => void; + children: ReactNode; +}>) { + const dialogRef = useRef(null); + const onDismissRef = useRef(onDismiss); + useEffect(() => { + onDismissRef.current = onDismiss; + }); + + // 자식(DatePicker)이 날짜 칸으로 포커스를 옮기기 전, 렌더 시점의 포커스가 레이어를 연 칸이다 + const [opener] = useState(() => document.activeElement); + const [zoom, setZoom] = useState(1); + + useEffect(() => { + const dialog = dialogRef.current; + return () => { + // 레이어 안에 포커스가 있던 채로 닫히면 연 칸으로 돌려준다 + const active = document.activeElement; + const focusWasInside = !active || active === document.body || dialog?.contains(active); + if (focusWasInside && opener instanceof HTMLElement && opener.isConnected) opener.focus(); + }; + }, [opener]); + + // 첫 페인트 전에 입력 칸 폭을 재서 달력 비율을 맞춘다 + useLayoutEffect(() => { + const dialog = dialogRef.current; + if (presentation !== "anchored" || !dialog) return; + const fit = () => setZoom(dialog.clientWidth / DATE_PICKER_WIDTH); + fit(); + const observer = new ResizeObserver(fit); + observer.observe(dialog); + return () => observer.disconnect(); + }, [presentation]); + + useEffect(() => { + if (presentation !== "anchored") return; + function handlePointerDown(event: PointerEvent) { + if (!boundaryRef?.current?.contains(event.target as Node)) onDismissRef.current(); + } + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [presentation, boundaryRef]); + + const dialog = ( +
    { + if (event.key === "Escape") { + // 바깥 모달(SettingsDialog)의 Escape 처리까지 닫히지 않도록 막는다 + event.preventDefault(); + event.stopPropagation(); + onEscape(); + return; + } + if (event.key !== "Tab" || presentation !== "popup") return; + const controls = [ + ...event.currentTarget.querySelectorAll('button:not(:disabled), [tabindex="0"]'), + ].filter((node) => node.tabIndex >= 0); + const first = controls[0]; + const last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + }} + onBlur={(event) => { + // 키보드로 레이어 밖 요소에 포커스가 가면 닫는다 (마우스는 바깥 클릭 처리) + if (presentation !== "anchored") return; + const next = event.relatedTarget; + if (next && !boundaryRef?.current?.contains(next)) onDismiss(); + }} + className={ + presentation === "anchored" + ? "absolute inset-x-0 top-full z-30 mt-3" + : "max-h-full w-full min-w-[300px] max-w-[353px] overflow-y-auto overscroll-contain rounded-[12px]" + } + > + {presentation === "anchored" ?
    {children}
    : children} +
    + ); + + if (presentation === "anchored") return dialog; + + return ( +
    { + if (event.target === event.currentTarget) onDismiss(); + }} + > + {dialog} +
    + ); +} diff --git a/src/components/date-picker/DateRangeField.tsx b/src/components/date-picker/DateRangeField.tsx new file mode 100644 index 00000000..c97c0d43 --- /dev/null +++ b/src/components/date-picker/DateRangeField.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRef } from "react"; + +import { CalendarIcon } from "@/assets/icons"; +import { useMobileView } from "@/contexts/MobileViewContext"; +import { cn } from "@/lib/utils"; + +import { formatYmdKoreanFull, formatYmdSlash, type DateRange, type RangeEdge } from "./calendar"; +import { DatePicker } from "./DatePicker"; +import { DatePickerLayer } from "./DatePickerLayer"; +import { useDateRangeDraft } from "./useDateRangeDraft"; + +const FIELDS: { edge: RangeEdge; label: string }[] = [ + { edge: "start", label: "시작 날짜" }, + { edge: "end", label: "종료 날짜" }, +]; + +/** + * Figma Date Picker Usage `Input Field와 함께 쓰일 때` — `Text Field Emphasis` 두 칸과 기간 달력. + * 웹은 누른 칸 아래에 그 칸 폭으로 붙이고, 모바일·`popupOnly`는 팝업으로 띄운다. + */ +export function DateRangeField({ + idPrefix, + value, + min, + readOnly = false, + popupOnly = false, + onChange, +}: Readonly<{ + idPrefix: string; + value: DateRange; + /** 이보다 이른 날짜는 고를 수 없다 (`YYYY-MM-DD`) */ + min?: string; + readOnly?: boolean; + /** 스크롤되는 모달처럼 칸 아래에 붙이기 어려운 곳 */ + popupOnly?: boolean; + onChange: (next: DateRange) => void; +}>) { + const containerRef = useRef(null); + const { isMobileDevice } = useMobileView(); + const draft = useDateRangeDraft(value, onChange); + const presentation = popupOnly || isMobileDevice ? "popup" : "anchored"; + + const picker = draft.isOpen ? ( + + + + ) : null; + + return ( +
    + {FIELDS.map(({ edge, label }) => { + const ymd = draft.range[edge]; + const focused = draft.activeEdge === edge; + const text = ( + + + + {formatYmdSlash(ymd) || "YY/MM/DD"} + + + ); + + return ( +
    + {readOnly ? ( +
    + {label} + {text} +
    + ) : ( + + )} + {presentation === "anchored" && draft.anchorEdge === edge ? picker : null} +
    + ); + })} + {presentation === "popup" ? picker : null} +
    + ); +} diff --git a/src/components/date-picker/MonthYearWheel.tsx b/src/components/date-picker/MonthYearWheel.tsx new file mode 100644 index 00000000..607307c3 --- /dev/null +++ b/src/components/date-picker/MonthYearWheel.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { + WheelPicker, + WheelPickerWrapper, + type WheelPickerOption, +} from "@ncdai/react-wheel-picker"; + +import type { YearMonth } from "./calendar"; + +/** Figma Date Picker `Month` — 행 40px. 링을 크게 잡아 위아래 3행이 시안 간격(40px)에 가깝게 보이고, 284px 밖은 잘라낸다 */ +const OPTION_HEIGHT = 40; +const VISIBLE_COUNT = 28; + +const CLASS_NAMES = { + optionItem: "text-title-m text-text-disabled", + highlightWrapper: "bg-fill text-title-m", + highlightItem: "text-text", +}; + +/** 연도·월 휠 — `min`보다 이른 달은 고를 수 없다 */ +export function MonthYearWheel({ + value, + min, + fromYear, + toYear, + onChange, +}: Readonly<{ + value: YearMonth; + min?: YearMonth; + fromYear: number; + toYear: number; + onChange: (next: YearMonth) => void; +}>) { + const yearOptions: WheelPickerOption[] = []; + for (let year = fromYear; year <= toYear; year++) { + yearOptions.push({ value: year, label: `${year}년` }); + } + const monthOptions: WheelPickerOption[] = Array.from({ length: 12 }, (_, month) => ({ + value: month, + label: `${month + 1}월`, + disabled: min != null && value.year === min.year && month < min.month, + })); + + function change(year: number, month: number) { + const clampedMonth = min && year === min.year ? Math.max(month, min.month) : month; + onChange({ year, month: clampedMonth }); + } + + return ( +
    +
    + +
    + change(year, value.month)} + /> +
    +
    + change(value.year, month)} + /> +
    +
    +
    + ); +} diff --git a/src/components/date-picker/calendar.test.ts b/src/components/date-picker/calendar.test.ts new file mode 100644 index 00000000..be200db1 --- /dev/null +++ b/src/components/date-picker/calendar.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { confirmPickedRange, displayPickedRange, pickRangeDate, type DateRange } from "./calendar"; + +const value: DateRange = { start: "2026-10-15", end: "2026-10-22" }; + +describe("pickRangeDate", () => { + it("changes only the start when the start is picked and keeps the current end", () => { + const { picked, edge } = pickRangeDate(value, {}, "start", "2026-10-17"); + expect(picked).toEqual({ start: "2026-10-17" }); + expect(edge).toBe("end"); + expect(displayPickedRange(value, picked)).toEqual({ start: "2026-10-17", end: "2026-10-22" }); + expect(confirmPickedRange(value, picked)).toEqual({ start: "2026-10-17", end: "2026-10-22" }); + }); + + it("changes only the end when the end is picked", () => { + const { picked, edge } = pickRangeDate(value, {}, "end", "2026-10-24"); + expect(picked).toEqual({ end: "2026-10-24" }); + expect(edge).toBe("start"); + expect(confirmPickedRange(value, picked)).toEqual({ start: "2026-10-15", end: "2026-10-24" }); + }); + + it("makes an end earlier than the start the new start and keeps the current end", () => { + const { picked, edge } = pickRangeDate(value, {}, "end", "2026-10-12"); + expect(picked).toEqual({ start: "2026-10-12" }); + expect(edge).toBe("end"); + expect(confirmPickedRange(value, picked)).toEqual({ start: "2026-10-12", end: "2026-10-22" }); + }); + + it("hides an end that falls before a new start until it is picked again, without changing it on confirm", () => { + const { picked } = pickRangeDate(value, {}, "start", "2026-10-25"); + expect(displayPickedRange(value, picked)).toEqual({ start: "2026-10-25", end: "" }); + expect(confirmPickedRange(value, picked)).toEqual({ start: "2026-10-25", end: "2026-10-22" }); + }); +}); + +describe("confirmPickedRange", () => { + it("changes nothing when no date was picked", () => { + expect(confirmPickedRange(value, {})).toBeNull(); + }); +}); diff --git a/src/components/date-picker/calendar.ts b/src/components/date-picker/calendar.ts new file mode 100644 index 00000000..f1e07c84 --- /dev/null +++ b/src/components/date-picker/calendar.ts @@ -0,0 +1,109 @@ +import { formatDateYmd, parseLocalYmd, WEEKDAYS_KO } from "@/lib/plan/tripRange"; + +/** 값은 모두 `YYYY-MM-DD` 문자열, 빈 문자열은 미선택 */ +export type DateRange = { start: string; end: string }; + +/** 다음 클릭이 채울 기간 끝 */ +export type RangeEdge = "start" | "end"; + +/** `month`는 0~11 */ +export type YearMonth = { year: number; month: number }; + +const MS_PER_DAY = 86_400_000; + +export function toYearMonth(ymd: string): YearMonth { + const d = parseLocalYmd(ymd); + return { year: d.getFullYear(), month: d.getMonth() }; +} + +export function monthIndex({ year, month }: YearMonth): number { + return year * 12 + month; +} + +export function addMonths({ year, month }: YearMonth, delta: number): YearMonth { + const index = year * 12 + month + delta; + return { year: Math.floor(index / 12), month: ((index % 12) + 12) % 12 }; +} + +export function addDays(ymd: string, delta: number): string { + const d = parseLocalYmd(ymd); + return formatDateYmd(new Date(d.getFullYear(), d.getMonth(), d.getDate() + delta)); +} + +/** 일요일 시작 주 단위 — 앞뒤 빈칸은 `null` */ +export function buildMonthWeeks({ year, month }: YearMonth): (string | null)[][] { + const leading = new Date(year, month, 1).getDay(); + const dayCount = new Date(year, month + 1, 0).getDate(); + const cells: (string | null)[] = Array.from({ length: leading }, () => null); + for (let day = 1; day <= dayCount; day++) { + cells.push(formatDateYmd(new Date(year, month, day))); + } + while (cells.length % 7) cells.push(null); + const weeks: (string | null)[][] = []; + for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); + return weeks; +} + +/** 달력에서 이번에 고른 날짜 — 고르지 않은 쪽은 비어 있어 바깥 값을 그대로 쓴다 */ +export type PickedRange = Partial; + +/** 달력에 보일 기간 — 고른 날짜를 바깥 값 위에 얹고, 새 시작일보다 앞선 기존 종료일은 다시 고를 때까지 비워 보인다 */ +export function displayPickedRange(value: DateRange, picked: PickedRange): DateRange { + const start = picked.start ?? value.start; + const end = picked.end ?? value.end; + return { start, end: start && end && end < start ? "" : end }; +} + +/** + * 시작 칸에서는 시작일을, 종료 칸에서는 종료일을 고른다. 종료 칸에서 시작일보다 앞선 날짜를 누르면 새 시작일이 된다. + * 고른 쪽만 바뀌고 다른 쪽은 그대로다. 다음 클릭은 반대쪽 칸을 채운다. + */ +export function pickRangeDate( + value: DateRange, + picked: PickedRange, + edge: RangeEdge, + ymd: string, +): { picked: PickedRange; edge: RangeEdge } { + const { start } = displayPickedRange(value, picked); + if (edge === "start" || !start || ymd < start) { + const keepPickedEnd = picked.end !== undefined && picked.end >= ymd; + return { picked: keepPickedEnd ? { start: ymd, end: picked.end } : { start: ymd }, edge: "end" }; + } + return { picked: { ...picked, end: ymd }, edge: "start" }; +} + +/** 닫을 때 반영할 기간 — 고르지 않은 쪽은 바깥 값 그대로이고, 고른 날짜가 없으면 `null`(아무것도 바꾸지 않는다) */ +export function confirmPickedRange(value: DateRange, picked: PickedRange): DateRange | null { + if (picked.start === undefined && picked.end === undefined) return null; + return { start: picked.start ?? value.start, end: picked.end ?? value.end }; +} + +/** `2026-09-20` → `26/09/20` */ +export function formatYmdSlash(ymd: string): string { + const [y, m, d] = ymd.split("-"); + return y && m && d ? `${y.slice(-2)}/${m}/${d}` : ""; +} + +/** `2026-09-20` → `2026년 9월 20일 일요일` (스크린리더용) */ +export function formatYmdKoreanFull(ymd: string): string { + const d = parseLocalYmd(ymd); + return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일 ${WEEKDAYS_KO[d.getDay()]}요일`; +} + +/** `2026-10-24` → `10.24 토` */ +function formatMonthDayWeekday(ymd: string): string { + const d = parseLocalYmd(ymd); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${mm}.${dd} ${WEEKDAYS_KO[d.getDay()]}`; +} + +/** 헤더 요약 — `10.24 토` 또는 `10.24 토 - 11.18 수 (25박)` */ +export function formatSelectionSummary({ start, end }: DateRange): string { + if (!start) return "날짜를 선택해 주세요"; + if (!end || end === start) return formatMonthDayWeekday(start); + const nights = Math.round( + (parseLocalYmd(end).getTime() - parseLocalYmd(start).getTime()) / MS_PER_DAY, + ); + return `${formatMonthDayWeekday(start)} - ${formatMonthDayWeekday(end)} (${nights}박)`; +} diff --git a/src/components/date-picker/useDateRangeDraft.ts b/src/components/date-picker/useDateRangeDraft.ts new file mode 100644 index 00000000..60d9ca6e --- /dev/null +++ b/src/components/date-picker/useDateRangeDraft.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; + +import { + confirmPickedRange, + displayPickedRange, + pickRangeDate, + type DateRange, + type PickedRange, + type RangeEdge, +} from "./calendar"; + +type Session = { + /** 이번에 달력에서 고른 날짜만 — 고르지 않은 쪽은 바깥 값(`value`)을 그대로 쓴다 */ + picked: PickedRange; + /** 다음 클릭이 채울 칸 */ + edge: RangeEdge; + /** 달력을 연 칸 — 달력은 이 칸 아래에 붙는다 */ + anchor: RangeEdge; +}; + +/** + * 달력이 열려 있는 동안 고른 날짜 — 확인하거나 바깥을 눌러 닫을 때 고른 쪽만 `onConfirm`으로 반영한다. + * 열 때 바깥 값을 복사해 두지 않으므로, 고르지 않은 쪽이나 아무것도 고르지 않고 닫은 경우 바깥 값을 덮어쓰지 않는다. + */ +export function useDateRangeDraft(value: DateRange, onConfirm: (next: DateRange) => void) { + const [session, setSession] = useState(null); + + return { + isOpen: session != null, + /** 다음 클릭이 채울 칸 — 입력 칸 강조에 쓴다 */ + activeEdge: session?.edge ?? null, + anchorEdge: session?.anchor ?? null, + /** 열려 있으면 고른 날짜를 얹은 기간, 닫혀 있으면 바깥 값 */ + range: session ? displayPickedRange(value, session.picked) : value, + open(edge: RangeEdge) { + setSession((current) => { + const picked = current?.picked ?? {}; + const { start } = displayPickedRange(value, picked); + return { picked, edge: edge === "end" && !start ? "start" : edge, anchor: edge }; + }); + }, + pick(ymd: string) { + setSession( + (current) => current && { ...pickRangeDate(value, current.picked, current.edge, ymd), anchor: current.anchor }, + ); + }, + cancel() { + setSession(null); + }, + /** 고른 날짜가 있으면 고른 쪽만 반영하고 닫는다 (확인 버튼·바깥 클릭) */ + confirm() { + const next = session ? confirmPickedRange(value, session.picked) : null; + if (next) onConfirm(next); + setSession(null); + }, + }; +} diff --git a/src/components/expenses/ExpenseProvider.tsx b/src/components/expenses/ExpenseProvider.tsx index 6192dae9..7052cd46 100644 --- a/src/components/expenses/ExpenseProvider.tsx +++ b/src/components/expenses/ExpenseProvider.tsx @@ -446,6 +446,7 @@ export function ExpenseEntryButton({ aria-label={`${scopeLabel} ${context.list.isSuccess && !scoped.length ? "비용 추가" : "비용 목록 열기"}`} aria-describedby={scheduleItemId === undefined ? undefined : summaryId} aria-haspopup="dialog" + data-filled={context.list.isSuccess && scoped.length > 0 ? "" : undefined} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); @@ -457,7 +458,7 @@ export function ExpenseEntryButton({ context.openScope(scope); } }} - className={cn(className, "min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")} + className={cn(className, "group min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")} > {icon}{summaryLabel} diff --git a/src/components/filter-dropdown.tsx b/src/components/filter-dropdown.tsx index 0073423e..4c50bd05 100644 --- a/src/components/filter-dropdown.tsx +++ b/src/components/filter-dropdown.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; -import { useOnClickOutside } from "@/hooks/useOnClickOutside"; +import { createPortal } from "react-dom"; +import { MapSearchIcon } from "@/components/map/MapSearchIcon"; import { cn } from "@/lib/utils"; import { @@ -29,14 +30,41 @@ export function FilterDropdown({ onOpenChange, menuClassName, }: FilterDropdownProps) { + const menuId = useId(); + const focusMenuOnOpen = useRef(false); const [open, setOpen] = useState(false); const ref = useRef(null); + const menuRef = useRef(null); + const triggerRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); useEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]); - useOnClickOutside(ref, () => setOpen(false)); + useEffect(() => { + if (!open) return; + if (focusMenuOnOpen.current) menuRef.current?.querySelector("button")?.focus(); + const dismissOutside = (event: MouseEvent) => { + if (!ref.current?.contains(event.target as Node) && !menuRef.current?.contains(event.target as Node)) setOpen(false); + }; + const dismissOnMove = (event: Event) => { + if (!menuRef.current?.contains(event.target as Node)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === "Escape") { setOpen(false); triggerRef.current?.focus(); } + }; + document.addEventListener("mousedown", dismissOutside); + document.addEventListener("keydown", escape); + window.addEventListener("scroll", dismissOnMove, true); + window.addEventListener("resize", dismissOnMove); + return () => { + document.removeEventListener("mousedown", dismissOutside); + document.removeEventListener("keydown", escape); + window.removeEventListener("scroll", dismissOnMove, true); + window.removeEventListener("resize", dismissOnMove); + }; + }, [open]); const isActive = value !== "all"; const selectedLabel = options.find((o) => o.value === value)?.label ?? label; @@ -44,7 +72,20 @@ export function FilterDropdown({ return (
    - {open && ( + {open && createPortal(
    @@ -79,6 +113,7 @@ export function FilterDropdown({ onClick={() => { onChange(opt.value); setOpen(false); + triggerRef.current?.focus(); }} className={cn( "w-full cursor-pointer px-3 py-2 text-left text-label-m-regular mobile:text-label-s-regular transition hover:bg-gray-50", @@ -90,7 +125,7 @@ export function FilterDropdown({ {opt.label} ))} -
    +
    , document.body, )}
    ); diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx index 8894a77b..70238ffe 100644 --- a/src/components/map/Map.search-rank.test.tsx +++ b/src/components/map/Map.search-rank.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { apiFetch } from "@/lib/api/client"; vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() })); -import { act, useEffect, type ReactNode } from "react"; +import { act, useEffect, type ReactNode, type Ref } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, expect, it, vi } from "vitest"; @@ -52,8 +52,8 @@ vi.mock("@/lib/analytics/track", () => ({ trackAnalyticsEvent: boundary.track, })); vi.mock("@vis.gl/react-google-maps", () => ({ - InfoWindow: ({ children }: { children: ReactNode }) => ( -
    {children}
    + InfoWindow: ({ children, anchor }: { children: ReactNode; anchor?: HTMLElement }) => ( +
    {children}
    ), MapControl: ({ children }: { children: ReactNode }) =>
    {children}
    , ControlPosition: { RIGHT_BOTTOM: 9 }, @@ -87,11 +87,15 @@ vi.mock("@vis.gl/react-google-maps", () => ({ AdvancedMarker: ({ children, onClick, + zIndex, + ref, }: { children: ReactNode; + zIndex?: number; + ref?: Ref; onClick?: (event: { stop: () => void }) => void; }) => ( - ), @@ -223,6 +227,9 @@ it("web preview closes independently and switching pins never fetches or opens d await act(async () => pin("결과4").click()); expect(host.querySelectorAll('[role="dialog"]')).toHaveLength(1); expect(host.querySelector('[role="dialog"]')?.textContent).toContain("결과4"); + expect(host.querySelector('[role="dialog"]')?.getAttribute("data-anchor-name")).toBe("결과4"); + await act(async () => pin("결과5").click()); + expect(host.querySelector('[role="dialog"]')?.getAttribute("data-anchor-name")).toBe("결과5"); expect(views()).toHaveLength(0); expect(apiFetch).toHaveBeenCalledTimes(calls); await act(async () => host.querySelector('[aria-label="미리보기 닫기"]')!.click()); @@ -231,18 +238,23 @@ it("web preview closes independently and switching pins never fetches or opens d await act(async () => pin("결과4").click()); expect(host.querySelector('[role="dialog"]')?.textContent).toContain("결과4"); }); -it("web specific autocomplete selection retains direct detail behavior when its pin is reopened", async () => { +it("web specific autocomplete opens detail directly and its pin reopens detail", async () => { await render(false); await act(async () => { await controller.confirm({ query: "원문", kind: "specific", generation: 0, session: { sessionToken: "token", expiresAt: Date.now() + 60000 }, prediction: prediction("result-4") }); }); + expect(host.querySelector('[data-search-place-preview]')).toBeNull(); + expect(host.querySelector('[aria-label="닫기"]')).not.toBeNull(); expect(views()).toHaveLength(1); await act(async () => host.querySelector('[aria-label="닫기"]')!.click()); + expect(host.querySelector('[data-search-place-preview]')).toBeNull(); const marker = [...host.querySelectorAll("[data-marker]")].find(b => b.textContent === "결과4")!; await act(async () => marker.click()); - expect(host.querySelector('[aria-label="검색 장소 미리보기"]')).toBeNull(); + expect(host.querySelector('[data-search-place-preview]')).toBeNull(); + expect(host.querySelector('[aria-label="닫기"]')).not.toBeNull(); expect(views()).toHaveLength(2); expect(views().at(-1)![1].rank_bucket).toBeUndefined(); }); + it("web pins preview Text Search data without fetching detail; arrow opens detail with original rank", async () => { await render(false); boundary.page.mockResolvedValue({ items, nextPageToken: null }); @@ -474,3 +486,42 @@ it("discover ownership cancels pending search without showing its anchor", async ), ).toHaveLength(0); }); + +it("selected preview label receives marker priority and transfers it to the next selection", async () => { + await render(false); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { await controller.confirm({ query: "카페", kind: "whole", session: null, generation: 0 }); }); + const markers = () => [...host.querySelectorAll("[data-marker]")]; + const first = () => markers().find(el => el.textContent === "결과1")!; + const second = () => markers().find(el => el.textContent === "결과2")!; + await act(async () => first().click()); + expect(Number(first().dataset.priority)).toBeGreaterThan(Number(second().dataset.priority || 0)); + expect(host.querySelector("[data-search-place-preview]")?.textContent).toContain("결과1"); + await act(async () => second().click()); + expect(Number(second().dataset.priority)).toBeGreaterThan(Number(first().dataset.priority || 0)); + expect(host.querySelector("[data-search-place-preview]")?.textContent).toContain("결과2"); +}); + +it.each(["whole", "specific"] as const)("%s search resets discover filters without losing search ownership or results", async kind => { + await render(false); + const button = (text: string) => [...document.querySelectorAll("button")].find(b => b.textContent === text)!; + await act(async () => button("음식점").click()); + await act(async () => button("영업 중").click()); + await act(async () => host.querySelector('[aria-label="평점 필터"]')!.click()); + await act(async () => button("4.5 이상").click()); + await act(async () => { useMapPinsFocusStore.getState().claimFocus("discover"); }); + boundary.page.mockResolvedValue({ items, nextPageToken: null }); + await act(async () => { + await controller.confirm({ query: "원문", kind, generation: 0, + session: { sessionToken: "token", expiresAt: Date.now() + 60000 }, prediction: prediction("result-4") }); + }); + expect(host.querySelector('[aria-label="카테고리 필터 초기화"]')).toBeNull(); + expect(button("호텔")).toBeDefined(); + expect(useMapPinsFocusStore.getState().focus).toBe("search"); + expect(usePlaceSearchStore.getState().status).toBe("done"); + expect(useSearchMapPinsStore.getState().pins.length).toBeGreaterThan(0); + expect(host.querySelectorAll('[data-marker]').length).toBeGreaterThan(0); + await act(async () => button("음식점").click()); + expect(host.querySelector('[aria-label="평점 필터"]')?.textContent).toBe("평점"); + expect(button("영업 중").getAttribute("aria-pressed")).toBe("false"); +}); diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 573a5165..3abf7dcd 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { Bookmark } from "lucide-react"; import { @@ -21,6 +21,7 @@ import { MAP_MAX_ZOOM, MAP_MIN_ZOOM, MAP_PAN_RESTRICTION, + moveMapTo, normalizeGooglePlaceResourceId, panToWithinWorld, readDestinationLatLngFromSession, @@ -35,6 +36,7 @@ import { readRoomMapViewport, writeRoomMapViewport, } from "@/lib/map-room-viewport-storage"; +import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens"; import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth"; import { isMobileMapPathname } from "@/lib/mobile-view"; import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; @@ -50,7 +52,7 @@ import { useRoomDetail } from "@/hooks/useRoomDetail"; import { useRoomsList } from "@/hooks/useRooms"; import { RouteIcon } from "@/assets/icons"; import { MapPinIcon } from "@/components/icons"; -import { MapPinWithPlaceName } from "@/components/map/MapPinWithPlaceName"; +import { MapPinWithPlaceName, MapPlaceName } from "@/components/map/MapPinWithPlaceName"; import { MapBookmarkPins } from "./MapBookmarkPins"; import { MAP_PIN_SELECTED_DISPLAY_SIZE_PX, @@ -62,6 +64,8 @@ import { MapDiscoverToolbar } from "./MapDiscoverToolbar"; import { RouteViewCameraController } from "./RouteViewCameraController"; import { MapDiscoverPlaces } from "./MapDiscoverPlaces"; import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes"; +import { PlanRouteStepper } from "./PlanRouteStepper"; +import { useWebPlanRouteMode } from "./useWebPlanRouteMode"; import type { OpenValue, RatingValue } from "./map-filters"; /** 모바일 경로 보기 — 고른 일차만 그리고, 카드 선택에 맞춰 지도를 옮긴다 */ @@ -93,7 +97,10 @@ export type MapRouteView = PlanItineraryRouteDay & { // ─── 웹 일정에서 고른 장소로 지도 이동 ───────────────────────────────────────── -/** 일정 카드·핀으로 고르면 줌은 그대로 두고 그 장소로 부드럽게 옮긴다(모바일 경로 보기의 카드 넘김과 같은 이동) */ +/** + * 일정 카드·핀·장소 이동 스테퍼로 고르면 줌은 그대로 두고 그 장소로 부드럽게 옮긴다(모바일 경로 보기의 카드 넘김과 같은 이동). + * 지도 핀으로 고르면 함께 연 상세(`SelectedPlaceController`)도 같은 곳으로 옮기는데, 같은 곳으로 가는 이동은 하나로 이어진다 + */ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null }>) { const map = useMap(); const rid = typeof roomId === "string" ? roomId.trim() : ""; @@ -103,7 +110,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null useEffect(() => { if (!map || focusSeq === null || lat === undefined || lng === undefined) return; - panToWithinWorld(map, { lat, lng }); + return moveMapTo(map, { lat, lng }); }, [map, focusSeq, lat, lng]); return null; @@ -113,6 +120,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null function SelectedPlaceController() { const map = useMap(); + const { isMobileDevice } = useMobileView(); const { selectedPlace, placeSelectionCameraRef } = useSelectedPlace(); useEffect(() => { @@ -121,17 +129,24 @@ function SelectedPlaceController() { placeSelectionCameraRef.current = "full"; if (mode === "none") return; - panToWithinWorld(map, selectedPlace.location); - if (mode === "full") { - map.setZoom(16); + const zoom = mode === "full" ? 16 : undefined; + // 모바일은 아직 구글 이동(`panTo`)을 그대로 쓴다 + if (isMobileDevice) { + panToWithinWorld(map, selectedPlace.location); + if (zoom !== undefined) map.setZoom(zoom); + return; } - }, [map, selectedPlace?.location, placeSelectionCameraRef]); + moveMapTo(map, selectedPlace.location, { zoom }); + }, [map, isMobileDevice, selectedPlace?.location, placeSelectionCameraRef]); return null; } const VIEWPORT_PERSIST_DEBOUNCE_MS = 400; +/** 오른쪽 아래 확대·축소(+/−) 자리 — 지도 끝 24 + 버튼 40 + 간격 12. 장소 이동 스테퍼가 이보다 오른쪽으로 가지 않는다 */ +const ZOOM_CONTROL_CLEARANCE_PX = 24 + 40 + 12; + /** bootstrap 복원 직후 Zustand `mapCenter`·줌을 검색/일정 UI와 맞춤 */ function MapBootstrapSync({ ready, @@ -255,10 +270,12 @@ function releaseSearchForMapSelection() { useSearchRecenterStore.getState().clearSearchSnapshot(); } -const SEARCH_PREVIEW_OFFSET: [number, number] = [0, -40]; +// Keep a small visual gap between the preview tail and the selected pin. +const SEARCH_PREVIEW_GAP_OFFSET: [number, number] = [0, -4]; function MapSearchResultPins() { const map = useMap(); + const [previewMarker, setPreviewMarker] = useState(null); const pins = useSearchMapPinsStore((s) => s.pins); const pinsFocus = useMapPinsFocusStore((s) => s.focus); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); @@ -268,18 +285,21 @@ function MapSearchResultPins() { const status = usePlaceSearchStore((s) => s.status); const kind = usePlaceSearchStore((s) => s.confirmationKind); const fittedItemsRef = useRef(null); - const preview = !isMobileDevice && kind === "whole" && !selectedPlace + const preview = pinsFocus === "search" && !isMobileDevice && kind === "whole" && !selectedPlace ? items.find((item) => item.googlePlaceId === searchSelectedId) : undefined; const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? ""; + useEffect(() => { + if (map && preview) panToWithinWorld(map, preview.location); + }, [map, preview]); + // 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다. useEffect(() => { if (!map || isMobileDevice || pinsFocus !== "search" || kind !== "whole" || status !== "done" || !items.length || usePlaceSearchStore.getState().selectedId || fittedItemsRef.current === items) return; fittedItemsRef.current = items; if (items.length === 1) { - panToWithinWorld(map, items[0].location); - map.setZoom(16); + moveMapTo(map, items[0].location, { zoom: 16 }); } else { const bounds = new google.maps.LatLngBounds(); items.forEach((item) => bounds.extend(item.location)); @@ -296,6 +316,9 @@ function MapSearchResultPins() { .map((pin) => ( { e.stop(); @@ -326,8 +349,8 @@ function MapSearchResultPins() { ))} {preview ? ( - { if (usePlaceSearchStore.getState().selectedId === preview.googlePlaceId) usePlaceSearchStore.getState().select(null); @@ -350,9 +373,11 @@ function MapSearchResultPins() { export default function TripMap({ routeView, onOpenRouteView, + search, }: { /** 있으면 경로 보기 — 검색·필터, 북마크·탐색 핀, 다른 일차 경로를 숨긴다 */ routeView?: MapRouteView; + search?: ReactNode; /** 있으면 지도 왼쪽 아래에 경로 보기 버튼을 둔다(모바일 지도) */ onOpenRouteView?: () => void; } = {}) { @@ -361,9 +386,10 @@ export default function TripMap({ const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? ""); const planStopPlaceIds = usePlanItineraryStopNormalizedPlaceIds(isPlanPage); - const { setSelectedPlace } = useSelectedPlace(); + const { selectedPlace, setSelectedPlace } = useSelectedPlace(); const { isMobileDevice } = useMobileView(); const clearPlanFocus = usePlanMapFocusStore((s) => s.clearFocus); + const planRouteMode = useWebPlanRouteMode(); const setMapCamera = useMapCenterStore((s) => s.setMapCamera); const [selectedCategoryId, setSelectedCategoryId] = useState( null, @@ -372,6 +398,14 @@ export default function TripMap({ const [openNow, setOpenNow] = useState("all"); const [showBookmarkPins, setShowBookmarkPins] = useState(true); + // Text search takes over the map at request start, including autocomplete selection. + useEffect(() => useMapPinsFocusStore.subscribe((state, previous) => { + if (state.focus !== "search" || state.writeEpoch === previous.writeEpoch) return; + setSelectedCategoryId(null); + setRating("all"); + setOpenNow("all"); + }), []); + const currentRoomId = useSessionStore((s) => s.currentRoomId); const { data: roomsData, isFetched: roomsFetched } = useRoomsList(); const { data: roomDetail, isFetched: roomDetailFetched } = @@ -516,7 +550,7 @@ export default function TripMap({ ); return ( -
    +
    {!bootstrap.ready ? (
    } - {/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다 */} + {/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다. + 오른쪽은 +/−와 같이 지도 끝에서 24px(globals.css `[data-map-controls-inset]`) */} {routeView || isMobileDevice ? null : ( -
    {bookmarkToggle}
    +
    {bookmarkToggle}
    )} )}
    + {/* 웹 동선보기는 숨기기만 한다 — 상세 패널 위치를 계속 재서 필터 한 줄 아래(최솟값)에 두고, 풀면 고른 카테고리·필터 그대로 보인다 */} {routeView ? null : (