diff --git a/Design.md b/Design.md index 3719d6df..2fffb557 100644 --- a/Design.md +++ b/Design.md @@ -112,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/docs/code-conventions/anti-patterns.md b/docs/code-conventions/anti-patterns.md index 9d7bca9f..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`·새 아이콘 패키지 -- ✓ `src/components/ui/`의 부품(`Spinner`·`Skeleton` 등)과 `ConfirmDialog`·`SettingsDialog`·`MobileBottomSheet`를 먼저 쓴다. 버튼은 `SettingsActionButton`·`alertDialogButtonClass`가 맞으면 쓰고, 아니면 그 자리 ` + + ``` @@ -140,7 +143,7 @@ function PlaceRow({ place }: { place: Place }) { boolean prop은 하나 늘 때마다 가능한 조합이 두 배가 되고, 그중 일부는 있을 수 없는 상태다. 변형은 이름 있는 값이나 합성으로 드러낸다. -- 구조·동작을 바꾸는 boolean prop 대신 children·slot이나 변형별 컴포넌트를 쓴다. 표시 변형은 boolean 여럿 대신 유니온 prop(`variant`·`size`)으로 받는다(`MobileBottomSheet`의 `variant: "menu" | "form"`). +- 구조·동작을 바꾸는 boolean prop 대신 children·slot이나 변형별 컴포넌트를 쓴다. 표시 변형은 boolean 여럿 대신 유니온 prop(`variant`·`size`)으로 받는다(`Badge`의 `variant: "weak" | "solid" | "outline"`·`tone`). - renderX prop 대신 children으로 넘긴다. - 쿼리 상태를 `isPending`·`isError` prop으로 쪼개 넘기지 않는다. 분기는 컨테이너에서 끝낸다(1-2). @@ -424,6 +427,33 @@ const sorted = data.places.sort(byOrder); const sorted = [...data.places].sort(byOrder); ``` +### 2-9. 폼은 TanStack Form, 검증은 칸마다 (Forms) + +입력 값·검증·제출 상태를 `useState`로 하나씩 들면 오류가 폼 아래 문장 하나로 뭉치고, 어느 칸이 틀렸는지 화면이 모른다. 대화상자·시트 안의 폼은 `@tanstack/react-form`으로 쓴다. + +- 값은 `useForm({ defaultValues })`가 갖고, 칸은 `form.Field`로 그린다. 검증은 칸마다 `validators`에 함수로 둔다(zod는 아직 쓰지 않는다 — 나중에 같은 자리에 스키마를 넣는다). 검증 함수는 `src/lib`의 순수 함수를 부른다. +- 칸 오류는 그 칸에 `aria-invalid`와 `FieldError`로 그린다. Select처럼 Figma에 오류 상태가 없는 칸도 Text Field 오류 모양을 쓴다. 권한·저장 실패·충돌처럼 칸이 없는 오류는 폼 아래 문장으로 둔다. +- 제출은 `form.handleSubmit()` → `onSubmit`에서 mutation의 `mutateAsync`. 버튼 `loading`과 대화상자 `closeDisabled`는 그 mutation의 `isPending` 하나로 정한다(2-4). + +```tsx +const form = useForm({ + defaultValues: { budget: initial.budgetKrw ?? "" }, + onSubmit: ({ value }) => saveBudget.mutateAsync({ budgetKrw: value.budget }), +}); + + budgetError(value) }}> + {(field) => ( + + 예산 (KRW) + field.handleChange(e.target.value)} + aria-invalid={field.state.meta.errors.length > 0} /> + {field.state.meta.errors[0]} + + )} + + +``` + ## 3. 퍼블리싱 ### 3-1. Auto Layout을 그대로 옮긴다 (Layout First) diff --git a/package-lock.json b/package-lock.json index 8198e11f..bb66e5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@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", @@ -4548,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", @@ -4568,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", @@ -4601,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", diff --git a/package.json b/package.json index 8e9911a0..6dd9c67d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@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", 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/_components/AddBookmarkModal.tsx b/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx index ea3dcec1..fbccb9bf 100644 --- a/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx +++ b/src/app/(main)/bookmark/_components/AddBookmarkModal.tsx @@ -1,7 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; -import { Field, FieldHeader, FieldLabel, FieldSuffix } from "@/components/ui/field"; +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"; @@ -28,158 +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" - /> - - -
- - 색상 - -
- {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 ? ( ({ 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 ? ( (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 (
{canAct && ( -
- - - {menuPosition && createPortal( -
- - -
, - document.body, - )} -
+ + {/* 지금 메뉴 폭 w-60을 유지한다 */} + + onTransfer(member.id)}> + + 방장 위임 + + onKick(member.id)}> + + 강제 퇴장 + + + )}
); 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,50 +224,27 @@ 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.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index d6423df0..eed0eb6c 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -1,24 +1,23 @@ "use client"; +import { MenuDotHorizontalIcon, PlusIcon, TrashIcon } from "@/assets/icons"; import { ExpenseIcon } from "@/components/icons/ExpenseIcon"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Switch } from "@/components/ui/switch"; -import { useOnClickOutside } from "@/hooks/useOnClickOutside"; 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; @@ -63,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, @@ -149,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 (
{showScheduleMenu ? ( -
- - {menuOpen && !isScheduleMenuDisabled ? ( -
- {onRequestAddExpense ? ( - - ) : null} - {onRequestInsertScheduleAfter ? ( - - ) : null} - {onRequestDeleteSchedule ? ( - - ) : null} -
- ) : null} -
+ + + + {onRequestAddExpense ? ( + + + {title} 비용 추가 + + ) : null} + {onRequestInsertScheduleAfter ? ( + + + 일차 추가 + + ) : null} + {onRequestDeleteSchedule ? ( + + + 일차 삭제 + + ) : null} + + ) : null}
{summaryAction || trackedSid !== undefined ? ( diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx index 6341196f..b80310a2 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemMemoForm.tsx @@ -5,6 +5,8 @@ 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"; @@ -36,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, @@ -241,8 +176,9 @@ export function PlanItemMemoEditor({ {draft.length}/{SCHEDULE_ITEM_MEMO_MAX_LENGTH} - - + 저장 +
- setOverwriteDialogOpen(false)} - onConfirm={() => void commitSave()} - /> + {overwriteDialogOpen ? ( + void commitSave()} + onCancel={() => setOverwriteDialogOpen(false)} + /> + ) : null} ); } diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx index b845c5d5..6cf42a12 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx @@ -5,13 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ mutateAsync: vi.fn(), + pending: false, success: vi.fn(), error: vi.fn(), })); vi.mock("@/hooks/useRooms", () => ({ useUpdateScheduleItem: () => ({ mutateAsync: mocks.mutateAsync, - isPending: false, + isPending: mocks.pending, }), })); vi.mock("sonner", () => ({ @@ -26,7 +27,7 @@ async function render() { await act(async () => root.render()); } async function click(label: string) { - const button = [...container.querySelectorAll("button")].find( + const button = [...document.querySelectorAll("button")].find( (el) => el.getAttribute("aria-label") === label || el.textContent === label, ); expect(button, label).toBeDefined(); @@ -34,7 +35,7 @@ async function click(label: string) { } const fields = ["시작 시", "시작 분", "종료 시", "종료 분"]; function input(column: number) { - const el = container.querySelector(`input[aria-label="${fields[column]}"]`); + const el = document.querySelector(`input[aria-label="${fields[column]}"]`); expect(el, fields[column]).not.toBeNull(); return el!; } @@ -49,6 +50,12 @@ async function type(column: number, value: string) { async function key(el: Element, key: string, shiftKey = false) { await act(async () => el.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }))); } +// Base UI는 처음 포커스를 다음 animation frame에 옮긴다 +async function nextFrame() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + }); +} function body() { return mocks.mutateAsync.mock.calls.at(-1)?.[0].body; } @@ -56,6 +63,7 @@ beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); HTMLElement.prototype.scrollTo = vi.fn(); vi.clearAllMocks(); + mocks.pending = false; mocks.mutateAsync.mockResolvedValue({}); container = document.createElement("div"); document.body.append(container); @@ -168,24 +176,26 @@ describe("schedule time editor", () => { }); describe("web time editing", () => { - it("prefills exact minutes and applies typed values with Enter", async () => { + it.each([0, 1, 2, 3])("prefills exact minutes and applies typed values with Enter from input %s", async (column) => { props.startTime = "09:07"; props.endTime = "10:13"; await render(); + await nextFrame(); expect(fields.map((_, i) => selected(i))).toEqual(["09", "07", "10", "13"]); expect(document.activeElement).toBe(input(0)); await type(0, "8"); expect(selected(0)).toBe("8"); await type(1, "5"); - await key(input(1), "Enter"); + await key(input(column), "Enter"); expect(body()).toEqual({ startTime: "08:05" }); }); it.each(["24", "-1", "abc", "", "100"])("shows invalid hour %s without saving", async (value) => { await render(); await type(0, value); expect(input(0).getAttribute("aria-invalid")).toBe("true"); - expect(container.querySelector('[role="alert"]')?.textContent).toBeTruthy(); + expect(document.querySelector('[role="alert"]')?.textContent).toBeTruthy(); await key(input(0), "Enter"); + expect(mocks.error).toHaveBeenCalledOnce(); expect(mocks.mutateAsync).not.toHaveBeenCalled(); await type(0, "22"); expect(input(0).getAttribute("aria-invalid")).toBe("false"); @@ -201,24 +211,38 @@ describe("web time editing", () => { await click("적용"); expect(body()).toEqual({ startTime: "23:59" }); }); - it("traps Tab at both ends and restores opener focus after Escape", async () => { + // Tab 순환(포커스 가두기)은 Base UI 대화상자가 맡는다. 바깥 요소는 aria-hidden으로 접근성 트리에서 빠진다 + it("hides outside content while open and restores opener focus after Escape", async () => { const opener = document.createElement("button"); document.body.append(opener); opener.focus(); props.onClose = vi.fn(() => root.render(null)); await render(); - const focusable = [...container.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled)')]; - focusable[0].focus(); - await key(focusable[0], "Tab", true); - expect(document.activeElement).toBe(focusable.at(-1)); - await key(focusable.at(-1)!, "Tab"); - expect(document.activeElement).toBe(focusable[0]); + await nextFrame(); + expect(document.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe(true); + expect(opener.getAttribute("aria-hidden")).toBe("true"); await key(input(0), "Escape"); + await nextFrame(); expect(props.onClose).toHaveBeenCalledOnce(); expect(mocks.mutateAsync).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(opener.hasAttribute("aria-hidden")).toBe(false); expect(document.activeElement).toBe(opener); opener.remove(); }); + it("does not close by Escape, X or cancel while saving and names the apply button as saving", async () => { + mocks.pending = true; + await render(); + const apply = document.querySelector('button[type="submit"]')!; + expect(apply.getAttribute("aria-busy")).toBe("true"); + expect(apply.getAttribute("aria-disabled")).toBe("true"); + expect(document.getElementById(apply.getAttribute("aria-labelledby")!)?.textContent).toBe("저장 중"); + await key(document.querySelector('[role="dialog"]')!, "Escape"); + await click("닫기"); + await click("취소"); + expect(props.onClose).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); it("keeps the draft open on server failure", async () => { mocks.mutateAsync.mockRejectedValue(new Error("synthetic failure")); await render(); diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.tsx index 14e917b5..501d2c22 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.tsx @@ -1,9 +1,19 @@ "use client"; -import { Clock, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useForm, useStore } from "@tanstack/react-form"; +import { useId, useRef } from "react"; import { toast } from "sonner"; +import { TimeClockIcon } from "@/assets/icons"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens"; import { @@ -23,6 +33,11 @@ type PlanItemTimeEditorProps = { onClose: () => void; }; +/** 칸 값이 undefined면 사용자가 아직 고치지 않은 칸이라 재조회된 서버 값을 따른다. ""는 비운 칸(초기화)이다 */ +type TimeDraftValues = { start: string | undefined; end: string | undefined }; + +const UNTOUCHED_DRAFT: TimeDraftValues = { start: undefined, end: undefined }; + const fieldLabelClass = PLAN_PLACE_CARD_TW.timeFieldLabel; function canonicalDraft(value: string): string { @@ -39,165 +54,153 @@ export function PlanItemTimeEditor({ onClose, }: PlanItemTimeEditorProps) { const serverStartHm = normalizeStartTimeToHm(startTime); - const serverEndHm = normalizeStartTimeToHm(endTime ?? ""); - // 편집한 필드의 초안은 보존하고 나머지는 재조회된 서버 값을 따릅니다. - const [draftStart, setStartHm] = useState(); - const [draftEnd, setEndHm] = useState(); - const startHm = canonicalDraft(draftStart ?? serverStartHm); - const endHm = canonicalDraft(draftEnd ?? serverEndHm); const { mutateAsync, isPending } = useUpdateScheduleItem(); - - const dialogRef = useRef(null); + const formId = useId(); + const formRef = useRef(null); const savingRef = useRef(false); - useEffect(() => { - const opener = document.activeElement; - dialogRef.current?.querySelector("input")?.focus(); - return () => { - if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); - }; - }, []); - - const durationPreview = - startHm && endHm ? computeDurationMinutesFromRange(startHm, endHm) : null; - - async function handleSave() { - if (isPending || savingRef.current || !dirty) return; - const validation = validateScheduleTimeDraft(startHm, endHm); - if (!validation.valid) { - toast.error(validation.message); - return; - } - - savingRef.current = true; - try { - await mutateAsync({ - roomId, - scheduleId, - itemId, - body: { - ...(startHm !== serverStartHm ? { startTime: startHm || null } : {}), - ...(endHm !== serverEndHm || (draftStart === "" && serverStartHm) - ? { endTime: endHm || null } - : {}), - }, - }); + // 서버 값과 합친 시각. 렌더 중(안내 문장·미리보기)과 제출 검증이 같은 계산을 쓴다 + const resolveTimes = (values: TimeDraftValues) => ({ + startHm: canonicalDraft(values.start ?? serverStartHm), + endHm: canonicalDraft(values.end ?? serverEndHm), + }); + + const form = useForm({ + // 편집한 칸의 초안만 값으로 들고 나머지는 서버 값을 따른다. defaultValues에 서버 값을 넣으면 + // 한 칸만 고쳐도 폼 전체가 touched가 되어 다른 칸이 재조회 값을 따라가지 못한다(FormApi.update) + defaultValues: UNTOUCHED_DRAFT, + validators: { + // 시작·종료 교차 검증이라 폼 단위다. 화면 안내 문장은 재조회로 서버 값이 바뀌어도 맞도록 렌더 중에 같은 함수로 계산한다 + onSubmit: ({ value }) => { + const { startHm, endHm } = resolveTimes(value); + const result = validateScheduleTimeDraft(startHm, endHm); + return result.valid ? undefined : result.message; + }, + }, + onSubmitInvalid: ({ formApi }) => { + const [message] = formApi.state.errors; + if (message) toast.error(message); + }, + onSubmit: async ({ value }) => { + const { startHm, endHm } = resolveTimes(value); + savingRef.current = true; + try { + await mutateAsync({ + roomId, + scheduleId, + itemId, + body: { + ...(startHm !== serverStartHm ? { startTime: startHm || null } : {}), + ...(endHm !== serverEndHm || (value.start === "" && serverStartHm) + ? { endTime: endHm || null } + : {}), + }, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "시간을 저장하지 못했어요.", + ); + return; + } finally { + savingRef.current = false; + } toast.success("시간을 저장했어요."); onClose(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "시간을 저장하지 못했어요.", - ); - } finally { - savingRef.current = false; - } - } + }, + }); + const draft = useStore(form.store, (state) => state.values); + const { startHm, endHm } = resolveTimes(draft); + const durationPreview = + startHm && endHm ? computeDurationMinutesFromRange(startHm, endHm) : null; const dirty = serverStartHm !== startHm || serverEndHm !== endHm; const validation = validateScheduleTimeDraft(startHm, endHm); - const canSave = !isPending && dirty && validation.valid; + const describedBy = validation.valid ? `${formId}-help` : `${formId}-help ${formId}-error`; + + function submit() { + // 라이브러리는 연타를 막지 않는다. isPending은 다음 렌더에 바뀌어 ref 락을 함께 본다 + if (isPending || savingRef.current || form.state.isSubmitting || !dirty) return; + void form.handleSubmit(); + } return ( -
{ - if (e.target === e.currentTarget && !isPending) onClose(); + { + if (!open) onClose(); }} + closeDisabled={isPending} > -
{ - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - if (!isPending && !savingRef.current) onClose(); - } else if (e.key === "Enter" && e.target instanceof HTMLInputElement) { - e.preventDefault(); - void handleSave(); - } else if (e.key === "Tab") { - const controls = [...e.currentTarget.querySelectorAll( - "button:not(:disabled), input:not(:disabled)", - )]; - const first = controls[0]; - const last = controls.at(-1); - if (!first) { - e.preventDefault(); - e.currentTarget.focus(); - } else if (e.shiftKey && (document.activeElement === first || document.activeElement === e.currentTarget)) { - e.preventDefault(); - last?.focus(); - } else if (!e.shiftKey && (document.activeElement === last || document.activeElement === e.currentTarget)) { - e.preventDefault(); - first.focus(); - } - } - }} - aria-modal="true" - aria-labelledby="plan-item-time-dialog-title" - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - className="max-h-[90dvh] w-full max-w-sm overflow-y-auto rounded-2xl bg-white shadow-xl" + {/* 처음 포커스는 시작 시 입력(지금 동작). 닫히면 Base UI가 연 버튼으로 포커스를 돌려준다 */} + formRef.current?.querySelector("input") ?? true} > -
- - - -

- 시간 설정 -

- - -
- -
-

+ +

+ + + + 시간 설정 + +
+ + +
{ + event.preventDefault(); + submit(); + }} + > +

24시간 기준 · 시와 분을 직접 입력하세요

{([ - { label: "시작", value: draftStart ?? serverStartHm, setValue: setStartHm, disabled: isPending }, - { label: "종료", value: draftEnd ?? serverEndHm, setValue: setEndHm, disabled: isPending || !normalizeStartTimeToHm(startHm) }, - ] as const).map(({ label, value, setValue, disabled }) => ( -
- - {label} - - -
+ { name: "start", label: "시작", serverValue: serverStartHm, disabled: isPending }, + { name: "end", label: "종료", serverValue: serverEndHm, disabled: isPending || !normalizeStartTimeToHm(startHm) }, + ] as const).map(({ name, label, serverValue, disabled }) => ( + + {(field) => ( +
+ + {label} + + { + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } + }} + /> +
+ )} +
))} {!validation.valid ? ( - ) : null} @@ -218,27 +221,21 @@ export function PlanItemTimeEditor({ ) : null}
- -
- - -
-
-
-
+ + + + }>취소 + + + + ); } diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx index bee1cee9..9c7bc2f0 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx @@ -181,6 +181,7 @@ export function PlanPageView() { const deleteDayDialog = dayToDelete ? ( void; disabled: boolean; describedBy: string; + onKeyDown?: KeyboardEventHandler; }>) { const parts = value ? value.split(":") : ["", ""]; const update = (index: number, next: string) => { @@ -40,6 +44,7 @@ export function TimeInput({ value={part} placeholder="--" disabled={disabled} + onKeyDown={onKeyDown} onFocus={(e) => e.currentTarget.select()} onChange={(e) => update(index, e.target.value)} className="h-10 w-full min-w-0 text-center text-body-m-regular tabular-nums outline-none disabled:opacity-40" diff --git a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx index cc17be6d..97fab0d7 100644 --- a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx @@ -1,15 +1,30 @@ "use client"; -import { useEffect, useId, useRef, useState } from "react"; +import { useForm, useStore } from "@tanstack/react-form"; +import { useId, useRef } from "react"; import { toast } from "sonner"; -import { MobileBottomSheet } from "@/components/mobile/MobileBottomSheet"; -import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"; import { Textarea } from "@/components/ui/textarea"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; const MEMO_MAX_LENGTH = 2000; +// 입력은 maxLength로 막히지만 저장 전에 칸에서 한 번 더 확인한다. 문구는 PC 메모 편집(PlanItemMemoForm)과 같다 +function memoLengthError(memo: string) { + return memo.trim().length > MEMO_MAX_LENGTH ? `메모는 ${MEMO_MAX_LENGTH}자 이하여야 해요.` : undefined; +} + type MobileMemoSheetProps = Readonly<{ roomId: string; scheduleId: number; @@ -29,25 +44,20 @@ export function MobileMemoSheet({ onClose, }: MobileMemoSheetProps) { const initial = memo.trim(); - const [draft, setDraft] = useState(initial); - const { mutateAsync, isPending } = useUpdateScheduleItem(); + // 저장과 삭제는 같은 API지만 진행 표시를 누른 버튼에만 두려고 따로 꺼낸다 + const { mutateAsync: saveMemo, isPending: isSaving } = useUpdateScheduleItem(); + const { mutateAsync: deleteMemo, isPending: isDeleting } = useUpdateScheduleItem(); + // 저장·삭제는 서로 배타적이라 닫기·입력 비활성에만 합쳐 쓴다 + const isWriting = isSaving || isDeleting; const textareaRef = useRef(null); - const memoId = useId(); - const descriptionId = useId(); - - // 시트가 열리면 바로 입력할 수 있게 포커스하고 커서를 기존 메모 끝에 둔다 - useEffect(() => { - const el = textareaRef.current; - if (!el) return; - el.focus(); - el.setSelectionRange(el.value.length, el.value.length); - }, []); - const hasMemo = initial.length > 0; - const dirty = draft.trim() !== initial; + const formId = useId(); + const memoId = `${formId}-memo`; + const descriptionId = `${formId}-memo-description`; + const errorId = `${formId}-memo-error`; - async function saveMemo(next: string) { + async function writeMemo(update: typeof saveMemo, next: string) { try { - await mutateAsync({ roomId, scheduleId, itemId, body: { memo: next } }); + await update({ roomId, scheduleId, itemId, body: { memo: next } }); toast.success(next ? "메모를 저장했어요." : "메모를 삭제했어요."); onClose(); } catch { @@ -55,53 +65,96 @@ export function MobileMemoSheet({ } } + const form = useForm({ + defaultValues: { memo: initial }, + onSubmit: ({ value }) => writeMemo(saveMemo, value.memo.trim()), + }); + const hasMemo = initial.length > 0; + // TanStack isDirty는 한 번 바뀌면 남아서, 되돌리면 다시 꺼지는 지금 기준(앞뒤 공백 무시 비교)을 그대로 쓴다 + const dirty = useStore(form.store, (state) => state.values.memo.trim() !== initial); + return ( - - - 메모 -