diff --git a/AGENTS.md b/AGENTS.md index 90f240c8..7ec1f2a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## 로딩 UI 원칙 - 화면 전환과 데이터 조회 중에 `불러오는 중…`, `확인 중…` 같은 문장형 로딩 안내를 화면에 노출하지 않는다. -- 최초 로딩에는 완료 후 화면과 배치·크기가 비슷한 스켈레톤을 우선 사용한다. 작은 영역에는 공통 `LoadingIndicator` 같은 간결한 로딩 표시를 사용한다. +- 최초 로딩에는 완료 후 화면과 배치·크기가 비슷한 스켈레톤을 우선 사용한다. 작은 영역에는 공통 `Spinner`(`src/components/ui/spinner`) 같은 간결한 로딩 표시를 사용한다. - 제목, 진행 현황, 버튼, 목록의 자리를 미리 확보해 로딩 전후에 화면이 튀거나 다른 화면이 잠깐 끼어 보이지 않게 한다. - 백그라운드 재조회에서는 이미 표시된 유효한 데이터를 유지하고, 새 데이터가 준비되면 갱신한다. 재조회만으로 전체 화면을 비우거나 금액을 임시 값으로 바꾸지 않는다. - 로딩 상태는 `role="status"`, `aria-label`, `aria-busy`, `sr-only` 등으로 접근성 도구에 전달할 수 있다. 화면에서 문구를 숨기는 것과 접근성 안내를 제거하는 것은 구분한다. diff --git a/Design.md b/Design.md index 5bc7bfc8..2fffb557 100644 --- a/Design.md +++ b/Design.md @@ -17,9 +17,9 @@ 예를들어서 피그마 페이지에 폰트가 17px이고 시멘틱 토큰과 css변수가 없다면 임의값을 사용한다. ### 1-3. 컴포넌트 -목표는 shadcn이다. 아직 설치 전이라 기존 공용 컴포넌트와 병행하고, 무엇을 쓸지는 [핵심 원칙 1-1](docs/code-conventions/principles.md)을 따른다. +목표는 shadcn(Base UI)이다. 옮기는 동안 기존 공용 컴포넌트와 병행하고, 무엇을 쓸지는 [핵심 원칙 1-1](docs/code-conventions/principles.md)을 따른다. - 공용: `src/components/<도메인>/`(`layout`, `place`, `map`, `settings` …) -- shadcn: 설치 후 `src/components/ui/` +- shadcn: `src/components/ui/` - 특정 페이지 전용: `src/app/**/_components/` ### 1-4. Figma 디자인 시스템 @@ -35,7 +35,8 @@ - 11 [Tabs](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5138-2632) — Tabs Line, Tabs Chip, Tab Item - 12 [Toast](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5285-4979) — Toast - 13 [Alert Dialog](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5619-553) — Alert Dialog -- 14·15 [Chip · Badge](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5624-159) — Chip(Solid·Outline), Badge(Weak·Solid·Outline, 톤 6종) +- 14 [Chips](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5624-159) — Chip(Solid·Outline) +- 15 [Badges](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5864-12983) — Badge(Weak·Solid·Outline, 톤 6종) - [Update Log](https://www.figma.com/design/Va7ys9C4lx9PIHOGvlaAID/Design-System?node-id=5635-11) — 디자인 시스템 변경 기록 --- @@ -111,7 +112,7 @@ import { MapPinIcon } from "@/assets/icons"; ### 3-3. 이벤트 피드백 - 사용자 액션에는 결과 피드백이 있어야 한다 -- alert는 toast, confirm은 `ConfirmDialog`를 쓰고 요구조건에 맞춘다(shadcn 설치 후 기준은 [핵심 원칙 1-1](docs/code-conventions/principles.md)) +- alert는 toast, confirm은 `ConfirmDialog`(ui `AlertDialog` 위)를 쓰고 요구조건에 맞춘다(shadcn 부품으로 옮기는 기준은 [핵심 원칙 1-1](docs/code-conventions/principles.md)) - 비동기 동작 중 중복 실행 방지(핵심 원칙 2-4) diff --git a/components.json b/components.json new file mode 100644 index 00000000..f5b65cba --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/docs/code-conventions/anti-patterns.md b/docs/code-conventions/anti-patterns.md index 5206acc2..a04687bf 100644 --- a/docs/code-conventions/anti-patterns.md +++ b/docs/code-conventions/anti-patterns.md @@ -37,8 +37,8 @@ - ✓ prop을 더하기 전에 계산 → slot → `variant` → 화면 훅 순서로 본다. `} />`(원칙 2-2·1-4) ### 6. 있는 부품 다시 만들기 -- 신호: 새 `fixed inset-0` 모달·바텀시트·로딩 표시, `SettingsActionButton`·`alertDialogButtonClass`가 맞는 자리에 복붙한 버튼 클래스, 새로 만든 범용 Button, `lucide-react`·새 아이콘 패키지 -- ✓ `ConfirmDialog`·`SettingsDialog`·`MobileBottomSheet`·`LoadingIndicator`를 먼저 쓴다. 버튼은 `SettingsActionButton`·`alertDialogButtonClass`가 맞으면 쓰고, 아니면 그 자리 ` - … +// ✓ 동작은 Base UI, 모양은 토큰을 입힌 ui 부품. 저장 중에는 closeDisabled로 Esc·딤·X를 막는다 + + + 초대 + … + + }>취소 + + + ``` +- Base UI 부품은 기본 요소(Trigger는 ` +``` + ## 3. 퍼블리싱 ### 3-1. Auto Layout을 그대로 옮긴다 (Layout First) @@ -534,7 +567,7 @@ z-index는 같은 스태킹 컨텍스트 안에서만 비교된다. 그래서 - 타이포는 토큰 하나로 쓴다. 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` 클래스 맵에 모은다. +- 클래스는 `cn()`으로만 합친다. 템플릿 리터럴은 겹친 클래스(`px-2`와 바깥 `px-4`)를 정리하지 못하고, 조립한 이름(`` `bg-${tone}` ``)은 Tailwind가 소스에서 찾지 못한다. 변형은 cva(`class-variance-authority`)로 모은다. - 상태 스타일은 aria 속성을 variant로 읽는다(`aria-expanded:bg-fill`, `aria-current="page"`는 `aria-[current=page]:`). aria가 없는 상태만 `data-*`로 둔다. 여러 하위가 함께 따르는 루트 상태(채팅 최소화·밀도)는 하위마다 prop으로 내리지 않고, 루트의 `data-minimized`를 하위가 `group-data-minimized/chat:`처럼 읽는다. - 전역 요소 스타일은 `@layer base`에 둔다. 레이어 밖 CSS는 모든 유틸리티를 이긴다. @@ -542,12 +575,17 @@ z-index는 같은 스태킹 컨텍스트 안에서만 비교된다. 그래서 // ✗ 문자열 결합과 조립한 클래스 이름 {label} -// ✓ 완성된 클래스 맵과 cn -const TONE_CLASS = { - neutral: "bg-fill text-text", - brand: "bg-primary-subtle text-primary", -} as const; -{label} +// ✓ 변형은 cva, 합치기는 cn +const tagVariants = cva("rounded-full px-2", { + variants: { + tone: { + neutral: "bg-fill text-text", + brand: "bg-primary-subtle text-primary", + }, + }, + defaultVariants: { tone: "neutral" }, +}); +{label} ``` ### 3-7. 시맨틱 마크업과 접근성 (Semantic HTML) @@ -575,7 +613,7 @@ const TONE_CLASS = { | 원칙 | 출처 | | :-- | :-- | | 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-1 | [shadcn/ui](https://ui.shadcn.com/docs), [Base UI](https://base-ui.com/react/overview/quick-start) | | 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` | @@ -590,4 +628,4 @@ const TONE_CLASS = { | 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) | +| 3-6 | Tailwind [Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files), [cva](https://cva.style/docs) | diff --git a/package-lock.json b/package-lock.json index 675fe989..bb66e5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,15 @@ "version": "0.1.0", "dependencies": { "@amplitude/unified": "^1.1.28", + "@base-ui/react": "^1.9.0", "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "@vis.gl/react-google-maps": "^1.8.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", @@ -2166,6 +2169,12 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.8.tgz", + "integrity": "sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==", + "license": "MIT" + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -2214,6 +2223,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -2562,6 +2631,44 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@googlemaps/js-api-loader": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz", @@ -4442,6 +4549,50 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.5.tgz", + "integrity": "sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.100.9", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", @@ -4462,6 +4613,46 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-form": { + "version": "1.33.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.33.5.tgz", + "integrity": "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.33.5", + "@tanstack/react-store": "^0.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", + "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-query": { "version": "5.100.9", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", @@ -4495,6 +4686,16 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", + "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -6001,6 +6202,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -11359,6 +11572,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12833,6 +13052,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 9432c968..6dd9c67d 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,15 @@ }, "dependencies": { "@amplitude/unified": "^1.1.28", + "@base-ui/react": "^1.9.0", "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "@vis.gl/react-google-maps": "^1.8.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", diff --git a/public/expenses/checkbox-checked.svg b/public/expenses/checkbox-checked.svg deleted file mode 100644 index 968b0219..00000000 --- a/public/expenses/checkbox-checked.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/expenses/checkbox.svg b/public/expenses/checkbox.svg deleted file mode 100644 index f38c06a5..00000000 --- a/public/expenses/checkbox.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/icons/packing/checkbox-checked.svg b/public/icons/packing/checkbox-checked.svg deleted file mode 100644 index 8fead7fd..00000000 --- a/public/icons/packing/checkbox-checked.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/icons/packing/checkbox.svg b/public/icons/packing/checkbox.svg deleted file mode 100644 index 1effd532..00000000 --- a/public/icons/packing/checkbox.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/map-search/attraction.svg b/public/map-search/attraction.svg deleted file mode 100644 index 30038055..00000000 --- a/public/map-search/attraction.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/public/map-search/clear.svg b/public/map-search/clear.svg deleted file mode 100644 index 129f6879..00000000 --- a/public/map-search/clear.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/map-search/hotel.svg b/public/map-search/hotel.svg deleted file mode 100644 index e3fad22b..00000000 --- a/public/map-search/hotel.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/public/map-search/museum.svg b/public/map-search/museum.svg deleted file mode 100644 index 50eb214f..00000000 --- a/public/map-search/museum.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/public/map-search/reset.svg b/public/map-search/reset.svg deleted file mode 100644 index f0c45e54..00000000 --- a/public/map-search/reset.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/map-search/restaurant.svg b/public/map-search/restaurant.svg deleted file mode 100644 index 09b7a658..00000000 --- a/public/map-search/restaurant.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/public/map-search/transit.svg b/public/map-search/transit.svg deleted file mode 100644 index 721fffd2..00000000 --- a/public/map-search/transit.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/public/rooms/figma/README.md b/public/rooms/figma/README.md index 0973baab..a21969e2 100644 --- a/public/rooms/figma/README.md +++ b/public/rooms/figma/README.md @@ -4,10 +4,9 @@ - 모바일: https://www.figma.com/design/3JnrQOfRePmycccIBfiPnZ?node-id=3314-5275 - 추출일: 2026-09-20 - `trip-cover.png`: Figma 원본 카드 사진. 현재 방 목록에서는 `public/rooms/covers/`의 사진 9장 중 방 ID로 선택한 이미지를 사용한다. -- `menu.svg`, `plus.svg`: Figma에서 내려받은 원본 아이콘. +- `plus.svg`: Figma에서 내려받은 원본 아이콘. - `plus-mobile.svg`: 모바일 생성 버튼의 20px 원본 아이콘. -- `user.svg`, `logout.svg`: 프로필 오버레이 원본 아이콘 (`3456:5418`). -- `invite.svg`, `trash.svg`: 방 더보기 오버레이 원본 아이콘 (`3449:3613`). +- `invite.svg`: 방 더보기 오버레이 원본 아이콘 (`3449:3613`). 더보기 버튼·프로필 메뉴·삭제 아이콘은 디자인 시스템 아이콘(`@/assets/icons`)을 쓴다. 광고 배너 영역에는 [마케팅 시안](https://www.figma.com/design/asf1pKmEosGS4xUx1qn3MV/%EB%A7%88%EC%BC%80%ED%8C%85?node-id=0-1)의 데스크톱·모바일 완성 배너 이미지를 각각 그대로 사용한다. 에셋은 만료되는 Figma 다운로드 URL 대신 로컬 파일을 참조한다. diff --git a/public/rooms/figma/logout.svg b/public/rooms/figma/logout.svg deleted file mode 100644 index 9cd3e7c2..00000000 --- a/public/rooms/figma/logout.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/rooms/figma/menu.svg b/public/rooms/figma/menu.svg deleted file mode 100644 index c1918c70..00000000 --- a/public/rooms/figma/menu.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/rooms/figma/trash.svg b/public/rooms/figma/trash.svg deleted file mode 100644 index b0dc1c9c..00000000 --- a/public/rooms/figma/trash.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/rooms/figma/user.svg b/public/rooms/figma/user.svg deleted file mode 100644 index 8cd8a3ab..00000000 --- a/public/rooms/figma/user.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/app/(main)/bookmark/[folderId]/page.tsx b/src/app/(main)/bookmark/[folderId]/page.tsx index f0ac1e8c..6e34b481 100644 --- a/src/app/(main)/bookmark/[folderId]/page.tsx +++ b/src/app/(main)/bookmark/[folderId]/page.tsx @@ -2,6 +2,7 @@ import { useParams, notFound } from "next/navigation"; import { useMemo } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; import { SetSectionMaxWidth } from "@/contexts/SectionWidthContext"; import { useBookmarkCategories } from "@/hooks/useRooms"; import { useSessionStore } from "@/stores/session-store"; @@ -11,19 +12,19 @@ import { BookmarkFolderDetailView } from "../_components/BookmarkFolderDetailVie function FolderDetailSkeleton() { return (
-
-
+ +
-
-
-
+ + +
); diff --git a/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx b/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx index 7b646559..fbccb9bf 100644 --- a/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx +++ b/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx @@ -1,6 +1,19 @@ "use client"; -import { useEffect, useState } from "react"; +import { useForm } from "@tanstack/react-form"; +import { useId, type ComponentProps } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldError, FieldHeader, FieldLabel, FieldSuffix } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import type { BookmarkFolder } from "@/types/bookmark"; import { UNTITLED_BOOKMARK_CATEGORY_LABEL } from "@/lib/bookmark-untitled-category-name"; @@ -26,168 +39,169 @@ function initialTitle(mode: Mode, initialFolder: BookmarkFolder | null) { return ""; } -function initialColor(mode: Mode, initialFolder: BookmarkFolder | null) { +function initialColor(mode: Mode, initialFolder: BookmarkFolder | null): string { if (mode === "edit" && initialFolder) return initialFolder.color; return COLOR_PRESETS[0]; } +// 빈 제목은 허용한다(저장하는 쪽이 "제목 없는 북마크 N"을 고른다). maxLength가 있어도 한글 조합 중에는 한 글자 넘게 들어갈 수 있어 제출 때 길이를 다시 본다 +function titleError(title: string) { + return title.length > TITLE_MAX_LENGTH ? `제목은 ${TITLE_MAX_LENGTH}자까지 쓸 수 있어요.` : undefined; +} + export function AddBookmarkModal({ mode, initialFolder, busy = false, onClose, onSave, - overlayZClass = "z-50", /** 비어 있을 때 실제로 쓰이는 이름(다음 번호 반영). 없으면 기본 라벨만 표시 */ untitledNameHint, /** 서버/저장 오류 시 인라인 표시 (토스트 대신) */ formError = null, + /** 닫힌 뒤 포커스를 돌려줄 곳. 메뉴 항목에서 열면 사라진 항목 대신 메뉴 버튼을 준다 */ + finalFocus, }: { mode: Mode; initialFolder: BookmarkFolder | null; busy?: boolean; onClose: () => void; onSave: (payload: { title: string; color: string }) => void; - /** 상위 모달 위에 겹칠 때만 지정 (예: `z-[65]`) */ - overlayZClass?: string; untitledNameHint?: string; formError?: string | null; + finalFocus?: ComponentProps["finalFocus"]; }) { - const [title, setTitle] = useState(() => initialTitle(mode, initialFolder)); - const [color, setColor] = useState(() => initialColor(mode, initialFolder)); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (busy) return; - const trimmed = title.trim(); - onSave({ - title: trimmed, - color, - }); - }; + const formId = useId(); + // 저장 요청(mutation)은 부모가 갖는다. 진행·실패는 부모의 busy·formError로 받는다 + const form = useForm({ + defaultValues: { + title: initialTitle(mode, initialFolder), + color: initialColor(mode, initialFolder), + }, + onSubmit: ({ value }) => { + onSave({ title: value.title.trim(), color: value.color }); + }, + }); return ( -
{ - if (ev.target === ev.currentTarget) onClose(); + { + if (!open) onClose(); }} > -
e.stopPropagation()} - > -

- {mode === "edit" ? "북마크 편집" : "새 북마크 생성"} -

+ + + {mode === "edit" ? "북마크 편집" : "새 북마크 생성"} + {formError ? ( -

+

{formError}

) : null} -
-
-
- - - {title.length}/{TITLE_MAX_LENGTH} - -
- setTitle(e.target.value)} - maxLength={TITLE_MAX_LENGTH} - placeholder={untitledNameHint ?? UNTITLED_BOOKMARK_CATEGORY_LABEL} - aria-describedby="bookmark-title-counter" - className="w-full rounded-xl border border-gray-border px-3 py-2.5 text-body-m-regular outline-none ring-primary/30 focus:border-primary focus:ring-2" - /> -
- -
- - 색상 - -
- {COLOR_PRESETS.map((hex) => ( -
-
- - setColor(e.target.value)} - className="h-10 w-14 cursor-pointer rounded-md border border-gray-border bg-white p-0.5" - /> - {color} -
-
+ { + event.preventDefault(); + // 라이브러리는 연타를 막지 않는다. busy는 부모 mutation의 isPending + if (busy || form.state.isSubmitting) return; + void form.handleSubmit(); + }} + > + titleError(value) }}> + {(field) => { + const error = field.state.meta.errors[0]; + const counterId = `${formId}-title-counter`; + const errorId = `${formId}-title-error`; + return ( + + + 제목 + + {field.state.value.length}/{TITLE_MAX_LENGTH} + + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + maxLength={TITLE_MAX_LENGTH} + placeholder={untitledNameHint ?? UNTITLED_BOOKMARK_CATEGORY_LABEL} + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${counterId} ${errorId}` : counterId} + /> + {error} + + ); + }} + -
- - -
+ + {(field) => ( +
+ + 색상 + +
+ {COLOR_PRESETS.map((hex) => ( +
+
+ + field.handleChange(e.target.value)} + className="h-10 w-14 cursor-pointer rounded-md border border-gray-border bg-white p-0.5" + /> + + {field.state.value} + +
+
+ )} +
-
-
+ + + }>취소 + + + + ); } diff --git a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx index 4d7e5a29..249b6a51 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFoldersView.tsx @@ -3,7 +3,7 @@ import { BookmarkPlus, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens"; import { messageForBookmarkCategorySaveError } from "@/lib/api/errors"; @@ -16,6 +16,12 @@ import { } from "@/components/layout/page-toolbar-button"; import { MainPageHeader } from "@/components/layout/MainPageHeader"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { pickUniqueUntitledBookmarkCategoryName } from "@/lib/bookmark-untitled-category-name"; import { @@ -63,25 +69,9 @@ export function BookmarkFoldersView() { const [editingFolder, setEditingFolder] = useState( null, ); - const [menuOpenId, setMenuOpenId] = useState(null); const [folderFormError, setFolderFormError] = useState(null); - const menuRef = useRef(null); - - const closeMenu = useCallback(() => setMenuOpenId(null), []); - - useEffect(() => { - if (!menuOpenId) return; - const onPointer = (e: MouseEvent | TouchEvent) => { - const el = menuRef.current; - if (el && !el.contains(e.target as Node)) closeMenu(); - }; - document.addEventListener("mousedown", onPointer); - document.addEventListener("touchstart", onPointer); - return () => { - document.removeEventListener("mousedown", onPointer); - document.removeEventListener("touchstart", onPointer); - }; - }, [menuOpenId, closeMenu]); + // 편집 대화상자는 메뉴 항목에서 열려 닫힌 뒤 돌아갈 항목이 없다. 마지막으로 연 행 메뉴 버튼으로 포커스를 돌려준다 + const menuTriggerRef = useRef(null); const openCreate = () => { setFolderFormError(null); @@ -92,7 +82,6 @@ export function BookmarkFoldersView() { }; const openEdit = (folder: BookmarkFolder) => { - closeMenu(); setFolderFormError(null); setModalKey((k) => k + 1); setModalMode("edit"); @@ -160,7 +149,6 @@ export function BookmarkFoldersView() { const categoryId = Number.parseInt(folder.id, 10); if (!Number.isFinite(categoryId)) return; - closeMenu(); setFolderToDelete({ categoryId, title: folder.title }); }; @@ -284,53 +272,35 @@ export function BookmarkFoldersView() {

-
{ + if (open && trigger instanceof HTMLElement) menuTriggerRef.current = trigger; + }} > - - {menuOpenId === folder.id && ( -
+ + {/* 진행 중에는 항목을 비활성으로만 둔다(메뉴 항목은 Button이 아니라 progress가 없다) */} + openEdit(folder)} + > + 편집 + + handleDelete(folder)} > - - -
- )} -
+ 삭제 + + +
))} @@ -358,10 +328,12 @@ export function BookmarkFoldersView() { }} onSave={handleSave} formError={folderFormError} + finalFocus={modalMode === "edit" ? menuTriggerRef : undefined} /> )} {folderToDelete ? ( 북마크 불러오는 중 {[0, 1, 2].map((row) => ( - ); diff --git a/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx b/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx index 5afdf411..4a5a7a95 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlacePreviewCard.tsx @@ -2,6 +2,7 @@ import { MapPin } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { useInViewport } from "@/hooks/useInViewport"; import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl"; import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl"; @@ -79,9 +80,9 @@ export function BookmarkPlacePreviewCard({ {name} {primaryTypeDisplayName ? ( - + {primaryTypeDisplayName} - + ) : null}
{address ? ( diff --git a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx index 9bee1426..f39fa87f 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.lifecycle.test.tsx @@ -17,20 +17,23 @@ vi.mock("./BookmarkPlacePreviewCard", () => ({ BookmarkPlacePreviewCard: () => null, })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("positions the portal at open, updates category phase and closes outside with cleanup", async () => { - const rect = vi - .spyOn(HTMLElement.prototype, "getBoundingClientRect") - .mockReturnValue({ - top: 100, - bottom: 140, - left: 100, - right: 300, - width: 200, - height: 40, - x: 100, - y: 100, - toJSON: () => ({}), - }); + +const flush = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +const menu = () => document.querySelector('[role="menu"]'); +const items = () => + [...(menu()?.querySelectorAll('[role="menuitem"]') ?? [])].map( + (item) => item.textContent?.trim(), + ); +const item = (name: string) => + [...(menu()?.querySelectorAll('[role="menuitem"]') ?? [])].find( + (candidate) => candidate.textContent?.trim() === name, + )!; + +// 위치 계산(트리거 아래 배치·화면 크기 변경)은 Base UI Positioner가 맡아 픽셀 값은 검사하지 않는다 +it("switches to the category phase inside the open menu, closes outside, and reopens on the main phase", async () => { const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); @@ -44,30 +47,30 @@ it("positions the portal at open, updates category phase and closes outside with />, ), ); - await act(async () => container.querySelector("button")!.click()); - let menu = document.querySelector('[role="menu"]')!; - expect(menu.style.top).toBe("144px"); - await act(async () => menu.querySelector("button")!.click()); - expect(menu.textContent).toContain("two"); - rect.mockReturnValue({ - top: 50, - bottom: 90, - left: 100, - right: 300, - width: 200, - height: 40, - x: 100, - y: 50, - toJSON: () => ({}), - }); - await act(async () => window.dispatchEvent(new Event("resize"))); - menu = document.querySelector('[role="menu"]')!; - expect(menu.style.top).toBe("94px"); + const trigger = container.querySelector( + 'button[aria-label="장소 메뉴"]', + )!; + await act(async () => trigger.click()); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(items()).toEqual(["카테고리 변경", "삭제"]); + + await act(async () => item("카테고리 변경").click()); + expect(menu()).not.toBeNull(); + expect(items()).toEqual(["뒤로", "two"]); + await act(async () => - document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })), + document.body.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true }), + ), ); - expect(document.querySelector('[role="menu"]')).toBeNull(); + await flush(); + expect(menu()).toBeNull(); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + await act(async () => trigger.click()); + expect(items()).toEqual(["카테고리 변경", "삭제"]); + await act(async () => root.unmount()); + expect(menu()).toBeNull(); container.remove(); - rect.mockRestore(); }); diff --git a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx index d3b7dad9..01827782 100644 --- a/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkPlaceRow.tsx @@ -1,15 +1,15 @@ "use client"; import { ChevronLeft, MoreHorizontal } from "lucide-react"; -import { - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { toast } from "sonner"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { BookmarkPlacePreviewCard } from "./BookmarkPlacePreviewCard"; import { @@ -19,19 +19,9 @@ import { } from "@/hooks/useRooms"; import type { BookmarkedPlace } from "@/types/bookmark"; +// '카테고리 변경'은 하위 메뉴 대신 같은 메뉴 안에서 목록으로 바꿔 보인다(지금 화면 흐름 유지, 디자이너 질문으로 남음) type MenuPhase = "main" | "pickCategory"; -type MenuPosition = { - top: number; - left: number; - width: number; -}; - -const MENU_GAP_PX = 4; -const MENU_WIDTH_PX = 184; -const MENU_MAX_HEIGHT_PX = 352; -const MENU_VIEWPORT_PADDING_PX = 8; - export function BookmarkPlaceRow({ place, roomId, @@ -54,9 +44,6 @@ export function BookmarkPlaceRow({ const [menuOpen, setMenuOpen] = useState(false); const [menuPhase, setMenuPhase] = useState("main"); - const [menuPosition, setMenuPosition] = useState(null); - const triggerRef = useRef(null); - const menuPanelRef = useRef(null); const { data: categories } = useBookmarkCategories(roomId); const { mutate: moveBookmark, isPending: moving } = useMoveRoomBookmark(); @@ -68,78 +55,6 @@ export function BookmarkPlaceRow({ const otherCategories = categories?.filter((c) => c.categoryId !== currentCategoryId) ?? []; - const closeMenu = useCallback(() => { - setMenuOpen(false); - setMenuPhase("main"); - }, []); - - const updateMenuPosition = useCallback(() => { - const triggerEl = triggerRef.current; - if (!triggerEl || !menuOpen) { - setMenuPosition(null); - return; - } - const rect = triggerEl.getBoundingClientRect(); - const estimatedHeight = - menuPhase === "main" - ? 96 - : Math.min( - 64 + Math.max(otherCategories.length, 1) * 40, - MENU_MAX_HEIGHT_PX, - ); - const spaceBelow = window.innerHeight - rect.bottom; - const top = - spaceBelow >= estimatedHeight + MENU_GAP_PX - ? rect.bottom + MENU_GAP_PX - : Math.max( - MENU_VIEWPORT_PADDING_PX, - rect.top - MENU_GAP_PX - estimatedHeight, - ); - const left = Math.min( - Math.max( - MENU_VIEWPORT_PADDING_PX, - rect.right - MENU_WIDTH_PX, - ), - window.innerWidth - MENU_WIDTH_PX - MENU_VIEWPORT_PADDING_PX, - ); - setMenuPosition({ - top, - left, - width: MENU_WIDTH_PX, - }); - }, [menuOpen, menuPhase, otherCategories.length]); - - const attachMenuPanel = useCallback((node: HTMLDivElement | null) => { - menuPanelRef.current = node; - if (node) updateMenuPosition(); - }, [updateMenuPosition]); - - useEffect(() => { - if (!menuOpen) return; - window.addEventListener("scroll", updateMenuPosition, true); - window.addEventListener("resize", updateMenuPosition); - return () => { - window.removeEventListener("scroll", updateMenuPosition, true); - window.removeEventListener("resize", updateMenuPosition); - }; - }, [menuOpen, updateMenuPosition]); - - useEffect(() => { - if (!menuOpen) return; - const onPointer = (e: MouseEvent | TouchEvent) => { - const target = e.target as Node; - if (triggerRef.current?.contains(target)) return; - if (menuPanelRef.current?.contains(target)) return; - closeMenu(); - }; - document.addEventListener("mousedown", onPointer); - document.addEventListener("touchstart", onPointer); - return () => { - document.removeEventListener("mousedown", onPointer); - document.removeEventListener("touchstart", onPointer); - }; - }, [menuOpen, closeMenu]); - const handleMoveTo = (targetCategoryId: number) => { if (!Number.isFinite(bookmarkId) || busy) return; moveBookmark( @@ -149,13 +64,13 @@ export function BookmarkPlaceRow({ categoryId: targetCategoryId, fromCategoryId: currentCategoryId, }, - { onSuccess: () => closeMenu() }, + // 이동 항목은 누른 뒤에도 메뉴를 열어 두고(closeOnClick={false}) 성공하면 닫는다 + { onSuccess: () => setMenuOpen(false) }, ); }; const handleDelete = () => { if (!Number.isFinite(bookmarkId) || busy) return; - closeMenu(); setConfirmOpen(true); }; @@ -183,111 +98,72 @@ export function BookmarkPlaceRow({ onClick={onOpenDetail} onThumbnailClick={onOpenDetail} /> -
- - - {menuOpen && - typeof document !== "undefined" && - createPortal( -
- {menuPhase === "main" ? ( - <> - - - - ) : ( - <> - -
- {otherCategories.length === 0 ? ( -

- 이동할 다른 카테고리가 없습니다. -

- ) : ( - otherCategories.map((c) => ( - - )) - )} - - )} -
, - document.body, - )} + + + + {/* 폭 184는 지금 메뉴 값. 높이는 지금 상한 22rem과 화면에 남은 높이 중 작은 쪽 */} + + {menuPhase === "main" ? ( + <> + setMenuPhase("pickCategory")} + > + 카테고리 변경 + + + 삭제 + + + ) : ( + <> + setMenuPhase("main")}> + + 뒤로 + + {otherCategories.length === 0 ? ( +

+ 이동할 다른 카테고리가 없습니다. +

+ ) : ( + otherCategories.map((c) => ( + handleMoveTo(c.categoryId)} + > + + {c.name} + + )) + )} + + )} +
+
{confirmOpen ? ( - - + } className="h-5 w-2/3" /> + } tone="subtle" className="h-4 w-1/2" /> ) : ( <> diff --git a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx index e2bee4b7..11f5bfb5 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceCard.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNod import { MobilePlanPlaceCard } from "@/app/(main)/plan/_components/mobile/MobilePlanPlaceCard"; import { DashedBorder, DashedPlaceholderBox } from "@/components/mobile/DashedPlaceholderBox"; +import { Skeleton } from "@/components/ui/skeleton"; import type { PlanPlace } from "@/lib/plan/types"; import { cn } from "@/lib/utils"; @@ -159,12 +160,12 @@ export function MobileRouteExpandedShell({ /** 처음 불러오는 동안 — 카드와 같은 자리를 잡는다 */ export function MobileRoutePlaceCardSkeleton() { return ( - - + + } className="size-6 shrink-0 rounded-[5.5px]" /> - - - + } className="h-5 w-2/3" /> + } tone="subtle" className="h-4 w-1/2" /> + } tone="subtle" className="mt-3 h-4 w-5/6" /> ); diff --git a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx index b42911e3..dd5fd488 100644 --- a/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx +++ b/src/app/(main)/map/_components/MobileRoutePlaceDetail.tsx @@ -6,6 +6,8 @@ import { useAnalyticsRoomId } from "@/hooks/useAnalyticsRoomId"; import { HeroImage, HeroSkeleton } from "@/components/place/HeroSection"; import { PlaceDetailTabs } from "@/components/place/PlaceDetailTabs"; import { usePlaceDetailData } from "@/components/place/usePlaceDetailData"; +import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; import { AnalyticsEvents, trackAnalyticsEvent, type AnalyticsSource } from "@/lib/analytics/track"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import type { SearchRankBucket } from "@/lib/analytics/context"; @@ -50,15 +52,19 @@ export function MobileRoutePlaceDetail({ function renderDetail() { if (isError && !detail && active) return ( -
-

장소 정보를 불러오지 못했어요.

- +
+ + 장소 정보를 불러오지 못했어요. + + + +
); if (showSkeleton) return ( - - + } className="h-5 w-24" /> + } tone="subtle" className="h-16 w-full rounded-md" /> ); return ( diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx index 3ae9471a..c8a91c7f 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { CopyCheckIcon, CopyIcon, RefreshIcon, ShareIcon } from "@/assets/icons"; -import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; +import { Spinner } from "@/components/ui/spinner"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -37,7 +37,7 @@ function InviteFallback({ canIssue, inviteCode, isRoomDetailLoading, isRoomDetai isRegenerating: boolean; }>) { if (isRoomDetailLoading || isRegenerating) { - return ; + return ; } let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요."; if (isRoomDetailError) message = "방 정보를 불러오지 못했어요."; diff --git a/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx b/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx index 69886680..109cc620 100644 --- a/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx +++ b/src/app/(main)/member-settings/_components/JoinRequestsSection.tsx @@ -55,8 +55,7 @@ function JoinRequestCard({
diff --git a/src/app/(main)/member-settings/_components/MemberCard.tsx b/src/app/(main)/member-settings/_components/MemberCard.tsx index ba2dc313..eb2cff13 100644 --- a/src/app/(main)/member-settings/_components/MemberCard.tsx +++ b/src/app/(main)/member-settings/_components/MemberCard.tsx @@ -1,9 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; - import { MenuDotHorizontalIcon, UserEditIcon, UserXIcon } from "@/assets/icons"; +import { Badge } from "@/components/ui/badge"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { UserAvatar } from "@/components/user/UserAvatar"; import type { RoomMemberStatus } from "@/lib/api/rooms"; import { cn } from "@/lib/utils"; @@ -51,46 +50,13 @@ export function MemberCard({ member, isViewerHost, onKick, onTransfer }: Readonl member.role !== "HOST" && !isLeft; - // 스크롤 영역(헤더 드롭다운) 안에서도 잘리지 않도록 메뉴는 body에 화면 기준 위치로 띄운다 - const [menuPosition, setMenuPosition] = useState<{ top: number; right: number } | null>(null); - const open = menuPosition !== null; - const buttonRef = useRef(null); - const menuRef = useRef(null); - - function toggleMenu() { - const rect = buttonRef.current?.getBoundingClientRect(); - setMenuPosition(open || !rect ? null : { top: rect.bottom + 4, right: window.innerWidth - rect.right }); - } - - useEffect(() => { - if (!open) return; - const close = () => setMenuPosition(null); - function handleClickOutside(e: MouseEvent) { - const target = e.target as Node; - if (!buttonRef.current?.contains(target) && !menuRef.current?.contains(target)) close(); - } - function handleEscape(e: KeyboardEvent) { - if (e.key === "Escape") close(); - } - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - window.addEventListener("scroll", close, true); - window.addEventListener("resize", close); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - window.removeEventListener("scroll", close, true); - window.removeEventListener("resize", close); - }; - }, [open]); - return (
@@ -99,68 +65,34 @@ export function MemberCard({ member, isViewerHost, onKick, onTransfer }: Readonl {member.isCurrentUser ? " (나)" : ""}
- + {member.role === "HOST" ? "방장" : "참여자"} - +
{canAct && ( -
- - - {menuPosition && createPortal( -
- - -
, - document.body, - )} -
+ + {/* 지금 메뉴 폭 w-60을 유지한다 */} + + onTransfer(member.id)}> + + 방장 위임 + + onKick(member.id)}> + + 강제 퇴장 + + + )}
); diff --git a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx index 36c2bac3..386cc879 100644 --- a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx +++ b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; +import { Spinner } from "@/components/ui/spinner"; import { MainPageHeader } from "@/components/layout/MainPageHeader"; import { AddMemberPanel } from "./AddMemberPanel"; import { JoinRequestsSection } from "./JoinRequestsSection"; @@ -54,7 +54,7 @@ export function RoomMembersSection() { ) : null} {isMembersLoading ? ( - + ) : ( )} diff --git a/src/app/(main)/member-settings/_components/useMemberActions.tsx b/src/app/(main)/member-settings/_components/useMemberActions.tsx index 6b00a6c7..b867ce2f 100644 --- a/src/app/(main)/member-settings/_components/useMemberActions.tsx +++ b/src/app/(main)/member-settings/_components/useMemberActions.tsx @@ -5,8 +5,15 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; -import { alertDialogButtonClass } from "@/components/settings/SettingsActionButton"; -import { SettingsDialog } from "@/components/settings/SettingsDialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useCurrentRoomMembership } from "@/hooks/useCurrentRoomMembership"; import { showRoomLeftToast } from "@/lib/stomp/forced-room-exit-dispatch"; import { useKickMember, useLeaveRoom, useTransferHost } from "@/hooks/useRooms"; @@ -32,12 +39,25 @@ function NoticeDialog({ onClose: () => void; }>) { return ( - -

{description}

- -
+ { + if (!open) onClose(); + }} + > + {/* 지금처럼 딤을 눌러도 닫힌다 */} + + + {title} + {description} + + + {/* AlertDialogAction은 눌러도 저절로 닫히지 않는다 */} + {actionLabel} + + + ); } @@ -97,7 +117,6 @@ export function useMemberActions() { <> {leaveDialog === "confirm" ? ( { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - const { data: bookmarkRows, isPending: bookmarksLoading, @@ -148,7 +149,6 @@ export function AddFromBookmarkModal({ useCreateScheduleItem(); const busy = isCreatingItem || addingGooglePlaceId != null; - const sheetDrag = useSheetDrag(onClose, busy); const handleAddPlace = useCallback( async (googlePlaceId: string) => { @@ -224,52 +224,29 @@ export function AddFromBookmarkModal({ ); return ( -
{ + if (!open) onClose(); + }} + closeDisabled={busy} > - {/* 딤은 시트와 따로 둬서 기준 시트처럼 딤만 서서히 나타나고 시트는 아래에서 올라온다 */} -
-
e.stopPropagation()} - className={cn( - "relative flex max-h-[min(85vh,560px)] w-full flex-col overflow-hidden", - // PC는 가운데 모달, 모바일 기기는 화면 폭과 관계없이 바텀시트 - "mx-auto max-w-md rounded-2xl border border-gray-border bg-white shadow-lg", - "mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px] mobile:border-x-0 mobile:border-b-0 mobile:shadow-[0_-8px_32px_-8px_rgba(0,0,0,0.12)]", - "mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200", - "pb-[max(env(safe-area-inset-bottom,0px),12px)]", - )} + {/* 높이 상한은 지금 값. 폴더 칩·제목은 고정하고 장소 목록만 스크롤한다. 지금 PC 모달에는 닫기 X가 없다(아래 닫기 버튼) */} + - -
- -
-

- 북마크에서 추가 -

-

- 북마크 장소를 이 일차에 추가해요. -

-
+ + 북마크에서 추가 + 북마크 장소를 이 일차에 추가해요. + - + {categoriesLoading ? ( -
+
) : categoriesError ? (

@@ -293,7 +270,8 @@ export function AddFromBookmarkModal({

) : ( <> -
+ {/* 칩 줄과 목록은 지금처럼 표면 끝까지 닿는다. 표면(Dialog p-5·시트 Body px-5)의 좌우 20px만큼 넓힌다 */} +
{categories.map((c) => { const selected = selectedCategoryId === c.categoryId; @@ -322,7 +300,7 @@ export function AddFromBookmarkModal({
-
+
{bookmarksLoading ? ( ) : bookmarksError ? ( @@ -377,17 +355,12 @@ export function AddFromBookmarkModal({ )} -
- -
-
-
+ + + + }>닫기 + + + ); } diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.menu.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.menu.test.tsx new file mode 100644 index 00000000..9808da82 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.menu.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { PlanDaySection } from "./PlanDaySection"; + +let host: HTMLDivElement; +let root: Root; +const outer = vi.fn(); +const addDay = vi.fn(); +const hostEvents = { onClick: outer, onMouseDown: outer, onPointerDown: outer, onTouchStart: outer }; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +it("keeps menu trigger and portal actions out of the day host while retaining keyboard controls", async () => { + await act(async () => root.render( +
+ +
, + )); + const trigger = document.querySelector('[aria-label="일차 메뉴"]')!; + for (const type of ["mousedown", "pointerdown", "touchstart"]) { + await act(async () => trigger.dispatchEvent(new MouseEvent(type, { bubbles: true }))); + } + await act(async () => trigger.click()); + expect(outer).not.toHaveBeenCalled(); + const item = document.querySelector('[role="menuitem"]')!; + expect(item).not.toBeNull(); + for (const type of ["mousedown", "pointerdown", "touchstart"]) { + await act(async () => item.dispatchEvent(new MouseEvent(type, { bubbles: true }))); + } + await act(async () => item.click()); + expect(addDay).toHaveBeenCalledOnce(); + expect(outer).not.toHaveBeenCalled(); + await act(async () => trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }))); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(document.querySelector('[aria-controls$="-panel"]')?.getAttribute("aria-expanded")).toBe("true"); +}); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.route-switch.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.route-switch.test.tsx new file mode 100644 index 00000000..717c8d81 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.route-switch.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { PlanDaySection } from "./PlanDaySection"; +import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +it("label and switch change route visibility without collapsing the day", async () => { + usePlanScheduleRouteVisibilityStore.setState({ visibleByScheduleId: {}, visibilityOrder: [] }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render(장소)); + const heading = host.querySelector('button[aria-expanded]')!; + const toggle = host.querySelector('[role="switch"]')!; + await act(async () => host.querySelector("label")!.click()); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(usePlanScheduleRouteVisibilityStore.getState().visibleByScheduleId[10]).toBe(false); + expect(heading.getAttribute("aria-expanded")).toBe("true"); + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(heading.getAttribute("aria-expanded")).toBe("true"); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index b13f43ff..eed0eb6c 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -1,22 +1,23 @@ "use client"; +import { MenuDotHorizontalIcon, PlusIcon, TrashIcon } from "@/assets/icons"; import { ExpenseIcon } from "@/components/icons/ExpenseIcon"; -import { useOnClickOutside } from "@/hooks/useOnClickOutside"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens"; import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store"; import { cn } from "@/lib/utils"; -import { - ChevronDown, - ChevronUp, - GripVertical, - MoreHorizontal, - Plus, - Trash2, -} from "lucide-react"; -import { useCallback, useId, useRef, useState } from "react"; -import type { CSSProperties, DragEvent, ReactNode, Ref } from "react"; +import { ChevronDown, ChevronUp, GripVertical } from "lucide-react"; +import { useCallback, useId, useState } from "react"; +import type { CSSProperties, DragEvent, ReactNode, Ref, SyntheticEvent } from "react"; export type PlanDaySectionDragHandleProps = { dragDisabled?: boolean; @@ -61,6 +62,16 @@ export type PlanDaySectionProps = { }; }; +// 메뉴는 body로 포털되지만 React 이벤트는 트리를 따라 섹션(일차 간 드롭 핸들러)으로 올라간다. +// 트리거와 포털 표면에서만 포인터 전파를 끊고, 키보드는 Base UI에 전달한다 +const stopPropagation = (event: SyntheticEvent) => event.stopPropagation(); +const pointerBoundary = { + onClick: stopPropagation, + onMouseDown: stopPropagation, + onPointerDown: stopPropagation, + onTouchStart: stopPropagation, +}; + export function PlanDaySection({ title, subtitle, @@ -119,10 +130,6 @@ export function PlanDaySection({ const setRouteVisible = usePlanScheduleRouteVisibilityStore( (s) => s.setRouteVisible, ); - const toggleRouteVisible = useCallback(() => { - if (trackedSid === undefined) return; - setRouteVisible(trackedSid, !routeVisible); - }, [trackedSid, routeVisible, setRouteVisible]); const toggle = useCallback(() => { if (trackedSid !== undefined) { @@ -151,8 +158,6 @@ export function PlanDaySection({ onRequestAddExpense != null || onRequestDeleteSchedule != null || onRequestInsertScheduleAfter != null; const [menuOpen, setMenuOpen] = useState(false); - const menuRef = useRef(null); - useOnClickOutside(menuRef, () => setMenuOpen(false)); return (
{subtitle ? ( - // 디자인 시스템 Badge / Weak / Brand / md — 배경색은 시맨틱 토큰이 없어 원시 토큰을 쓴다 - + {title} - + ) : null}
@@ -229,88 +233,45 @@ export function PlanDaySection({ {showScheduleMenu ? ( -
- - {menuOpen && !isScheduleMenuDisabled ? ( -
- {onRequestAddExpense ? ( - - ) : null} - {onRequestInsertScheduleAfter ? ( - - ) : null} - {onRequestDeleteSchedule ? ( - - ) : null} -
- ) : null} -
+ + + + {onRequestAddExpense ? ( + + + {title} 비용 추가 + + ) : null} + {onRequestInsertScheduleAfter ? ( + + + 일차 추가 + + ) : null} + {onRequestDeleteSchedule ? ( + + + 일차 삭제 + + ) : null} + + ) : null}
{summaryAction || trackedSid !== undefined ? ( @@ -320,27 +281,16 @@ export function PlanDaySection({ )}>
{summaryAction}
{trackedSid !== undefined ? ( - + setRouteVisible(trackedSid, checked)} + /> + ) : null}
) : null} diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx index cadd1c3f..b80310a2 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx @@ -5,6 +5,9 @@ import { MemoIcon } from "@/components/icons"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens"; import { renderTextWithLinks } from "@/lib/text/renderTextWithLinks"; @@ -35,73 +38,6 @@ function isLocallyDirty(draft: string, editStartMemo: string): boolean { return draft !== editStartMemo; } -function MemoOverwriteConfirmDialog({ - open, - isPending, - onCancel, - onConfirm, -}: { - open: boolean; - isPending: boolean; - onCancel: () => void; - onConfirm: () => void; -}) { - useEffect(() => { - if (!open) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onCancel(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [open, onCancel]); - - if (!open) return null; - - return ( -
{ - if (ev.target === ev.currentTarget) onCancel(); - }} - > -
e.stopPropagation()} - className="w-full max-w-sm rounded-2xl border border-gray-border bg-white p-5 shadow-lg" - > -

- 다른 사람의 입력을 덮어씁니다! -

-

- 다른 멤버가 메모를 수정했어요. 저장하면 그 내용 대신 지금 작성 중인 메모로 바뀝니다. -

-
- - -
-
-
- ); -} - export function PlanItemMemoReadOnly({ memo, onDelete, @@ -224,24 +160,25 @@ export function PlanItemMemoEditor({
-