-
+
}
>
diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg
new file mode 100644
index 00000000..0a1b50ec
--- /dev/null
+++ b/src/assets/icons/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/icons/checkbox-activated.svg b/src/assets/icons/checkbox-activated.svg
new file mode 100644
index 00000000..b25d08a3
--- /dev/null
+++ b/src/assets/icons/checkbox-activated.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/icons/checkbox.svg b/src/assets/icons/checkbox.svg
new file mode 100644
index 00000000..8ba21e2c
--- /dev/null
+++ b/src/assets/icons/checkbox.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/icons/circle-ban.svg b/src/assets/icons/circle-ban.svg
new file mode 100644
index 00000000..c7f063da
--- /dev/null
+++ b/src/assets/icons/circle-ban.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/icons/circle-check.svg b/src/assets/icons/circle-check.svg
new file mode 100644
index 00000000..be0dffff
--- /dev/null
+++ b/src/assets/icons/circle-check.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/icons/index.tsx b/src/assets/icons/index.tsx
index c46c9cab..e31c896c 100644
--- a/src/assets/icons/index.tsx
+++ b/src/assets/icons/index.tsx
@@ -2,10 +2,15 @@ import ArrowSortSvg from "@/assets/icons/arrow-sort.svg";
import CalendarAddSvg from "@/assets/icons/calendar-add.svg";
import CalendarSvg from "@/assets/icons/calendar.svg";
import ChatSvg from "@/assets/icons/chat.svg";
+import CheckSvg from "@/assets/icons/check.svg";
+import CheckboxActivatedSvg from "@/assets/icons/checkbox-activated.svg";
+import CheckboxSvg from "@/assets/icons/checkbox.svg";
import ChevronDownSvg from "@/assets/icons/chevron-down.svg";
import ChevronLeftSvg from "@/assets/icons/chevron-left.svg";
import ChevronRightSvg from "@/assets/icons/chevron-right.svg";
+import CircleBanSvg from "@/assets/icons/circle-ban.svg";
import CircleCancelSvg from "@/assets/icons/circle-cancel.svg";
+import CircleCheckSvg from "@/assets/icons/circle-check.svg";
import CloseSvg from "@/assets/icons/close.svg";
import CoinSvg from "@/assets/icons/coin.svg";
import CopySvg from "@/assets/icons/copy.svg";
@@ -19,6 +24,8 @@ import MenuDotHorizontalSvg from "@/assets/icons/menu-dot-horizontal.svg";
import MenuDotVerticalSvg from "@/assets/icons/menu-dot-vertical.svg";
import MenuHamburgerSvg from "@/assets/icons/menu-hamburger.svg";
import PlusSvg from "@/assets/icons/plus.svg";
+import ProgressSvg from "@/assets/icons/progress.svg";
+import RadioButtonSvg from "@/assets/icons/radio-button.svg";
import ReactionStarSvg from "@/assets/icons/reaction-star.svg";
import RefreshSvg from "@/assets/icons/refresh.svg";
import RouteSvg from "@/assets/icons/route.svg";
@@ -28,9 +35,16 @@ import SearchSvg from "@/assets/icons/search.svg";
import SettingsSvg from "@/assets/icons/settings.svg";
import SendSvg from "@/assets/icons/send.svg";
import ShareSvg from "@/assets/icons/share.svg";
+import SignLogoutSvg from "@/assets/icons/sign-logout.svg";
import TimeClockSvg from "@/assets/icons/time-clock.svg";
import TrashSvg from "@/assets/icons/trash.svg";
+import TravelHotelSvg from "@/assets/icons/travel-hotel.svg";
+import TravelMuseumSvg from "@/assets/icons/travel-museum.svg";
+import TravelRestaurantSvg from "@/assets/icons/travel-restaurant.svg";
+import TravelThemeparkSvg from "@/assets/icons/travel-themepark.svg";
+import TravelTrainSvg from "@/assets/icons/travel-train.svg";
import UserEditSvg from "@/assets/icons/user-edit.svg";
+import UserSvg from "@/assets/icons/user.svg";
import UserXSvg from "@/assets/icons/user-x.svg";
import WriteAddSvg from "@/assets/icons/write-add.svg";
import WriteSvg from "@/assets/icons/write.svg";
@@ -43,10 +57,15 @@ export const ArrowSortIcon = createIcon(ArrowSortSvg, "ArrowSortIcon");
export const CalendarAddIcon = createIcon(CalendarAddSvg, "CalendarAddIcon");
export const CalendarIcon = createIcon(CalendarSvg, "CalendarIcon");
export const ChatIcon = createIcon(ChatSvg, "ChatIcon");
+export const CheckIcon = createIcon(CheckSvg, "CheckIcon");
+export const CheckboxActivatedIcon = createIcon(CheckboxActivatedSvg, "CheckboxActivatedIcon");
+export const CheckboxIcon = createIcon(CheckboxSvg, "CheckboxIcon");
export const ChevronDownIcon = createIcon(ChevronDownSvg, "ChevronDownIcon");
export const ChevronLeftIcon = createIcon(ChevronLeftSvg, "ChevronLeftIcon");
export const ChevronRightIcon = createIcon(ChevronRightSvg, "ChevronRightIcon");
+export const CircleBanIcon = createIcon(CircleBanSvg, "CircleBanIcon");
export const CircleCancelIcon = createIcon(CircleCancelSvg, "CircleCancelIcon");
+export const CircleCheckIcon = createIcon(CircleCheckSvg, "CircleCheckIcon");
export const CloseIcon = createIcon(CloseSvg, "CloseIcon");
export const CoinIcon = createIcon(CoinSvg, "CoinIcon");
export const CopyCheckIcon = createIcon(CopyCheckSvg, "CopyCheckIcon");
@@ -60,6 +79,8 @@ export const MenuDotHorizontalIcon = createIcon(MenuDotHorizontalSvg, "MenuDotHo
export const MenuDotVerticalIcon = createIcon(MenuDotVerticalSvg, "MenuDotVerticalIcon");
export const MenuHamburgerIcon = createIcon(MenuHamburgerSvg, "MenuHamburgerIcon");
export const PlusIcon = createIcon(PlusSvg, "PlusIcon");
+export const ProgressIcon = createIcon(ProgressSvg, "ProgressIcon");
+export const RadioButtonIcon = createIcon(RadioButtonSvg, "RadioButtonIcon");
export const ReactionStarIcon = createIcon(ReactionStarSvg, "ReactionStarIcon");
export const RefreshIcon = createIcon(RefreshSvg, "RefreshIcon");
// Figma DS에 없어 Tabler `route`(outline)를 받아 씀 — Design.md "Figma에 추가 요청할 아이콘" 참고
@@ -70,9 +91,16 @@ export const SearchIcon = createIcon(SearchSvg, "SearchIcon");
export const SettingsIcon = createIcon(SettingsSvg, "SettingsIcon");
export const SendIcon = createIcon(SendSvg, "SendIcon");
export const ShareIcon = createIcon(ShareSvg, "ShareIcon");
+export const SignLogoutIcon = createIcon(SignLogoutSvg, "SignLogoutIcon");
export const TimeClockIcon = createIcon(TimeClockSvg, "TimeClockIcon");
export const TrashIcon = createIcon(TrashSvg, "TrashIcon");
+export const TravelHotelIcon = createIcon(TravelHotelSvg, "TravelHotelIcon");
+export const TravelMuseumIcon = createIcon(TravelMuseumSvg, "TravelMuseumIcon");
+export const TravelRestaurantIcon = createIcon(TravelRestaurantSvg, "TravelRestaurantIcon");
+export const TravelThemeparkIcon = createIcon(TravelThemeparkSvg, "TravelThemeparkIcon");
+export const TravelTrainIcon = createIcon(TravelTrainSvg, "TravelTrainIcon");
export const UserEditIcon = createIcon(UserEditSvg, "UserEditIcon");
+export const UserIcon = createIcon(UserSvg, "UserIcon");
export const UserXIcon = createIcon(UserXSvg, "UserXIcon");
export const WriteAddIcon = createIcon(WriteAddSvg, "WriteAddIcon");
export const WriteIcon = createIcon(WriteSvg, "WriteIcon");
diff --git a/src/assets/icons/progress.svg b/src/assets/icons/progress.svg
new file mode 100644
index 00000000..d0972030
--- /dev/null
+++ b/src/assets/icons/progress.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/assets/icons/radio-button.svg b/src/assets/icons/radio-button.svg
new file mode 100644
index 00000000..292909f7
--- /dev/null
+++ b/src/assets/icons/radio-button.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/icons/sign-logout.svg b/src/assets/icons/sign-logout.svg
new file mode 100644
index 00000000..0b3ee22b
--- /dev/null
+++ b/src/assets/icons/sign-logout.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/assets/icons/travel-hotel.svg b/src/assets/icons/travel-hotel.svg
new file mode 100644
index 00000000..1a720751
--- /dev/null
+++ b/src/assets/icons/travel-hotel.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/assets/icons/travel-museum.svg b/src/assets/icons/travel-museum.svg
new file mode 100644
index 00000000..43da85eb
--- /dev/null
+++ b/src/assets/icons/travel-museum.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/travel-restaurant.svg b/src/assets/icons/travel-restaurant.svg
new file mode 100644
index 00000000..897c0243
--- /dev/null
+++ b/src/assets/icons/travel-restaurant.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/assets/icons/travel-themepark.svg b/src/assets/icons/travel-themepark.svg
new file mode 100644
index 00000000..8d9d7f52
--- /dev/null
+++ b/src/assets/icons/travel-themepark.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/assets/icons/travel-train.svg b/src/assets/icons/travel-train.svg
new file mode 100644
index 00000000..5941cd20
--- /dev/null
+++ b/src/assets/icons/travel-train.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/icons/user.svg b/src/assets/icons/user.svg
new file mode 100644
index 00000000..cc2d2ef9
--- /dev/null
+++ b/src/assets/icons/user.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/components/AppToaster.tsx b/src/components/AppToaster.tsx
index c312d363..1e91460b 100644
--- a/src/components/AppToaster.tsx
+++ b/src/components/AppToaster.tsx
@@ -1,14 +1,13 @@
"use client";
-import { Toaster } from "sonner";
-
+import { Toaster } from "@/components/ui/sonner";
import { readIsMobileDevice } from "@/lib/mobile-view";
/** --toast-bottom-inset은 모바일 하단 탭바가 있을 때만 globals.css에서 정의된다 */
const MOBILE_BOTTOM_OFFSET = "calc(var(--toast-bottom-inset, 0px) + 16px)";
export function AppToaster() {
if (!readIsMobileDevice()) {
- return ;
+ return ;
}
return (
@@ -16,7 +15,6 @@ export function AppToaster() {
position="bottom-right"
offset={{ bottom: MOBILE_BOTTOM_OFFSET }}
mobileOffset={{ bottom: MOBILE_BOTTOM_OFFSET }}
- richColors
/>
);
}
diff --git a/src/components/agreements/AgreementConsentSection.tsx b/src/components/agreements/AgreementConsentSection.tsx
index 93c60f7a..2237c2be 100644
--- a/src/components/agreements/AgreementConsentSection.tsx
+++ b/src/components/agreements/AgreementConsentSection.tsx
@@ -1,6 +1,8 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Spinner } from "@/components/ui/spinner";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
@@ -55,53 +57,48 @@ export function AgreementConsentSection({ onStateChange }: Props) {
if (isPending) {
return (
-
+
);
}
if (isError) {
return (
-
-
- 약관을 불러오지 못했습니다
-
-
+
+ 약관을 불러오지 못했습니다
+
{error instanceof Error
? error.message
: "잠시 후 다시 시도해 주세요."}
-
-
void refetch()}
- className="mt-2 text-label-l-regular mobile:text-label-m-regular font-medium text-primary underline-offset-2 hover:underline"
- >
- 다시 시도
-
-
+
+
+ void refetch()}
+ className="text-label-l-regular mobile:text-label-m-regular text-primary underline-offset-2 hover:underline"
+ >
+ 다시 시도
+
+
+
);
}
if (items.length === 0) {
return (
-
-
+
+
현재 동의할 약관 정보를 불러올 수 없습니다. 잠시 후 다시 시도해
주세요.
-
-
+
+
);
}
return (
-
- toggleAll(event.target.checked)}
- />
+
+
필수 약관 전체 동의
@@ -116,11 +113,10 @@ export function AgreementConsentSection({ onStateChange }: Props) {
className="flex items-center gap-2 rounded-lg px-2 py-2 transition-colors hover:bg-white/80"
>
- toggleItem(id, event.target.checked)}
+ onCheckedChange={(checked) => toggleItem(id, checked)}
/>
필수
diff --git a/src/components/agreements/PolicyDocumentView.tsx b/src/components/agreements/PolicyDocumentView.tsx
index ccfdcd47..cb7804ab 100644
--- a/src/components/agreements/PolicyDocumentView.tsx
+++ b/src/components/agreements/PolicyDocumentView.tsx
@@ -1,6 +1,6 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { Spinner } from "@/components/ui/spinner";
import { useCurrentAgreements } from "@/hooks/useCurrentAgreements";
import { findAgreementByType } from "@/lib/agreements/paths";
@@ -21,7 +21,7 @@ export function PolicyDocumentView({ agreementType }: Props) {
return (
);
}
diff --git a/src/components/analytics/AnalyticsConsentSettings.test.tsx b/src/components/analytics/AnalyticsConsentSettings.test.tsx
index c5cb5a0e..ad875514 100644
--- a/src/components/analytics/AnalyticsConsentSettings.test.tsx
+++ b/src/components/analytics/AnalyticsConsentSettings.test.tsx
@@ -30,6 +30,7 @@ describe("AnalyticsConsentSettingsView", () => {
consent={consent}
draft={consent}
onSave={vi.fn()}
+ onCancel={vi.fn()}
message=""
onGrant={vi.fn()}
onDeny={vi.fn()}
@@ -54,6 +55,7 @@ describe("AnalyticsConsentSettingsView", () => {
consent="denied"
draft="denied"
onSave={vi.fn()}
+ onCancel={vi.fn()}
message={message}
onGrant={vi.fn()}
onDeny={vi.fn()}
@@ -72,6 +74,7 @@ it("preserves the policy destination and all consent notice meanings in a disclo
draft="pending"
message=""
onSave={vi.fn()}
+ onCancel={vi.fn()}
onGrant={vi.fn()}
onDeny={vi.fn()}
/>,
diff --git a/src/components/analytics/AnalyticsConsentSettings.tsx b/src/components/analytics/AnalyticsConsentSettings.tsx
index c9115fef..82e8db19 100644
--- a/src/components/analytics/AnalyticsConsentSettings.tsx
+++ b/src/components/analytics/AnalyticsConsentSettings.tsx
@@ -3,7 +3,9 @@
import Link from "next/link";
import { useId, useState, useSyncExternalStore } from "react";
-import { SettingsActionButton } from "@/components/settings/SettingsActionButton";
+import { Button } from "@/components/ui/button";
+import { DialogFooter } from "@/components/ui/dialog";
+import { Switch } from "@/components/ui/switch";
import {
denyAnalyticsConsent,
grantAnalyticsConsent,
@@ -20,6 +22,7 @@ type AnalyticsConsentSettingsViewProps = {
message: string;
draft: AnalyticsConsentState;
onSave: () => void;
+ onCancel: () => void;
onDeny: () => void;
onGrant: () => void;
};
@@ -43,6 +46,7 @@ export function AnalyticsConsentSettingsView({
onGrant,
draft,
onSave,
+ onCancel,
}: AnalyticsConsentSettingsViewProps) {
const categoryId = useId();
const descriptionId = useId();
@@ -66,26 +70,14 @@ export function AnalyticsConsentSettingsView({
Google Analytics로 방문과 기능 사용 통계를 분석합니다.
- (checked ? onGrant() : onDeny())}
aria-labelledby={categoryId}
aria-describedby={
consent === "pending" ? `${descriptionId} ${hintId}` : descriptionId
}
- onClick={allowed ? onDeny : onGrant}
- className="flex min-h-11 shrink-0 cursor-pointer items-center rounded-lg px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
- >
-
-
-
-
+ />
{consent === "pending" && (
@@ -102,22 +94,19 @@ export function AnalyticsConsentSettingsView({
삭제합니다.
-
-
- 개인정보 처리방침
-
-
- 저장
-
-
+
+ 개인정보 처리방침
+
+ {/* 대화상자 Footer(취소 Neutral Weak → 저장 Neutral Solid). 간격 20은 Modal 영역 간격이고, 저장 결과 문장은 버튼 아래에 붙는다 */}
+
+
+ 취소
+
+ 저장
+
void }>) {
const consent = useSyncExternalStore(
analyticsConsentStore.subscribe,
analyticsConsentStore.getSnapshot,
@@ -155,6 +145,7 @@ export function AnalyticsConsentSettings() {
onGrant={() => select("granted")}
onDeny={() => select("denied")}
onSave={save}
+ onCancel={onCancel}
/>
);
}
diff --git a/src/components/analytics/CookieConsentBanner.tsx b/src/components/analytics/CookieConsentBanner.tsx
index 2028da64..eedb8ea9 100644
--- a/src/components/analytics/CookieConsentBanner.tsx
+++ b/src/components/analytics/CookieConsentBanner.tsx
@@ -4,10 +4,7 @@ import Link from "next/link";
import { CookieSettingsButton } from "./CookieSettingsProvider";
import { motion, useReducedMotion } from "framer-motion";
-import {
- SettingsActionButton,
- SettingsActionButtonRow,
-} from "@/components/settings/SettingsActionButton";
+import { Button } from "@/components/ui/button";
import { AGREEMENT_PUBLIC_PATH } from "@/lib/agreements/paths";
type CookieConsentBannerProps = {
@@ -21,6 +18,7 @@ export function CookieConsentBanner({
}: CookieConsentBannerProps) {
const reduceMotion = useReducedMotion();
+ // z-40: 앱(isolate 묶음) 위, Base UI 대화상자·시트(z-50 포털) 아래다. 배너의 '쿠키 설정'으로 연 대화상자가 배너를 덮고 딤이 배너를 막는다
return (
@@ -45,14 +43,14 @@ export function CookieConsentBanner({
-
-
+
+
거부
-
-
+
+
허용
-
-
+
+
);
diff --git a/src/components/analytics/CookieSettingsEntrypoints.test.tsx b/src/components/analytics/CookieSettingsEntrypoints.test.tsx
index b67996f3..7f791b8d 100644
--- a/src/components/analytics/CookieSettingsEntrypoints.test.tsx
+++ b/src/components/analytics/CookieSettingsEntrypoints.test.tsx
@@ -19,6 +19,15 @@ import { SiteFooter } from "@/components/layout/SiteFooter";
import { CookieConsentBanner } from "./CookieConsentBanner";
import { ConsentGatedAnalytics } from "./ConsentGatedAnalytics";
+// 쿠키 설정은 ui Dialog(Base UI)라 document.body로 포털된 role="dialog"다
+const dialogs = () => document.querySelectorAll('[role="dialog"]');
+// Base UI는 처음 포커스를 requestAnimationFrame 뒤에 옮긴다
+const settle = () =>
+ act(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
it("opens the same dialog from footer and banner without navigation, even with analytics disabled", async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.history.replaceState({}, "", "/home");
@@ -42,13 +51,17 @@ it("opens the same dialog from footer and banner without navigation, even with a
expect(triggers).toHaveLength(2);
for (const trigger of triggers) {
await act(async () => trigger.click());
- expect(document.querySelectorAll('dialog[open]')).toHaveLength(1);
+ await settle();
+ expect(dialogs()).toHaveLength(1);
+ expect(dialogs()[0].contains(document.activeElement)).toBe(true);
expect(window.location.pathname).toBe("/home");
await act(async () =>
document
.querySelector('[aria-label="쿠키 설정 닫기"]')!
.click(),
);
+ await settle();
+ expect(dialogs()).toHaveLength(0);
expect(document.activeElement).toBe(trigger);
}
expect(accept).not.toHaveBeenCalled();
@@ -112,8 +125,9 @@ it("returns focus to the footer when saving consent removes the original banner
(node) => node.textContent === "쿠키 설정",
)!;
await act(async () => trigger.click());
+ await settle();
const dialogButtons = () => [
- ...document.querySelectorAll('dialog[open] button'),
+ ...document.querySelectorAll('[role="dialog"] button'),
];
await act(async () =>
(
@@ -123,11 +137,14 @@ it("returns focus to the footer when saving consent removes the original banner
).click(),
);
expect(host.querySelector('[aria-label="쿠키 동의"]')).toBeNull();
+ expect(dialogs()).toHaveLength(1);
await act(async () =>
document
.querySelector('[aria-label="쿠키 설정 닫기"]')!
.click(),
);
+ await settle();
+ expect(dialogs()).toHaveLength(0);
expect(document.activeElement).toBe(host.querySelector("footer button"));
} finally {
await act(async () => root.unmount());
diff --git a/src/components/analytics/CookieSettingsLandingEntry.test.tsx b/src/components/analytics/CookieSettingsLandingEntry.test.tsx
index bd3e1147..c169f5e5 100644
--- a/src/components/analytics/CookieSettingsLandingEntry.test.tsx
+++ b/src/components/analytics/CookieSettingsLandingEntry.test.tsx
@@ -25,7 +25,14 @@ it("opens from the compatibility query once and preserves unrelated URL state",
,
),
);
- expect(document.querySelector('dialog[open]')).not.toBeNull();
+ // 쿠키 설정은 ui Dialog(Base UI)라 document.body로 포털된 role="dialog"다. 처음 포커스는 requestAnimationFrame 뒤에 옮긴다
+ const settle = () =>
+ act(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ await settle();
+ expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1);
expect(
window.location.pathname + window.location.search + window.location.hash,
).toBe("/?keep=1#team");
@@ -34,7 +41,8 @@ it("opens from the compatibility query once and preserves unrelated URL state",
.querySelector('[aria-label="쿠키 설정 닫기"]')!
.click(),
);
- expect(document.querySelector('dialog[open]')).toBeNull();
+ await settle();
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(host.querySelector("button"));
} finally {
await act(async () => root.unmount());
diff --git a/src/components/analytics/CookieSettingsModal.test.tsx b/src/components/analytics/CookieSettingsModal.test.tsx
index 9c960431..59a64ea0 100644
--- a/src/components/analytics/CookieSettingsModal.test.tsx
+++ b/src/components/analytics/CookieSettingsModal.test.tsx
@@ -26,14 +26,44 @@ function button(label: string) {
expect(found, label).toBeTruthy();
return found!;
}
+// 쿠키 설정은 ui Dialog(Base UI)라 document.body로 포털된 role="dialog"다
+function dialog() {
+ return document.querySelector('[role="dialog"]');
+}
function toggle() {
return document.querySelector('[role="switch"]')!;
}
+function resultMessage() {
+ return dialog()!.querySelector('[aria-live="polite"]')!;
+}
+function isPageScrollLocked() {
+ return [document.documentElement, document.body].some(
+ (element) => element.style.overflowY === "hidden",
+ );
+}
+// Base UI는 처음 포커스를 requestAnimationFrame 뒤에, 스크롤 잠금·해제를 setTimeout 0 뒤에 반영한다
+async function settle() {
+ await act(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+// 브라우저의 Tab 이동처럼 문서 순서상 다음(이전) tabbable로 포커스를 옮긴다.
+// 대화상자 앞뒤의 보이지 않는 칸에 닿으면 Base UI가 포커스를 대화상자 안 반대쪽 끝으로 되돌린다
+async function pressTab(shift = false) {
+ const tabbables = [...document.querySelectorAll("*")].filter(
+ (node) => node.tabIndex >= 0 && !(node as HTMLButtonElement).disabled,
+ );
+ const index = tabbables.indexOf(document.activeElement as HTMLElement);
+ await act(async () => tabbables[index + (shift ? -1 : 1)].focus());
+ await settle();
+}
async function toggleDraft() {
await act(async () => toggle().click());
}
async function click(label: string) {
await act(async () => button(label).click());
+ await settle();
}
async function mount() {
host = document.createElement("div");
@@ -70,73 +100,39 @@ afterEach(async () => {
});
describe("cookie settings modal", () => {
- it("reserves matching scrollbar gutters with disclosure closed and expanded", async () => {
- await click("쿠키 설정");
- const dialog = document.querySelector('dialog[open]')!;
- const details = dialog.querySelector("details")!;
- for (const expanded of [false, true]) {
- if (expanded)
- await act(async () => details.querySelector("summary")!.click());
- expect(details.open).toBe(expanded);
- // jsdom has no layout engine: guard the CSS contract; Hermes measures
- // actual insets with classic scrollbars, including constrained heights.
- expect(
- dialog.classList.contains("[scrollbar-gutter:stable_both-edges]"),
- ).toBe(true);
- expect(dialog.classList.contains("overflow-y-auto")).toBe(true);
- expect(dialog.classList.contains("max-w-[640px]")).toBe(true);
- expect(dialog.classList.contains("max-h-[calc(100dvh-2rem)]")).toBe(true);
- }
- });
-
it("opens in place, traps keyboard focus and dismisses with Escape without saving", async () => {
await click("쿠키 설정");
- const dialog = document.querySelector('dialog[open]')!;
- expect(dialog).not.toBeNull();
- expect(dialog.getAttribute("aria-modal")).toBe("true");
+ const opened = dialog()!;
+ expect(opened).not.toBeNull();
+ expect(opened.hasAttribute("data-open")).toBe(true);
expect(
- document.getElementById(dialog.getAttribute("aria-labelledby")!)
+ document.getElementById(opened.getAttribute("aria-labelledby")!)
?.textContent,
).toBe("쿠키 설정");
- expect(dialog.contains(document.activeElement)).toBe(true);
- expect(host.hasAttribute("inert")).toBe(true);
+ expect(opened.contains(document.activeElement)).toBe(true);
+ // 모달 바깥(앱)은 보조기기에서 가려진다
+ expect(host.getAttribute("aria-hidden")).toBe("true");
expect(button("저장").disabled).toBe(false);
await toggleDraft();
- expect(dialog.textContent).not.toContain("저장을 누르면");
+ expect(opened.textContent).not.toContain("저장을 누르면");
expect(toggle().getAttribute("aria-checked")).toBe("true");
expect(document.cookie).not.toContain("uttae_analytics_consent");
const first = button("쿠키 설정 닫기");
const last = button("저장");
last.focus();
- await act(async () =>
- last.dispatchEvent(
- new KeyboardEvent("keydown", {
- key: "Tab",
- bubbles: true,
- cancelable: true,
- }),
- ),
- );
+ await pressTab();
expect(document.activeElement).toBe(first);
- await act(async () =>
- first.dispatchEvent(
- new KeyboardEvent("keydown", {
- key: "Tab",
- shiftKey: true,
- bubbles: true,
- cancelable: true,
- }),
- ),
- );
+ await pressTab(true);
expect(document.activeElement).toBe(last);
await act(async () =>
last.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
),
);
- expect(document.querySelector('dialog[open]')).toBeNull();
+ await settle();
+ expect(dialog()).toBeNull();
expect(document.activeElement).toBe(button("쿠키 설정"));
- expect(host.hasAttribute("inert")).toBe(false);
+ expect(host.hasAttribute("aria-hidden")).toBe(false);
expect(
window.location.pathname + window.location.search + window.location.hash,
).toBe("/home?view=all#footer");
@@ -150,17 +146,68 @@ describe("cookie settings modal", () => {
expect(toggle().getAttribute("aria-checked")).toBe("false");
await toggleDraft();
await click("쿠키 설정 닫기");
+ expect(dialog()).toBeNull();
expect(document.cookie).toContain("v1:denied");
await click("쿠키 설정");
expect(toggle().getAttribute("aria-checked")).toBe("false");
await toggleDraft();
+ // 딤(배경)을 누르면 닫힌다
await act(async () =>
- (document.querySelector('[aria-label="쿠키 설정 배경 닫기"]') as HTMLElement).click(),
+ document.querySelector('[data-slot="dialog-overlay"]')!.click(),
);
- expect(document.querySelector('dialog[open]')).toBeNull();
+ await settle();
+ expect(dialog()).toBeNull();
expect(document.cookie).toContain("v1:denied");
});
+ it("closes with 취소 and discards the edited choice without saving it", async () => {
+ document.cookie = "uttae_analytics_consent=v1:denied; Path=/";
+ await click("쿠키 설정");
+ await toggleDraft();
+ expect(toggle().getAttribute("aria-checked")).toBe("true");
+ await click("취소");
+ expect(dialog()).toBeNull();
+ expect(document.activeElement).toBe(button("쿠키 설정"));
+ // 저장(grantAnalyticsConsent)이 실행됐다면 쿠키·스토어가 granted로 바뀐다
+ expect(document.cookie).toContain("uttae_analytics_consent=v1:denied");
+ expect(document.cookie).not.toContain("v1:granted");
+ expect(analyticsConsentStore.getSnapshot()).toBe("denied");
+ await click("쿠키 설정");
+ expect(toggle().getAttribute("aria-checked")).toBe("false");
+ expect(resultMessage().textContent).toBe("");
+ });
+
+ it("closes untouched pending with 취소 without recording a denial", async () => {
+ await click("쿠키 설정");
+ await click("취소");
+ expect(dialog()).toBeNull();
+ // 저장(denyAnalyticsConsent)이 실행됐다면 GA 철회가 불리고 denied 쿠키가 남는다
+ expect(revokeGoogleAnalyticsConsent).not.toHaveBeenCalled();
+ expect(document.cookie).not.toContain("uttae_analytics_consent");
+ expect(analyticsConsentStore.getSnapshot()).toBe("pending");
+ });
+
+ it("shows the save result in a live region below the buttons and keeps the dialog open", async () => {
+ await click("쿠키 설정");
+ const live = resultMessage();
+ expect(live.textContent).toBe("");
+ for (const label of ["취소", "저장"]) {
+ expect(
+ button(label).compareDocumentPosition(live) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ }
+ await click("저장");
+ expect(dialog()).not.toBeNull();
+ expect(resultMessage().textContent).toBe("분석 쿠키를 거부했습니다.");
+ // 선택을 바꾸면 지난 결과 문장은 지운다
+ await toggleDraft();
+ expect(resultMessage().textContent).toBe("");
+ await click("저장");
+ expect(dialog()).not.toBeNull();
+ expect(resultMessage().textContent).toBe("분석 쿠키를 허용했습니다.");
+ });
+
it("persists save through reopen/remount and withdraws via existing consent actions", async () => {
await click("쿠키 설정");
await toggleDraft();
@@ -173,9 +220,7 @@ describe("cookie settings modal", () => {
write: writeAnalyticsConsentCookie,
}).getSnapshot(),
).toBe("granted");
- expect(document.querySelector('[aria-live="polite"]')?.textContent).toBe(
- "분석 쿠키를 허용했습니다.",
- );
+ expect(resultMessage().textContent).toBe("분석 쿠키를 허용했습니다.");
await click("쿠키 설정 닫기");
await click("쿠키 설정");
expect(toggle().getAttribute("aria-checked")).toBe("true");
@@ -198,8 +243,8 @@ it("keeps the dialog open and reports a blocked cookie write accurately", async
await toggleDraft();
vi.spyOn(document, "cookie", "set").mockImplementation(() => {});
await click("저장");
- expect(document.querySelector('dialog[open]')).not.toBeNull();
- expect(document.querySelector('[aria-live="polite"]')?.textContent).toContain(
+ expect(dialog()).not.toBeNull();
+ expect(resultMessage().textContent).toContain(
"브라우저에 저장하지 못했습니다",
);
expect(toggle().getAttribute("aria-checked")).toBe("true");
@@ -226,9 +271,7 @@ describe("analytics allow switch", () => {
expect(
descriptionIds.map((id) => document.getElementById(id)!.textContent).join(" "),
).toContain("Google Analytics");
- expect(document.querySelector('dialog[open]')!.textContent).not.toContain(
- "저장을 누르면",
- );
+ expect(dialog()!.textContent).not.toContain("저장을 누르면");
expect(analyticsConsentStore.getSnapshot()).toBe("pending");
await act(async () => toggle!.click());
expect(toggle!.getAttribute("aria-checked")).toBe("true");
@@ -270,7 +313,8 @@ it("retains the draft through disclosure and keyboard focus, without applying it
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
),
);
- expect(document.querySelector('dialog[open]')).toBeNull();
+ await settle();
+ expect(dialog()).toBeNull();
expect(document.activeElement).toBe(button("쿠키 설정"));
expect(analyticsConsentStore.getSnapshot()).toBe("granted");
await click("쿠키 설정");
@@ -278,13 +322,14 @@ it("retains the draft through disclosure and keyboard focus, without applying it
});
it("cancels untouched pending with X and restores body scroll", async () => {
- const overflow = document.body.style.overflow;
+ expect(isPageScrollLocked()).toBe(false);
await click("쿠키 설정");
- expect(document.body.style.overflow).toBe("hidden");
+ expect(isPageScrollLocked()).toBe(true);
await click("쿠키 설정 닫기");
+ expect(dialog()).toBeNull();
expect(analyticsConsentStore.getSnapshot()).toBe("pending");
expect(document.cookie).not.toContain("uttae_analytics_consent");
- expect(document.body.style.overflow).toBe(overflow);
+ expect(isPageScrollLocked()).toBe(false);
await click("쿠키 설정");
expect(toggle().getAttribute("aria-checked")).toBe("false");
});
diff --git a/src/components/analytics/CookieSettingsProvider.tsx b/src/components/analytics/CookieSettingsProvider.tsx
index b7967a56..258d9bde 100644
--- a/src/components/analytics/CookieSettingsProvider.tsx
+++ b/src/components/analytics/CookieSettingsProvider.tsx
@@ -5,28 +5,67 @@ import {
useEffect,
useCallback,
useContext,
+ useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import { useSearchParams } from "next/navigation";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { COOKIE_SETTINGS_QUERY_KEY } from "@/lib/analytics/paths";
import { AnalyticsConsentSettings } from "./AnalyticsConsentSettings";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
const OpenCookieSettingsContext = createContext<() => void>(() => {});
+/**
+ * 닫힐 때 포커스를 돌려줄 곳. 동의를 저장하면 배너(연 트리거)가 사라지므로 Base UI 기본(연 요소)만으로는 포커스가 사라진다.
+ * 연 요소 → 남은 쿠키 설정 트리거(푸터 등) → 앱 main 순서로 고른다(옛 SettingsDialog 규칙).
+ * main은 탭 순서에 없어 Base UI에 넘기면 그 안의 첫 tabbable로 가므로, 잠시 tabindex를 주고 직접 포커스한 뒤 false(Base UI는 옮기지 않음)를 돌려준다
+ */
+function getCookieSettingsReturnFocus(opener: Element | null): HTMLElement | false {
+ if (opener instanceof HTMLElement && opener !== document.body && opener.isConnected) return opener;
+ const fallback = document.querySelector("[data-cookie-settings-trigger]");
+ if (fallback) return fallback;
+ const main = document.querySelector("main, [role=main]");
+ if (!main) return false;
+ const previousTabIndex = main.getAttribute("tabindex");
+ main.setAttribute("tabindex", "-1");
+ main.focus({ preventScroll: true });
+ if (previousTabIndex === null) main.removeAttribute("tabindex");
+ else main.setAttribute("tabindex", previousTabIndex);
+ return false;
+}
+
export function CookieSettingsProvider({ children }: Readonly<{ children: ReactNode }>) {
const [open, setOpen] = useState(false);
- const openSettings = useCallback(() => setOpen(true), []);
- const closeSettings = useCallback(() => setOpen(false), []);
+ // 연 순간 포커스가 있던 요소. 버튼은 자기에게 포커스한 뒤 열고, 레거시 URL 진입은 body다
+ const openerRef = useRef(null);
+ const openSettings = useCallback(() => {
+ openerRef.current = document.activeElement;
+ setOpen(true);
+ }, []);
return (
{children}
+ {/* 열 때마다 저장된 선택을 다시 읽도록(AnalyticsConsentSettings draft) 닫으면 언마운트한다 */}
{open && (
-
-
-
+ {
+ if (!nextOpen) setOpen(false);
+ }}
+ >
+ getCookieSettingsReturnFocus(openerRef.current)}
+ >
+
+ 쿠키 설정
+
+ setOpen(false)} />
+
+
)}
);
diff --git a/src/components/auth/AuthFlowSpinner.tsx b/src/components/auth/AuthFlowSpinner.tsx
index 895fd817..021c4e84 100644
--- a/src/components/auth/AuthFlowSpinner.tsx
+++ b/src/components/auth/AuthFlowSpinner.tsx
@@ -1,8 +1,10 @@
+import { Spinner } from "@/components/ui/spinner";
+
/** 로그인·OAuth 콜백 등 전체 화면 로딩 표시 (배경·스피너 동일). */
export function AuthFlowSpinner() {
return (
);
}
diff --git a/src/components/chat/ChatInputBar.tsx b/src/components/chat/ChatInputBar.tsx
index 93f964de..22553e57 100644
--- a/src/components/chat/ChatInputBar.tsx
+++ b/src/components/chat/ChatInputBar.tsx
@@ -33,10 +33,13 @@ function mentionSuffixMatchesAi(valueFromAt: string): boolean {
return t.length === 0 || "ai".startsWith(t);
}
-/** 고정 인풋바 높이 — 텍스트 2줄(line-height 1.5) + 버튼 행 */
-const CHAT_INPUT_BAR_HEIGHT = {
- default: 100,
- minimized: 110,
+/**
+ * 고정 인풋바 높이 = 위 테두리 1 + 버튼 행 43 + 입력 영역 여백 20(바깥 위 4·아래 8, 입력칸 위아래 4) + 글자 줄.
+ * PC는 3줄(14px 글자, 행간 20 × 3 = 60)이라 124, 모바일 기기는 지금 높이 100, 작은 패널(12px 글자)은 110(약 3줄)
+ */
+const CHAT_INPUT_BAR_HEIGHT_CLASS = {
+ default: "h-[124px] mobile:h-[100px]",
+ minimized: "h-[110px]",
} as const;
function scrollInputToCaret(el: HTMLTextAreaElement) {
@@ -270,12 +273,10 @@ export function ChatInputBar({
return (
- {showImage ? (
- // eslint-disable-next-line @next/next/no-img-element -- 멤버 프로필 URL 가변
-
setImgFailed(true)}
- />
- ) : null}
- {showPlaceholderOverlay ? (
-
-
-
- ) : null}
-
- );
-}
diff --git a/src/components/chat/messages/ChatMessageGroup.tsx b/src/components/chat/messages/ChatMessageGroup.tsx
index 511d4a0f..a5f83732 100644
--- a/src/components/chat/messages/ChatMessageGroup.tsx
+++ b/src/components/chat/messages/ChatMessageGroup.tsx
@@ -3,11 +3,12 @@ import { ChatMarkdownContent } from "@/components/chat/ChatMarkdownContent";
import type { ChatMarkdownVariant } from "@/components/chat/chat-markdown-components";
import { getChatPanelLook } from "@/components/chat/chat-panel-look";
import { AiResponseBubble } from "@/components/chat/messages/AiResponseBubble";
-import { ChatMemberAvatarRing } from "@/components/chat/messages/ChatMemberAvatarRing";
+import { ChatSenderAvatar } from "@/components/chat/messages/ChatSenderAvatar";
import type { ChatMessageTextTypography } from "@/components/chat/chat-typography";
+import { Avatar } from "@/components/ui/avatar";
+import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import type { AiRequestStatus, ChatMessage } from "@/types/chat";
-import { Loader2 } from "lucide-react";
function labelForAiStatus(status: AiRequestStatus): string {
const labels: Record
= {
@@ -64,8 +65,9 @@ function AiRequestMetaRow({
{ar.aiStatus === "PROCESSING" ? (
{labelForAiStatus(ar.aiStatus)}
-
@@ -152,13 +154,7 @@ export function OtherMessageGroup({
return (
-
+
{first.sender}
@@ -267,18 +263,15 @@ export function AiMessageGroup({
return (
-
+ {/* 아래에 보이는 'WOORI' 라벨이 이름을 알리므로 그림은 장식으로 숨긴다 */}
+
{/* eslint-disable-next-line @next/next/no-img-element -- public 정적 SVG(내장 래스터) */}
-
+
WOORI
diff --git a/src/components/chat/messages/ChatMessageList.tsx b/src/components/chat/messages/ChatMessageList.tsx
index 1bbeebe4..c37ea9ec 100644
--- a/src/components/chat/messages/ChatMessageList.tsx
+++ b/src/components/chat/messages/ChatMessageList.tsx
@@ -1,6 +1,6 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { Spinner } from "@/components/ui/spinner";
import {
useCallback,
@@ -458,7 +458,7 @@ export function ChatMessageList({
isMinimized && "mb-1.5 min-h-4 text-body-xs-regular",
)}
>
-
+
) : null}
{readMarkerMessageId != null &&
@@ -526,7 +526,7 @@ export function ChatMessageList({
isMinimized && "mt-1.5 min-h-4 text-body-xs-regular",
)}
>
-
+
) : null}
, "size" | "ring"> & {
+ message: Pick
;
+};
+
+/** 채팅 보낸 사람의 사진 자리. 방을 나간 사람은 사진이 있어도 사람 아이콘을 보인다 */
+export function ChatSenderAvatar({ message, ...avatarProps }: Readonly) {
+ return (
+
+ );
+}
diff --git a/src/components/chat/messages/NewMessagePreviewButton.tsx b/src/components/chat/messages/NewMessagePreviewButton.tsx
index 9aac7ce9..fa468e8b 100644
--- a/src/components/chat/messages/NewMessagePreviewButton.tsx
+++ b/src/components/chat/messages/NewMessagePreviewButton.tsx
@@ -1,8 +1,8 @@
"use client";
+import { ChatSenderAvatar } from "@/components/chat/messages/ChatSenderAvatar";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/types/chat";
-import { ChatMemberAvatarRing } from "./ChatMemberAvatarRing";
export const PLACE_SHARE_PREVIEW_TEXT = "장소를 공유했습니다.";
@@ -36,16 +36,7 @@ export function NewMessagePreviewButton({
isMinimized && "gap-1.5 py-1 pl-1 pr-3",
)}
>
-
+
-
+
diff --git a/src/components/date-picker/DatePickerLayer.tsx b/src/components/date-picker/DatePickerLayer.tsx
index a90889b3..452b23cd 100644
--- a/src/components/date-picker/DatePickerLayer.tsx
+++ b/src/components/date-picker/DatePickerLayer.tsx
@@ -1,19 +1,28 @@
"use client";
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
+import { Dialog } from "@/components/ui/dialog";
+import { Popover, PopoverContent } from "@/components/ui/popover";
+
import { DATE_PICKER_WIDTH } from "./DatePicker";
export type DatePickerPresentation = "anchored" | "popup";
/**
- * 달력 표시 방식 — `anchored`는 `relative` 입력 칸 아래에 붙고, `popup`은 화면 가운데에 띄운다.
+ * 달력 표시 방식 — `anchored`는 누른 입력 칸 아래에 붙는 Popover, `popup`은 화면 가운데 Dialog다.
* 모바일과 스크롤되는 모달 안에서는 `popup`을 쓴다 (Figma Date Picker Usage).
* `anchored`는 입력 칸 폭에 맞춰 달력 전체를 같은 비율로 줄이거나 늘린다.
* `popup`은 가로 최소 300px이고, 화면보다 높으면(6주짜리 달·가로 모드) 달력 안에서 세로로 스크롤한다.
+ * 닫힘은 둘 다 Esc = 취소(`onEscape`), 바깥 클릭·키보드로 벗어남 = 확정(`onDismiss`)이다.
+ * 열고 닫힐 때 포커스는 Base UI에 맡기지 않는다: 열면 DatePicker가 날짜 칸으로 옮기고, 닫히면 아래 effect가 연 칸으로 돌려준다.
+ * 열린 동안만 마운트한다(닫힘 애니메이션 없음, 지금과 같음).
*/
export function DatePickerLayer({
presentation,
+ anchorRef,
boundaryRef,
label,
onDismiss,
@@ -21,7 +30,9 @@ export function DatePickerLayer({
children,
}: Readonly<{
presentation: DatePickerPresentation;
- /** `anchored`에서 바깥 클릭 판정 범위 — 입력 칸과 레이어를 함께 감싼 요소 */
+ /** `anchored`에서 달력을 붙일 입력 칸 — 달력 폭·비율의 기준 */
+ anchorRef?: RefObject;
+ /** `anchored`에서 바깥 클릭·포커스 이탈 판정 범위 — 입력 칸 두 개를 함께 감싼 요소 */
boundaryRef?: RefObject;
label: string;
/** 바깥 클릭·키보드로 레이어를 벗어날 때 */
@@ -30,10 +41,6 @@ export function DatePickerLayer({
children: ReactNode;
}>) {
const dialogRef = useRef(null);
- const onDismissRef = useRef(onDismiss);
- useEffect(() => {
- onDismissRef.current = onDismiss;
- });
// 자식(DatePicker)이 날짜 칸으로 포커스를 옮기기 전, 렌더 시점의 포커스가 레이어를 연 칸이다
const [opener] = useState(() => document.activeElement);
@@ -49,82 +56,90 @@ export function DatePickerLayer({
};
}, [opener]);
- // 첫 페인트 전에 입력 칸 폭을 재서 달력 비율을 맞춘다
+ // 첫 페인트 전에 입력 칸 폭을 재서 달력 비율을 맞춘다. 달력(Popover bare)은 이 칸과 같은 폭이다
useLayoutEffect(() => {
- const dialog = dialogRef.current;
- if (presentation !== "anchored" || !dialog) return;
- const fit = () => setZoom(dialog.clientWidth / DATE_PICKER_WIDTH);
+ const anchor = anchorRef?.current;
+ if (presentation !== "anchored" || !anchor) return;
+ const fit = () => setZoom(anchor.clientWidth / DATE_PICKER_WIDTH);
fit();
const observer = new ResizeObserver(fit);
- observer.observe(dialog);
+ observer.observe(anchor);
return () => observer.disconnect();
- }, [presentation]);
-
- useEffect(() => {
- if (presentation !== "anchored") return;
- function handlePointerDown(event: PointerEvent) {
- if (!boundaryRef?.current?.contains(event.target as Node)) onDismissRef.current();
- }
- document.addEventListener("pointerdown", handlePointerDown);
- return () => document.removeEventListener("pointerdown", handlePointerDown);
- }, [presentation, boundaryRef]);
-
- const dialog = (
- {
- if (event.key === "Escape") {
- // 바깥 모달(SettingsDialog)의 Escape 처리까지 닫히지 않도록 막는다
- event.preventDefault();
- event.stopPropagation();
- onEscape();
- return;
- }
- if (event.key !== "Tab" || presentation !== "popup") return;
- const controls = [
- ...event.currentTarget.querySelectorAll
('button:not(:disabled), [tabindex="0"]'),
- ].filter((node) => node.tabIndex >= 0);
- const first = controls[0];
- const last = controls.at(-1);
- if (event.shiftKey && document.activeElement === first) {
- event.preventDefault();
- last?.focus();
- } else if (!event.shiftKey && document.activeElement === last) {
- event.preventDefault();
- first?.focus();
- }
- }}
- onBlur={(event) => {
- // 키보드로 레이어 밖 요소에 포커스가 가면 닫는다 (마우스는 바깥 클릭 처리)
- if (presentation !== "anchored") return;
- const next = event.relatedTarget;
- if (next && !boundaryRef?.current?.contains(next)) onDismiss();
- }}
- className={
- presentation === "anchored"
- ? "absolute inset-x-0 top-full z-30 mt-3"
- : "max-h-full w-full min-w-[300px] max-w-[353px] overflow-y-auto overscroll-contain rounded-[12px]"
- }
- >
- {presentation === "anchored" ? {children}
: children}
-
- );
+ }, [presentation, anchorRef]);
- if (presentation === "anchored") return dialog;
+ if (presentation === "anchored") {
+ return (
+ // 포커스는 달력 안에서만 돈다(WAI-ARIA 날짜 선택 대화상자). 달력이 body로 포털돼 Tab으로 빠져나가면 다음 칸이 아니라 페이지 맨 앞으로 갔다.
+ // 바깥 클릭은 그대로 뒤로 전달되고(확정), Esc는 취소다
+ {
+ if (open) return;
+ if (details.reason === "escape-key" || details.reason === "close-press") {
+ onEscape();
+ return;
+ }
+ // 두 칸 묶음 안(다른 날짜 칸)을 누르거나 그리로 포커스가 가면 닫지 않는다. 그 칸의 onClick이 달력을 다시 붙인다
+ const next = details.reason === "focus-out" ? document.activeElement : details.event.target;
+ if (next instanceof Node && boundaryRef?.current?.contains(next)) {
+ details.cancel();
+ return;
+ }
+ onDismiss();
+ }}
+ >
+
+
+ {children}
+
+ {/* Base UI는 닫기 부품이 있어야 포커스를 가둔다(PopoverPopup modal && hasClosePart). 화면 읽기 프로그램 터치 사용자가 빠져나갈 길이기도 하다.
+ 보이지 않고 Tab 순서에도 넣지 않는다(키보드는 Esc·달력의 취소). 누르면 Esc처럼 취소다 */}
+
+ 날짜 선택 닫기
+
+
+
+ );
+ }
return (
- {
- if (event.target === event.currentTarget) onDismiss();
+ {
+ if (open) return;
+ if (details.reason === "escape-key") onEscape();
+ else onDismiss(); // 딤(바깥) 클릭 = 확정
}}
>
- {dialog}
-
+
+ {/* 표면은 DatePicker 카드가 그려 ui DialogContent(Modal 표면·여백) 대신 Base UI 파트로 지금 팝업 모양을 그대로 둔다.
+ 딤은 ui Dialog와 같은 값이고, 바깥 대화상자(여행 정보 수정) 위에서도 덮도록 forceRender로 늘 그린다 */}
+
+ {/* 화면 폭이 300px+여백보다 좁으면 가운데 정렬이 양옆을 자르지 않게 왼쪽부터 놓고 가로로 스크롤한다 */}
+
+
+ {children}
+
+
+
+
);
}
diff --git a/src/components/date-picker/DateRangeField.tsx b/src/components/date-picker/DateRangeField.tsx
index c97c0d43..c57864e2 100644
--- a/src/components/date-picker/DateRangeField.tsx
+++ b/src/components/date-picker/DateRangeField.tsx
@@ -18,7 +18,7 @@ const FIELDS: { edge: RangeEdge; label: string }[] = [
/**
* Figma Date Picker Usage `Input Field와 함께 쓰일 때` — `Text Field Emphasis` 두 칸과 기간 달력.
- * 웹은 누른 칸 아래에 그 칸 폭으로 붙이고, 모바일·`popupOnly`는 팝업으로 띄운다.
+ * 웹은 누른 칸 아래에 그 칸 폭으로 붙이고(Popover), 모바일·`popupOnly`는 팝업(Dialog)으로 띄운다.
*/
export function DateRangeField({
idPrefix,
@@ -38,6 +38,7 @@ export function DateRangeField({
onChange: (next: DateRange) => void;
}>) {
const containerRef = useRef(null);
+ const fieldRefs = { start: useRef(null), end: useRef(null) };
const { isMobileDevice } = useMobileView();
const draft = useDateRangeDraft(value, onChange);
const presentation = popupOnly || isMobileDevice ? "popup" : "anchored";
@@ -45,6 +46,7 @@ export function DateRangeField({
const picker = draft.isOpen ? (
+
{readOnly ? (
{label}
@@ -105,6 +107,7 @@ export function DateRangeField({
{text}
)}
+ {/* 달력은 포털로 뜨지만 React 위치는 그 칸 안에 둔다. 칸을 바꾸면 달력이 새로 열리고, Tab 순서가 칸 바로 뒤로 이어진다 */}
{presentation === "anchored" && draft.anchorEdge === edge ? picker : null}
);
diff --git a/src/components/expenses/ExpenseAmountInput.tsx b/src/components/expenses/ExpenseAmountInput.tsx
index 13aa960a..5341600b 100644
--- a/src/components/expenses/ExpenseAmountInput.tsx
+++ b/src/components/expenses/ExpenseAmountInput.tsx
@@ -1,8 +1,9 @@
"use client";
import { useLayoutEffect, useRef, type ComponentProps } from "react";
+import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
-import { expenseInputClass, formatExpenseAmount } from "./ExpenseViews";
+import { formatExpenseAmount } from "./ExpenseViews";
export function ExpenseAmountInput({
value,
@@ -35,14 +36,11 @@ export function ExpenseAmountInput({
caret.current = null;
}, [formatted]);
return (
-
({ mobile: true, busy: false }));
vi.mock("@/contexts/MobileViewContext", () => ({
useMobileView: () => ({ isMobileDevice: state.mobile }),
@@ -26,82 +28,149 @@ let host: HTMLDivElement;
const close = vi.fn();
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- HTMLDialogElement.prototype.showModal = function () { this.open = true; };
- HTMLDialogElement.prototype.close = function () { this.open = false; };
state.mobile = true; state.busy = false; close.mockReset();
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals();
});
-async function pointer(target: Element, type: string, button = 0) {
- await act(async () => target.dispatchEvent(new MouseEvent(type, { bubbles: true, button })));
+
+async function settle() {
+ // Base UI는 열림·닫힘을 microtask·requestAnimationFrame·타이머로 반영한다
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+}
+// 대화상자는 document.body로 포털된다. 사용자가 누르는 딤은 PC에서는 딤 요소, 시트에서는 딤 위를 덮는 시트 바깥 빈 곳이다
+const surface = () => document.querySelector
('[role="dialog"]')!;
+const dim = () => document.querySelector(
+ state.mobile ? '[data-slot="drawer-viewport"]' : '[data-slot="dialog-overlay"]',
+)!;
+const buttonNamed = (label: string) => document.querySelector(`[aria-label="${label}"]`)!;
+function pointer(target: Element, type: string) {
+ target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, detail: type === "click" ? 1 : 0 }));
+}
+/** 마우스 왼쪽 버튼으로 눌렀다 뗀다. 누른 곳과 뗀 곳이 다르면 click은 둘의 공통 조상이 아니라 뗀 곳에서 받는다고 단순화한다 */
+async function press(start: Element, end: Element = start) {
+ await act(async () => {
+ pointer(start, "pointerdown");
+ pointer(start, "mousedown");
+ pointer(end, "pointerup");
+ pointer(end, "mouseup");
+ pointer(end, "click");
+ });
+ await settle();
+}
+async function pressEscape() {
+ await act(async () => {
+ (document.activeElement ?? document.body).dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
+ );
+ });
+ await settle();
}
-for (const kind of ["editor", "list"] as const) {
- describe(`${kind} backdrop dismissal`, () => {
+const kinds = {
+ editor: {
+ closeLabel: "닫기",
+ render: () => ,
+ },
+ list: {
+ closeLabel: "비용 목록 닫기",
+ render: () => (
+ {}}
+ onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close} />
+ ),
+ },
+} as const;
+
+for (const [kind, { closeLabel, render }] of Object.entries(kinds)) {
+ describe(`${kind} dismissal`, () => {
async function mount() {
- await act(async () => root.render(kind === "editor"
- ?
- : {}}
- onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close} />));
- return host.querySelector("dialog")!;
+ await act(async () => root.render(render()));
+ await settle();
+ expect(surface()).not.toBeNull();
}
- it("dismisses once when a mobile backdrop press is released", async () => {
- const dialog = await mount();
- await pointer(dialog, "pointerdown");
- expect(close).not.toHaveBeenCalled();
- await pointer(dialog, "pointerup");
- expect(close).toHaveBeenCalledOnce();
- await pointer(dialog, "pointerup");
+ it("closes the mobile sheet once when its dim is pressed", async () => {
+ await mount();
+ expect(document.querySelector('[data-slot="drawer-content"]')).not.toBeNull();
+ await press(dim());
expect(close).toHaveBeenCalledOnce();
});
- it("keeps content interactions and gestures ending outside open", async () => {
- const dialog = await mount();
- const content = host.querySelector("h2")!;
- await pointer(content, "pointerdown");
- await pointer(dialog, "pointerup");
- await pointer(dialog, "pointerdown");
- await pointer(content, "pointerup");
- await pointer(content, "pointerdown");
- await pointer(content, "pointerup");
- expect(close).not.toHaveBeenCalled();
- });
- it("discards a cancelled backdrop gesture", async () => {
- const dialog = await mount();
- await pointer(dialog, "pointerdown");
- await pointer(dialog, "pointercancel");
- await pointer(dialog, "pointerup");
- expect(close).not.toHaveBeenCalled();
- });
- it("ignores secondary-button backdrop presses", async () => {
- const dialog = await mount();
- await pointer(dialog, "pointerdown", 2);
- await pointer(dialog, "pointerup", 2);
+ it("keeps the mobile sheet open for presses inside it, including a press dragged out onto the dim", async () => {
+ await mount();
+ const title = surface().querySelector("h2")!;
+ await press(title);
+ await press(title, dim());
expect(close).not.toHaveBeenCalled();
+ // 그 뒤 딤을 제대로 누르면 닫힌다
+ await press(dim());
+ expect(close).toHaveBeenCalledOnce();
});
- it("keeps desktop backdrop presses open", async () => {
+ it("keeps the desktop dialog open when its dim is pressed", async () => {
state.mobile = false;
- const dialog = await mount();
- await pointer(dialog, "pointerdown"); await pointer(dialog, "pointerup");
+ await mount();
+ expect(document.querySelector('[data-slot="dialog-content"]')).not.toBeNull();
+ await press(dim());
expect(close).not.toHaveBeenCalled();
+ expect(surface()).not.toBeNull();
+ // 막힌 것은 딤만이다
+ await pressEscape();
+ expect(close).toHaveBeenCalledOnce();
+ });
+ it.each([true, false])("closes by Escape and the close button when idle (mobile %s)", async (mobile) => {
+ state.mobile = mobile;
+ await mount();
+ await pressEscape();
+ expect(close).toHaveBeenCalledOnce();
+ await act(async () => buttonNamed(closeLabel).click());
+ await settle();
+ expect(close).toHaveBeenCalledTimes(2);
});
- it("blocks backdrop and Escape dismissal while busy", async () => {
+ it.each([true, false])("never closes by dim, Escape, close or cancel while busy (mobile %s)", async (mobile) => {
+ state.mobile = mobile;
state.busy = true;
- const dialog = await mount();
- await pointer(dialog, "pointerdown"); await pointer(dialog, "pointerup");
- const cancel = new Event("cancel", { cancelable: true });
- await act(async () => dialog.dispatchEvent(cancel));
- expect(cancel.defaultPrevented).toBe(true);
+ await mount();
+ await press(dim());
+ await pressEscape();
+ const closeButton = buttonNamed(closeLabel);
+ expect(closeButton.disabled).toBe(true);
+ await act(async () => closeButton.click());
+ const cancel = [...surface().querySelectorAll("button")].find((button) => button.textContent === "취소");
+ if (kind === "editor") {
+ expect(cancel!.disabled).toBe(true);
+ await act(async () => cancel!.click());
+ }
+ await settle();
expect(close).not.toHaveBeenCalled();
- });
- it("preserves Escape dismissal without a pointer gesture", async () => {
- const dialog = await mount();
- const cancel = new Event("cancel", { cancelable: true });
- await act(async () => dialog.dispatchEvent(cancel));
- expect(cancel.defaultPrevented).toBe(true);
+ expect(surface()).not.toBeNull();
+ // 저장·삭제가 끝나면 다시 닫힌다
+ state.busy = false;
+ await act(async () => root.render(render()));
+ await pressEscape();
expect(close).toHaveBeenCalledOnce();
});
});
}
+
+describe("settlement dialog dismissal", () => {
+ async function mount() {
+ await act(async () => root.render(
+ 정산 내용
,
+ ));
+ await settle();
+ }
+ // 읽기 전용이라 PC·시트 모두 딤으로 닫는다(옛 ExpenseDialog와 같음)
+ it.each([true, false])("closes by dim, Escape and the close button (mobile %s)", async (mobile) => {
+ state.mobile = mobile;
+ await mount();
+ await press(surface().querySelector("p")!);
+ expect(close).not.toHaveBeenCalled();
+ await press(dim());
+ expect(close).toHaveBeenCalledTimes(1);
+ await pressEscape();
+ expect(close).toHaveBeenCalledTimes(2);
+ await act(async () => buttonNamed("정산 요약 닫기").click());
+ await settle();
+ expect(close).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/src/components/expenses/ExpenseBudget.test.tsx b/src/components/expenses/ExpenseBudget.test.tsx
index 25d4bc21..61870355 100644
--- a/src/components/expenses/ExpenseBudget.test.tsx
+++ b/src/components/expenses/ExpenseBudget.test.tsx
@@ -168,7 +168,11 @@ it.each([
await submit();
expect(mocks.save).not.toHaveBeenCalled();
expect(input().value).toBe(value === "1000000000000000" ? "1,000,000,000,000,000" : value);
- expect(document.querySelector('[role="alert"]')).not.toBeNull();
+ // 입력 오류는 칸의 오류 상태와 칸 아래 FieldError로 보인다
+ expect(input().getAttribute("aria-invalid")).toBe("true");
+ const error = document.getElementById(input().getAttribute("aria-describedby")!);
+ expect(error?.getAttribute("role")).toBe("alert");
+ expect(error?.textContent).toContain("1~15자리 숫자");
});
it("locks duplicate submit and close while pending, and preserves draft on failure", async () => {
let reject!: (e: Error) => void;
@@ -182,7 +186,14 @@ it("locks duplicate submit and close while pending, and preserves draft on failu
await type("456");
await submit();
await submit();
+ // 저장 중에는 저장 버튼이 progress로 바뀌고 접근성 이름은 '저장 중'이다
+ const saving = document.querySelector('button[type="submit"]')!;
+ expect(saving.getAttribute("aria-busy")).toBe("true");
+ expect(document.getElementById(saving.getAttribute("aria-labelledby")!)?.textContent).toBe("저장 중");
await click("예산 수정 닫기");
+ await act(async () => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
expect(mocks.save).toHaveBeenCalledTimes(1);
expect(mocks.close).not.toHaveBeenCalled();
await act(async () => reject(new Error("offline")));
@@ -245,7 +256,9 @@ it("summary opens shared modal for unset or zero budget", async () => {
expect(text()).not.toContain("미설정");
expect([...host.querySelectorAll("h3")].find(h => h.textContent === "전체 예산")?.parentElement?.querySelector("p")?.textContent).toBe("—");
await click("예산 설정");
- expect(document.querySelector("dialog")).not.toBeNull();
+ const dialog = document.querySelector('[role="dialog"]');
+ expect(dialog?.hasAttribute("data-open")).toBe(true);
+ expect(dialog?.querySelector("h2")?.textContent).toBe("예산 설정");
expect(input().value).toBe("");
});
it("shows the whole-room total without duplicating page-level rate information", async () => {
diff --git a/src/components/expenses/ExpenseBudgetModal.tsx b/src/components/expenses/ExpenseBudgetModal.tsx
index 1859a872..532abcbd 100644
--- a/src/components/expenses/ExpenseBudgetModal.tsx
+++ b/src/components/expenses/ExpenseBudgetModal.tsx
@@ -1,22 +1,32 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { useForm } from "@tanstack/react-form";
+import { useId, useRef, useState, type FormEvent } from "react";
-import { useCallback, useId, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Field, FieldError, FieldLabel } from "@/components/ui/field";
+import { Spinner } from "@/components/ui/spinner";
import {
ExpenseApiError,
type ExpenseBudget,
} from "@/lib/api/rooms/expenses";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
-import {
- SettingsActionButton,
- SettingsActionButtonRow,
-} from "@/components/settings/SettingsActionButton";
import { ExpenseAmountInput } from "./ExpenseAmountInput";
import { useExpenseContext } from "./ExpenseProvider";
-import {
- expenseButtonClass,
- formatExpenseAmount,
-} from "./ExpenseViews";
+import { formatExpenseAmount } from "./ExpenseViews";
+
+function budgetError(value: string) {
+ return /^\d{1,15}$/.test(value)
+ ? undefined
+ : "예산은 1~15자리 숫자로 입력해 주세요. 음수·소수점·공백은 사용할 수 없어요.";
+}
export function ExpenseBudgetModal({
initial,
@@ -26,22 +36,48 @@ export function ExpenseBudgetModal({
onClose: () => void;
}>) {
const context = useExpenseContext();
+ // 연 순간의 예산으로 제목·칸을 정한다. 열린 동안 서버 값이 바뀌어도 칸을 다시 채우지 않는다
const [opened] = useState(initial);
- const [draft, setDraft] = useState(initial.budgetKrw ?? "");
const [version, setVersion] = useState(initial.version);
- const [error, setError] = useState("");
+ // 입력 검증 오류는 칸의 Error 상태(form.Field)로, 저장 실패(네트워크·충돌·서버 메시지)는 칸 밖 문장으로 그린다
+ const [saveError, setSaveError] = useState(null);
const [pending, setPending] = useState(false);
const [conflict, setConflict] = useState<{
latest?: ExpenseBudget;
failed?: boolean;
} | null>(null);
+ // 검증이 비동기로 끝나기 전 두 번째 제출과, 저장·재조회가 겹치는 것을 막는다
const lock = useRef(false);
+ const formId = useId();
const inputId = useId();
const errorId = useId();
- // Stable callback keeps the shared dialog's focus scope intact while typing.
- const close = useCallback(() => {
- if (!lock.current) onClose();
- }, [onClose]);
+ const title = opened.budgetKrw === null ? "예산 설정" : "예산 수정";
+
+ const form = useForm({
+ defaultValues: { budget: opened.budgetKrw ?? "" },
+ onSubmit: async ({ value }) => {
+ setPending(true);
+ setSaveError(null);
+ try {
+ await context.saveBudget({
+ budgetKrw: value.budget,
+ expectedVersion: version,
+ });
+ onClose();
+ } catch (e) {
+ setSaveError(e instanceof Error ? e.message : "예산 저장에 실패했어요.");
+ if (
+ e instanceof ExpenseApiError &&
+ e.status === 409 &&
+ e.code === "BUDGET_CONFLICT"
+ )
+ await recover();
+ } finally {
+ setPending(false);
+ }
+ },
+ });
+
async function recover() {
setConflict({});
try {
@@ -62,151 +98,149 @@ export function ExpenseBudgetModal({
setPending(false);
}
}
- async function save() {
- if (lock.current || context.budgetBusy || !context.canManage || conflict)
- return;
- if (!/^\d{1,15}$/.test(draft)) {
- setError(
- "예산은 1~15자리 숫자로 입력해 주세요. 음수·소수점·공백은 사용할 수 없어요.",
- );
- return;
- }
+ async function submit() {
lock.current = true;
- setPending(true);
- setError("");
try {
- await context.saveBudget({
- budgetKrw: draft,
- expectedVersion: version,
- });
- onClose();
- } catch (e) {
- setError(e instanceof Error ? e.message : "예산 저장에 실패했어요.");
- if (
- e instanceof ExpenseApiError &&
- e.status === 409 &&
- e.code === "BUDGET_CONFLICT"
- )
- await recover();
+ await form.handleSubmit();
} finally {
lock.current = false;
- setPending(false);
}
}
+ function handleFormSubmit(event: FormEvent) {
+ event.preventDefault();
+ // 라이브러리는 연타를 막지 않는다
+ if (lock.current || form.state.isSubmitting || context.budgetBusy || !context.canManage || conflict)
+ return;
+ void submit();
+ }
const busy = pending || context.budgetBusy;
return (
- {
+ if (!open) onClose();
+ }}
+ // 저장·최신 예산 재조회 중에는 Esc·딤·X로 닫지 않는다(지금 동작)
+ closeDisabled={pending}
>
-
+
+ }>
취소
-
-
+
- {busy ? "저장 중…" : "저장"}
-
-
-
-
+ 저장
+
+
+
+
);
}
diff --git a/src/components/expenses/ExpenseBudgetSummary.tsx b/src/components/expenses/ExpenseBudgetSummary.tsx
index fde3c966..2afda562 100644
--- a/src/components/expenses/ExpenseBudgetSummary.tsx
+++ b/src/components/expenses/ExpenseBudgetSummary.tsx
@@ -1,7 +1,7 @@
"use client";
import { ExpenseKrwAmount } from "./ExpenseKrw";
-import { useCallback, useState } from "react";
+import { useState } from "react";
import type { ExpenseBudget } from "@/lib/api/rooms/expenses";
import { useExpenseContext } from "./ExpenseProvider";
import { ExpenseBudgetModal } from "./ExpenseBudgetModal";
@@ -13,7 +13,6 @@ export function ExpenseBudgetSummary() {
const { budget, krwSummary, canManage, budgetBusy, syncStatus } =
useExpenseContext();
const [opened, setOpened] = useState(null);
- const close = useCallback(() => setOpened(null), []);
const reference = krwSummary.data;
const amount = budget.data?.budgetKrw;
const converted = reference?.convertedTotalKrw;
@@ -108,7 +107,7 @@ export function ExpenseBudgetSummary() {
: "—"}
- {opened && }
+ {opened && setOpened(null)} />}
);
}
diff --git a/src/components/expenses/ExpenseCategorySummary.tsx b/src/components/expenses/ExpenseCategorySummary.tsx
index c21a5a16..25082faf 100644
--- a/src/components/expenses/ExpenseCategorySummary.tsx
+++ b/src/components/expenses/ExpenseCategorySummary.tsx
@@ -1,5 +1,5 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { Spinner } from "@/components/ui/spinner";
import { ExpenseKrwAmount } from "./ExpenseKrw";
import { useId, useState } from "react";
import { ChevronDownIcon } from "@/assets/icons";
@@ -48,7 +48,7 @@ export function ExpenseCategorySummary() {
className={`${expanded ? "block" : "hidden"} @min-[800px]/expenses:block`}
>
{list.isPending && (
-
+
)}
{list.isSuccess && !rows.length && (
diff --git a/src/components/expenses/ExpenseCurrencyPicker.test.tsx b/src/components/expenses/ExpenseCurrencyPicker.test.tsx
index 66669319..4c45f443 100644
--- a/src/components/expenses/ExpenseCurrencyPicker.test.tsx
+++ b/src/components/expenses/ExpenseCurrencyPicker.test.tsx
@@ -1,95 +1,112 @@
-import { act, create, type ReactTestRenderer } from "react-test-renderer";
-import { afterEach, expect, it, vi } from "vitest";
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ExpenseCurrencyPicker } from "./ExpenseCurrencyPicker";
-let renderer: ReactTestRenderer;
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+let host: HTMLDivElement;
+let root: Root;
const onChange = vi.fn();
const currencies = ["USD", "KRW", "EUR", "JPY"].map((currency) => ({
currency,
fractionDigits: 2,
maximumAmount: "999999.99",
}));
-async function mount(disabled = false) {
+beforeEach(() => {
onChange.mockClear();
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+});
+async function mount({ value = "KRW", list = currencies, disabled = false } = {}) {
+ await act(async () =>
+ root.render( ),
+ );
+}
+// Base UI는 열고 닫을 때 microtask·timeout으로 포커스·목록을 옮긴다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+const trigger = () => document.querySelector('[role="combobox"][aria-haspopup="dialog"]')!;
+const search = () => document.querySelector('input[aria-label="통화 검색"]');
+const options = () => [...document.querySelectorAll('[role="option"]')];
+async function open() {
+ await act(async () => trigger().click());
+ await flush();
+}
+// 사용자가 친 글자로 보이도록 inputType을 준다. Base UI는 inputType이 없는 입력(자동 완성)에는 첫 결과를 강조하지 않는다
+async function type(value: string) {
+ const input = search()!;
await act(async () => {
- renderer = create(
- ,
- );
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
+ input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
});
+ await flush();
+}
+async function key(target: Element, key: string) {
+ await act(async () => {
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
+ });
+ await flush();
}
-afterEach(async () => {
- if (renderer) await act(async () => renderer.unmount());
-});
-const trigger = () => renderer.root.findByProps({ "aria-haspopup": "dialog" });
-const options = () => renderer.root.findAllByProps({ role: "option" });
-it("opens search only from the dropdown and searches Korean names or currency codes", async () => {
+it("opens search only from the trigger and searches Korean names or currency codes", async () => {
await mount();
- expect(renderer.root.findAllByType("input")).toHaveLength(0);
- await act(async () => trigger().props.onClick());
+ expect(trigger().textContent).toContain("KRW");
+ expect(search()).toBeNull();
+ await open();
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
+ expect(document.activeElement).toBe(search());
expect(options()).toHaveLength(4);
- const search = renderer.root.findByType("input");
- await act(async () => search.props.onChange({ target: { value: "달러" } }));
- expect(options()).toHaveLength(1);
- expect(JSON.stringify(renderer.toJSON())).toContain("USD");
- await act(async () => options()[0].props.onClick());
+ await type("달러");
+ expect(options().map((option) => option.textContent)).toEqual(["미국 달러USD"]);
+ await act(async () => options()[0].click());
+ await flush();
expect(onChange).toHaveBeenCalledWith("USD");
- expect(renderer.root.findAllByType("input")).toHaveLength(0);
- await act(async () => trigger().props.onClick());
- await act(async () =>
- renderer.root
- .findByType("input")
- .props.onChange({ target: { value: " eur " } }),
- );
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+ await open();
+ await type(" eur ");
expect(options()).toHaveLength(1);
- await act(async () => options()[0].props.onClick());
+ await act(async () => options()[0].click());
expect(onChange).toHaveBeenLastCalledWith("EUR");
});
it("does not allow unsupported currencies or change the selection on Escape", async () => {
await mount();
- await act(async () => trigger().props.onClick());
- await act(async () =>
- renderer.root
- .findByType("input")
- .props.onChange({ target: { value: "ZZZ" } }),
- );
+ await open();
+ await type("ZZZ");
expect(options()).toHaveLength(0);
- expect(JSON.stringify(renderer.toJSON())).toContain("검색 결과가 없어요");
- const preventDefault = vi.fn(),
- stopPropagation = vi.fn();
- await act(async () =>
- renderer.root
- .findByProps({ role: "dialog" })
- .props.onKeyDown({ key: "Escape", preventDefault, stopPropagation }),
- );
+ expect(document.querySelector('[role="dialog"]')!.textContent).toContain("검색 결과가 없어요.");
+ await key(search()!, "Escape");
expect(onChange).not.toHaveBeenCalled();
- expect(preventDefault).toHaveBeenCalled();
- expect(stopPropagation).toHaveBeenCalled();
- expect(renderer.root.findAllByType("input")).toHaveLength(0);
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+ expect(trigger().textContent).toContain("KRW");
});
-it("selects search results with the keyboard", async () => {
+it("selects the first search result with Enter", async () => {
await mount();
- await act(async () => trigger().props.onClick());
- await act(async () =>
- renderer.root
- .findByType("input")
- .props.onChange({ target: { value: "엔" } }),
- );
- await act(async () =>
- renderer.root
- .findByType("input")
- .props.onKeyDown({
- key: "Enter",
- preventDefault() {},
- nativeEvent: { isComposing: false },
- }),
- );
+ await open();
+ await type("엔");
+ expect(options().map((option) => option.textContent)).toEqual(["일본 엔화JPY"]);
+ await key(search()!, "Enter");
expect(onChange).toHaveBeenCalledWith("JPY");
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+});
+
+it("does not open while disabled", async () => {
+ await mount({ disabled: true });
+ await open();
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+ expect(search()).toBeNull();
});
+
+it("shows the form value on the trigger while the currency list is missing", async () => {
+ await mount({ list: [] });
+ expect(trigger().textContent).toContain("KRW");
+ await mount({ value: "", list: [] });
+ expect(trigger().textContent).toBe("통화 선택");
+});
+
diff --git a/src/components/expenses/ExpenseCurrencyPicker.tsx b/src/components/expenses/ExpenseCurrencyPicker.tsx
index 2697c257..ea77a8af 100644
--- a/src/components/expenses/ExpenseCurrencyPicker.tsx
+++ b/src/components/expenses/ExpenseCurrencyPicker.tsx
@@ -1,15 +1,31 @@
"use client";
-import { useEffect, useId, useRef, useState } from "react";
-import { ChevronDownIcon } from "@/assets/icons";
-import { Check, Search } from "lucide-react";
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxLabel,
+ ComboboxList,
+ ComboboxTrigger,
+ ComboboxValue,
+} from "@/components/ui/combobox";
+import { Field } from "@/components/ui/field";
import type { ExpenseCurrency } from "@/lib/api/rooms/expenses";
const currencyNames = new Intl.DisplayNames(["ko"], { type: "currency" });
function currencyName(code: string) {
return currencyNames.of(code) ?? code;
}
+// 검색어 앞뒤 공백은 무시하고, 통화 코드·한국어 이름에 포함되면 찾는다(대소문자 무시). Base UI 기본 필터는 공백을 다듬지 않는다
+function matchCurrency(item: ExpenseCurrency, query: string) {
+ return `${item.currency} ${currencyName(item.currency)}`
+ .toLocaleLowerCase()
+ .includes(query.trim().toLocaleLowerCase());
+}
+/** 통화 선택. 트리거를 누르면 팝업 안 검색 입력과 서버 통화 목록이 열린다(목록 밖 통화는 고를 수 없다) */
export function ExpenseCurrencyPicker({
value,
currencies,
@@ -21,198 +37,56 @@ export function ExpenseCurrencyPicker({
onChange: (currency: string) => void;
disabled?: boolean;
}) {
- const [open, setOpen] = useState(false);
- const [search, setSearch] = useState("");
- const [active, setActive] = useState(0);
- const root = useRef(null);
- const trigger = useRef(null);
- const input = useRef(null);
- const list = useRef(null);
- const id = useId();
- const isOpen = open && !disabled;
- const query = search.trim().toLocaleLowerCase();
- const results = currencies.filter((c) =>
- (c.currency + " " + currencyName(c.currency))
- .toLocaleLowerCase()
- .includes(query),
- );
- const activeIndex = Math.min(active, results.length - 1);
-
- function close(restoreFocus = true) {
- setOpen(false);
- if (restoreFocus) trigger.current?.focus();
- }
- function select(code: string) {
- if (disabled) return;
- onChange(code);
- close();
- }
- useEffect(() => {
- if (!isOpen) return;
- input.current?.focus();
- const element = root.current;
- if (!element) return;
- const outside = (event: PointerEvent) => {
- if (!element.contains(event.target as Node)) setOpen(false);
- };
- element.ownerDocument.addEventListener("pointerdown", outside);
- return () =>
- element.ownerDocument.removeEventListener("pointerdown", outside);
- }, [isOpen]);
- useEffect(() => {
- if (isOpen)
- list.current?.children[activeIndex]?.scrollIntoView({ block: "nearest" });
- }, [activeIndex, isOpen, search]);
-
+ // 통화 목록이 아직 없거나(조회 중·실패) 비용의 통화가 목록에 없어도 트리거에는 폼 값을 보인다(옛 동작).
+ // 대체 항목은 목록에 없어 고르거나 체크되지 않고, 트리거 글자와 같은 값 비교(통화 코드)에만 쓰인다
+ const selected =
+ currencies.find((currency) => currency.currency === value) ??
+ (value ? { currency: value, fractionDigits: 0, maximumAmount: "0" } : null);
return (
- {
- if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
+
{
+ if (next) onChange(next.currency);
}}
+ itemToStringLabel={(currency: ExpenseCurrency) => currencyName(currency.currency)}
+ isItemEqualToValue={(a, b) => a.currency === b.currency}
+ filter={matchCurrency}
+ // 검색어를 입력하면 첫 결과를 강조해 Enter로 바로 고른다(옛 동작: 검색어가 바뀔 때마다 첫 결과가 활성)
+ autoHighlight
+ disabled={disabled}
>
-
- 통화
-
- {
- if (disabled) return;
- setSearch("");
- setActive(0);
- setOpen(!isOpen);
- }}
- className="mt-1 flex h-12 w-full min-w-0 items-center gap-2 rounded-lg border border-border bg-fill-subtle px-3 py-2 text-left text-[14px] leading-5 font-medium cursor-pointer transition-colors enabled:hover:border-primary/40 enabled:hover:bg-gray-50 aria-expanded:border-primary/50 aria-expanded:bg-primary/5 focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
- >
-
- {value || "통화 선택"}
- {value && (
-
- {currencyName(value)}
-
- )}
-
-
-
- {isOpen && (
- {
- if (event.key === "Escape") {
- event.preventDefault();
- event.stopPropagation();
- close();
- }
- }}
- className="absolute left-0 top-full z-30 mt-2 w-[min(20rem,calc(100vw-5rem))] max-w-[calc(100vw-5rem)] overflow-hidden rounded-2xl border border-gray-border bg-white shadow-xl"
- >
-
-
-
- {
- setSearch(event.target.value);
- setActive(0);
- }}
- onKeyDown={(event) => {
- if (event.nativeEvent.isComposing) return;
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
- event.preventDefault();
- setActive(
- Math.max(
- 0,
- Math.min(
- results.length - 1,
- activeIndex + (event.key === "ArrowDown" ? 1 : -1),
- ),
- ),
- );
- } else if (event.key === "Enter") {
- event.preventDefault();
- if (results[activeIndex])
- select(results[activeIndex].currency);
- }
- }}
- className="min-h-11 w-full min-w-0 bg-transparent text-body-m-regular outline-none"
- />
-
-
-
- {results.map((c, index) => (
- event.preventDefault()}
- onClick={() => select(c.currency)}
- className={`flex min-h-14 w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left cursor-pointer transition-colors hover:bg-primary/10 ${index === activeIndex ? "bg-primary/5" : ""}`}
- >
-
-
- {currencyName(c.currency)}
+
+ 통화
+
+
+ {(currency: ExpenseCurrency | null) =>
+ currency ? (
+ <>
+ {currency.currency}
+ {/* 좁은 칸(모바일 기기·좁은 화면)에서는 코드만 보인다. 비용 편집 폼이 이 조건에서 통화 칸을 줄인다 */}
+
+ {currencyName(currency.currency)}
-
- {c.currency}
-
-
- {c.currency === value && (
-
- )}
-
- ))}
-
- {!results.length && (
-
- 검색 결과가 없어요.
-
+ >
+ ) : (
+ "통화 선택"
+ )
+ }
+
+
+
+
+
+
+ {(currency: ExpenseCurrency) => (
+
+ {currencyName(currency.currency)}
+
)}
-
- )}
-
+
+ 검색 결과가 없어요.
+
+
);
}
diff --git a/src/components/expenses/ExpenseDialog.drag.test.tsx b/src/components/expenses/ExpenseDialog.drag.test.tsx
deleted file mode 100644
index 96b91443..00000000
--- a/src/components/expenses/ExpenseDialog.drag.test.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-// @vitest-environment jsdom
-import { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import { ExpenseDialog } from "./ExpenseDialog";
-
-let root: Root;
-let container: HTMLDivElement;
-const close = vi.fn();
-const capture = vi.fn();
-beforeEach(() => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- vi.stubGlobal("matchMedia", () => ({ matches: true }));
- document.documentElement.classList.add("is-mobile-device");
- HTMLDialogElement.prototype.showModal = function () { this.open = true; };
- HTMLDialogElement.prototype.close = function () { this.open = false; };
- HTMLElement.prototype.setPointerCapture = capture;
- HTMLElement.prototype.hasPointerCapture = () => true;
- HTMLElement.prototype.releasePointerCapture = vi.fn();
- close.mockReset(); capture.mockReset();
- container = document.createElement("div");
- document.body.append(container);
- root = createRoot(container);
- act(() => root.render( ));
-});
-afterEach(() => { act(() => root.unmount()); container.remove(); document.documentElement.className = ""; vi.unstubAllGlobals(); });
-function pointer(target: Element, type: string, y: number, x = 20) {
- const event = new Event(type, { bubbles: true });
- Object.assign(event, { pointerId: 1, isPrimary: true, button: 0, clientX: x, clientY: y });
- act(() => target.dispatchEvent(event));
-}
-function drag(distance: number, end = "pointerup", target: Element = container.querySelector("header")!) {
- pointer(target, "pointerdown", 20);
- pointer(target, "pointermove", 20 + distance);
- pointer(target, end, 20 + distance);
-}
-it("follows a downward header gesture and dismisses after a deliberate drag", () => {
- const header = container.querySelector("header")!;
- pointer(header, "pointerdown", 20);
- pointer(header, "pointermove", 140);
- expect(container.querySelector("dialog")!.style.transform).toBe("translateY(120px)");
- pointer(header, "pointerup", 140);
- expect(close).toHaveBeenCalledOnce();
-});
-it.each([[30, "pointerup"], [130, "pointercancel"], [-100, "pointerup"]])("snaps back for %s px and %s", (distance, end) => {
- drag(Number(distance), String(end));
- expect(close).not.toHaveBeenCalled();
- expect(container.querySelector("dialog")!.style.transform).not.toContain("translateY(");
-});
-it("ignores desktop drags even on narrow screens and supports mobile devices", () => {
- document.documentElement.classList.remove("is-mobile-device");
- drag(130);
- expect(close).not.toHaveBeenCalled();
- document.documentElement.classList.add("is-mobile-device");
- drag(130);
- expect(close).toHaveBeenCalledOnce();
-});
-it("does not intercept buttons or form input gestures", () => {
- drag(130, "pointerup", container.querySelector("button")!);
- drag(130, "pointerup", container.querySelector("input")!);
- expect(close).not.toHaveBeenCalled();
- expect(capture).not.toHaveBeenCalled();
-});
-it("ignores a horizontal gesture", () => {
- const header = container.querySelector("header")!;
- pointer(header, "pointerdown", 20);
- pointer(header, "pointermove", 140, 240);
- pointer(header, "pointerup", 140, 240);
- expect(close).not.toHaveBeenCalled();
-});
-
-// Only data dependencies are stubbed; editor rendering and pointer handling stay real.
-const editorState = vi.hoisted(() => ({ busy: false }));
-vi.mock("./ExpenseProvider", () => ({ useExpenseContext: () => ({
- roomId: "room", currentUserId: 1, canManage: true, busy: editorState.busy,
- members: [{ userId: 1, nickname: "나", status: "ACTIVE", role: "MEMBER" }],
- memberStatus: "success", schedules: [], schedulesReady: true,
- currencies: { isSuccess: true, data: [{ currency: "KRW", fractionDigits: 0, maximumAmount: "999999999999999" }] },
-}) }));
-vi.mock("@/hooks/useRooms", () => ({ useSchedulePlanPlaces: () => ({ data: [], isSuccess: true }) }));
-import { ExpenseEditor } from "./ExpenseEditor";
-
-it("never dismisses the editor when a save is pending, including a save starting mid-drag", () => {
- editorState.busy = false;
- act(() => root.render( ));
- const title = container.querySelector("h2")!;
- pointer(title, "pointerdown", 20);
- pointer(title, "pointermove", 140);
- editorState.busy = true;
- act(() => root.render( ));
- pointer(title, "pointerup", 140);
- expect(close).not.toHaveBeenCalled();
- expect(container.querySelector("dialog")!.style.transform).toBe("");
- drag(130, "pointerup", title);
- expect(close).not.toHaveBeenCalled();
- editorState.busy = false;
- act(() => root.render( ));
- drag(130, "pointerup", title);
- expect(close).toHaveBeenCalledOnce();
-});
-
-it.each(["pointerup", "pointercancel"])("does not restart the editor entrance animation after %s", (end) => {
- editorState.busy = false;
- act(() => root.render( ));
- drag(30, end, container.querySelector("h2")!);
- expect(container.querySelector("dialog")!.style.animation).toBe("none");
- expect(close).not.toHaveBeenCalled();
-});
-it("snaps back without animation when reduced motion is requested", () => {
- vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduced-motion") || query.includes("max-width") }));
- drag(30);
- expect(container.querySelector("dialog")!.style.transition).toBe("none");
-});
-
-import { ExpenseScopePanel } from "./ExpenseScopePanel";
-function mountScope() {
- act(() => root.render( {}} onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close}
- />));
-}
-it("dismisses the day/place list sheet by dragging its header", () => {
- mountScope();
- drag(130);
- expect(close).toHaveBeenCalledOnce();
-});
-it("leaves the desktop scope panel transform untouched", () => {
- document.documentElement.classList.remove("is-mobile-device");
- mountScope();
- drag(130);
- expect(close).not.toHaveBeenCalled();
- expect(container.querySelector("dialog")!.style.transform).toBe("");
-});
diff --git a/src/components/expenses/ExpenseDialog.tsx b/src/components/expenses/ExpenseDialog.tsx
index 58c04c1d..96578ba7 100644
--- a/src/components/expenses/ExpenseDialog.tsx
+++ b/src/components/expenses/ExpenseDialog.tsx
@@ -1,92 +1,59 @@
"use client";
-import { useSheetDrag } from "@/components/mobile/useSheetDrag";
-import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle";
-import { useEffect, useId, useRef, type ReactNode } from "react";
-import { CloseIcon } from "@/assets/icons";
+import type { ReactNode } from "react";
-/** Native modal focus containment, with a sheet surface on small screens. */
+import {
+ ResponsiveDialog,
+ ResponsiveDialogBody,
+ ResponsiveDialogContent,
+ ResponsiveDialogDescription,
+ ResponsiveDialogFooter,
+ ResponsiveDialogHeader,
+ ResponsiveDialogTitle,
+} from "@/components/ui/responsive-dialog";
+import { useMobileView } from "@/contexts/MobileViewContext";
+
+/**
+ * 가계부 읽기 전용 대화상자(정산 요약). PC는 Dialog(720), 모바일 기기는 아래 시트(ResponsiveDialog).
+ * 조건부로 마운트해 열고, 닫기 요청(Esc·딤·X·스와이프)은 onClose로 받는다.
+ */
export function ExpenseDialog({
title,
onClose,
children,
footer,
- settlement = false,
description,
}: Readonly<{
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
- settlement?: boolean;
+ /** 시트에서만 제목 아래 보조 줄로 보인다(지금 화면과 같음) */
description?: ReactNode;
}>) {
- const sheetDrag = useSheetDrag(onClose);
- const ref = useRef(null);
- const titleId = useId();
- const heading = useRef(null);
- useEffect(() => {
- const element = ref.current;
- const trigger = element?.ownerDocument.activeElement;
- element?.showModal();
- heading.current?.focus({ preventScroll: true });
- return () => {
- element?.close();
- if (trigger && trigger instanceof HTMLElement && trigger.isConnected)
- trigger.focus();
- };
- }, []);
- useEffect(() => {
- const element = ref.current;
- const dismissBackdrop = (event: MouseEvent) => {
- if (event.target === element) onClose();
- };
- element?.addEventListener("click", dismissBackdrop);
- return () => element?.removeEventListener("click", dismissBackdrop);
- }, [onClose]);
+ const { isMobileDevice } = useMobileView();
return (
- {
- event.preventDefault();
- onClose();
+ {
+ if (!open) onClose();
}}
- className={`fixed inset-0 m-auto ${settlement ? "mobile:h-[calc(100dvh-92px)] mobile:max-h-[calc(100dvh-92px)]" : "mobile:h-[70dvh] mobile:max-h-[70dvh]"} max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-[720px] overflow-hidden rounded-xl border-0 bg-white p-0 text-text shadow-xl backdrop:bg-black/40 mobile:backdrop:bg-[#0f1724]/35 mobile:backdrop:animate-in mobile:backdrop:fade-in-0 mobile:backdrop:duration-200 mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200 mobile:mb-0 mobile:w-full mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px]`}
>
-
-
-
-
-
-
- {title}
-
- {description &&
{description}
}
-
-
-
-
-
-
-
- {children}
-
- {footer && (
-
- )}
-
-
+
+
+ {title}
+ {description && isMobileDevice ? (
+ {description}
+ ) : null}
+
+ {children}
+ {footer ? {footer} : null}
+
+
);
}
diff --git a/src/components/expenses/ExpenseEditor.pending.test.tsx b/src/components/expenses/ExpenseEditor.pending.test.tsx
index 242486d6..94eba2f3 100644
--- a/src/components/expenses/ExpenseEditor.pending.test.tsx
+++ b/src/components/expenses/ExpenseEditor.pending.test.tsx
@@ -1,16 +1,19 @@
-import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Expense } from "@/lib/api/rooms/expenses";
import type { RoomMember } from "@/lib/api/rooms/types";
import type { MemberQueryStatus } from "@/lib/expenses/expense-policy";
import { ExpenseEditor } from "./ExpenseEditor";
-import { ExpenseRolePicker } from "./ExpenseViews";
const mocks = vi.hoisted(() => ({ save: vi.fn(), close: vi.fn(), state: {} }));
vi.mock("./ExpenseProvider", () => ({ useExpenseContext: () => mocks.state }));
+vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: false }) }));
vi.mock("@/hooks/useRooms", () => ({
useSchedulePlanPlaces: () => ({ data: [], isSuccess: true }),
}));
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const members: RoomMember[] = [
[1, "A", "ACTIVE"],
@@ -38,12 +41,13 @@ const base: Expense = {
payerUserIds: [1, 2],
participantUserIds: [1],
version: 0,
- name: null, createdAt: "",
+ name: null,
+ createdAt: "",
updatedAt: "",
};
-let renderer: ReactTestRenderer;
+let host: HTMLDivElement;
+let root: Root;
beforeEach(() => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.save.mockReset();
mocks.close.mockReset();
mocks.state = {
@@ -60,29 +64,70 @@ beforeEach(() => {
busy: false,
save: mocks.save,
};
+ // 칸 오류를 보이는 곳까지 스크롤한다. jsdom에는 scrollIntoView가 없다
+ Element.prototype.scrollIntoView = vi.fn();
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
});
afterEach(async () => {
- if (renderer) await act(async () => renderer.unmount());
- vi.unstubAllGlobals();
+ await act(async () => root.unmount());
+ host.remove();
+ document.body.replaceChildren();
+ Reflect.deleteProperty(Element.prototype, "scrollIntoView");
});
async function mount(expense: Expense | undefined = base) {
- await act(async () => {
- renderer = create( );
- });
+ await act(async () => root.render( ));
+}
+// Base UI는 열고 닫을 때 microtask·timeout으로 포커스를 옮긴다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+const dialog = () => document.querySelector('[role="dialog"]')!;
+const form = () => dialog().querySelector("form")!;
+type Role = "결제자" | "부담자";
+function picker(title: Role) {
+ return [...dialog().querySelectorAll("fieldset[aria-labelledby]")].find(
+ (fieldset) => document.getElementById(fieldset.getAttribute("aria-labelledby")!)?.firstElementChild?.textContent === title,
+ )!;
}
-function picker(title: "결제자" | "부담자") {
- return renderer.root.findAllByType(ExpenseRolePicker).find((p) => p.props.title === title)!;
+const text = (title: Role) => picker(title).textContent ?? "";
+// 체크박스 행(고를 수 있는 멤버·알 수 없는 멤버)의 선택된 사람
+function checkedIds(title: Role) {
+ return [...picker(title).querySelectorAll("label")]
+ .filter((row) => row.querySelector('[role="checkbox"]')?.getAttribute("aria-checked") === "true")
+ .map((row) => Number(row.querySelector("[data-user-id]")!.getAttribute("data-user-id")));
}
-function text(node: ReactTestInstance | string): string {
- return typeof node === "string" ? node : node.children.map(text).join("");
+// 제목 줄의 '선택 N명'. 승인 대기 멤버는 체크박스 없이 선택에 남는다
+const selectedCount = (title: Role) => Number(/선택 (\d+)명/.exec(text(title))![1]);
+const checkboxes = (title: Role) => picker(title).querySelectorAll('[role="checkbox"]');
+function checkboxOf(title: Role, label: string) {
+ return [...picker(title).querySelectorAll("label")]
+ .find((row) => row.textContent?.includes(label))!
+ .querySelector('[role="checkbox"]')!;
}
-function removeButton(title: "결제자" | "부담자") {
- const buttons = picker(title).findAllByType("button").filter((b) => text(b) === "제외");
+function removeButton(title: Role) {
+ const buttons = [...picker(title).querySelectorAll("button")].filter((node) => node.textContent === "제외");
expect(buttons).toHaveLength(1);
return buttons[0];
}
+// 결제자·부담자를 함께 검증한 오류는 두 칸 아래 하나로 보인다
+function roleError() {
+ return picker("결제자").parentElement!.parentElement!.querySelector(':scope > [data-slot="field-error"]')?.textContent ?? null;
+}
+const formMessage = () => form().querySelector(':scope > p[role="alert"]')?.textContent ?? null;
+const saveButton = () => dialog().querySelector('button[type="submit"]')!;
+async function click(element: HTMLElement) {
+ await act(async () => element.click());
+ await flush();
+}
async function submit() {
- await act(async () => renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }));
+ await click(saveButton());
+}
+async function type(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(element, value);
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ });
}
it.each(["결제자", "부담자"] as const)(
@@ -91,17 +136,19 @@ it.each(["결제자", "부담자"] as const)(
const expense = title === "결제자" ? base : { ...base, payerUserIds: [1], participantUserIds: [1, 2] };
const source = structuredClone(expense);
await mount(expense);
- expect(picker(title).props.selected).toEqual([1, 2]);
- expect(text(picker(title))).toContain("B");
- expect(text(picker(title))).toContain("승인 대기");
+ expect(checkedIds(title)).toEqual([1]);
+ expect(selectedCount(title)).toBe(2);
+ expect(text(title)).toContain("B");
+ expect(text(title)).toContain("승인 대기");
// A and LEFT C are checkboxes; pending B is removal-only and new pending D is absent.
- expect(picker(title).findAllByType("input")).toHaveLength(2);
- expect(text(picker(title))).not.toContain("D");
- expect(text(picker(title === "결제자" ? "부담자" : "결제자"))).not.toContain("승인 대기");
- await act(async () => removeButton(title).props.onClick());
- expect(picker(title).props.selected).toEqual([1]);
- expect(text(picker(title))).not.toContain("승인 대기");
- expect(picker(title).findAllByType("button")).toHaveLength(0);
+ expect(checkboxes(title)).toHaveLength(2);
+ expect(text(title)).not.toContain("D");
+ expect(text(title === "결제자" ? "부담자" : "결제자")).not.toContain("승인 대기");
+ await click(removeButton(title));
+ expect(checkedIds(title)).toEqual([1]);
+ expect(selectedCount(title)).toBe(1);
+ expect(text(title)).not.toContain("승인 대기");
+ expect(picker(title).querySelectorAll("button")).toHaveLength(0);
await submit();
expect(mocks.save).toHaveBeenCalledWith({
expenseGroup: "PREPARATION", scheduleId: null, scheduleItemId: null,
@@ -114,75 +161,73 @@ it.each(["결제자", "부담자"] as const)(
it.each(["memo", "amount", "unchanged"])("retains PENDING and rejects %s edits until explicit removal", async (edit) => {
await mount();
- if (edit === "memo") {
- await act(async () => renderer.root.findByType("textarea").props.onChange({ target: { value: "edited" } }));
- } else if (edit === "amount") {
- await act(async () => renderer.root.findAllByType("input").find((i) => i.props.inputMode === "decimal")!.props.onChange({ target: { value: "200" } }));
- }
+ if (edit === "memo") await type(dialog().querySelector("textarea")!, "edited");
+ else if (edit === "amount") await type(dialog().querySelector('input[inputmode="decimal"]')!, "200");
await submit();
expect(mocks.save).not.toHaveBeenCalled();
- expect(picker("결제자").props.selected).toEqual([1, 2]);
- expect(text(renderer.root.findByProps({ role: "alert" }))).toContain("선택할 수 없는 멤버");
- expect(text(picker("결제자"))).toContain("승인 대기");
+ expect(selectedCount("결제자")).toBe(2);
+ expect(roleError()).toContain("선택할 수 없는 멤버");
+ expect(formMessage()).toBeNull();
+ expect(text("결제자")).toContain("승인 대기");
});
it("removes the same PENDING person independently from each role", async () => {
await mount({ ...base, participantUserIds: [1, 2] });
- await act(async () => removeButton("결제자").props.onClick());
- expect(picker("결제자").props.selected).toEqual([1]);
- expect(picker("부담자").props.selected).toEqual([1, 2]);
+ await click(removeButton("결제자"));
+ expect(selectedCount("결제자")).toBe(1);
+ expect(selectedCount("부담자")).toBe(2);
+ expect(text("부담자")).toContain("승인 대기");
await submit();
expect(mocks.save).not.toHaveBeenCalled();
- await act(async () => removeButton("부담자").props.onClick());
+ await click(removeButton("부담자"));
await submit();
expect(mocks.save).toHaveBeenCalledWith(expect.objectContaining({ payerUserIds: [1], participantUserIds: [1] }), 10, 0);
});
it.each(["결제자", "부담자"] as const)("reports a clear error when the last %s is removed", async (title) => {
await mount({ ...base, payerUserIds: title === "결제자" ? [2] : [1], participantUserIds: title === "부담자" ? [2] : [1] });
- await act(async () => removeButton(title).props.onClick());
- expect(picker(title).props.selected).toEqual([]);
+ await click(removeButton(title));
+ expect(selectedCount(title)).toBe(0);
await submit();
expect(mocks.save).not.toHaveBeenCalled();
- expect(text(renderer.root.findByProps({ role: "alert" }))).toContain("각각 한 명 이상");
+ expect(roleError()).toContain("각각 한 명 이상");
});
it("cancels explicit removal without mutating source and restores it on reopen", async () => {
const expense = structuredClone(base);
await mount(expense);
- await act(async () => removeButton("결제자").props.onClick());
- await act(async () => renderer.root.findAllByType("button").find((b) => text(b) === "취소")!.props.onClick());
+ await click(removeButton("결제자"));
+ await click([...dialog().querySelectorAll("button")].find((node) => node.textContent === "취소")!);
expect(mocks.close).toHaveBeenCalledOnce();
expect(mocks.save).not.toHaveBeenCalled();
expect(expense).toEqual(base);
- await act(async () => renderer.unmount());
+ await act(async () => root.render(null));
await mount(expense);
- expect(picker("결제자").props.selected).toEqual([1, 2]);
- expect(text(picker("결제자"))).toContain("승인 대기");
+ expect(selectedCount("결제자")).toBe(2);
+ expect(text("결제자")).toContain("승인 대기");
});
it("never offers PENDING in a new expense", async () => {
- await act(async () => { renderer = create( ); });
+ await act(async () => root.render( ));
for (const title of ["결제자", "부담자"] as const) {
- expect(picker(title).props.selected).toEqual([]);
- expect(picker(title).findAllByType("input")).toHaveLength(2);
- expect(text(picker(title))).not.toContain("B");
- expect(text(picker(title))).not.toContain("D");
- expect(picker(title).findAllByType("button")).toHaveLength(0);
+ expect(selectedCount(title)).toBe(0);
+ expect(checkboxes(title)).toHaveLength(2);
+ expect(text(title)).not.toContain("B");
+ expect(text(title)).not.toContain("D");
+ expect(picker(title).querySelectorAll("button")).toHaveLength(0);
}
});
it("preserves unknown roles, disallows copying/readding unknowns, and still selects LEFT", async () => {
await mount({ ...base, payerUserIds: [1, 99], participantUserIds: [1, 98] });
- expect(text(picker("결제자"))).toContain("(알 수 없음)");
- expect(text(picker("결제자"))).not.toContain("#98");
- expect(text(picker("부담자"))).not.toContain("#99");
+ expect(text("결제자")).toContain("(알 수 없음)");
+ expect(text("결제자")).not.toContain("#98");
+ expect(text("부담자")).not.toContain("#99");
await submit();
expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ payerUserIds: [1, 99], participantUserIds: [1, 98] }), 10, 0);
- const labels = picker("결제자").findAllByType("label");
- await act(async () => labels.find((l) => text(l).includes("#99"))!.findByType("input").props.onChange({ target: { checked: false } }));
- expect(text(picker("결제자"))).not.toContain("#99");
- await act(async () => labels.find((l) => text(l).includes("나간 멤버"))!.findByType("input").props.onChange({ target: { checked: true } }));
+ await click(checkboxOf("결제자", "#99"));
+ expect(text("결제자")).not.toContain("#99");
+ await click(checkboxOf("결제자", "나간 멤버"));
await submit();
expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ payerUserIds: [1, 3], participantUserIds: [1, 98] }), 10, 0);
});
@@ -190,15 +235,19 @@ it("preserves unknown roles, disallows copying/readding unknowns, and still sele
it.each(["pending", "error"] as MemberQueryStatus[])("does not infer membership or alter selections during member %s", async (memberStatus) => {
await mount();
mocks.state = { ...mocks.state, members: [], memberStatus };
- await act(async () => renderer.update( ));
- expect(renderer.root.findAllByType(ExpenseRolePicker)).toHaveLength(0);
- for (const label of ["승인 대기", "(알 수 없음)", "나간 멤버", "제외"]) expect(text(renderer.root)).not.toContain(label);
- expect(renderer.root.findByProps({ type: "submit" }).props.disabled).toBe(true);
- await submit();
+ await act(async () => root.render( ));
+ expect(dialog().querySelectorAll("fieldset[aria-labelledby]")).toHaveLength(0);
+ for (const label of ["승인 대기", "(알 수 없음)", "나간 멤버", "제외"]) expect(dialog().textContent).not.toContain(label);
+ expect(saveButton().disabled).toBe(true);
+ // 저장 버튼이 비활성이어도 제출 경로(Enter 등)가 저장하지 않는다
+ await act(async () => form().requestSubmit());
+ await flush();
expect(mocks.save).not.toHaveBeenCalled();
mocks.state = { ...mocks.state, members, memberStatus: "success" };
- await act(async () => renderer.update( ));
- expect(picker("결제자").props.selected).toEqual([1, 2]);
- expect(picker("부담자").props.selected).toEqual([1]);
- expect(text(picker("결제자"))).toContain("승인 대기");
+ await act(async () => root.render( ));
+ expect(checkedIds("결제자")).toEqual([1]);
+ expect(selectedCount("결제자")).toBe(2);
+ expect(checkedIds("부담자")).toEqual([1]);
+ expect(selectedCount("부담자")).toBe(1);
+ expect(text("결제자")).toContain("승인 대기");
});
diff --git a/src/components/expenses/ExpenseEditor.test.tsx b/src/components/expenses/ExpenseEditor.test.tsx
index b1789d1a..a6577c76 100644
--- a/src/components/expenses/ExpenseEditor.test.tsx
+++ b/src/components/expenses/ExpenseEditor.test.tsx
@@ -1,23 +1,23 @@
+// @vitest-environment jsdom
// Place-cache subscriptions are covered by ExpensePlaceLabel.test.tsx.
vi.mock("./ExpensePlaceLabel", () => ({ ExpensePlaceLabel: () => null }));
vi.mock("@/lib/api/config", () => ({ API_BASE: "http://fixture" }));
-import { ExpenseSelect } from "./ExpenseSelect";
-import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ExpenseApiError, type Expense } from "@/lib/api/rooms/expenses";
import { ExpenseEditor, type ExpenseEntry } from "./ExpenseEditor";
-import { ExpenseCurrencyPicker } from "./ExpenseCurrencyPicker";
-import { ExpenseRolePicker } from "./ExpenseViews";
-beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const mocks = vi.hoisted(() => ({
save: vi.fn(),
readLatest: vi.fn(),
close: vi.fn(),
state: null as unknown,
+ mobile: false,
}));
-vi.mock("./ExpenseProvider", () => ({
- useExpenseContext: () => mocks.state,
-}));
+vi.mock("./ExpenseProvider", () => ({ useExpenseContext: () => mocks.state }));
+vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: mocks.mobile }) }));
vi.mock("@/hooks/useRooms", () => ({
useSchedulePlanPlaces: () => ({
data: [{ itemId: 100, title: "경복궁" }],
@@ -30,830 +30,708 @@ const currencies = [
{ currency: "USD", fractionDigits: 2, maximumAmount: "999999999999999.99" },
{ currency: "KRW", fractionDigits: 0, maximumAmount: "999999999999999" },
];
+const base: Expense = {
+ expenseGroup: "TRIP_DAY",
+ scheduleId: 10,
+ scheduleItemId: 100,
+ totalAmount: "10.01",
+ currency: "USD",
+ category: "OTHER",
+ memo: "",
+ payerUserIds: [1, 99],
+ participantUserIds: [1],
+ id: 10,
+ version: 0,
+ name: null,
+ createdAt: "",
+ updatedAt: "",
+};
+const categories = [
+ ["FLIGHT", "항공"],
+ ["ACCOMMODATION", "숙박"],
+ ["FOOD", "식비"],
+ ["TRANSPORT", "교통"],
+ ["SHOPPING", "쇼핑"],
+ ["SIGHTSEEING", "관광"],
+ ["OTHER", "기타"],
+] as const;
-function stubLocalStorage() {
- const entries = new Map();
- const storage = {
- get length() { return entries.size; },
- key(index: number) { return [...entries.keys()][index] ?? null; },
- getItem(key: string) { return entries.get(key) ?? null; },
- setItem(key: string, value: string) { entries.set(key, value); },
- removeItem(key: string) { entries.delete(key); },
- clear() { entries.clear(); },
+let host: HTMLDivElement;
+let root: Root;
+// 오류 문장을 보이는 곳까지 스크롤하는 동작을 본다. jsdom에는 scrollIntoView가 없다
+const scrollIntoView = vi.fn();
+beforeEach(() => {
+ localStorage.clear();
+ mocks.mobile = false;
+ scrollIntoView.mockReset();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(Element.prototype, "scrollIntoView");
+ Reflect.deleteProperty(window, "matchMedia");
+});
+
+function contextState(original: Expense | null) {
+ return {
+ currentUserId: 1,
+ roomId: "r",
+ members: [{ userId: 1, status: "ACTIVE", role: "HOST", nickname: "하나", profileImageUrl: null }],
+ memberStatus: "success",
+ canManage: true,
+ schedules: [
+ { scheduleId: 10, dayNumber: 1 },
+ { scheduleId: 11, dayNumber: 2 },
+ ],
+ schedulesReady: true,
+ currencies: { data: currencies, isSuccess: true, isError: false },
+ save: mocks.save,
+ readLatest: mocks.readLatest,
+ list: { isSuccess: true, data: original ? [original] : [] },
+ busy: false,
};
- vi.stubGlobal("window", { localStorage: storage, matchMedia: () => ({ matches: true }) });
- return entries;
+}
+function setState(patch: object) {
+ mocks.state = { ...(mocks.state as object), ...patch };
+}
+async function render(node: ReactNode) {
+ await act(async () => root.render(node));
+}
+async function mount(original: Expense | null = base, entry: ExpenseEntry = {}) {
+ mocks.save.mockReset();
+ mocks.readLatest.mockReset();
+ mocks.close.mockReset();
+ mocks.state = contextState(original);
+ await render(null);
+ await render( );
+}
+// 새 비용 편집기를 다시 연다(닫았다 다시 연 것과 같다). 저장된 통화 선호를 연 순간에 읽는다
+async function reopenNewEditor() {
+ await render(null);
+ await render( );
}
+// Base UI는 열고 닫을 때 microtask·timeout으로 포커스·목록을 옮긴다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+const dialog = () => document.querySelector('[role="dialog"][data-slot="dialog-content"]');
+const form = () => dialog()!.querySelector("form")!;
+const button = (name: string) =>
+ [...dialog()!.querySelectorAll("button")].find(
+ (node) => node.getAttribute("aria-label") === name || node.textContent === name,
+ );
+const saveButton = () => dialog()!.querySelector('button[type="submit"]')!;
+const amountInput = () => dialog()!.querySelector('input[inputmode="decimal"]')!;
+const nameInput = () => dialog()!.querySelector('input[name="expense-title"]')!;
+const memoInput = () => dialog()!.querySelector("textarea")!;
+const selectTrigger = (label: string) =>
+ dialog()!.querySelector(`[role="combobox"][aria-label="${label}"]`)!;
+const currencyTrigger = () => dialog()!.querySelector('[role="combobox"][aria-haspopup="dialog"]')!;
+// 통화 트리거는 코드 + 한국어 이름을 보이고, 값이 없으면 "통화 선택"을 보인다
+const currencyValue = () => {
+ const text = currencyTrigger().textContent ?? "";
+ return text === "통화 선택" ? "" : text.slice(0, 3);
+};
+const fieldOf = (element: Element) => element.closest('[data-slot="field"]')!;
+const fieldError = (element: Element) => fieldOf(element).querySelector('[data-slot="field-error"]')?.textContent ?? null;
+// 폼 아래 문장(저장 실패·권한·충돌 등). 칸 오류(FieldError)는 각 칸 안에 있다
+const formMessage = () => form().querySelector(":scope > p[role=\"alert\"]")?.textContent ?? null;
+
+async function key(target: Element, key: string) {
+ await act(async () => {
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
+ });
+ await flush();
+}
+async function type(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(element, value);
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+async function click(element: HTMLElement) {
+ await act(async () => element.click());
+ await flush();
+}
+async function openSelect(label: string) {
+ await click(selectTrigger(label));
+ const list = document.getElementById(selectTrigger(label).getAttribute("aria-controls")!)!;
+ return [...list.querySelectorAll('[role="option"]')];
+}
+async function choose(label: string, optionText: string) {
+ const option = (await openSelect(label)).find((node) => node.textContent === optionText);
+ expect(option, `${label}: ${optionText}`).toBeDefined();
+ await click(option!);
+}
+async function chooseCurrency(code: string) {
+ await click(currencyTrigger());
+ const popup = document.querySelector('[role="dialog"][aria-label="통화 선택"]')!;
+ const option = [...popup.querySelectorAll('[role="option"]')].find((node) => node.textContent?.endsWith(code));
+ await click(option!);
+}
+async function clickSave() {
+ await click(saveButton());
+ await flush();
+}
+// 저장 버튼이 비활성인 상태에서도 제출 경로(Enter 등)가 저장을 막는지 본다
+async function submitForm() {
+ await act(async () => form().requestSubmit());
+ await flush();
+}
async function completeNewExpense() {
- const amount = renderer.root.findAllByType("input")
- .find((input) => input.props.inputMode === "decimal")!;
- const currency = renderer.root.findByType(ExpenseCurrencyPicker).props.value;
- await act(async () => amount.props.onChange({ target: { value: currency === "USD" ? "100.00" : "100" } }));
- await act(async () => categorySelect().props.onChange("OTHER"));
- await act(async () => renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }));
+ await type(amountInput(), currencyValue() === "USD" ? "100.00" : "100");
+ await choose("카테고리", "기타");
+ await clickSave();
}
-async function rerenderNewEditor() {
- await act(async () => renderer.unmount());
- await act(async () => { renderer = create( ); });
-}
+const rolePicker = (title: "결제자" | "부담자") =>
+ [...dialog()!.querySelectorAll("fieldset[aria-labelledby]")].find(
+ (fieldset) => document.getElementById(fieldset.getAttribute("aria-labelledby")!)?.firstElementChild?.textContent === title,
+ )!;
+const roleCheckboxes = (title: "결제자" | "부담자") => [...rolePicker(title).querySelectorAll('[role="checkbox"]')];
+const roleRows = (title: "결제자" | "부담자") =>
+ [...rolePicker(title).querySelectorAll("label")].map((row) => ({
+ userId: Number(row.querySelector("[data-user-id]")!.getAttribute("data-user-id")),
+ checked: row.querySelector('[role="checkbox"]')!.getAttribute("aria-checked") === "true",
+ checkbox: row.querySelector('[role="checkbox"]')!,
+ }));
+const checkedIds = (title: "결제자" | "부담자") => roleRows(title).filter((row) => row.checked).map((row) => row.userId);
it("remembers currency only after a successful new expense save", async () => {
- const entries = stubLocalStorage();
await mount(null);
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
- await act(async () => renderer.root.findByType(ExpenseCurrencyPicker).props.onChange("USD"));
- expect(entries.size).toBe(0);
+ expect(currencyValue()).toBe("KRW");
+ await chooseCurrency("USD");
+ expect(currencyValue()).toBe("USD");
+ expect(localStorage).toHaveLength(0);
await completeNewExpense();
expect(mocks.save).toHaveBeenCalledOnce();
- expect([...entries.values()]).toEqual(["USD"]);
+ expect(Object.values({ ...localStorage })).toEqual(["USD"]);
await mount(null);
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("USD");
+ expect(currencyValue()).toBe("USD");
});
it("does not remember cancelled, failed, or edited expense currency", async () => {
- const entries = stubLocalStorage();
await mount(null);
- await act(async () => renderer.root.findByType(ExpenseCurrencyPicker).props.onChange("USD"));
- await act(async () => renderer.root.findByProps({ "aria-label": "닫기" }).props.onClick());
- expect(entries.size).toBe(0);
+ await chooseCurrency("USD");
+ await click(button("닫기")!);
+ expect(mocks.close).toHaveBeenCalledOnce();
+ expect(localStorage).toHaveLength(0);
+ await mount(null);
+ await chooseCurrency("USD");
mocks.save.mockRejectedValueOnce(new Error("저장 실패"));
await completeNewExpense();
- expect(entries.size).toBe(0);
+ expect(mocks.save).toHaveBeenCalledOnce();
+ expect(localStorage).toHaveLength(0);
await mount();
- await act(async () => renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }));
- expect(entries.size).toBe(0);
+ await clickSave();
+ expect(mocks.save).toHaveBeenCalledOnce();
+ expect(localStorage).toHaveLength(0);
});
it("scopes the remembered currency to the authenticated user and room", async () => {
- stubLocalStorage();
await mount(null);
- await act(async () => renderer.root.findByType(ExpenseCurrencyPicker).props.onChange("USD"));
+ await chooseCurrency("USD");
await completeNewExpense();
- mocks.state = { ...(mocks.state as object), currentUserId: 2 };
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
- mocks.state = { ...(mocks.state as object), currentUserId: 1, roomId: "another-room" };
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
- mocks.state = { ...(mocks.state as object), roomId: "r" };
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("USD");
+ setState({ currentUserId: 2 });
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("KRW");
+ setState({ currentUserId: 1, roomId: "another-room" });
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("KRW");
+ setState({ roomId: "r" });
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("USD");
});
it("uses the current list for stale preference and reads tab changes on the next open", async () => {
- const entries = stubLocalStorage();
await mount(null);
- await act(async () => renderer.root.findByType(ExpenseCurrencyPicker).props.onChange("USD"));
+ await chooseCurrency("USD");
await completeNewExpense();
- const key = [...entries.keys()][0];
- entries.set(key, "BAD");
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
- entries.set(key, "USD");
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("USD");
- mocks.state = { ...(mocks.state as object), currencies: { data: [currencies[0]], isSuccess: true } };
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("USD");
- entries.set(key, "KRW");
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("USD");
+ const storageKey = localStorage.key(0)!;
+ localStorage.setItem(storageKey, "BAD");
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("KRW");
+ localStorage.setItem(storageKey, "USD");
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("USD");
+ setState({ currencies: { data: [currencies[0]], isSuccess: true } });
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("USD");
+ localStorage.setItem(storageKey, "KRW");
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("USD");
});
it("applies preference after currencies load without replacing a later manual choice", async () => {
- const entries = stubLocalStorage();
await mount(null);
- await act(async () => renderer.root.findByType(ExpenseCurrencyPicker).props.onChange("USD"));
+ await chooseCurrency("USD");
await completeNewExpense();
- mocks.state = { ...(mocks.state as object), currencies: { data: undefined, isSuccess: false } };
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("");
- mocks.state = { ...(mocks.state as object), currencies: { data: currencies, isSuccess: true } };
- await act(async () => renderer.update( ));
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("USD");
- await act(async () => renderer.root.findByType(ExpenseCurrencyPicker).props.onChange("KRW"));
- entries.set([...entries.keys()][0], "USD");
- mocks.state = { ...(mocks.state as object), currencies: { data: [...currencies], isSuccess: true } };
- await act(async () => renderer.update( ));
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
+ setState({ currencies: { data: undefined, isSuccess: false } });
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("");
+ setState({ currencies: { data: currencies, isSuccess: true } });
+ await render( );
+ expect(currencyValue()).toBe("USD");
+ await chooseCurrency("KRW");
+ localStorage.setItem(localStorage.key(0)!, "USD");
+ setState({ currencies: { data: [...currencies], isSuccess: true } });
+ await render( );
+ expect(currencyValue()).toBe("KRW");
});
it("keeps the normal default when storage is unavailable or identity is missing", async () => {
- vi.stubGlobal("window", { get localStorage() { throw new Error("blocked"); } });
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
await mount(null);
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
- mocks.state = { ...(mocks.state as object), currentUserId: undefined };
- await rerenderNewEditor();
- expect(renderer.root.findByType(ExpenseCurrencyPicker).props.value).toBe("KRW");
+ expect(currencyValue()).toBe("KRW");
+ setState({ currentUserId: undefined });
+ await reopenNewEditor();
+ expect(currencyValue()).toBe("KRW");
});
it("preselects self in both roles for new expenses and keeps self first", async () => {
await mount(null);
- const state = mocks.state as { members: { userId: number }[] };
- mocks.state = {
- ...state,
+ const state = mocks.state as { members: object[] };
+ setState({
currentUserId: 2,
- members: [
- ...state.members,
- {
- userId: 2,
- status: "ACTIVE",
- role: "MEMBER",
- nickname: "나",
- profileImageUrl: null,
- },
- ],
- };
- await act(async () =>
- renderer.update(
- ,
- ),
- );
- for (const picker of renderer.root.findAllByType(ExpenseRolePicker)) {
- expect(picker.props.selected).toEqual([2]);
- expect(
- picker
- .findAll(
- (node) =>
- node.type === "span" && node.props["data-user-id"] !== undefined,
- )
- .map((node) => node.props["data-user-id"]),
- ).toEqual([2, 1]);
- await act(async () =>
- picker
- .findAllByType("input")[0]
- .props.onChange({ target: { checked: false } }),
- );
+ members: [...state.members, { userId: 2, status: "ACTIVE", role: "MEMBER", nickname: "나", profileImageUrl: null }],
+ });
+ await render( );
+ for (const title of ["결제자", "부담자"] as const) {
+ expect(checkedIds(title)).toEqual([2]);
+ expect(roleRows(title).map((row) => row.userId)).toEqual([2, 1]);
+ await click(roleRows(title)[0].checkbox);
}
- await act(async () =>
- renderer.update(
- ,
- ),
- );
- for (const picker of renderer.root.findAllByType(ExpenseRolePicker))
- expect(picker.props.selected).toEqual([]);
+ await render( );
+ for (const title of ["결제자", "부담자"] as const) expect(checkedIds(title)).toEqual([]);
});
it("formats typed amounts without changing exact submitted decimals", async () => {
await mount();
- const amount = renderer.root
- .findAllByType("input")
- .find((i) => i.props.inputMode === "decimal")!;
- await act(async () =>
- amount.props.onChange({ target: { value: "999,999,999,999,999.99" } }),
- );
- expect(amount.props.value).toBe("999,999,999,999,999.99");
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
- expect(mocks.save).toHaveBeenCalledWith(
- expect.objectContaining({ totalAmount: "999999999999999.99" }),
- 10,
- 0,
- );
-});
-const base = {
- expenseGroup: "TRIP_DAY" as const,
- scheduleId: 10,
- scheduleItemId: 100,
- totalAmount: "10.01",
- currency: "USD",
- category: "OTHER" as const,
- memo: "",
- payerUserIds: [1, 99],
- participantUserIds: [1],
- id: 10,
- version: 0,
- name: null, createdAt: "",
- updatedAt: "",
-};
-let renderer: ReactTestRenderer;
-async function mount(
- original: Expense | null = base,
- entry: ExpenseEntry = {},
- createNodeMock?: (element: { type: unknown }) => unknown,
-) {
- if (renderer) await act(async () => renderer.unmount());
- mocks.save.mockReset();
- mocks.readLatest.mockReset();
- mocks.close.mockReset();
- mocks.state = {
- currentUserId: 1,
- roomId: "r",
- members: [
- {
- userId: 1,
- status: "ACTIVE",
- role: "HOST",
- nickname: "하나",
- profileImageUrl: null,
- },
- ],
- memberStatus: "success",
- canManage: true,
- schedules: [
- { scheduleId: 10, dayNumber: 1 },
- { scheduleId: 11, dayNumber: 2 },
- ],
- schedulesReady: true,
- currencies: { data: currencies, isSuccess: true, isError: false },
- save: mocks.save,
- readLatest: mocks.readLatest,
- list: { isSuccess: true, data: original ? [original] : [] },
- busy: false,
- };
- await act(async () => {
- renderer = create(
- ,
- createNodeMock ? { createNodeMock } : undefined,
- );
- });
-}
-afterEach(async () => {
- if (renderer) await act(async () => renderer.unmount());
- vi.unstubAllGlobals();
+ await type(amountInput(), "999,999,999,999,999.99");
+ expect(amountInput().value).toBe("999,999,999,999,999.99");
+ await clickSave();
+ expect(mocks.save).toHaveBeenCalledWith(expect.objectContaining({ totalAmount: "999999999999999.99" }), 10, 0);
});
+
it("preserves original unknown payer and exact amount when editing", async () => {
await mount();
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ await clickSave();
expect(mocks.save).toHaveBeenCalledWith(
- expect.objectContaining({
- payerUserIds: [1, 99],
- participantUserIds: [1],
- totalAmount: "10.01",
- scheduleItemId: 100,
- }),
+ expect.objectContaining({ payerUserIds: [1, 99], participantUserIds: [1], totalAmount: "10.01", scheduleItemId: 100 }),
10,
0,
);
expect(mocks.close).toHaveBeenCalledOnce();
});
+
it("clears both links for preparation and clears place when changing day", async () => {
await mount();
- const select = renderer.root.findAllByType(ExpenseSelect)[0];
- await act(async () => select.props.onChange("11"));
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ await choose("일차 구분", "2일차");
+ expect(selectTrigger("연결 장소 (선택)").textContent).toBe("장소 연결 없음");
+ await clickSave();
expect(mocks.save).toHaveBeenLastCalledWith(
- expect.objectContaining({
- expenseGroup: "TRIP_DAY",
- scheduleId: 11,
- scheduleItemId: null,
- }),
+ expect.objectContaining({ expenseGroup: "TRIP_DAY", scheduleId: 11, scheduleItemId: null }),
10,
0,
);
await mount();
- await act(async () =>
- renderer.root
- .findAllByType(ExpenseSelect)[0]
- .props.onChange("PREPARATION"),
- );
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ await choose("일차 구분", "여행 준비");
+ await clickSave();
expect(mocks.save).toHaveBeenLastCalledWith(
- expect.objectContaining({
- expenseGroup: "PREPARATION",
- scheduleId: null,
- scheduleItemId: null,
- }),
+ expect.objectContaining({ expenseGroup: "PREPARATION", scheduleId: null, scheduleItemId: null }),
10,
0,
);
});
+
it("keeps the place selector visible but disabled for travel preparation", async () => {
await mount(null);
- const placeSelect = () => renderer.root.findAllByType(ExpenseSelect)
- .find((select) => select.props.label === "연결 장소 (선택)");
- const placeLabel = () => renderer.root.findAllByType("p")
- .find((paragraph) => paragraph.children.join("") === "연결 장소 (선택)");
-
- expect(placeSelect()).toBeDefined();
- expect(placeSelect()!.props.disabled).toBe(true);
- expect(placeSelect()!.props.options).toEqual([{ value: "", label: "장소 연결 없음" }]);
- expect(placeSelect()!.findByType("button").props.disabled).toBe(true);
- expect(placeLabel()!.props.className).toContain("opacity-50");
-
- const daySelect = renderer.root.findAllByType(ExpenseSelect)[0];
- expect(daySelect.props.label).toBe("일차 구분");
- await act(async () => daySelect.props.onChange("10"));
- expect(placeSelect()!.props.disabled).toBe(false);
- expect(placeSelect()!.props.options).toContainEqual({ value: "100", label: "경복궁" });
- expect(placeLabel()!.props.className).not.toContain("opacity-50");
-
- await act(async () => daySelect.props.onChange("PREPARATION"));
- expect(placeSelect()!.props.disabled).toBe(true);
- expect(placeSelect()!.props.options).toEqual([{ value: "", label: "장소 연결 없음" }]);
- expect(placeLabel()!.props.className).toContain("opacity-50");
+ const place = () => selectTrigger("연결 장소 (선택)");
+ expect(selectTrigger("일차 구분").textContent).toBe("여행 준비");
+ expect(place().textContent).toBe("장소 연결 없음");
+ expect(place().hasAttribute("data-disabled")).toBe(true);
+
+ await choose("일차 구분", "1일차");
+ expect(place().hasAttribute("data-disabled")).toBe(false);
+ const options = await openSelect("연결 장소 (선택)");
+ expect(options.map((option) => option.textContent)).toEqual(["장소 연결 없음", "경복궁"]);
+ await key(document.activeElement!, "Escape");
+
+ await choose("일차 구분", "여행 준비");
+ expect(place().hasAttribute("data-disabled")).toBe(true);
+ expect(place().textContent).toBe("장소 연결 없음");
});
+
it("blocks wrong currency scale without truncating and guards rapid duplicate submit", async () => {
await mount();
- const amount = renderer.root
- .findAllByType("input")
- .find((i) => i.props.inputMode === "decimal")!;
- await act(async () =>
- amount.props.onChange({ target: { value: "10.001" } }),
- );
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ await type(amountInput(), "10.001");
+ await clickSave();
expect(mocks.save).not.toHaveBeenCalled();
- await act(async () =>
- amount.props.onChange({ target: { value: "10.01" } }),
- );
+ expect(amountInput().value).toBe("10.001");
+ expect(amountInput().getAttribute("aria-invalid")).toBe("true");
+ expect(fieldError(amountInput())).toBe("금액은 정수부 1~15자리, 소수 2자리로 입력해 주세요. 공백·기호·지수는 사용할 수 없어요.");
+ await type(amountInput(), "10.01");
+ expect(amountInput().hasAttribute("aria-invalid")).toBe(false);
+ expect(fieldError(amountInput())).toBeNull();
let finish!: () => void;
- mocks.save.mockImplementation(
- () =>
- new Promise((r) => {
- finish = r;
- }),
- );
+ mocks.save.mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
await act(async () => {
- const submit = renderer.root.findByType("form").props.onSubmit;
- void submit({ preventDefault() {} });
- void submit({ preventDefault() {} });
+ saveButton().click();
+ saveButton().click();
});
+ await flush();
expect(mocks.save).toHaveBeenCalledOnce();
- await act(async () => {
- finish();
- });
+ await act(async () => finish());
});
+
it("does not submit when members fail and shows failure without removing historical data", async () => {
await mount();
- mocks.state = {
- ...(mocks.state as object),
- memberStatus: "error",
- canManage: false,
- };
- await act(async () =>
- renderer.update(
- ,
- ),
- );
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ setState({ memberStatus: "error", canManage: false });
+ await render( );
+ expect(saveButton().disabled).toBe(true);
+ await submitForm();
expect(mocks.save).not.toHaveBeenCalled();
- expect(JSON.stringify(renderer.toJSON())).not.toContain("(알 수 없음)");
+ expect(dialog()!.textContent).toContain("멤버 정보 조회 실패");
+ expect(dialog()!.textContent).not.toContain("(알 수 없음)");
});
it.each([
[{}, "PREPARATION", null, null],
[{ scheduleId: 10 }, "TRIP_DAY", 10, null],
[{ scheduleId: 10, scheduleItemId: 100 }, "TRIP_DAY", 10, 100],
-] as const)(
- "creates an expense from each real entry context %j",
- async (entry, expenseGroup, scheduleId, scheduleItemId) => {
- await mount(null, entry);
- const amount = renderer.root
- .findAllByType("input")
- .find((i) => i.props.inputMode === "decimal")!;
- await act(async () =>
- amount.props.onChange({ target: { value: "100" } }),
- );
- for (const checkbox of renderer.root
- .findAllByType("input")
- .filter((i) => i.props.type === "checkbox" && !i.props.checked))
- await act(async () =>
- checkbox.props.onChange({ target: { checked: true } }),
- );
- await act(async () => categorySelect().props.onChange("OTHER"));
- await act(async () =>
- renderer.root
- .findByType("form")
- .props.onSubmit({ preventDefault() {} }),
- );
- expect(mocks.save).toHaveBeenCalledWith(
- {
- expenseGroup,
- scheduleId,
- scheduleItemId,
- totalAmount: "100",
- currency: "KRW",
- category: "OTHER",
- name: null,
- memo: "",
- payerUserIds: [1],
- participantUserIds: [1],
- },
- undefined,
- undefined,
- );
- },
-);
+] as const)("creates an expense from each real entry context %j", async (entry, expenseGroup, scheduleId, scheduleItemId) => {
+ await mount(null, entry);
+ await type(amountInput(), "100");
+ for (const title of ["결제자", "부담자"] as const)
+ for (const row of roleRows(title).filter((row) => !row.checked)) await click(row.checkbox);
+ await choose("카테고리", "기타");
+ await clickSave();
+ expect(mocks.save).toHaveBeenCalledWith(
+ {
+ expenseGroup,
+ scheduleId,
+ scheduleItemId,
+ totalAmount: "100",
+ currency: "KRW",
+ category: "OTHER",
+ name: null,
+ memo: "",
+ payerUserIds: [1],
+ participantUserIds: [1],
+ },
+ undefined,
+ undefined,
+ );
+});
+
it("cannot re-add an unknown payer after deselection and keeps input on server rejection", async () => {
await mount();
- const checkboxes = renderer.root
- .findAllByType("input")
- .filter((i) => i.props.type === "checkbox");
- expect(checkboxes).toHaveLength(3);
- await act(async () =>
- checkboxes[1].props.onChange({ target: { checked: false } }),
- );
- expect(
- renderer.root
- .findAllByType("input")
- .filter((i) => i.props.type === "checkbox"),
- ).toHaveLength(2);
+ expect(roleCheckboxes("결제자")).toHaveLength(2);
+ expect(roleCheckboxes("부담자")).toHaveLength(1);
+ const unknown = roleRows("결제자").find((row) => row.userId === 99)!;
+ await click(unknown.checkbox);
+ expect(roleRows("결제자").map((row) => row.userId)).toEqual([1]);
mocks.save.mockRejectedValue(new Error("서버 검증 실패"));
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
- expect(mocks.save).toHaveBeenCalledWith(
- expect.objectContaining({ payerUserIds: [1], totalAmount: "10.01" }),
- 10,
- 0,
- );
+ await clickSave();
+ expect(mocks.save).toHaveBeenCalledWith(expect.objectContaining({ payerUserIds: [1], totalAmount: "10.01" }), 10, 0);
expect(mocks.close).not.toHaveBeenCalled();
- expect(JSON.stringify(renderer.toJSON())).toContain("서버 검증 실패");
+ expect(formMessage()).toBe("서버 검증 실패");
});
-const categories = [
- ["FLIGHT", "항공"],
- ["ACCOMMODATION", "숙박"],
- ["FOOD", "식비"],
- ["TRANSPORT", "교통"],
- ["SHOPPING", "쇼핑"],
- ["SIGHTSEEING", "관광"],
- ["OTHER", "기타"],
-] as const;
-function categorySelect() {
- const selects = renderer.root
- .findAllByType(ExpenseSelect)
- .filter((node) => node.props.name === "category");
- expect(selects, "a category selector is available").toHaveLength(1);
- return selects[0];
-}
async function fillNewExpense() {
- const amount = renderer.root
- .findAllByType("input")
- .find((i) => i.props.inputMode === "decimal")!;
- await act(async () => amount.props.onChange({ target: { value: "100" } }));
- for (const checkbox of renderer.root
- .findAllByType("input")
- .filter((i) => i.props.type === "checkbox" && !i.props.checked)) {
- await act(async () =>
- checkbox.props.onChange({ target: { checked: true } }),
- );
- }
+ await type(amountInput(), "100");
+ for (const title of ["결제자", "부담자"] as const)
+ for (const row of roleRows(title).filter((row) => !row.checked)) await click(row.checkbox);
}
+
it("requires explicit selection and offers exactly seven labeled categories in approved order", async () => {
await mount(null);
await fillNewExpense();
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ await clickSave();
expect(mocks.save).not.toHaveBeenCalled();
- expect(categorySelect().props.value).toBe("");
- expect(
- categorySelect().props.options.map(
- (o: { value: string; label: string }) => [o.value, o.label],
- ),
- ).toEqual(categories);
-});
-it.each(categories)(
- "creates the selected category %s (%s)",
- async (category) => {
- await mount(null);
- await fillNewExpense();
- await act(async () => categorySelect().props.onChange(category));
- await act(async () =>
- renderer.root
- .findByType("form")
- .props.onSubmit({ preventDefault() {} }),
- );
- expect(mocks.save).toHaveBeenCalledWith(
- expect.objectContaining({ category, totalAmount: "100" }),
- undefined,
- undefined,
- );
- },
-);
-it.each(categories)(
- "retains %s (%s) on edit and permits explicit reclassification",
- async (category) => {
- await mount({ ...base, category });
- expect(categorySelect().props.value).toBe(category);
- await act(async () =>
- renderer.root
- .findByType("form")
- .props.onSubmit({ preventDefault() {} }),
- );
- expect(mocks.save).toHaveBeenLastCalledWith(
- expect.objectContaining({ category }),
- 10,
- 0,
- );
- const replacement = category === "OTHER" ? "FLIGHT" : "OTHER";
- await act(async () => categorySelect().props.onChange(replacement));
- mocks.save.mockRejectedValueOnce(new Error("카테고리 저장 실패"));
- await act(async () =>
- renderer.root
- .findByType("form")
- .props.onSubmit({ preventDefault() {} }),
- );
- expect(mocks.save).toHaveBeenLastCalledWith(
- expect.objectContaining({ category: replacement }),
- 10,
- 0,
- );
- expect(categorySelect().props.value).toBe(replacement);
- expect(JSON.stringify(renderer.toJSON())).toContain("카테고리 저장 실패");
- },
-);
+ expect(selectTrigger("카테고리").textContent).toBe("카테고리 선택");
+ const options = await openSelect("카테고리");
+ expect(options.map((option) => option.textContent)).toEqual(categories.map(([, label]) => label));
+});
+
+it("shows the missing category under its field and clears it once a category is chosen", async () => {
+ await mount(null);
+ await fillNewExpense();
+ await clickSave();
+ expect(mocks.save).not.toHaveBeenCalled();
+ const category = selectTrigger("카테고리");
+ expect(category.getAttribute("aria-invalid")).toBe("true");
+ expect(fieldError(category)).toBe("카테고리를 선택해 주세요.");
+ // 칸 오류는 폼 아래 문장으로 겹쳐 보이지 않는다
+ expect(formMessage()).toBeNull();
+ expect(form().hasAttribute("aria-describedby")).toBe(false);
+ // 다른 칸은 오류가 없다
+ expect(fieldError(amountInput())).toBeNull();
+ await choose("카테고리", "식비");
+ expect(selectTrigger("카테고리").hasAttribute("aria-invalid")).toBe(false);
+ expect(fieldError(selectTrigger("카테고리"))).toBeNull();
+ await clickSave();
+ expect(mocks.save).toHaveBeenCalledWith(expect.objectContaining({ category: "FOOD" }), undefined, undefined);
+});
+
+it.each(categories)("creates the selected category %s (%s)", async (category, label) => {
+ await mount(null);
+ await fillNewExpense();
+ await choose("카테고리", label);
+ await clickSave();
+ expect(mocks.save).toHaveBeenCalledWith(expect.objectContaining({ category, totalAmount: "100" }), undefined, undefined);
+});
+
+it.each(categories)("retains %s (%s) on edit and permits explicit reclassification", async (category, label) => {
+ await mount({ ...base, category });
+ expect(selectTrigger("카테고리").textContent).toBe(label);
+ await clickSave();
+ expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ category }), 10, 0);
+ const [replacement, replacementLabel] = category === "OTHER" ? categories[0] : categories[6];
+ await choose("카테고리", replacementLabel);
+ mocks.save.mockRejectedValueOnce(new Error("카테고리 저장 실패"));
+ await clickSave();
+ expect(mocks.save).toHaveBeenLastCalledWith(expect.objectContaining({ category: replacement }), 10, 0);
+ expect(selectTrigger("카테고리").textContent).toBe(replacementLabel);
+ expect(formMessage()).toBe("카테고리 저장 실패");
+});
+
+it("closes only the open list on Escape, not the editor", async () => {
+ await mount();
+ await openSelect("카테고리");
+ expect(selectTrigger("카테고리").getAttribute("aria-expanded")).toBe("true");
+ await key(document.activeElement!, "Escape");
+ expect(selectTrigger("카테고리").getAttribute("aria-expanded")).toBe("false");
+ await click(currencyTrigger());
+ expect(currencyTrigger().getAttribute("aria-expanded")).toBe("true");
+ await key(document.activeElement!, "Escape");
+ expect(currencyTrigger().getAttribute("aria-expanded")).toBe("false");
+ expect(mocks.close).not.toHaveBeenCalled();
+ expect(dialog()).not.toBeNull();
+});
-// Model native modal focus separately from renderer nodes: closing alone does not
-// restore focus here, matching the browser defect reported by Hermes.
+it.each([false, true])("closes from the backdrop only on mobile devices (mobile: %s)", async (mobile) => {
+ mocks.mobile = mobile;
+ await mount();
+ const backdrop = document.querySelector(
+ mobile ? '[data-slot="drawer-overlay"]' : '[data-slot="dialog-overlay"]',
+ )!;
+ await act(async () => {
+ backdrop.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
+ backdrop.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+ backdrop.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+ backdrop.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+ backdrop.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flush();
+ expect(mocks.close).toHaveBeenCalledTimes(mobile ? 1 : 0);
+});
+
+// 편집기를 연 버튼에 포커스를 두고 열고, 닫히면(onClose) 부모처럼 편집기를 내린다
async function mountWithOpener() {
- class FocusTarget {
- isConnected = true;
- focus = vi.fn(() => {
- ownerDocument.activeElement = this;
- });
- }
- const opener = new FocusTarget();
- const closeButton = new FocusTarget();
- const ownerDocument = { activeElement: opener };
- const nativeDialog = {
- ownerDocument,
- showModal: vi.fn(() => closeButton.focus()),
- close: vi.fn(),
- };
- vi.stubGlobal("HTMLElement", FocusTarget);
- await mount(base, {}, (element) =>
- element.type === "dialog" ? nativeDialog : null,
- );
- mocks.close.mockImplementation(() => renderer.unmount());
- expect(ownerDocument.activeElement).toBe(closeButton);
- return { opener, closeButton, ownerDocument, nativeDialog };
+ const opener = document.createElement("button");
+ opener.textContent = "비용 추가";
+ document.body.append(opener);
+ opener.focus();
+ await mount();
+ mocks.close.mockImplementation(() => root.render(null));
+ // Base UI는 다음 프레임에 대화상자 안 첫 포커스 요소로 포커스를 옮긴다
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
+ expect(dialog()!.contains(document.activeElement)).toBe(true);
+ return opener;
+}
+async function settle() {
+ await flush();
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
}
-it.each(["Escape", "닫기", "취소", "save"])(
- "restores connected opener focus after %s closes the editor",
- async (path) => {
- const { opener, ownerDocument, nativeDialog } = await mountWithOpener();
- await act(async () => {
- if (path === "Escape") {
- const preventDefault = vi.fn();
- renderer.root.findByType("dialog").props.onCancel({ preventDefault });
- expect(preventDefault).toHaveBeenCalledOnce();
- } else if (path === "save") {
- await renderer.root
- .findByType("form")
- .props.onSubmit({ preventDefault() {} });
- } else {
- renderer.root
- .findAllByType("button")
- .find(
- (b) => (b.props["aria-label"] ?? b.children.join("")) === path,
- )!
- .props.onClick();
- }
- });
- expect(mocks.close).toHaveBeenCalledOnce();
- expect(nativeDialog.close).toHaveBeenCalledOnce();
- expect(opener.focus).toHaveBeenCalledOnce();
- expect(ownerDocument.activeElement).toBe(opener);
- },
-);
+it.each(["Escape", "닫기", "취소", "save"])("returns focus to the opener after %s closes the editor", async (path) => {
+ const opener = await mountWithOpener();
+ if (path === "Escape") await key(document.activeElement!, "Escape");
+ else if (path === "save") await clickSave();
+ else await click(button(path)!);
+ await settle();
+ expect(mocks.close).toHaveBeenCalledOnce();
+ expect(dialog()).toBeNull();
+ expect(document.activeElement).toBe(opener);
+});
it("does not focus an opener removed while the editor was open", async () => {
- const { opener } = await mountWithOpener();
- opener.isConnected = false;
- await act(async () => renderer.unmount());
- expect(opener.focus).not.toHaveBeenCalled();
+ const opener = await mountWithOpener();
+ const focus = vi.spyOn(opener, "focus");
+ opener.remove();
+ await key(document.activeElement!, "Escape");
+ await settle();
+ expect(mocks.close).toHaveBeenCalledOnce();
+ expect(focus).not.toHaveBeenCalled();
+ expect(document.activeElement).not.toBe(opener);
});
-it("keeps focus in the editor and locks dismissal during save, then restores it on success", async () => {
- const { opener, closeButton, ownerDocument } = await mountWithOpener();
+it("locks every dismissal during save and shows progress, then returns focus on success", async () => {
+ const opener = await mountWithOpener();
let finish!: () => void;
- mocks.save.mockImplementation(
- () =>
- new Promise((resolve) => {
- finish = resolve;
- }),
- );
- await act(async () => {
- void renderer.root
- .findByType("form")
- .props.onSubmit({ preventDefault() {} });
- });
- for (const label of ["닫기", "취소"]) {
- expect(
- renderer.root
- .findAllByType("button")
- .find(
- (b) => (b.props["aria-label"] ?? b.children.join("")) === label,
- )!.props.disabled,
- ).toBe(true);
- }
- await act(async () =>
- renderer.root
- .findByType("dialog")
- .props.onCancel({ preventDefault() {} }),
- );
+ mocks.save.mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
+ await clickSave();
+ expect(saveButton().getAttribute("aria-busy")).toBe("true");
+ expect(saveButton().getAttribute("aria-disabled")).toBe("true");
+ expect(document.getElementById(saveButton().getAttribute("aria-labelledby")!)!.textContent).toBe("저장 중");
+ expect(button("닫기")!.disabled).toBe(true);
+ expect(button("취소")!.disabled).toBe(true);
+ await click(button("닫기")!);
+ await click(button("취소")!);
+ await key(document.activeElement!, "Escape");
+ await click(saveButton());
expect(mocks.close).not.toHaveBeenCalled();
- expect(opener.focus).not.toHaveBeenCalled();
- expect(ownerDocument.activeElement).toBe(closeButton);
+ expect(mocks.save).toHaveBeenCalledOnce();
+ expect(dialog()!.contains(document.activeElement)).toBe(true);
await act(async () => finish());
- expect(opener.focus).toHaveBeenCalledOnce();
+ await settle();
+ expect(mocks.close).toHaveBeenCalledOnce();
+ expect(document.activeElement).toBe(opener);
});
-it("keeps the editor focused on save rejection and restores focus on later dismissal", async () => {
- const { opener, closeButton, ownerDocument } = await mountWithOpener();
+it("keeps the editor open on save rejection and returns focus on later dismissal", async () => {
+ const opener = await mountWithOpener();
mocks.save.mockRejectedValueOnce(new Error("저장 실패"));
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
+ await clickSave();
expect(mocks.close).not.toHaveBeenCalled();
- expect(opener.focus).not.toHaveBeenCalled();
- expect(ownerDocument.activeElement).toBe(closeButton);
- await act(async () =>
- renderer.root
- .findByType("dialog")
- .props.onCancel({ preventDefault() {} }),
- );
- expect(opener.focus).toHaveBeenCalledOnce();
+ expect(formMessage()).toBe("저장 실패");
+ expect(saveButton().hasAttribute("aria-busy")).toBe(false);
+ expect(dialog()!.contains(document.activeElement)).toBe(true);
+ await key(document.activeElement!, "Escape");
+ await settle();
+ expect(mocks.close).toHaveBeenCalledOnce();
+ expect(document.activeElement).toBe(opener);
});
-async function submitVersioned() {
- await act(async () =>
- renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }),
- );
-}
-function recoveryButton(label: string) {
- return renderer.root
- .findAllByType("button")
- .find((b) => b.children.includes(label));
-}
+const recoveryButton = (label: string) => button(label);
it("keeps the opened version when background list and entry refresh", async () => {
await mount();
- mocks.state = {
- ...(mocks.state as object),
- list: { isSuccess: true, data: [{ ...base, version: 9 }] },
- };
- await act(async () =>
- renderer.update(
- ,
- ),
- );
- await submitVersioned();
+ setState({ list: { isSuccess: true, data: [{ ...base, version: 9 }] } });
+ await render( );
+ await clickSave();
expect(mocks.save).toHaveBeenLastCalledWith(expect.anything(), 10, 0);
});
+
it("retains the draft and requires explicit latest-record review before saving again", async () => {
await mount();
- await act(async () =>
- renderer.root
- .findByType("textarea")
- .props.onChange({ target: { value: "my draft" } }),
- );
- mocks.save.mockRejectedValueOnce(
- new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"),
- );
- mocks.readLatest.mockResolvedValueOnce({
- ...base,
- version: 2,
- memo: "remote memo",
- });
- await submitVersioned();
+ await type(memoInput(), "my draft");
+ mocks.save.mockRejectedValueOnce(new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"));
+ mocks.readLatest.mockResolvedValueOnce({ ...base, version: 2, memo: "remote memo" });
+ await clickSave();
expect(mocks.readLatest).toHaveBeenCalledWith(10);
expect(mocks.close).not.toHaveBeenCalled();
- expect(renderer.root.findByType("textarea").props.value).toBe("my draft");
- expect(JSON.stringify(renderer.toJSON())).toContain("remote memo");
- await submitVersioned();
+ expect(memoInput().value).toBe("my draft");
+ expect(dialog()!.textContent).toContain("remote memo");
+ expect(saveButton().disabled).toBe(true);
+ await submitForm();
expect(mocks.save).toHaveBeenCalledTimes(1);
- await act(async () =>
- recoveryButton("최신 비용 확인 후 수정 계속")!.props.onClick(),
- );
+ await click(recoveryButton("최신 비용 확인 후 수정 계속")!);
expect(mocks.save).toHaveBeenCalledTimes(1);
- await submitVersioned();
+ await clickSave();
expect(mocks.save).toHaveBeenLastCalledWith(
- expect.objectContaining({
- memo: "my draft",
- payerUserIds: [1, 99],
- totalAmount: "10.01",
- }),
+ expect.objectContaining({ memo: "my draft", payerUserIds: [1, 99], totalAmount: "10.01" }),
10,
2,
);
});
-it.each([undefined, { ...base, version: 2, scheduleId: 11 }])(
- "blocks retry for a deleted or moved conflict target",
- async (latest) => {
- await mount();
- mocks.save.mockRejectedValueOnce(
- new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"),
- );
- mocks.readLatest.mockResolvedValueOnce(latest);
- await submitVersioned();
- await submitVersioned();
- expect(mocks.save).toHaveBeenCalledTimes(1);
- expect(recoveryButton("최신 비용 확인 후 수정 계속")).toBeUndefined();
- expect(mocks.close).not.toHaveBeenCalled();
- },
-);
+
+it.each([undefined, { ...base, version: 2, scheduleId: 11 }])("blocks retry for a deleted or moved conflict target", async (latest) => {
+ await mount();
+ mocks.save.mockRejectedValueOnce(new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"));
+ mocks.readLatest.mockResolvedValueOnce(latest);
+ await clickSave();
+ await submitForm();
+ expect(mocks.save).toHaveBeenCalledTimes(1);
+ expect(recoveryButton("최신 비용 확인 후 수정 계속")).toBeUndefined();
+ expect(dialog()!.textContent).toContain(latest ? "연결 위치가 변경되었어요" : "비용이 삭제되었어요");
+ expect(mocks.close).not.toHaveBeenCalled();
+});
+
it("blocks save after failed conflict read until a fresh read and explicit review", async () => {
await mount();
- mocks.save.mockRejectedValueOnce(
- new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"),
- );
+ mocks.save.mockRejectedValueOnce(new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"));
mocks.readLatest.mockRejectedValueOnce(new Error("offline"));
- await submitVersioned();
- await submitVersioned();
+ await clickSave();
+ expect(dialog()!.textContent).toContain("최신 비용 조회에 실패했어요");
+ await submitForm();
expect(mocks.save).toHaveBeenCalledTimes(1);
mocks.readLatest.mockResolvedValueOnce({ ...base, version: 3 });
- await act(async () =>
- recoveryButton("최신 비용 다시 조회")!.props.onClick(),
- );
+ await click(recoveryButton("최신 비용 다시 조회")!);
expect(mocks.save).toHaveBeenCalledTimes(1);
- await act(async () =>
- recoveryButton("최신 비용 확인 후 수정 계속")!.props.onClick(),
- );
- await submitVersioned();
+ await click(recoveryButton("최신 비용 확인 후 수정 계속")!);
+ await clickSave();
expect(mocks.save).toHaveBeenLastCalledWith(expect.anything(), 10, 3);
});
+
it.each([{ data: [] }, { data: [{ ...base, version: 1, scheduleId: 11 }] }])(
"blocks saving a target already known deleted or moved",
async ({ data }) => {
await mount();
- mocks.state = {
- ...(mocks.state as object),
- list: { isSuccess: true, data },
- };
- await act(async () =>
- renderer.update(
- ,
- ),
- );
- await submitVersioned();
+ setState({ list: { isSuccess: true, data } });
+ await render( );
+ expect(saveButton().disabled).toBe(true);
+ expect(formMessage()).toContain("비용이 삭제되었거나 다른 위치로 이동했어요");
+ await submitForm();
expect(mocks.save).not.toHaveBeenCalled();
},
);
it("reveals every save failure, including a repeated error message", async () => {
- const scrollIntoView = vi.fn();
- await mount(base, {}, element => element.type === "p" ? { scrollIntoView } : null);
+ await mount();
mocks.save.mockRejectedValue(new Error("저장 실패"));
- const submit = () => renderer.root.findByType("form").props.onSubmit({ preventDefault() {} });
- await act(async () => { await submit(); });
+ await clickSave();
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
- await act(async () => { await submit(); });
+ expect((scrollIntoView.mock.contexts[0] as Element).textContent).toBe("저장 실패");
+ await clickSave();
expect(scrollIntoView).toHaveBeenCalledTimes(2);
- expect(JSON.stringify(renderer.toJSON())).toContain("저장 실패");
+ expect(formMessage()).toBe("저장 실패");
});
-it("reveals repeated validation errors without smooth scrolling when reduced motion is requested", async () => {
- vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) });
- const scrollIntoView = vi.fn();
- await mount(null, {}, element => element.type === "p" ? { scrollIntoView } : null);
- for (let attempt = 0; attempt < 2; attempt++) {
- await act(async () => {
- await renderer.root.findByType("form").props.onSubmit({ preventDefault() {} });
- });
- }
+it("reveals repeated field errors without smooth scrolling when reduced motion is requested", async () => {
+ window.matchMedia = vi.fn(() => ({ matches: true })) as unknown as typeof window.matchMedia;
+ await mount(null);
+ for (let attempt = 0; attempt < 2; attempt++) await clickSave();
expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest", behavior: "instant" });
+ // 첫 칸 오류(금액)를 보여 준다
+ expect((scrollIntoView.mock.contexts[1] as Element).getAttribute("data-slot")).toBe("field-error");
+ expect(fieldOf(scrollIntoView.mock.contexts[1] as Element)).toBe(fieldOf(amountInput()));
expect(mocks.save).not.toHaveBeenCalled();
});
it("edits an optional independent name, uses the design name prompt, and explicitly clears it", async () => {
await mount(null);
- await act(async () => categorySelect().props.onChange("FOOD"));
- let name = renderer.root.findByProps({ name: "expense-title" });
- expect(name.props.placeholder).toBe("제목 없음");
- expect(name.props.maxLength).toBe(100);
- await act(async () => name.props.onChange({ target: { value: "점심" } }));
+ expect(nameInput().placeholder).toBe("제목 없음");
+ expect(nameInput().maxLength).toBe(100);
+ await type(nameInput(), "점심");
await completeNewExpense();
expect(mocks.save.mock.lastCall?.[0]).toMatchObject({ name: "점심", memo: "" });
await mount(null);
- name = renderer.root.findByProps({ name: "expense-title" });
- await act(async () => name.props.onChange({ target: { value: " \u3000" } }));
+ await type(nameInput(), " ");
await completeNewExpense();
expect(mocks.save.mock.lastCall?.[0].name).toBeNull();
});
it("omits an unchanged name on PATCH, clears explicitly, and rejects raw overlength before saving", async () => {
await mount({ ...base, name: "기존 이름" });
- const submit = async () => act(async () => renderer.root.findByType("form").props.onSubmit({ preventDefault() {} }));
- await submit();
+ await clickSave();
expect(mocks.save.mock.lastCall?.[0]).not.toHaveProperty("name");
- await act(async () => renderer.root.findByProps({ name: "expense-title" }).props.onChange({ target: { value: "" } }));
- await submit();
+ await type(nameInput(), "");
+ await clickSave();
expect(mocks.save.mock.lastCall?.[0]).toMatchObject({ name: null, memo: base.memo });
mocks.save.mockClear();
- await act(async () => renderer.root.findByProps({ name: "expense-title" }).props.onChange({ target: { value: "😀".repeat(50) + " " } }));
- await submit();
+ await type(nameInput(), "😀".repeat(50) + " ");
+ await clickSave();
expect(mocks.save).not.toHaveBeenCalled();
+ expect(nameInput().getAttribute("aria-invalid")).toBe("true");
+ expect(fieldError(nameInput())).toBe("제목은 공백을 포함해 100자 이내로 입력해 주세요.");
});
diff --git a/src/components/expenses/ExpenseEditor.tsx b/src/components/expenses/ExpenseEditor.tsx
index 4fc3a7e5..8826fae4 100644
--- a/src/components/expenses/ExpenseEditor.tsx
+++ b/src/components/expenses/ExpenseEditor.tsx
@@ -1,15 +1,23 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
-import { useMobileView } from "@/contexts/MobileViewContext";
-import { useSheetDrag } from "@/components/mobile/useSheetDrag";
-import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle";
-
-import { cn } from "@/lib/utils";
-import { ExpenseSelect } from "./ExpenseSelect";
-import { ExpenseCurrencyPicker } from "./ExpenseCurrencyPicker";
-import { ExpenseAmountInput } from "./ExpenseAmountInput";
-import { CloseIcon } from "@/assets/icons";
+import { revalidateLogic, useForm, useStore } from "@tanstack/react-form";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Field, FieldCount, FieldError, FieldFooter, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ ResponsiveDialog,
+ ResponsiveDialogBody,
+ ResponsiveDialogClose,
+ ResponsiveDialogContent,
+ ResponsiveDialogDescription,
+ ResponsiveDialogFooter,
+ ResponsiveDialogHeader,
+ ResponsiveDialogTitle,
+} from "@/components/ui/responsive-dialog";
+import { Spinner } from "@/components/ui/spinner";
+import { Textarea } from "@/components/ui/textarea";
+import { useMobileView } from "@/contexts/MobileViewContext";
import {
ExpenseApiError,
expenseCategories,
@@ -18,22 +26,31 @@ import {
type ExpenseInput,
} from "@/lib/api/rooms/expenses";
import { expenseTitle, normalizeExpenseName, expenseNameError } from "@/lib/expenses/expense-name";
-import { validateExpense } from "@/lib/expenses/expense-policy";
+import {
+ amountError,
+ expenseCategoryError,
+ expenseLinkError,
+ expenseMemoError,
+ expenseRolesError,
+} from "@/lib/expenses/expense-policy";
import { preferredExpenseCurrency, rememberExpenseCurrency } from "@/lib/expenses/expense-currency-preference";
import { useSchedulePlanPlaces } from "@/hooks/useRooms";
+
+import { ExpenseSelect } from "./ExpenseSelect";
+import { ExpenseCurrencyPicker } from "./ExpenseCurrencyPicker";
+import { ExpenseAmountInput } from "./ExpenseAmountInput";
import { useExpenseContext } from "./ExpenseProvider";
-import {
- ExpenseList,
- ExpenseRolePicker,
- expenseButtonClass,
- expenseInputClass,
-} from "./ExpenseViews";
+import { ExpenseList, ExpenseRolePicker } from "./ExpenseViews";
+
export type ExpenseEntry = {
expense?: Expense;
scheduleId?: number;
scheduleItemId?: number;
};
+// 카테고리는 새 비용에서 고르기 전까지 빈 값이다
+type ExpenseDraft = Omit & { category: ExpenseInput["category"] | "" };
+
function ExpenseEditorConflict({
conflict,
conflictMoved,
@@ -53,7 +70,7 @@ function ExpenseEditorConflict({
let message = "비용이 삭제되었어요. 입력 내용은 복사할 수 있어요.";
if (pending) message = "최신 비용 확인 중…";
else if (conflict.failed) message = "최신 비용 조회에 실패했어요. 저장은 중단돼요.";
- const unavailable = pending ? : {message}
;
+ const unavailable = pending ? : {message}
;
return (
) : (
-
최신 비용 확인 후 수정 계속
-
+
)}
>
) : (
unavailable
)}
{conflict.failed && (
-
+
최신 비용 다시 조회
-
+
)}
);
@@ -114,6 +126,16 @@ function amountPlaceholder(currency: { fractionDigits: number } | undefined) {
return currency.fractionDigits ? `0.${"0".repeat(currency.fractionDigits)}` : "0";
}
+// 오류 문장이 스크롤 밖이면 보이는 곳까지 스크롤한다(움직임 줄이기 설정이면 바로)
+function revealError(element: Element | null | undefined) {
+ const reduceMotion = typeof window !== "undefined" &&
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+ element?.scrollIntoView({
+ block: "nearest",
+ behavior: reduceMotion ? "instant" : "smooth",
+ });
+}
+
export function ExpenseEditor({
initial,
onClose,
@@ -122,6 +144,7 @@ export function ExpenseEditor({
onClose: () => void;
}) {
const context = useExpenseContext();
+ const { isMobileDevice } = useMobileView();
// This snapshot advances only after the user reviews a conflict response.
const [original, setOriginal] = useState(initial.expense);
const [conflict, setConflict] = useState<{
@@ -136,9 +159,8 @@ export function ExpenseEditor({
)
? context.currentUserId
: undefined;
- const [body, setBody] = useState<
- Omit & { category: ExpenseInput["category"] | "" }
- >(() =>
+ // 연 순간의 값으로 폼을 채운다. 열린 동안 서버 값이 바뀌어도(충돌 확인 뒤 original 교체 포함) 입력 칸은 다시 채우지 않는다
+ const [defaultValues] = useState(() =>
original
? { ...original }
: {
@@ -158,19 +180,7 @@ export function ExpenseEditor({
participantUserIds: self === undefined ? [] : [self],
},
);
- const manuallySelectedCurrency = useRef(null);
- const preferenceScope = `${context.currentUserId ?? ""}:${context.roomId}`;
- useEffect(() => {
- if (original || !context.currencies.isSuccess || manuallySelectedCurrency.current === preferenceScope)
- return;
- const selected = preferredExpenseCurrency(
- context.currentUserId,
- context.roomId,
- context.currencies.data ?? [],
- );
- setBody((previous) => previous.currency === selected
- ? previous : { ...previous, currency: selected });
- }, [original, context.currentUserId, context.roomId, context.currencies.data, context.currencies.isSuccess, preferenceScope]);
+ // 칸 오류 없이 폼 아래에 보이는 문장(준비 안 됨·저장 실패). 칸 오류는 각 칸의 FieldError다
const [error, setError] = useState("");
const [errorOccurrence, setErrorOccurrence] = useState(0);
const errorMessageRef = useRef(null);
@@ -180,36 +190,80 @@ export function ExpenseEditor({
}
useEffect(() => {
if (!error) return;
- const reduceMotion = typeof window !== "undefined" &&
- window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
- errorMessageRef.current?.scrollIntoView({
- block: "nearest",
- behavior: reduceMotion ? "instant" : "smooth",
- });
+ revealError(errorMessageRef.current);
}, [error, errorOccurrence]);
const [saving, setSaving] = useState(false);
const submitLock = useRef(false);
- const dialog = useRef(null);
- const titleId = useId();
- const heading = useRef(null);
+ const formRef = useRef(null);
+ const formId = useId();
const errorId = useId();
- const places = useSchedulePlanPlaces(context.roomId, body.scheduleId);
- useEffect(() => {
- const element = dialog.current;
- const opener = element?.ownerDocument.activeElement;
- element?.showModal();
- heading.current?.focus({ preventScroll: true });
- return () => {
- element?.close();
- if (opener && opener instanceof HTMLElement && opener.isConnected)
- opener.focus();
- };
- }, []);
+ const nameId = useId();
+ const amountId = useId();
+ const dayId = useId();
+ const placeId = useId();
+ const categoryId = useId();
+ const memoId = useId();
const pending = saving || context.busy;
- const sheetDrag = useSheetDrag(onClose, pending);
- const { isMobileDevice } = useMobileView();
- // 바텀시트는 기준 시트처럼 딤을 눌러 닫는다. 시트 안에서 시작한 드래그가 딤에서 끝나면 닫지 않는다.
- const backdropPress = useRef(false);
+
+ const form = useForm({
+ defaultValues,
+ // 제출 전에는 검증하지 않고, 한 번 제출한 뒤에는 입력할 때마다 그 칸을 다시 검증한다
+ validationLogic: revalidateLogic(),
+ // 어느 칸이든 바꾸면 폼 아래 문장(저장 실패 등)을 지운다(지금 동작)
+ listeners: { onChange: () => reportError("") },
+ onSubmit: async ({ value }) => {
+ const category = value.category;
+ if (!isExpenseCategory(category)) return;
+ setSaving(true);
+ reportError("");
+ // Send only request fields, never id/timestamps from the original response.
+ const payload: ExpenseInput = {
+ expenseGroup: value.expenseGroup,
+ scheduleId: value.scheduleId,
+ scheduleItemId: value.scheduleItemId,
+ totalAmount: value.totalAmount,
+ currency: value.currency,
+ category,
+ ...(original && value.name === original.name ? {} : { name: normalizeExpenseName(value.name) }),
+ memo: value.memo,
+ payerUserIds: value.payerUserIds,
+ participantUserIds: value.participantUserIds,
+ };
+ try {
+ await context.save(payload, original?.id, original?.version);
+ if (!original) rememberExpenseCurrency(context.currentUserId, context.roomId, payload.currency);
+ onClose();
+ } catch (e) {
+ if (
+ e instanceof ExpenseApiError &&
+ (e.code === "EXPENSE_CONFLICT" || e.code === "EXPENSE_NOT_FOUND")
+ ) {
+ await recover();
+ }
+ reportError(e instanceof Error ? e.message : "비용을 저장하지 못했어요.");
+ } finally {
+ setSaving(false);
+ }
+ },
+ });
+ const body = useStore(form.store, (state) => state.values);
+
+ const manuallySelectedCurrency = useRef(null);
+ const preferenceScope = `${context.currentUserId ?? ""}:${context.roomId}`;
+ useEffect(() => {
+ if (original || !context.currencies.isSuccess || manuallySelectedCurrency.current === preferenceScope)
+ return;
+ const selected = preferredExpenseCurrency(
+ context.currentUserId,
+ context.roomId,
+ context.currencies.data ?? [],
+ );
+ // 기본 통화를 채우는 것이라 사용자 입력처럼 다루지 않는다(오류 문장 유지, 검증·변경 표시 없음)
+ if (form.getFieldValue("currency") !== selected)
+ form.setFieldValue("currency", selected, { dontUpdateMeta: true, dontRunListeners: true, dontValidate: true });
+ }, [form, original, context.currentUserId, context.roomId, context.currencies.data, context.currencies.isSuccess, preferenceScope]);
+
+ const places = useSchedulePlanPlaces(context.roomId, body.scheduleId);
const currentTarget = context.list?.data?.find(
(e) => e.id === original?.id,
);
@@ -246,343 +300,354 @@ export function ExpenseEditor({
context.currencies.isSuccess &&
context.schedulesReady &&
(body.expenseGroup === "PREPARATION" || places.isSuccess);
- const currency = context.currencies.data?.find(
- (c) => c.currency === body.currency,
- );
- function change(
- key: K,
- value: ExpenseInput[K],
- ) {
- setBody((prev) => ({ ...prev, [key]: value }));
- reportError("");
+ const currencyOf = (code: string) => context.currencies.data?.find((c) => c.currency === code);
+ const currency = currencyOf(body.currency);
+ // 일차 구분·연결 장소 검증에 쓰는 일차 목록. 장소는 지금 고른 일차의 것만 안다
+ const days = (scheduleId: number | null) => context.schedules.map((s) => ({
+ ...s,
+ items:
+ s.scheduleId === scheduleId
+ ? (places.data ?? []).flatMap((p) =>
+ p.itemId === undefined ? [] : [{ itemId: p.itemId }],
+ )
+ : [],
+ }));
+
+ async function submit() {
+ submitLock.current = true;
+ try {
+ await form.handleSubmit();
+ } finally {
+ submitLock.current = false;
+ }
+ // 칸 오류가 스크롤 밖이면 첫 오류를 보여 준다(지금 폼 아래 오류 문장과 같은 처리)
+ if (!form.state.isValid) revealError(formRef.current?.querySelector("[data-slot=field-error]"));
}
- async function submit(event: FormEvent) {
+ function handleFormSubmit(event: FormEvent) {
event.preventDefault();
- if (submitLock.current || pending) return;
+ // 라이브러리는 연타를 막지 않는다. submitLock은 검증이 비동기로 끝나기 전 두 번째 제출을 막는다
+ if (submitLock.current || form.state.isSubmitting || pending) return;
if (conflict || targetChanged) return;
if (!ready) {
reportError("멤버·통화·일차 정보를 확인한 뒤 다시 시도해 주세요.");
return;
}
- const nameError = expenseNameError(body.name);
- if (nameError) { reportError(nameError); return; }
- const category = body.category;
- if (!isExpenseCategory(category)) {
- reportError("카테고리를 선택해 주세요.");
- return;
- }
- const days = context.schedules.map((s) => ({
- ...s,
- items:
- s.scheduleId === body.scheduleId
- ? (places.data ?? []).flatMap((p) =>
- p.itemId === undefined ? [] : [{ itemId: p.itemId }],
- )
- : [],
- }));
- const validation = validateExpense(
- { ...body, category },
- context.currencies.data ?? [],
- context.members,
- original,
- days,
- );
- if (validation) {
- reportError(validation);
- return;
- }
- submitLock.current = true;
- setSaving(true);
- reportError("");
- // Send only request fields, never id/timestamps from the original response.
- const payload: ExpenseInput = {
- expenseGroup: body.expenseGroup,
- scheduleId: body.scheduleId,
- scheduleItemId: body.scheduleItemId,
- totalAmount: body.totalAmount,
- currency: body.currency,
- category,
- ...(original && body.name === original.name ? {} : { name: normalizeExpenseName(body.name) }),
- memo: body.memo,
- payerUserIds: body.payerUserIds,
- participantUserIds: body.participantUserIds,
- };
- try {
- await context.save(payload, original?.id, original?.version);
- if (!original) rememberExpenseCurrency(context.currentUserId, context.roomId, payload.currency);
- onClose();
- } catch (e) {
- if (
- e instanceof ExpenseApiError &&
- (e.code === "EXPENSE_CONFLICT" || e.code === "EXPENSE_NOT_FOUND")
- ) {
- await recover();
- }
- reportError(e instanceof Error ? e.message : "비용을 저장하지 못했어요.");
- } finally {
- submitLock.current = false;
- setSaving(false);
- }
+ void submit();
}
+
const memberFallback = context.memberStatus === "error"
? 멤버 정보 조회 실패. 기존 선택은 유지되며 저장은 잠시 중단돼요.
- : ;
+ : ;
return (
- {
- event.preventDefault();
- if (!pending) onClose();
- }}
- onPointerDown={(event) => {
- backdropPress.current = event.button === 0 && event.target === event.currentTarget;
+ {
+ if (!open) onClose();
}}
- onPointerUp={(event) => {
- const startedOnBackdrop = backdropPress.current;
- backdropPress.current = false;
- if (isMobileDevice && !pending && startedOnBackdrop && event.target === event.currentTarget) onClose();
- }}
- onPointerCancel={() => {
- backdropPress.current = false;
- }}
- className="fixed inset-0 m-auto max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-[640px] overflow-hidden rounded-xl border-0 bg-white p-0 text-black shadow-xl backdrop:bg-black/40 mobile:backdrop:bg-[#0f1724]/35 mobile:backdrop:animate-in mobile:backdrop:fade-in-0 mobile:backdrop:duration-200 mobile:mb-0 mobile:h-[calc(100dvh-92px)] mobile:max-h-[calc(100dvh-92px)] mobile:w-full mobile:max-w-none mobile:rounded-b-none mobile:rounded-t-[20px] mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200"
+ closeDisabled={pending}
+ // PC는 딤을 눌러도 닫지 않고(입력 보호), 시트는 딤을 눌러 닫는다(지금 동작)
+ disablePointerDismissal={!isMobileDevice}
>
-
-
-
-
-
-
- {original ? "비용 수정" : "비용 추가"}
-
-
{original ? expenseTitle(original) : "제목과 결제 정보를 입력해주세요."}
-
-
+
+ {original ? "비용 수정" : "비용 추가"}
+ {/* 보조 줄은 시트에서만 보인다(지금 화면과 같음) */}
+ {isMobileDevice ? (
+
+ {original ? expenseTitle(original) : "제목과 결제 정보를 입력해주세요."}
+
+ ) : null}
+
+
+
-
-
-
-
-
-
-
- 제목 (선택)
- change("name", event.target.value)} />
-
- {/* 통화 이름이 숨는 조건(ExpenseCurrencyPicker의 mobile·max-sm)에서는 통화 칸을 코드 3글자 + ▼ 폭으로 줄여 금액 칸을 넓힌다 */}
-
- {
- manuallySelectedCurrency.current = preferenceScope;
- change("currency", value);
- }}
- />
-
- 금액
- change("totalAmount", value)}
- placeholder={amountPlaceholder(currency)}
- />
-
-
- {!context.currencies.isSuccess && (
-
- {context.currencies.isError
- ? "통화 목록 조회에 실패했어요."
- : "통화 목록을 불러오는 중…"}
-
- )}
-
-
일차 구분
-
({
- value: String(s.scheduleId),
- label: `${s.dayNumber}일차`,
- })),
- ]}
- onChange={(value) => {
- setBody((prev) => ({
- ...prev,
- expenseGroup:
- value === "PREPARATION" ? "PREPARATION" : "TRIP_DAY",
- scheduleId: value === "PREPARATION" ? null : Number(value),
- scheduleItemId: null,
- }));
- reportError("");
- }}
- />
-
-
-
+
expenseNameError(value) ?? undefined }}>
+ {(field) => {
+ const fieldError = field.state.meta.errors[0];
+ return (
+
+ 제목 (선택)
+ field.handleChange(event.target.value)}
+ onBlur={field.handleBlur}
+ aria-invalid={fieldError ? true : undefined}
+ aria-describedby={fieldError ? `${nameId}-error` : undefined} />
+ {fieldError}
+
+ );
+ }}
+
+ {/* 통화 이름이 숨는 조건(ExpenseCurrencyPicker의 mobile·max-sm)에서는 통화 칸을 코드 3글자 + ▼ 폭으로 줄여 금액 칸을 넓힌다.
+ 112 = 트리거 테두리 2 + 좌우 여백 24 + 간격 8 + ▼ 자리 32 + 코드 46(16px 글자에서 가장 넓은 MWK 37 + 여유) */}
+
+
+ {(field) => (
+ {
+ manuallySelectedCurrency.current = preferenceScope;
+ field.handleChange(value);
+ }}
+ />
+ )}
+
+
+ amountError(value, currencyOf(fieldApi.form.getFieldValue("currency"))) ?? undefined,
+ }}
+ >
+ {(field) => {
+ const fieldError = field.state.meta.errors[0];
+ return (
+
+ 금액
+ field.handleChange(value)}
+ placeholder={amountPlaceholder(currency)}
+ aria-invalid={fieldError ? true : undefined}
+ aria-describedby={fieldError ? `${amountId}-error` : undefined}
+ />
+ {fieldError}
+
+ );
+ }}
+
+
+ {!context.currencies.isSuccess && (
+
+ {context.currencies.isError
+ ? "통화 목록 조회에 실패했어요."
+ : "통화 목록을 불러오는 중…"}
+
)}
- >
- 연결 장소 (선택)
-
-
p.itemId !== undefined)
- .map((p) => ({ value: String(p.itemId), label: p.title })),
- ]}
- onChange={(value) =>
- change("scheduleItemId", value ? Number(value) : null)
- }
- />
- {body.expenseGroup === "TRIP_DAY" && !places.isSuccess && (
-
- {places.isError
- ? "장소 조회에 실패했어요. 새로고침 후 다시 시도해 주세요."
- : "장소 확인 중…"}
+
+ 일차 구분
+ ({
+ value: String(s.scheduleId),
+ label: `${s.dayNumber}일차`,
+ })),
+ ]}
+ onChange={(value) => {
+ // 일차를 바꾸면 연결 장소를 비운다. 마지막 setFieldValue가 연결 장소 칸을 다시 검증하고 폼 아래 문장을 지운다
+ form.setFieldValue("expenseGroup", value === "PREPARATION" ? "PREPARATION" : "TRIP_DAY");
+ form.setFieldValue("scheduleId", value === "PREPARATION" ? null : Number(value));
+ form.setFieldValue("scheduleItemId", null);
+ }}
+ />
+
+
{
+ const values = fieldApi.form.state.values;
+ return expenseLinkError(values, days(values.scheduleId)) ?? undefined;
+ },
+ }}
+ >
+ {(field) => {
+ const fieldError = field.state.meta.errors[0];
+ return (
+
+ 연결 장소 (선택)
+ p.itemId !== undefined)
+ .map((p) => ({ value: String(p.itemId), label: p.title })),
+ ]}
+ onChange={(value) => field.handleChange(value ? Number(value) : null)}
+ aria-invalid={Boolean(fieldError)}
+ />
+ {fieldError}
+ {body.expenseGroup === "TRIP_DAY" && !places.isSuccess && (
+
+ {places.isError
+ ? "장소 조회에 실패했어요. 새로고침 후 다시 시도해 주세요."
+ : "장소 확인 중…"}
+
+ )}
+
+ );
+ }}
+
+ expenseCategoryError(value) ?? undefined }}>
+ {(field) => {
+ const fieldError = field.state.meta.errors[0];
+ return (
+
+ 카테고리
+ {
+ if (isExpenseCategory(value)) field.handleChange(value);
+ }}
+ aria-invalid={Boolean(fieldError)}
+ />
+ {fieldError}
+
+ );
+ }}
+
+ {context.memberStatus === "success" ? (
+ // 결제자·부담자는 함께 검증한다(각각 한 명 이상·중복 없음·고를 수 있는 멤버). 오류는 두 칸 아래 하나로 보인다
+
+ expenseRolesError(fieldApi.form.state.values, context.members, original) ?? undefined,
+ }}
+ >
+ {(payers) => (
+
+
+ payers.handleChange(ids)}
+ />
+
+ {(participants) => (
+ participants.handleChange(ids)}
+ />
+ )}
+
+
+
{payers.state.meta.errors[0]}
+
+ )}
+
+ ) : (
+ memberFallback
+ )}
+ expenseMemoError(value) ?? undefined }}>
+ {(field) => {
+ const fieldError = field.state.meta.errors[0];
+ return (
+
+ 메모 (선택)
+ field.handleChange(e.target.value)}
+ onBlur={field.handleBlur}
+ aria-invalid={fieldError ? true : undefined}
+ aria-describedby={fieldError ? `${memoId}-error` : undefined}
+ />
+
+ {fieldError}
+ {field.state.value?.length ?? 0}/1000
+
+
+ );
+ }}
+
+
+ {!context.canManage && context.memberStatus === "success" && (
+
+ 현재 참여 중인 방장과 멤버만 비용을 변경할 수 있어요.
)}
-
-
-
카테고리
-
{
- if (isExpenseCategory(value)) change("category", value);
- }}
- />
-
- {context.memberStatus === "success" ? (
-
-
change("payerUserIds", ids)}
- />
- change("participantUserIds", ids)}
+ {targetChanged && (
+
+ 비용이 삭제되었거나 다른 위치로 이동했어요. 입력 내용을 복사한 뒤
+ 최신 비용을 다시 열어 주세요.
+
+ )}
+ {conflict && (
+ {
+ setOriginal(conflict.latest);
+ setConflict(null);
+ reportError("");
+ }}
+ onRecover={() => void recover()}
/>
-
- ) : (
- memberFallback
- )}
-
- 메모 (선택)
- change("memo", e.target.value)}
- />
-
- {body.memo?.length ?? 0}/1000
-
-
-
- {!context.canManage && context.memberStatus === "success" && (
-
- 현재 참여 중인 방장과 멤버만 비용을 변경할 수 있어요.
-
- )}
- {targetChanged && (
-
- 비용이 삭제되었거나 다른 위치로 이동했어요. 입력 내용을 복사한 뒤
- 최신 비용을 다시 열어 주세요.
-
- )}
- {conflict && (
-
{
- setOriginal(conflict.latest);
- setConflict(null);
- reportError("");
- }}
- onRecover={() => void recover()}
- />
- )}
- {error && (
-
- {error}
-
- )}
-
-
-
- 취소
-
-
+ {error}
+
+ )}
+
+
+
+ }>취소
+
- {pending ? "저장 중…" : "저장"}
-
-
-
-
+ 저장
+
+
+
+
);
}
diff --git a/src/components/expenses/ExpenseItemMenu.tsx b/src/components/expenses/ExpenseItemMenu.tsx
index b354cec0..bfccc2b5 100644
--- a/src/components/expenses/ExpenseItemMenu.tsx
+++ b/src/components/expenses/ExpenseItemMenu.tsx
@@ -1,6 +1,12 @@
"use client";
-import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
+import { useRef, useState } from "react";
import { TrashIcon, WriteIcon } from "@/assets/icons";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
export function ExpenseItemMenu({
label,
@@ -14,82 +20,35 @@ export function ExpenseItemMenu({
onDelete: () => void;
}>) {
const [open, setOpen] = useState(false);
- const root = useRef(null);
const trigger = useRef(null);
- const id = useId();
- useEffect(() => {
- if (!open || !root.current) return;
- const element = root.current;
- const outside = (event: PointerEvent) => {
- if (!element.contains(event.target as Node)) setOpen(false);
- };
- element.ownerDocument.addEventListener("pointerdown", outside);
- return () =>
- element.ownerDocument.removeEventListener("pointerdown", outside);
- }, [open]);
+ // 항목이 여는 대화상자(비용 편집·삭제 확인)가 닫힌 뒤 포커스가 이 트리거로 돌아오도록, 동작 전에 트리거로 먼저 옮긴다(지금 동작).
+ // 메뉴 항목은 닫히면서 사라져 대화상자가 그 항목을 복귀 대상으로 잡으면 안 된다
function choose(action: () => void) {
- if (busy) return;
trigger.current?.focus();
- setOpen(false);
action();
}
- function handleEscape(event: KeyboardEvent) {
- if (event.key !== "Escape" || !open) return;
- event.preventDefault();
- event.stopPropagation();
- setOpen(false);
- trigger.current?.focus();
- }
+ // 처리 중(busy)에는 트리거가 비활성이고 열려 있던 메뉴도 숨긴다(지금 동작)
return (
- {
- if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
- }}
-
- >
-
+ setOpen(!open)}
className="flex size-8 @min-[800px]/expense-list:w-6 cursor-pointer items-center justify-center rounded-md text-text-subtle hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-50"
>
{/* eslint-disable-next-line @next/next/no-img-element -- local Figma vector asset */}
-
- {open && !busy && (
-
- choose(onEdit)}
- className="flex min-h-11 w-full cursor-pointer items-center gap-2 px-4 text-body-s-regular hover:bg-fill focus-visible:outline-primary"
- >
-
- 수정
-
- choose(onDelete)}
- className="flex min-h-11 w-full cursor-pointer items-center gap-2 px-4 text-body-s-regular text-status-negative hover:bg-fill focus-visible:outline-primary"
- >
-
- 삭제
-
-
- )}
-
+
+
+ choose(onEdit)}>
+
+ 수정
+
+ choose(onDelete)}>
+
+ 삭제
+
+
+
);
}
diff --git a/src/components/expenses/ExpenseLedgerInteractions.test.tsx b/src/components/expenses/ExpenseLedgerInteractions.test.tsx
index 1d3ec6f8..16b9bd7d 100644
--- a/src/components/expenses/ExpenseLedgerInteractions.test.tsx
+++ b/src/components/expenses/ExpenseLedgerInteractions.test.tsx
@@ -27,16 +27,21 @@ let root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", () => ({ matches: true }));
- HTMLDialogElement.prototype.showModal = function () {
- this.setAttribute("open", "");
- };
- HTMLDialogElement.prototype.close = function () {
- this.removeAttribute("open");
- };
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
+// Base UI는 열고 닫은 뒤 포커스·언마운트를 다음 틱(프레임)에 한다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+const sheet = () => document.querySelector('[role="dialog"]');
+// 딤·바깥 누르기: Base UI는 pointerdown·mousedown에서 시작해 click으로 끝난 누르기만 바깥 누르기로 본다
+async function pressOutside(target: HTMLElement) {
+ await act(async () => {
+ for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
+ target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button: 0 }));
+ }
+ });
+}
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
@@ -61,34 +66,42 @@ it("chooses a mobile category and restores its trigger, while cancel leaves sele
}
await act(async () => root.render( ));
const trigger = host.querySelector('[role="combobox"]')!;
- trigger.focus();
- await act(async () => trigger.click());
- expect(host.querySelector("dialog[open]")).not.toBeNull();
- expect(document.activeElement).toBe(host.querySelector("dialog h2"));
- await act(async () =>
- host.querySelector('input[value="FOOD"]')!.click(),
- );
+ const open = async () => {
+ trigger.focus();
+ await act(async () => trigger.click());
+ await flush();
+ expect(sheet()?.hasAttribute("data-open")).toBe(true);
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ };
+ const expectClosedOnTrigger = async () => {
+ await flush();
+ expect(sheet()).toBeNull();
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(document.activeElement).toBe(trigger);
+ };
+ await open();
+ // 첫 포커스는 Base UI 기본대로 시트 안으로 간다
+ expect(sheet()!.contains(document.activeElement)).toBe(true);
+ expect(sheet()!.querySelector("h2")?.textContent).toBe("카테고리");
+ const food = [...sheet()!.querySelectorAll('[role="radio"]')]
+ .find((radio) => radio.closest("label")?.textContent?.includes("식비"))!;
+ await act(async () => food.click());
expect(trigger.textContent).toContain("식비");
- expect(host.querySelector("dialog")).toBeNull();
- expect(document.activeElement).toBe(trigger);
- await act(async () => trigger.click());
+ await expectClosedOnTrigger();
+ await open();
await act(async () =>
- host
- .querySelector("dialog")!
- .dispatchEvent(new Event("cancel", { cancelable: true })),
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
);
- expect(host.querySelector("dialog")).toBeNull();
+ await expectClosedOnTrigger();
expect(trigger.textContent).toContain("식비");
- await act(async () => trigger.click());
- await act(async () => host.querySelector('[aria-label="카테고리 닫기"]')!.click());
- expect(host.querySelector("dialog")).toBeNull();
+ await open();
+ await act(async () => document.querySelector('[aria-label="카테고리 닫기"]')!.click());
+ await expectClosedOnTrigger();
expect(trigger.textContent).toContain("식비");
- expect(document.activeElement).toBe(trigger);
- await act(async () => trigger.click());
- await act(async () => host.querySelector("dialog")!.click());
- expect(host.querySelector("dialog")).toBeNull();
+ await open();
+ await pressOutside(document.querySelector('[data-slot="drawer-overlay"]')!);
+ await expectClosedOnTrigger();
expect(trigger.textContent).toContain("식비");
- expect(document.activeElement).toBe(trigger);
});
it("closes the item disclosure on Escape, outside pointer and focus departure without acting", async () => {
@@ -107,29 +120,36 @@ it("closes the item disclosure on Escape, outside pointer and focus departure wi
>,
),
);
- const trigger = host.querySelector("[aria-expanded]")!;
- await act(async () => trigger.click());
- const editButton = host.querySelector(
- '[aria-label="비용 수정"]',
- )!;
- editButton.focus();
+ const trigger = host.querySelector('[aria-label="항공 비용 더보기"]')!;
+ const open = async () => {
+ await act(async () => trigger.click());
+ await flush();
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ };
+ await open();
+ // 메뉴는 body로 포털된다. 항목은 Base UI Menu.Item(role="menuitem")
+ const editItem = document.querySelector('[role="menuitem"][aria-label="비용 수정"]')!;
+ expect(editItem).not.toBeNull();
+ editItem.focus();
await act(async () =>
- editButton.dispatchEvent(
+ editItem.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
),
);
+ await flush();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(trigger);
- await act(async () => trigger.click());
- await act(async () =>
- document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })),
- );
+ await open();
+ await pressOutside(document.body);
+ await flush();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
- await act(async () => trigger.click());
- await act(async () =>
- host.querySelector("#outside")!.focus(),
- );
+ await open();
+ // Tab으로 마지막 항목을 지나면 포커스가 메뉴 포털 끝의 포커스 가드로 간다. jsdom은 Tab 이동이 없어 그 가드로 직접 옮긴다
+ const guards = document.querySelectorAll("[data-base-ui-portal] [data-base-ui-focus-guard]");
+ await act(async () => guards[guards.length - 1].focus());
+ await flush();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(document.activeElement).toBe(host.querySelector("#outside"));
expect(edit).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
diff --git a/src/components/expenses/ExpenseNameFallback.test.tsx b/src/components/expenses/ExpenseNameFallback.test.tsx
index d2938af1..24e7d7a8 100644
--- a/src/components/expenses/ExpenseNameFallback.test.tsx
+++ b/src/components/expenses/ExpenseNameFallback.test.tsx
@@ -1,4 +1,7 @@
+// @vitest-environment jsdom
import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { act as domAct } from "react";
+import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ExpenseList } from "./ExpenseViews";
@@ -28,12 +31,22 @@ it.each([26, null])("uses category only in ledger titles regardless of linked pl
expect(JSON.stringify(renderer.toJSON())).toContain("별도 메모");
});
it("keeps scoped expense titles and edit labels category-only, with memo and place separate", async () => {
- await act(async () => { renderer = create( {}} onRetry={vi.fn()} onClose={vi.fn()} /> ); });
- const edit = renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 수정"))!;
- expect(edit.props["aria-label"]).toBe("식비 1,000 KRW 비용 수정");
- expect(renderer.root.findByType("li").findAllByType("span").some(span => span.children.length === 1 && span.children[0] === "식비")).toBe(true);
- expect(JSON.stringify(renderer.toJSON())).toContain("별도 메모");
- expect(JSON.stringify(renderer.toJSON())).toContain("경복궁");
+ // 비용 목록 대화상자는 Base UI Dialog라 body로 포털된다(react-test-renderer는 포털을 못 그린다)
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ try {
+ await domAct(async () => root.render( {}} onRetry={vi.fn()} onClose={vi.fn()} /> ));
+ const dialog = document.querySelector('[role="dialog"]')!;
+ const edit = [...dialog.querySelectorAll("button")].find(b => b.getAttribute("aria-label")?.endsWith("비용 수정"))!;
+ expect(edit.getAttribute("aria-label")).toBe("식비 1,000 KRW 비용 수정");
+ expect([...dialog.querySelector("li")!.querySelectorAll("span")].some(span => span.childNodes.length === 1 && span.textContent === "식비")).toBe(true);
+ expect(dialog.textContent).toContain("별도 메모");
+ expect(dialog.textContent).toContain("경복궁");
+ } finally {
+ await domAct(async () => root.unmount());
+ host.remove();
+ }
});
it("uses persisted names for ledger titles while keeping category and memo separate", async () => {
diff --git a/src/components/expenses/ExpensePanel.test.tsx b/src/components/expenses/ExpensePanel.test.tsx
index 153cc68e..3b5fa3f0 100644
--- a/src/components/expenses/ExpensePanel.test.tsx
+++ b/src/components/expenses/ExpensePanel.test.tsx
@@ -1,11 +1,12 @@
+// @vitest-environment jsdom
vi.mock("@/lib/api/config", () => ({ API_BASE: "http://fixture" }));
-import { ExpenseSelect } from "./ExpenseSelect";
-import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ExpenseApiError, type Expense } from "@/lib/api/rooms/expenses";
import { ExpensePanel } from "./ExpensePanel";
-import { ConfirmDialog } from "@/components/settings/ConfirmDialog";
-beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
+import contractFixture from "./krw-contract.fixture.json";
+
const mocks = vi.hoisted(() => ({
state: null as unknown,
remove: vi.fn(),
@@ -13,72 +14,26 @@ const mocks = vi.hoisted(() => ({
open: vi.fn(),
refresh: vi.fn(),
}));
-
-it("opens an expense in the selected trip day", async () => {
- await mount();
- mocks.open.mockClear();
- await act(async () =>
- renderer.root.findByProps({ "aria-label": "Day 1 비용" }).props.onClick(),
- );
- const add = renderer.root
- .findAllByType("button")
- .find((b) => b.props["aria-label"] === "비용 추가");
- expect(add).toBeDefined();
- await act(async () => add!.props.onClick());
- expect(mocks.open).toHaveBeenCalledWith({ scheduleId: 10 });
-});
-
-it("keeps a single reference travel total visible while filtering", async () => {
- await mount();
- mocks.state = {
- ...(mocks.state as object),
- summary: {
- isSuccess: true,
- data: {
- currencies: [
- {
- currency: "USD",
- totalAmount: "999999999999999.99",
- categories: [],
- days: [],
- individuals: [],
- transfers: [],
- },
- {
- currency: "KRW",
- totalAmount: "12300",
- categories: [],
- days: [],
- individuals: [],
- transfers: [],
- },
- ],
- },
- },
- };
- await act(async () => renderer.update( ));
- await act(async () =>
- renderer.root.findAllByType("button").find(b => b.props["aria-label"] === "여행 준비 비용")!.props.onClick(),
- );
- const text = JSON.stringify(renderer.toJSON());
- expect(text).not.toContain("999,999,999,999,999.99");
- expect(text).not.toContain("12,300");
- expect(text).toContain("501");
- expect(text.match(/여행 전체 비용/g)).toHaveLength(1);
- expect(text).not.toContain("원화 참고 비용");
-});
vi.mock("./ExpenseProvider", () => ({
useExpenseContext: () => mocks.state,
ExpenseEntryButton: () => 준비 비용 추가 ,
}));
-vi.mock("@/components/settings/ConfirmDialog", () => ({
- ConfirmDialog: () => null,
-}));
-let renderer: ReactTestRenderer;
+
+// 메뉴·정산 요약 대화상자·삭제 확인창은 Base UI라 body로 포털된다. react-dom으로 그리고 document에서 찾는다
+let host: HTMLDivElement;
+let root: Root;
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
afterEach(async () => {
- if (renderer) await act(async () => renderer.unmount());
+ await act(async () => root.unmount());
+ host.remove();
vi.unstubAllGlobals();
});
+
async function mount() {
const base = {
version: 0,
@@ -145,52 +100,139 @@ async function mount() {
};
mocks.remove.mockReset();
mocks.readLatest.mockReset();
- await act(async () => {
- renderer = create( );
- });
+ await rerender();
+}
+async function rerender() {
+ await act(async () => root.render( ));
}
+// Base UI는 열고 닫은 뒤 포커스 이동·언마운트를 다음 틱에 한다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+
+// 접근성 이름: aria-labelledby(Button loading이면 loadingLabel span) → aria-label → 글자
+function accessibleName(element: Element) {
+ const labelledBy = element.getAttribute("aria-labelledby");
+ if (labelledBy)
+ return labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ").trim();
+ return element.getAttribute("aria-label") ?? element.textContent?.trim() ?? "";
+}
+function buttonsIn(scope: ParentNode = document) {
+ return [...scope.querySelectorAll('button, [role="menuitem"]')];
+}
+function button(name: string, scope: ParentNode = document) {
+ return buttonsIn(scope).find((element) => accessibleName(element) === name);
+}
+async function press(name: string, scope: ParentNode = document) {
+ const target = button(name, scope);
+ expect(target, `button: ${name}`).toBeDefined();
+ await act(async () => target!.click());
+ await flush();
+}
+const text = () => document.body.textContent ?? "";
+const rows = () => host.querySelectorAll("li");
+const expenseRows = (id: number) => host.querySelectorAll(`[data-expense-id="${id}"]`);
+const summaryDialog = () => document.querySelector('[role="dialog"]');
+const confirmDialog = () => document.querySelector('[role="alertdialog"]');
+const selectedTotal = () => host.querySelector('[aria-label="선택한 비용 합계"]')!;
+// ExpenseKrwAmount는 원화 합계가 아직 없으면 '원화 금액 확인 중' 대시를 그린다
+const krwPending = (scope: Element) => scope.querySelector('[aria-label="원화 금액 확인 중"]') !== null;
+
+async function openFirstMenu() {
+ const trigger = buttonsIn(host).find((element) => element.getAttribute("aria-label")?.endsWith("비용 더보기"));
+ expect(trigger).toBeDefined();
+ await act(async () => trigger!.click());
+ await flush();
+}
+async function answerConfirm(confirmed: boolean) {
+ const dialog = confirmDialog();
+ expect(dialog).not.toBeNull();
+ await press(confirmed ? "삭제" : "취소", dialog!);
+}
+async function chooseCategory(label: string) {
+ const trigger = host.querySelector('[role="combobox"][aria-label="카테고리 필터"]');
+ expect(trigger).not.toBeNull();
+ await act(async () => trigger!.click());
+ await flush();
+ const option = [...document.querySelectorAll('[role="option"]')].find((node) => node.textContent === label);
+ expect(option, `option: ${label}`).toBeDefined();
+ await act(async () => option!.click());
+ await flush();
+}
+
+it("opens an expense in the selected trip day", async () => {
+ await mount();
+ mocks.open.mockClear();
+ await press("Day 1 비용");
+ await press("비용 추가");
+ expect(mocks.open).toHaveBeenCalledWith({ scheduleId: 10 });
+});
+
+it("keeps a single reference travel total visible while filtering", async () => {
+ await mount();
+ mocks.state = {
+ ...(mocks.state as object),
+ summary: {
+ isSuccess: true,
+ data: {
+ currencies: [
+ {
+ currency: "USD",
+ totalAmount: "999999999999999.99",
+ categories: [],
+ days: [],
+ individuals: [],
+ transfers: [],
+ },
+ {
+ currency: "KRW",
+ totalAmount: "12300",
+ categories: [],
+ days: [],
+ individuals: [],
+ transfers: [],
+ },
+ ],
+ },
+ },
+ };
+ await rerender();
+ await press("여행 준비 비용");
+ expect(text()).not.toContain("999,999,999,999,999.99");
+ expect(text()).not.toContain("12,300");
+ expect(text()).toContain("501");
+ expect(text().match(/여행 전체 비용/g)).toHaveLength(1);
+ expect(text()).not.toContain("원화 참고 비용");
+});
+
it("filters preparation and trip day without altering server summary scope", async () => {
await mount();
- await act(async () =>
- renderer.root.findAllByType("button").find(b => b.props["aria-label"] === "여행 준비 비용")!.props.onClick(),
- );
- expect(renderer.root.findAllByType("li")).toHaveLength(1);
- expect(JSON.stringify(renderer.toJSON())).toContain("준비");
- expect(JSON.stringify(renderer.toJSON())).not.toContain("현장");
- await act(async () =>
- renderer.root
- .findAllByType("button")
- .find((b) => b.children.includes("정산 요약"))!
- .props.onClick(),
- );
- expect(JSON.stringify(renderer.toJSON())).not.toContain("방 전체 비용을 기준으로 정산해요.");
+ await press("여행 준비 비용");
+ expect(rows()).toHaveLength(1);
+ expect(host.textContent).toContain("준비");
+ expect(host.textContent).not.toContain("현장");
+ await press("정산 요약");
+ expect(summaryDialog()).not.toBeNull();
+ expect(text()).not.toContain("방 전체 비용을 기준으로 정산해요.");
});
+
it("requires confirmation and preserves list with visible deletion failure", async () => {
await mount();
await openFirstMenu();
- let button = renderer.root
- .findAllByType("button")
- .find((b) => b.props["aria-label"] === "비용 삭제")!;
- await act(async () => button.props.onClick());
+ await press("비용 삭제");
await answerConfirm(false);
expect(mocks.remove).not.toHaveBeenCalled();
- expect(renderer.root.findAllByType(ConfirmDialog)).toHaveLength(0);
+ expect(confirmDialog()).toBeNull();
mocks.remove.mockRejectedValue(new Error("삭제 실패"));
await openFirstMenu();
- button = renderer.root
- .findAllByType("button")
- .find((b) => b.props["aria-label"] === "비용 삭제")!;
- await act(async () => button.props.onClick());
+ await press("비용 삭제");
await answerConfirm(true);
- expect(JSON.stringify(renderer.toJSON())).toContain("삭제 실패");
- expect(renderer.root.findAllByType("li")).toHaveLength(2);
+ expect(confirmDialog()).toBeNull();
+ expect(host.textContent).toContain("삭제 실패");
+ expect(rows()).toHaveLength(2);
});
it("returns to all expenses when the selected day is deleted remotely", async () => {
await mount();
- await act(async () =>
- renderer.root.findByProps({ "aria-label": "Day 1 비용" }).props.onClick(),
- );
+ await press("Day 1 비용");
const state = mocks.state as { list: { data: { id: number }[] } };
mocks.state = {
...state,
@@ -201,25 +243,11 @@ it("returns to all expenses when the selected day is deleted remotely", async ()
data: state.list.data.filter((e) => e.id === 1),
},
};
- await act(async () => renderer.update( ));
- expect(renderer.root.findByProps({ "aria-label": "전체 비용" }).props["aria-pressed"]).toBe(true);
- expect(renderer.root.findAllByType("li")).toHaveLength(1);
+ await rerender();
+ expect(button("전체 비용")?.getAttribute("aria-pressed")).toBe("true");
+ expect(rows()).toHaveLength(1);
});
-async function openFirstMenu() {
- await act(async () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 더보기"))!.props.onClick());
-}
-async function answerConfirm(confirmed: boolean) {
- const dialog = renderer.root.findByType(ConfirmDialog);
- await act(async () =>
- confirmed ? dialog.props.onConfirm() : dialog.props.onCancel(),
- );
-}
-function panelButton(label: string) {
- return renderer.root
- .findAllByType("button")
- .find((b) => b.children.includes(label) || (label === "삭제" && b.props["aria-label"] === "비용 삭제"));
-}
it("retains deletion selection and requires confirmation of the fetched version without automatic retry", async () => {
await mount();
const state = mocks.state as { list: { data: Expense[] } };
@@ -233,11 +261,11 @@ it("retains deletion selection and requires confirmation of the fetched version
);
mocks.readLatest.mockResolvedValueOnce(latest);
await openFirstMenu();
- await act(async () => panelButton("삭제")!.props.onClick());
+ await press("비용 삭제");
await answerConfirm(true);
expect(mocks.readLatest).toHaveBeenCalledWith(1);
expect(mocks.remove).toHaveBeenCalledTimes(1);
- expect(JSON.stringify(renderer.toJSON())).toContain("remote delete review");
+ expect(host.textContent).toContain("remote delete review");
mocks.state = {
...state,
list: {
@@ -245,14 +273,15 @@ it("retains deletion selection and requires confirmation of the fetched version
data: [{ ...latest, version: 4 }, state.list.data[1]],
},
};
- await act(async () => renderer.update( ));
- await act(async () => panelButton("최신 비용 확인 후 삭제")!.props.onClick());
+ await rerender();
+ await press("최신 비용 확인 후 삭제");
await answerConfirm(false);
expect(mocks.remove).toHaveBeenCalledTimes(1);
- await act(async () => panelButton("최신 비용 확인 후 삭제")!.props.onClick());
+ await press("최신 비용 확인 후 삭제");
await answerConfirm(true);
expect(mocks.remove).toHaveBeenLastCalledWith(latest);
});
+
it.each(["missing", "offline"])(
"does not retry delete when recovery is %s",
async (mode) => {
@@ -263,10 +292,10 @@ it.each(["missing", "offline"])(
if (mode === "missing") mocks.readLatest.mockResolvedValueOnce(undefined);
else mocks.readLatest.mockRejectedValueOnce(new Error("offline"));
await openFirstMenu();
- await act(async () => panelButton("삭제")!.props.onClick());
+ await press("비용 삭제");
await answerConfirm(true);
expect(mocks.remove).toHaveBeenCalledTimes(1);
- expect(panelButton("최신 비용 확인 후 삭제")).toBeUndefined();
+ expect(button("최신 비용 확인 후 삭제")).toBeUndefined();
},
);
@@ -299,45 +328,37 @@ it("keeps budget and original-currency settlement without comparison or explanat
},
},
};
- await act(async () => renderer.update( ));
- expect(JSON.stringify(renderer.toJSON())).toContain("전체 예산");
- expect(renderer.root.findAllByProps({ "aria-label": "예산 비교" })).toHaveLength(0);
- await act(async () =>
- renderer.root
- .findAllByType("button")
- .find((b) => b.children.includes("정산 요약"))!
- .props.onClick(),
- );
- const rendered = JSON.stringify(renderer.toJSON());
- expect(rendered).toContain("0.50");
- expect(rendered).toContain("USD");
- expect(rendered).toContain("KRW 정산");
- expect(rendered).toContain("500");
- expect(rendered).not.toContain("참고 잔여 예산");
- expect(rendered).not.toContain("통화별로 따로 정산");
- expect(rendered).not.toContain("아래 금액을 확인하고 직접 송금");
+ await rerender();
+ expect(host.textContent).toContain("전체 예산");
+ expect(document.querySelectorAll('[aria-label="예산 비교"]')).toHaveLength(0);
+ await press("정산 요약");
+ const dialog = summaryDialog()!;
+ expect(dialog.textContent).toContain("USD 0.50");
+ expect(dialog.querySelector('[aria-label="KRW 정산"]')?.textContent).toContain("500");
+ expect(text()).not.toContain("참고 잔여 예산");
+ expect(text()).not.toContain("통화별로 따로 정산");
+ expect(text()).not.toContain("아래 금액을 확인하고 직접 송금");
});
+
it("closes the expense panel after room access is revoked", async () => {
await mount();
mocks.state = { ...(mocks.state as object), revoked: true };
- await act(async () => renderer.update( ));
- expect(renderer.toJSON()).toBeNull();
+ await rerender();
+ expect(host.childElementCount).toBe(0);
});
-
it.each(["ready", "pending", "disconnected"])(
"does not offer a manual expense refresh while sync is %s",
async (syncStatus) => {
await mount();
mocks.state = { ...(mocks.state as object), syncStatus };
- await act(async () => renderer.update( ));
- expect(renderer.root.findAllByType("button").filter((button) =>
- /새로고침|조회 다시 시도/.test(button.props["aria-label"] ?? ""),
+ await rerender();
+ expect(buttonsIn().filter((element) =>
+ /새로고침|조회 다시 시도/.test(accessibleName(element)),
)).toHaveLength(0);
- const rendered = JSON.stringify(renderer.toJSON());
- expect(rendered).not.toContain("새로고침");
- if (syncStatus === "disconnected") expect(rendered).toContain("연결 복구 안내");
- if (syncStatus === "pending") expect(rendered).toContain("최신 비용 확인 중");
+ expect(text()).not.toContain("새로고침");
+ if (syncStatus === "disconnected") expect(text()).toContain("연결 복구 안내");
+ if (syncStatus === "pending") expect(text()).toContain("최신 비용 확인 중");
},
);
@@ -352,21 +373,21 @@ it.each(["sync", "members", "list", "summary", "budget", "krwSummary"])(
: failed === "members" ? { memberStatus: "error", canManage: false }
: { [failed]: { ...(healthy[failed] as object), isSuccess: false, isError: true } }),
};
- await act(async () => renderer.update( ));
- expect(JSON.stringify(renderer.toJSON())).not.toContain("새로고침");
- const retry = () => renderer.root.findAllByType("button")
- .find((button) => /조회 다시 시도/.test(button.props["aria-label"] ?? ""));
+ await rerender();
+ expect(text()).not.toContain("새로고침");
+ const retry = () => buttonsIn(host).find((element) => /조회 다시 시도/.test(accessibleName(element)));
expect(retry()).toBeDefined();
let finish!: () => void;
mocks.refresh.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
- await act(async () => retry()!.props.onClick());
- expect(retry()!.props.disabled).toBe(true);
+ await act(async () => retry()!.click());
+ // 패널(대화상자 밖) 재시도는 문구와 native disabled로 진행을 보인다
+ expect((retry() as HTMLButtonElement).disabled).toBe(true);
+ expect(accessibleName(retry()!)).toBe("조회 다시 시도 중");
await act(async () => { mocks.state = healthy; finish(); });
expect(retry()).toBeUndefined();
},
);
-
it.each(["initial load", "change", "reconnect"])(
"removes the top success explanation and its layout slot after %s succeeds",
async (scenario) => {
@@ -377,20 +398,18 @@ it.each(["initial load", "change", "reconnect"])(
...healthy,
syncStatus: scenario === "reconnect" ? "disconnected" : "pending",
};
- await act(async () => renderer.update( ));
- expect(JSON.stringify(renderer.toJSON())).toContain(
+ await rerender();
+ expect(text()).toContain(
scenario === "reconnect" ? "연결 복구 안내" : "최신 비용 확인 중",
);
mocks.state = healthy;
- await act(async () => renderer.update( ));
+ await rerender();
}
- const section = renderer.root.findByProps({ "aria-label": "비용 및 정산" });
- const first = section.children[0];
- expect(typeof first).not.toBe("string");
- if (typeof first === "string") throw new Error("Expected panel heading");
- expect(first.findAllByType("h1").map((heading) => heading.children.join("")))
+ const section = host.querySelector('section[aria-label="비용 및 정산"]')!;
+ const first = section.firstElementChild!;
+ expect([...first.querySelectorAll("h1")].map((heading) => heading.textContent))
.toEqual(["가계부"]);
- expect(JSON.stringify(renderer.toJSON())).not.toMatch(/최근 비용 조회 완료|30초 간격/);
+ expect(text()).not.toMatch(/최근 비용 조회 완료|30초 간격/);
},
);
@@ -404,17 +423,14 @@ it("opens the whole-trip settlement without extra analysis or scope menus", asyn
transfers: [{ fromUserId: 3, toUserId: 4, amount: "12345" }],
}] } },
};
- await act(async () => renderer.update( ));
- const click = async (label: string) => act(async () => {
- renderer.root.findAllByType("button").find(b => b.children.includes(label))!.props.onClick();
- });
- await click("정산 요약");
- expect(JSON.stringify(renderer.toJSON())).toContain("12,345");
- const labels = renderer.root.findAllByType("button").map(button => button.children.filter(child => typeof child === "string").join(""));
+ await rerender();
+ await press("정산 요약");
+ expect(summaryDialog()?.textContent).toContain("12,345");
+ const labels = [...document.querySelectorAll('button, [role="tab"]')].map(accessibleName);
expect(labels).not.toContain("비용 분석");
expect(labels).not.toContain("내 정산");
- expect(JSON.stringify(renderer.toJSON())).not.toContain("정산 보기 옵션");
-
+ expect(document.querySelector('[role="tablist"]')).toBeNull();
+ expect(text()).not.toContain("정산 보기 옵션");
});
it.each([
@@ -424,10 +440,10 @@ it.each([
])("announces %s sync status with a native block output", async (syncStatus, message) => {
await mount();
mocks.state = { ...(mocks.state as object), syncStatus };
- await act(async () => renderer.update( ));
- const output = renderer.root.findAllByType("output").find(node => node.children.join("").startsWith(message));
+ await rerender();
+ const output = [...host.querySelectorAll("output")].find((node) => node.textContent?.startsWith(message));
expect(output).toBeDefined();
- expect(output!.props.style).toEqual({ display: "block" });
+ expect(output!.style.display).toBe("block");
});
it("intersects day and category filters while keeping the trip budget visible", async () => {
@@ -437,32 +453,57 @@ it("intersects day and category filters while keeping the trip budget visible",
{ ...state.list.data[0], category: "FOOD" },
{ ...state.list.data[1], category: "OTHER" },
] } };
- await act(async () => renderer.update( ));
- const category = () => renderer.root.findAllByType(ExpenseSelect).find(s => s.props.label === "카테고리 필터")!;
- expect(category()).toBeDefined();
- await act(async () => category().props.onChange("FOOD"));
- expect(renderer.root.findAllByProps({ "data-expense-id": 1 })).toHaveLength(1);
- expect(renderer.root.findAllByProps({ "data-expense-id": 2 })).toHaveLength(0);
- await act(async () => renderer.root.findByProps({ "aria-label": "Day 1 비용" }).props.onClick());
- expect(renderer.root.findAllByProps({ "data-expense-id": 1 })).toHaveLength(0);
- expect(JSON.stringify(renderer.toJSON())).toContain("501");
+ await rerender();
+ await chooseCategory("식비");
+ expect(expenseRows(1)).toHaveLength(1);
+ expect(expenseRows(2)).toHaveLength(0);
+ await press("Day 1 비용");
+ expect(expenseRows(1)).toHaveLength(0);
+ expect(host.textContent).toContain("501");
});
it("can retry a failed summary from inside its modal", async () => {
await mount();
mocks.refresh.mockClear();
mocks.state = { ...(mocks.state as object), summary: { isError: true, isSuccess: false } };
- await act(async () => renderer.update( ));
- await act(async () => panelButton("정산 요약")!.props.onClick());
- const dialog = renderer.root.findByType("dialog");
- const retry = dialog.findAllByType("button").find(b => b.children.includes("조회 다시 시도"));
- expect(retry).toBeDefined();
- await act(async () => retry!.props.onClick());
+ await rerender();
+ await press("정산 요약");
+ const dialog = summaryDialog()!;
+ expect(button("조회 다시 시도", dialog)).toBeDefined();
+ await press("조회 다시 시도", dialog);
expect(mocks.refresh).toHaveBeenCalledOnce();
});
-import contractFixture from "./krw-contract.fixture.json";
-import { ExpenseKrwAmount } from "./ExpenseKrw";
+it("shows retry progress in the summary modal button while the panel retry stays natively disabled", async () => {
+ await mount();
+ mocks.refresh.mockReset();
+ mocks.state = { ...(mocks.state as object), summary: { isError: true, isSuccess: false } };
+ await rerender();
+ await press("정산 요약");
+ const dialog = summaryDialog()!;
+ let finish!: () => void;
+ mocks.refresh.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ await press("조회 다시 시도", dialog);
+ // 대화상자 안 버튼은 progress(loading): 접근성 이름은 loadingLabel, 포커스는 남고 native disabled 대신 aria-disabled
+ const modalRetry = button("조회 다시 시도 중", dialog) as HTMLButtonElement;
+ expect(modalRetry).toBeDefined();
+ expect(modalRetry.getAttribute("aria-busy")).toBe("true");
+ expect(modalRetry.getAttribute("aria-disabled")).toBe("true");
+ expect(modalRetry.disabled).toBe(false);
+ // 패널(대화상자 밖) 재시도는 지금처럼 문구가 바뀌고 native disabled
+ const panelRetry = button("조회 다시 시도 중", host) as HTMLButtonElement;
+ expect(panelRetry).toBeDefined();
+ expect(panelRetry.disabled).toBe(true);
+ expect(panelRetry.hasAttribute("aria-busy")).toBe(false);
+ expect(panelRetry.textContent).toContain("조회 다시 시도 중");
+ // 진행 중에 다시 눌러도 요청은 한 번이다
+ await act(async () => modalRetry.click());
+ expect(mocks.refresh).toHaveBeenCalledOnce();
+ await act(async () => finish());
+ expect(button("조회 다시 시도", dialog)?.hasAttribute("aria-busy")).toBe(false);
+ expect(button("조회 다시 시도", host)).toBeDefined();
+});
+
it("renders a complete filtered snapshot independently of a partial whole room and hides it on filter transition", async () => {
await mount();
const whole = { ...contractFixture.response, convertedTotalKrw: "508", isComplete: false, missingCurrencies: ["KWD"], stale: true };
@@ -470,77 +511,80 @@ it("renders a complete filtered snapshot independently of a partial whole room a
const setKrwFilters = vi.fn();
mocks.state = { ...(mocks.state as object), krwSummary: { data: whole, isSuccess: true },
krwFilters: {}, setKrwFilters, filteredKrwSummary: { data: selected, isSuccess: true } };
- await act(async () => renderer.update( ));
- const selectedTotal = renderer.root.findByProps({ "aria-label": "선택한 비용 합계" }).findByType(ExpenseKrwAmount);
- expect(selectedTotal.props.total).toEqual(selected.filtered);
- const allAmounts = renderer.root.findAllByType(ExpenseKrwAmount);
- expect(allAmounts.some(node => node.props.total?.convertedTotalKrw === "508" && !node.props.total.isComplete)).toBe(true);
- await act(async () => renderer.root.findByProps({ "aria-label": "Day 1 비용" }).props.onClick());
+ await rerender();
+ expect(selectedTotal().textContent).toContain("501원");
+ expect(selectedTotal().textContent).not.toContain("508원");
+ // 방 전체 합계는 부분 합계(508원)와 원화 합계 안내를 함께 보여 준다
+ const wholeAmount = [...host.querySelectorAll("span")].find((span) => span.textContent === "508원");
+ expect(wholeAmount?.parentElement?.querySelector('button[aria-label="원화 합계 안내"]')).not.toBeNull();
+ await press("Day 1 비용");
expect(setKrwFilters).toHaveBeenLastCalledWith({ expenseGroup: "TRIP_DAY", scheduleId: 10 });
- expect(renderer.root.findByProps({ "aria-label": "선택한 비용 합계" }).findByType(ExpenseKrwAmount).props.total).toBeUndefined();
+ expect(krwPending(selectedTotal())).toBe(true);
+ expect(selectedTotal().textContent).not.toContain("501원");
});
it("keeps known row and day KRW amounts while a new filtered total loads without mixing category scopes", async () => {
- await mount();
- const current = mocks.state as { list: { data: Expense[] }; krwSummary: { data: typeof contractFixture.response } };
- const row = { ...current.list.data[1], category: "FOOD" as const };
- const dayTotal = { convertedTotalKrw: "13500", isComplete: true, missingCurrencies: [], originalTotals: [] };
- const whole = { ...current.krwSummary.data, expenses: [{ expense: row, convertedAmountKrw: "13500", isComplete: true, missingCurrencies: [] }], days: [{ expenseGroup: "TRIP_DAY", scheduleId: 10, total: dayTotal }] };
- mocks.state = { ...(mocks.state as object), list: { isSuccess: true, data: [row] }, krwSummary: { isSuccess: true, data: whole }, filteredKrwSummary: { isPending: true, isFetching: true }, krwFilters: {} };
- await act(async () => renderer.update( ));
- await act(async () => renderer.root.findByProps({ "aria-label": "Day 1 비용" }).props.onClick());
- expect(renderer.root.findByProps({ "aria-label": "선택한 비용 합계" }).findByType(ExpenseKrwAmount).props.total).toEqual(dayTotal);
- expect(JSON.stringify(renderer.toJSON())).toContain("13,500원");
- expect(JSON.stringify(renderer.toJSON())).not.toContain("최신 비용 확인 중");
- await act(async () => renderer.root.findByType(ExpenseSelect).props.onChange("FOOD"));
- expect(renderer.root.findByProps({ "aria-label": "선택한 비용 합계" }).findByType(ExpenseKrwAmount).props.total).toBeUndefined();
- expect(JSON.stringify(renderer.toJSON())).toContain("13,500원");
+ await mount();
+ const current = mocks.state as { list: { data: Expense[] }; krwSummary: { data: typeof contractFixture.response } };
+ const row = { ...current.list.data[1], category: "FOOD" as const };
+ const dayTotal = { convertedTotalKrw: "13500", isComplete: true, missingCurrencies: [], originalTotals: [] };
+ const whole = { ...current.krwSummary.data, expenses: [{ expense: row, convertedAmountKrw: "13500", isComplete: true, missingCurrencies: [] }], days: [{ expenseGroup: "TRIP_DAY", scheduleId: 10, total: dayTotal }] };
+ mocks.state = { ...(mocks.state as object), list: { isSuccess: true, data: [row] }, krwSummary: { isSuccess: true, data: whole }, filteredKrwSummary: { isPending: true, isFetching: true }, krwFilters: {} };
+ await rerender();
+ await press("Day 1 비용");
+ expect(selectedTotal().textContent).toContain("13,500원");
+ expect(host.textContent).toContain("13,500원");
+ expect(text()).not.toContain("최신 비용 확인 중");
+ await chooseCategory("식비");
+ expect(krwPending(selectedTotal())).toBe(true);
+ expect(selectedTotal().textContent).not.toContain("13,500원");
+ expect(host.textContent).toContain("13,500원");
});
it("keeps the visible ledger unchanged during background tab-return reads", async () => {
- await mount();
- const healthy = { ...(mocks.state as Record), list: { isSuccess: true, data: contractFixture.response.expenses.map(row => row.expense) }, krwSummary: { data: contractFixture.response, isSuccess: true }, krwFilters: {}, filteredKrwSummary: { data: contractFixture.response, isSuccess: true } };
- mocks.state = healthy;
- await act(async () => renderer.update( ));
- const before = JSON.stringify(renderer.toJSON());
- mocks.state = { ...healthy, syncStatus: "refreshing", list: { ...healthy.list, isFetching: true }, krwSummary: { ...healthy.krwSummary, isFetching: true }, filteredKrwSummary: { ...healthy.filteredKrwSummary, isFetching: true } };
- await act(async () => renderer.update( ));
- expect(JSON.stringify(renderer.toJSON())).toBe(before);
+ await mount();
+ const healthy = { ...(mocks.state as Record), list: { isSuccess: true, data: contractFixture.response.expenses.map((row) => row.expense) }, krwSummary: { data: contractFixture.response, isSuccess: true }, krwFilters: {}, filteredKrwSummary: { data: contractFixture.response, isSuccess: true } };
+ mocks.state = healthy;
+ await rerender();
+ const before = host.innerHTML;
+ mocks.state = { ...healthy, syncStatus: "refreshing", list: { ...healthy.list, isFetching: true }, krwSummary: { ...healthy.krwSummary, isFetching: true }, filteredKrwSummary: { ...healthy.filteredKrwSummary, isFetching: true } };
+ await rerender();
+ expect(host.innerHTML).toBe(before);
});
-it('switches category rows and day totals together without an intermediate currency fallback', async () => {
- await mount();
- const current=mocks.state as {list:{data:Expense[]};krwSummary:{data:typeof contractFixture.response}};
- const food={...current.list.data[1],category:'FOOD' as const};
- const other={...food,id:99,category:'OTHER' as const};
- const total={convertedTotalKrw:'200',isComplete:true,missingCurrencies:[],originalTotals:[]};
- const whole={...current.krwSummary.data,expenses:[food,other].map(expense=>({expense,convertedAmountKrw:'100',isComplete:true,missingCurrencies:[]})),days:[{expenseGroup:'TRIP_DAY',scheduleId:10,total}]};
- mocks.state={...(mocks.state as object),list:{isSuccess:true,data:[food,other]},krwSummary:{isSuccess:true,data:whole},krwFilters:{},setKrwFilters:vi.fn(),filteredKrwSummary:{isPending:true,isFetching:true}};
- await act(async()=>renderer.update( ));
- const group=()=>renderer.root.findByProps({'aria-label':'1일차 비용'});
- const dayAmount=()=>group().findAllByType(ExpenseKrwAmount)[0].props.total?.convertedTotalKrw;
- expect(dayAmount()).toBe('200');
- await act(async()=>renderer.root.findByType(ExpenseSelect).props.onChange('FOOD'));
- expect(dayAmount()).toBe('200');
- expect(renderer.root.findAllByProps({'data-expense-id':99})).toHaveLength(1);
- const selected={...whole,expenses:[whole.expenses[0]],days:[{...whole.days[0],total:{...total,convertedTotalKrw:'100'}}]};
- mocks.state={...(mocks.state as object),krwFilters:{category:'FOOD'},filteredKrwSummary:{isSuccess:true,data:selected}};
- await act(async()=>renderer.update( ));
- expect(dayAmount()).toBe('100');
- expect(renderer.root.findAllByProps({'data-expense-id':99})).toHaveLength(0);
- mocks.state={...(mocks.state as object),filteredKrwSummary:{isSuccess:true,isFetching:true,data:selected}};
- await act(async()=>renderer.update( ));
- expect(dayAmount()).toBe('100');
+it("switches category rows and day totals together without an intermediate currency fallback", async () => {
+ await mount();
+ const current = mocks.state as { list: { data: Expense[] }; krwSummary: { data: typeof contractFixture.response } };
+ const food = { ...current.list.data[1], category: "FOOD" as const };
+ const other = { ...food, id: 99, category: "OTHER" as const };
+ const total = { convertedTotalKrw: "200", isComplete: true, missingCurrencies: [], originalTotals: [] };
+ const whole = { ...current.krwSummary.data, expenses: [food, other].map((expense) => ({ expense, convertedAmountKrw: "100", isComplete: true, missingCurrencies: [] })), days: [{ expenseGroup: "TRIP_DAY", scheduleId: 10, total }] };
+ mocks.state = { ...(mocks.state as object), list: { isSuccess: true, data: [food, other] }, krwSummary: { isSuccess: true, data: whole }, krwFilters: {}, setKrwFilters: vi.fn(), filteredKrwSummary: { isPending: true, isFetching: true } };
+ await rerender();
+ // 일차 묶음의 제목 줄(목록 위)에 그 일차의 원화 합계가 보인다
+ const dayAmount = () => host.querySelector('section[aria-label="1일차 비용"]')!.firstElementChild!.textContent!.match(/([\d,]+)원/)?.[1];
+ expect(dayAmount()).toBe("200");
+ await chooseCategory("식비");
+ expect(dayAmount()).toBe("200");
+ expect(expenseRows(99)).toHaveLength(1);
+ const selected = { ...whole, expenses: [whole.expenses[0]], days: [{ ...whole.days[0], total: { ...total, convertedTotalKrw: "100" } }] };
+ mocks.state = { ...(mocks.state as object), krwFilters: { category: "FOOD" }, filteredKrwSummary: { isSuccess: true, data: selected } };
+ await rerender();
+ expect(dayAmount()).toBe("100");
+ expect(expenseRows(99)).toHaveLength(0);
+ mocks.state = { ...(mocks.state as object), filteredKrwSummary: { isSuccess: true, isFetching: true, data: selected } };
+ await rerender();
+ expect(dayAmount()).toBe("100");
});
-it.each(['disconnected', 'pending', 'error', 'ready'])('preserves known day totals during %s instead of switching to original currencies', async (syncStatus) => {
- await mount();
- const current=mocks.state as {krwSummary:{data:typeof contractFixture.response}};
- const total={convertedTotalKrw:'12345',isComplete:false,missingCurrencies:['AED'],originalTotals:[]};
- const summary={...current.krwSummary.data,days:[{expenseGroup:'PREPARATION',scheduleId:null,total}]};
- mocks.state={...(mocks.state as object),syncStatus,krwSummary:{data:summary,isSuccess:syncStatus!=='error',isError:syncStatus==='error',isFetching:syncStatus==='pending'||syncStatus==='ready'}};
- await act(async()=>renderer.update( ));
- const heading=renderer.root.findAllByType('section').find(node=>node.props['aria-label']==='여행 준비 비용')!.findAllByType(ExpenseKrwAmount)[0];
- expect(heading.props.total).toEqual(total);
- expect(JSON.stringify(renderer.toJSON())).toContain('12,345원');
+it.each(["disconnected", "pending", "error", "ready"])("preserves known day totals during %s instead of switching to original currencies", async (syncStatus) => {
+ await mount();
+ const current = mocks.state as { krwSummary: { data: typeof contractFixture.response } };
+ const total = { convertedTotalKrw: "12345", isComplete: false, missingCurrencies: ["AED"], originalTotals: [] };
+ const summary = { ...current.krwSummary.data, days: [{ expenseGroup: "PREPARATION", scheduleId: null, total }] };
+ mocks.state = { ...(mocks.state as object), syncStatus, krwSummary: { data: summary, isSuccess: syncStatus !== "error", isError: syncStatus === "error", isFetching: syncStatus === "pending" || syncStatus === "ready" } };
+ await rerender();
+ const heading = host.querySelector('section[aria-label="여행 준비 비용"]')!.firstElementChild!;
+ expect(heading.textContent).toContain("12,345원");
+ expect(heading.textContent).not.toContain("KRW");
});
diff --git a/src/components/expenses/ExpensePanel.tsx b/src/components/expenses/ExpensePanel.tsx
index 5c8043b2..2bfba9df 100644
--- a/src/components/expenses/ExpensePanel.tsx
+++ b/src/components/expenses/ExpensePanel.tsx
@@ -1,7 +1,7 @@
"use client";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
-import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
import { expenseTitle } from "@/lib/expenses/expense-name";
import { MainPageHeader } from "@/components/layout/MainPageHeader";
@@ -11,8 +11,7 @@ import { ExpenseSelect } from "./ExpenseSelect";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ExpenseKrwAmount, ExpenseRateNote } from "./ExpenseKrw";
-import { RefreshCw } from "lucide-react";
-import { PlusIcon } from "@/assets/icons";
+import { PlusIcon, RefreshIcon } from "@/assets/icons";
import { ExpenseDialog } from "./ExpenseDialog";
import { ExpenseCategorySummary } from "./ExpenseCategorySummary";
import { totalsByCurrency } from "@/lib/expenses/expense-scope";
@@ -28,7 +27,6 @@ import { useExpenseContext } from "./ExpenseProvider";
import {
ExpenseList,
ExpenseSummaryView,
- expenseButtonClass,
formatExpenseAmount,
} from "./ExpenseViews";
type DeleteConflict = { selected: Expense; latest?: Expense; failed?: boolean };
@@ -51,7 +49,7 @@ function ExpenseDeleteConflict({
if (deleting) message = "최신 비용 확인 중…";
else if (conflict.failed)
message = "최신 비용 조회에 실패했어요. 삭제는 중단돼요.";
- const unavailable = deleting ? : {message}
;
+ const unavailable = deleting ? : {message}
;
return (
{}}
busy={false}
/>
- onRemove(conflict.latest!)}
>
최신 비용 확인 후 삭제
-
+
>
) : (
unavailable
)}
{conflict.failed && (
- onRecover(conflict.selected)}
>
최신 비용 다시 조회
-
+
)}
-
삭제 취소
-
+
);
}
@@ -307,24 +305,15 @@ export function ExpensePanel() {
label: `Day ${s.dayNumber}`,
})),
];
- const addButton = (
-
- context.open(
- activeFilter !== "ALL" && activeFilter !== "PREPARATION"
- ? { scheduleId: Number(activeFilter) }
- : {},
- )
- }
- className={cn(expenseButtonClass, "inline-flex items-center justify-center gap-1 bg-primary text-white enabled:hover:bg-primary-strong")}
- >
-
- 비용 추가
-
- );
+ function openNewExpense() {
+ context.open(
+ activeFilter !== "ALL" && activeFilter !== "PREPARATION"
+ ? { scheduleId: Number(activeFilter) }
+ : {},
+ );
+ }
+ // 패널(대화상자 밖)의 조회 재시도 진행은 지금처럼 문구와 native disabled로 보인다
+ const retryLabel = refreshing ? "조회 다시 시도 중" : "조회 다시 시도";
if (context.revoked) return null;
return (
- setTab("summary")}
- >
+
+
setTab("summary")}>
정산 요약
-
- {context.canManage && addButton}
+
+ {context.canManage && (
+
+
+ 비용 추가
+
+ )}
}
/>
@@ -361,28 +351,29 @@ export function ExpensePanel() {
{readFailed && (
- void refresh()}
>
-
- {refreshing ? "조회 다시 시도 중" : "조회 다시 시도"}
-
+
+ {retryLabel}
+
)}
-
- {context.canManage && addButton}
-
setTab("summary")}
- >
+
+ {context.canManage && (
+
+ 비용 추가
+
+ )}
+ setTab("summary")}>
정산 요약
-
+
{conflict && (
@@ -416,10 +407,14 @@ export function ExpensePanel() {
조회 다시 시도 버튼을 눌러 주세요.
)}
-
+ {/* 카테고리 필터 칸 184: 목록 폭이 트리거 폭과 같아서, 체크가 붙은 '전체 카테고리' 행(글자 85 + 여백 32 + 체크 자리 48)이
+ 목록 테두리 2와 스크롤 막대 15(목록이 8행이라 스크롤됨, 막대가 늘 보이는 설정)를 빼고도 한 줄에 들어가는 폭 */}
+
+ {/* 일차가 많으면 가로로 넘친다. 마우스는 세로 휠로 가로 이동이 안 되므로 PC는 얇은 스크롤 막대를 보여 끌 수 있게 하고(북마크에서 추가 칩 줄과 같은 모양),
+ 모바일 기기는 손가락으로 밀면 되니 막대를 숨긴다 */}
{dayOptions.map((option) => (
{context.filteredKrwSummary?.isError && 선택한 비용의 원화 조회에 실패했어요. 조회 다시 시도 버튼을 눌러 주세요.
}
- {context.list.isPending ? : context.list.isSuccess && (
+ {context.list.isPending ? : context.list.isSuccess && (
<>
currency.individuals.map(person => person.userId))).size}명` : undefined}
onClose={() => setTab("list")}
- settlement
- footer={
-
- setTab("list")}
- >
- 확인
-
-
- }
+ footer={ setTab("list")}>확인 }
>
{context.summary.isPending && (
-
+
)}
{context.summary.isError && (
정산 조회에 실패했어요.
-
void refresh()}
>
- {refreshing ? "조회 다시 시도 중" : "조회 다시 시도"}
-
+ 조회 다시 시도
+
)}
{context.summary.isSuccess &&
@@ -555,7 +542,6 @@ export function ExpensePanel() {
{expenseToDelete ? (
({
unsubscribe: vi.fn(),
}));
const stompClient = { subscribe: stomp.subscribe };
-/** 버튼 안 `` 라벨까지 이어 붙인 글자 */
-const textOf = (node: ReactTestInstance): string =>
- node.children.map((child) => (typeof child === "string" ? child : textOf(child))).join("");
vi.mock("@/contexts/StompContext", () => ({
useStompContext: () => ({ connected: stomp.connected, client: stompClient }),
@@ -53,14 +51,24 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/hooks/useSessionUser", () => ({
useSessionUser: () => ({ data: analytics.userId === undefined ? undefined : { id: analytics.userId } }),
}));
+// 편집기가 연 대상(일차·장소)을 칸에 보이는 이름으로 확인하려고, 필요한 테스트만 일차·장소를 채운다
+const rooms = vi.hoisted(() => ({
+ schedules: [] as { scheduleId: number; roomId: string; dayNumber: number; date: string; createdAt: string }[],
+ places: {} as Record,
+ none: [] as { itemId: number; title: string }[],
+}));
+beforeEach(() => { rooms.schedules = []; rooms.places = {}; });
vi.mock("@/hooks/useRooms", () => ({
- useSchedulePlanPlaces: () => ({ data: [], isSuccess: true }),
+ useSchedulePlanPlaces: (_roomId: string, scheduleId: number | null) => ({
+ data: (scheduleId === null ? undefined : rooms.places[scheduleId]) ?? rooms.none,
+ isSuccess: true,
+ }),
useDeleteRoomSchedule: () => ({ mutate: vi.fn(), isPending: false }),
useCreateRoomSchedule: () => ({ mutateAsync: vi.fn(), isPending: false }),
useRoomSchedules: () => ({
- data: [],
+ data: rooms.schedules,
isSuccess: true,
- refetch: async () => ({ data: [] }),
+ refetch: async () => ({ data: rooms.schedules }),
}),
}));
vi.mock("@/lib/api/rooms/members", () => ({ getRoomMembers: mocks.members }));
@@ -79,6 +87,9 @@ vi.mock("@/lib/api/rooms/expenses", async (importOriginal) => ({
const analytics = vi.hoisted(() => ({ send: vi.fn(), userId: 1 as number | undefined }));
vi.mock("@/lib/analytics/client", () => ({ sendAnalyticsDataCommand: analytics.send }));
beforeEach(() => { analytics.send.mockClear(); analytics.userId = 1; });
+// jsdom에는 window.location이 있어 이벤트에 공통 페이지 정보(정규화 URL)가 함께 붙는다. 그 밖의 속성은 보내지 않는다
+const sentEvent = (name: string) =>
+ ["event", name, { room_id: "r", page_path: "/", page_location: "http://localhost:3000/" }];
import { ExpensePanel } from "./ExpensePanel";
import { ExpenseProvider, useExpenseContext } from "./ExpenseProvider";
function Probe() {
@@ -89,10 +100,59 @@ function Probe() {
);
}
-let renderer: ReactTestRenderer;
+// 대화상자·확인창은 Base UI가 document.body로 포털한다. react-test-renderer는 포털을 그리지 못해 react-dom으로 그리고 document에서 찾는다
+let root: Root | null = null;
+let host: HTMLDivElement | null = null;
+/** 처음이면 새 root에, 이미 있으면 같은 root에 다시 그린다. act 안에서 부른다 */
+function draw(ui: ReactNode) {
+ if (!root) {
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+ }
+ root.render(ui);
+}
+function unmount() {
+ root?.unmount();
+ root = null;
+ host?.remove();
+ host = null;
+}
+/** 포털된 대화상자까지 포함해 아무것도 그리지 않았다 */
+function rendersNothing() {
+ return host?.childNodes.length === 0 && document.querySelector('[role="dialog"], [role="alertdialog"]') === null;
+}
+const buttons = () => [...document.querySelectorAll("button")];
+const buttonLabelled = (label: string) => document.querySelector(`[aria-label="${label}"]`);
+const dialogs = () => [...document.querySelectorAll('[role="dialog"]')];
+const dialogTitled = (title: string) => dialogs().find(dialog => dialog.querySelector("h2")?.textContent === title);
+/** 비용 추가·수정 편집기(ExpenseEditor) 대화상자 */
+const editors = () => dialogs().filter(dialog => ["비용 추가", "비용 수정"].includes(dialog.querySelector("h2")?.textContent ?? ""));
+const confirmation = () => document.querySelector('[role="alertdialog"]');
+/** 편집기가 연 대상. 일차 구분·연결 장소 칸에 보이는 이름 */
+function editorTarget(editor: HTMLElement) {
+ return {
+ day: editor.querySelector('[role="combobox"][aria-label="일차 구분"]')?.textContent,
+ place: editor.querySelector('[role="combobox"][aria-label="연결 장소 (선택)"]')?.textContent,
+ };
+}
+async function settle() {
+ // Base UI 열림·닫힘(microtask·requestAnimationFrame)과 쿼리 반영을 기다린다
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+}
+async function click(element: HTMLElement) {
+ await act(async () => element.click());
+ await settle();
+}
+/** 편집기 취소 버튼으로 닫는다 */
+async function closeEditor() {
+ const [editor] = editors();
+ await click([...editor.querySelectorAll("button")].find(button => button.textContent === "취소")!);
+ expect(editors()).toHaveLength(0);
+}
let client: QueryClient;
afterEach(async () => {
- await act(async () => renderer?.unmount());
+ await act(async () => unmount());
client?.clear();
vi.unstubAllGlobals();
});
@@ -104,7 +164,7 @@ it("does not treat partial STOMP member cache as a successful full member query"
],
});
await act(async () => {
- renderer = create(
+ draw(
@@ -112,7 +172,7 @@ it("does not treat partial STOMP member cache as a successful full member query"
,
);
});
- expect(renderer.root.findByType("p").children.join("")).toBe("pending:false");
+ expect(host!.querySelector("p")!.textContent).toBe("pending:false");
});
import type { Expense, ExpenseKrwSummary } from "@/lib/api/rooms/expenses";
@@ -155,7 +215,7 @@ async function mountMutations(options: { panel?: boolean; summary?: () => Promis
setSessionUserCache(client, { id: 1, email: "", nickname: "", profileImageUrl: null, provider: "GOOGLE", tutorialCompleted: true });
useSessionStore.setState({ sessionReady: true, currentRoomId: "r" });
await act(async () => {
- renderer = create(
+ draw(
@@ -197,7 +257,7 @@ it("forwards the reviewed version and reflects PATCH before summary refresh comp
expect.objectContaining({ expectedVersion: 2 }),
);
expect(client.getQueryData(expenseKeys.list("r"))).toEqual([updated]);
- expect(analytics.send.mock.calls).toEqual([["event", "expense_updated", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("expense_updated")]);
await act(async () => {
finishList([updated]);
finish({ currencies: [] });
@@ -236,7 +296,7 @@ it("forwards DELETE reviewed version and removes the record before summary refre
});
expect(mocks.remove).toHaveBeenLastCalledWith("r", 10, 2);
expect(client.getQueryData(expenseKeys.list("r"))).toEqual([]);
- expect(analytics.send.mock.calls).toEqual([["event", "expense_deleted", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("expense_deleted")]);
await act(async () => {
finishList([]);
finish({ currencies: [] });
@@ -296,7 +356,7 @@ it("saves as ACTIVE MEMBER, guards duplicate writes and installs PUT result", as
currency: "KRW",
version: 1,
});
- expect(analytics.send.mock.calls).toEqual([["event", "expense_budget_saved", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("expense_budget_saved")]);
});
it("fresh budget recovery propagates failure instead of accepting cached budget", async () => {
await mountMutations();
@@ -353,7 +413,7 @@ it("disconnect hides synchronized status and revocation stops subscription and c
expect(context.syncStatus).toBe("ready");
stomp.connected = false;
await act(async () => {
- renderer.update(
+ draw(
@@ -367,7 +427,7 @@ it("disconnect hides synchronized status and revocation stops subscription and c
getExpenseRecovery(client, "r").revoke();
});
expect(client.getQueryData(expenseKeys.list("r"))).toBeUndefined();
- expect(renderer.toJSON()).toBeNull();
+ expect(rendersNothing()).toBe(true);
});
it("late PATCH cannot replace a newer version read during its request", async () => {
await mountMutations();
@@ -393,7 +453,7 @@ it("late PATCH cannot replace a newer version read during its request", async ()
expect(
client.getQueryData(expenseKeys.list("r"))?.[0],
).toMatchObject({ version: 9, memo: "newer" });
- expect(analytics.send.mock.calls).toEqual([["event", "expense_updated", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("expense_updated")]);
});
import { beginExpenseRoomAdmission, getExpenseRecovery } from "@/lib/expenses/expense-recovery";
it("late PUT never replaces a newer explicit recovery read while trailing GET is pending", async () => {
@@ -425,7 +485,7 @@ it("late PUT never replaces a newer explicit recovery read while trailing GET is
finish({ budgetKrw: "100", currency: "KRW", version: 1 });
});
expect(client.getQueryData(expenseKeys.budget("r"))).toEqual(newer);
- expect(analytics.send.mock.calls).toEqual([["event", "expense_budget_saved", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("expense_budget_saved")]);
await act(async () => {
finishRead(newer);
await saving;
@@ -531,7 +591,7 @@ it("revocation during a mutation prevents its response from repopulating caches"
await saving;
});
expect(client.getQueryData(expenseKeys.list("r"))).toBeUndefined();
- expect(renderer.toJSON()).toBeNull();
+ expect(rendersNothing()).toBe(true);
});
it("keeps a committed create successful when its follow-up summary read fails", async () => {
await mountMutations();
@@ -549,7 +609,7 @@ it("keeps a committed create successful when its follow-up summary read fails",
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(context.syncStatus).toBe("error");
- expect(analytics.send.mock.calls).toEqual([["event", "expense_created", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("expense_created")]);
});
it("new authorized lifetime permits writes while late old PATCH and PUT stay fenced", async () => {
@@ -596,8 +656,7 @@ vi.mock("@/hooks/useChatPanelOpen", () => ({ useChatPanelOpen: () => true }));
vi.mock("@/lib/rooms", () => ({ validateRoomAccess: async () => "ok" }));
import { MainRoomGate } from "@/components/layout/MainRoomGate";
import { ExpenseEntryButton } from "./ExpenseProvider";
-import { ExpenseScopePanel } from "./ExpenseScopePanel";
-import { ExpensePlaceLabel } from "./ExpensePlaceLabel";
+import { formatExpenseAmount } from "./ExpenseViews";
import { useSessionStore } from "@/stores/session-store";
it("shares one room subscription across route children and resets it on selected-room changes", async () => {
@@ -608,29 +667,30 @@ it("shares one room subscription across route children and resets it on selected
mocks.list.mockResolvedValue([]);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const tree = (label: string) => ;
- await act(async () => { renderer = create(tree("장소 비용")); });
- expect(renderer.toJSON()).toBeNull();
+ await act(async () => { draw(tree("장소 비용")); });
+ expect(rendersNothing()).toBe(true);
expect(stomp.subscribe).not.toHaveBeenCalled();
gateState.roomContextReady = true;
- await act(async () => { renderer.update(tree("장소 비용")); });
+ await act(async () => { draw(tree("장소 비용")); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
- expect(renderer.root.findAllByType("button").some(b => textOf(b).includes("장소 비용"))).toBe(true);
+ expect(buttons().some(b => b.textContent?.includes("장소 비용"))).toBe(true);
expect(stomp.subscribe).toHaveBeenCalledTimes(1);
expect(stomp.subscribe.mock.calls[0][0]).toBe("/topic/rooms/r/expenses");
- await act(async () => { renderer.update(tree("일차 비용")); });
+ await act(async () => { draw(tree("일차 비용")); });
expect(stomp.subscribe).toHaveBeenCalledTimes(1);
expect(stomp.unsubscribe).not.toHaveBeenCalled();
- await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation: () => {} }));
- expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(1);
+ await click(host!.querySelector("button")!);
+ expect(editors()).toHaveLength(1);
gateState.roomId = "other";
- await act(async () => { renderer.update(tree("다른 방")); });
- expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
+ await act(async () => { draw(tree("다른 방")); });
+ await settle();
+ expect(editors()).toHaveLength(0);
expect(stomp.unsubscribe).toHaveBeenCalledTimes(1);
expect(stomp.subscribe).toHaveBeenCalledTimes(2);
expect(stomp.subscribe.mock.lastCall![0]).toBe("/topic/rooms/other/expenses");
gateState.roomId = null;
- await act(async () => { renderer.update(tree("선택 안 됨")); });
- expect(renderer.toJSON()).toBeNull();
+ await act(async () => { draw(tree("선택 안 됨")); });
+ expect(rendersNothing()).toBe(true);
expect(stomp.unsubscribe).toHaveBeenCalledTimes(2);
expect(gateRouter.replace).toHaveBeenCalledWith("/home");
});
@@ -639,28 +699,30 @@ vi.mock("@/hooks/useRoomDetail", () => ({ useRoomDetail: () => ({ data: undefine
vi.mock("@/hooks/usePlanScheduleDayReorder", () => ({ usePlanScheduleDayReorder: () => ({ getSectionProps: () => ({}), listContainerProps: {}, isMovePending: false }) }));
vi.mock("@/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock", () => ({ PlanScheduleDayBlock: () => null }));
import { PlanPageView } from "@/app/(main)/plan/_components/itinerary/PlanPageView";
-import { ExpenseEditor } from "./ExpenseEditor";
it("mobile plan shares the provider with the cost page while day/place entries keep their target", async () => {
gateState.roomId = "r";
useSessionStore.setState({ sessionReady: true, currentRoomId: "r" });
mocks.members.mockResolvedValue({ members: [{ userId: 1, role: "HOST", status: "ACTIVE" }] });
mocks.list.mockResolvedValue([]);
+ rooms.schedules = [{ scheduleId: 10, roomId: "r", dayNumber: 1, date: "2026-10-01", createdAt: "" }];
+ rooms.places = { 10: [{ itemId: 20, title: "남산타워" }] };
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- await act(async () => { renderer = create( ); });
+ await act(async () => { draw( ); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
- expect(renderer.root.findAllByType("a").some(a => a.props.href === "/cost")).toBe(false);
+ expect(document.querySelectorAll('a[href="/cost"]')).toHaveLength(0);
expect(stomp.subscribe).toHaveBeenCalledTimes(1);
- for (const [label, initial] of [["일차 비용", { scheduleId: 10 }], ["장소 비용", { scheduleId: 10, scheduleItemId: 20 }]] as const) {
- const button = renderer.root.findAllByType("button").find(b => textOf(b).includes(label))!;
- await act(async () => button.props.onClick({ stopPropagation: () => {} }));
- expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(1);
- expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual(initial);
- await act(async () => renderer.root.findByType(ExpenseEditor).props.onClose());
+ for (const [label, target] of [["일차 비용", { day: "1일차", place: "장소 연결 없음" }], ["장소 비용", { day: "1일차", place: "남산타워" }]] as const) {
+ const button = buttons().find(b => b.textContent?.includes(label))!;
+ await click(button);
+ expect(editors()).toHaveLength(1);
+ expect(editors()[0].querySelector("h2")!.textContent).toBe("비용 추가");
+ expect(editorTarget(editors()[0])).toEqual(target);
+ await closeEditor();
}
const { default: CostPage } = await import("@/app/(main)/cost/page");
- await act(async () => { renderer.update( ); });
- expect(renderer.root.findByType("h1").children).toEqual(["가계부"]);
+ await act(async () => { draw( ); });
+ expect(host!.querySelector("h1")!.textContent).toBe("가계부");
expect(stomp.subscribe).toHaveBeenCalledTimes(1);
expect(stomp.unsubscribe).not.toHaveBeenCalled();
});
@@ -672,28 +734,28 @@ it("cost page hides healthy refresh and retries failed reads through the same pr
mocks.members.mockResolvedValue({ members: [{ userId: 1, role: "HOST", status: "ACTIVE" }] });
mocks.list.mockResolvedValue([record]);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- await act(async () => { renderer = create( ); });
+ await act(async () => { draw( ); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
- expect(renderer.root.findByType("h1").children).toEqual(["가계부"]);
- expect(JSON.stringify(renderer.toJSON())).toContain("old");
+ expect(host!.querySelector("h1")!.textContent).toBe("가계부");
+ expect(document.body.textContent).toContain("old");
expect(stomp.subscribe).toHaveBeenCalledTimes(1);
- expect(renderer.root.findAllByType("button").filter(b => /새로고침|조회 다시 시도/.test(b.props["aria-label"] ?? ""))).toHaveLength(0);
+ expect(buttons().filter(b => /새로고침|조회 다시 시도/.test(b.getAttribute("aria-label") ?? ""))).toHaveLength(0);
mocks.list.mockRejectedValue(new Error("list offline"));
await act(async () => { await expect(getExpenseRecovery(client, "r").refresh("all")).rejects.toThrow("list offline"); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
- expect(JSON.stringify(renderer.toJSON())).toContain("list offline");
+ expect(document.body.textContent).toContain("list offline");
mocks.list.mockResolvedValue([{ ...record, memo: "recovered expense" }]);
const previousKrw = mocks.krw.getMockImplementation()!;
mocks.krw.mockImplementation(async () => ({
...await previousKrw(),
expenses: [{ expense: { ...record, memo: "recovered expense" }, convertedAmountKrw: "100", missingCurrencies: [], isComplete: true }],
}));
- const retry = renderer.root.findAllByType("button").find(b => b.props["aria-label"] === "조회 다시 시도");
- expect(retry).toBeDefined();
- await act(async () => { retry!.props.onClick(); await new Promise(resolve => setTimeout(resolve, 20)); });
- expect(JSON.stringify(renderer.toJSON())).toContain("recovered expense");
+ const retry = buttonLabelled("조회 다시 시도");
+ expect(retry).not.toBeNull();
+ await act(async () => { retry!.click(); await new Promise(resolve => setTimeout(resolve, 20)); });
+ expect(document.body.textContent).toContain("recovered expense");
mocks.krw.mockImplementation(previousKrw);
- expect(renderer.root.findAllByType("button").filter(b => /새로고침|조회 다시 시도/.test(b.props["aria-label"] ?? ""))).toHaveLength(0);
+ expect(buttons().filter(b => /새로고침|조회 다시 시도/.test(b.getAttribute("aria-label") ?? ""))).toHaveLength(0);
});
@@ -719,9 +781,9 @@ it.each(["reconnect", "visible"])(
);
stomp.connected = false;
- await act(async () => { renderer.update(render()); });
+ await act(async () => { draw(render()); });
stomp.connected = true;
- await act(async () => { renderer.update(render()); });
+ await act(async () => { draw(render()); });
} else {
const { getExpenseRecovery } = await import("@/lib/expenses/expense-recovery");
await act(async () => { await getExpenseRecovery(client, "r").refresh("visible"); });
@@ -733,11 +795,14 @@ it.each(["reconnect", "visible"])(
},
);
+const day2 = { scheduleId: 2, roomId: "r", dayNumber: 1, date: "2026-10-01", createdAt: "" };
async function mountPlaceExpenseButton(records: Expense[]) {
+ rooms.schedules = [day2];
+ rooms.places = { 2: [{ itemId: 3, title: "경복궁" }] };
await mountMutations();
await act(async () => {
client.setQueryData(expenseKeys.list("r"), records);
- renderer.update(
+ draw(
@@ -746,33 +811,42 @@ async function mountPlaceExpenseButton(records: Expense[]) {
);
});
}
+/** 일정 카드의 비용 버튼(ExpenseEntryButton). 화면에 이 버튼 하나만 그린다 */
+const entryButton = () => host!.querySelector("button")!;
+/** 비용 목록 대화상자의 행 삭제 버튼 */
+const deleteButton = () => buttons().find(b => b.getAttribute("aria-label")?.endsWith("비용 삭제"));
it("opens a place cost list instead of editing only the latest linked expense", async () => {
const latest = { ...record, id: 12, scheduleId: 2, scheduleItemId: 3, totalAmount: "12345", name: null, createdAt: "2026-09-16T02:00:00Z" };
await mountPlaceExpenseButton([
latest,
- { ...latest, id: 99, name: null, createdAt: "2026-09-15T02:00:00Z", updatedAt: "2026-09-17T02:00:00Z" },
+ // 어느 비용을 열었는지 편집기 메모 칸으로 구분한다
+ { ...latest, id: 99, name: null, memo: "older", createdAt: "2026-09-15T02:00:00Z", updatedAt: "2026-09-17T02:00:00Z" },
{ ...latest, id: 100, scheduleItemId: 4, name: null, createdAt: "2026-09-18T02:00:00Z" },
]);
await act(async () => {
client.setQueryData(scheduleItemsQueryKey("r", 2), [{ itemId: 3, title: "경복궁" }]);
});
- const button = renderer.root.findByType("button");
- expect(button.findByType("span").children.join("")).toBe("24,690 KRW");
- await act(async () => button.props.onClick({ stopPropagation() {} }));
- expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
- expect(renderer.root.findAllByType("h2").some(heading => heading.children.join("") === "장소 비용")).toBe(true);
- const panel = renderer.root.findByType(ExpenseScopePanel);
- expect(panel.findAllByType(ExpensePlaceLabel)).toHaveLength(2);
- expect(JSON.stringify(renderer.toJSON())).toContain("경복궁");
- const add = renderer.root.findByType(ExpenseScopePanel).findAllByType("button").find(b => b.children.join("").includes("비용 추가"))!;
- await act(async () => add.props.onClick());
- expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual({ scheduleId: 2, scheduleItemId: 3 });
- await act(async () => renderer.root.findByType(ExpenseEditor).props.onClose());
- const edits = renderer.root.findByType(ExpenseScopePanel).findAllByType("button").filter(b => b.props["aria-label"]?.endsWith("비용 수정"));
+ expect(entryButton().querySelector("span")!.textContent).toBe("24,690 KRW");
+ await click(entryButton());
+ expect(editors()).toHaveLength(0);
+ const panel = dialogTitled("장소 비용")!;
+ expect(panel).toBeDefined();
+ // 장소 이름 표시(ExpensePlaceLabel)는 비용 행마다 하나
+ expect(panel.querySelectorAll('[title="경복궁"]')).toHaveLength(2);
+ expect(panel.textContent).toContain("경복궁");
+ const add = [...panel.querySelectorAll("button")].find(b => b.textContent?.includes("비용 추가"))!;
+ await click(add);
+ expect(editors()).toHaveLength(1);
+ expect(editors()[0].querySelector("h2")!.textContent).toBe("비용 추가");
+ expect(editorTarget(editors()[0])).toEqual({ day: "1일차", place: "경복궁" });
+ await closeEditor();
+ const edits = [...panel.querySelectorAll("button")].filter(b => b.getAttribute("aria-label")?.endsWith("비용 수정"));
expect(edits).toHaveLength(2);
- await act(async () => edits[1].props.onClick());
- expect(renderer.root.findByType(ExpenseEditor).props.initial.expense.id).toBe(99);
+ await click(edits[1]);
+ expect(editors()).toHaveLength(1);
+ expect(editors()[0].querySelector("h2")!.textContent).toBe("비용 수정");
+ expect(editors()[0].querySelector("textarea")!.value).toBe("older");
});
it.each([
@@ -781,6 +855,7 @@ it.each([
{ rows: [["KRW", "1000"], ["USD", "10"], ["USD", "20"]], label: "30 USD 외 1건" },
{ rows: [["USD", "10"], ["EUR", "20"], ["EUR", "30"], ["USD", "5"]], label: "15 USD 외 2건" },
])("day summary $label opens all expenses for that day", async ({ rows, label }) => {
+ rooms.schedules = [day2];
await mountMutations();
await act(async () => {
client.setQueryData(expenseKeys.list("r"), [
@@ -789,21 +864,21 @@ it.each([
})),
{ ...record, id: 100, scheduleId: 3, scheduleItemId: null, totalAmount: "500", currency: "KRW" },
]);
- renderer.update(
+ draw(
,
);
});
- const button = renderer.root.findByType("button");
- expect(button.findByType("span").children.join("")).toBe(label);
- await act(async () => button.props.onClick({ stopPropagation() {} }));
- const panel = renderer.root.findByType(ExpenseScopePanel);
- expect(panel.findAllByType("h2")[0].children.join("")).toBe("1일차 비용");
- expect(panel.findAllByType("li")).toHaveLength(rows.length);
- await act(async () => panel.findAllByType("button").find(b => b.children.join("").includes("비용 추가"))!.props.onClick());
- expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual({ scheduleId: 2 });
+ expect(entryButton().querySelector("span")!.textContent).toBe(label);
+ await click(entryButton());
+ const panel = dialogTitled("1일차 비용")!;
+ expect(panel).toBeDefined();
+ expect(panel.querySelectorAll("li")).toHaveLength(rows.length);
+ await click([...panel.querySelectorAll("button")].find(b => b.textContent?.includes("비용 추가"))!);
+ expect(editors()).toHaveLength(1);
+ expect(editorTarget(editors()[0])).toEqual({ day: "1일차", place: "장소 연결 없음" });
});
it("returns to add mode after the last linked expense is deleted", async () => {
@@ -812,10 +887,11 @@ it("returns to add mode after the last linked expense is deleted", async () => {
client.setQueryData(expenseKeys.list("r"), []);
await new Promise((resolve) => setTimeout(resolve, 20));
});
- const button = renderer.root.findByType("button");
- expect(button.findByType("span").children.join("")).toBe("비용 추가");
- await act(async () => button.props.onClick({ stopPropagation() {} }));
- expect(renderer.root.findByType(ExpenseEditor).props.initial).toEqual({ scheduleId: 2, scheduleItemId: 3 });
+ expect(entryButton().querySelector("span")!.textContent).toBe("비용 추가");
+ await click(entryButton());
+ expect(dialogTitled("장소 비용")).toBeUndefined();
+ expect(editors()).toHaveLength(1);
+ expect(editorTarget(editors()[0])).toEqual({ day: "1일차", place: "경복궁" });
});
it("isolates AND-filtered KRW snapshots and refreshes them on expense invalidation without replacing the whole room", async () => {
@@ -883,7 +959,7 @@ it("never reports a foreground load during successful tab-return revalidation",
useEffect(() => { statuses.push(value.syncStatus); }, [value.syncStatus]);
return null;
}
- await act(async () => renderer.update( ));
+ await act(async () => draw( ));
const previous = context.krwSummary.data!;
let finish!: (value: ExpenseKrwSummary) => void;
mocks.krw.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
@@ -909,20 +985,21 @@ it("confirms a scoped expense deletion, supports cancel, and updates the card to
mocks.remove.mockReset();
mocks.remove.mockResolvedValue(undefined);
mocks.list.mockResolvedValue([]);
- await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
- const deleteButton = () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!;
+ await click(entryButton());
expect(deleteButton()).toBeDefined();
- await act(async () => deleteButton().props.onClick());
+ await click(deleteButton()!);
+ expect(confirmation()).not.toBeNull();
expect(mocks.remove).not.toHaveBeenCalled();
- await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 취소" }).props.onClick());
+ await click(buttonLabelled("비용 삭제 취소")!);
+ expect(confirmation()).toBeNull();
expect(mocks.remove).not.toHaveBeenCalled();
- await act(async () => deleteButton().props.onClick());
- await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick());
+ await click(deleteButton()!);
+ await click(buttonLabelled("비용 삭제 확인")!);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(mocks.remove).toHaveBeenCalledExactlyOnceWith("r", 10, 2);
- expect(renderer.root.findByType(ExpenseScopePanel).findAllByType("li")).toHaveLength(0);
- expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
- expect(renderer.root.findByProps({ "aria-label": "장소 비용 추가" })).toBeDefined();
+ expect(dialogTitled("장소 비용")!.querySelectorAll("li")).toHaveLength(0);
+ expect(editors()).toHaveLength(0);
+ expect(buttonLabelled("장소 비용 추가")).not.toBeNull();
});
@@ -931,17 +1008,22 @@ it("keeps a scoped expense after a failed delete and prevents duplicate submissi
mocks.remove.mockReset();
let reject!: (reason: Error) => void;
mocks.remove.mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; }));
- await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
- const button = renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"));
+ await click(entryButton());
+ const button = deleteButton();
expect(button).toBeDefined();
- await act(async () => button!.props.onClick());
- const confirm = renderer.root.findByProps({ "aria-label": "비용 삭제 확인" });
- await act(async () => { confirm.props.onClick(); confirm.props.onClick(); });
+ await click(button!);
+ const confirm = buttonLabelled("비용 삭제 확인")!;
+ await act(async () => { confirm.click(); confirm.click(); });
expect(mocks.remove).toHaveBeenCalledTimes(1);
- expect(renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.disabled).toBe(true);
+ // 삭제 중에는 progress로 바뀌고 누를 수 없다(focusableWhenDisabled라 native disabled 대신 aria-disabled)
+ expect(confirm.getAttribute("aria-busy")).toBe("true");
+ expect(confirm.getAttribute("aria-disabled")).toBe("true");
+ expect(document.getElementById(confirm.getAttribute("aria-labelledby")!)!.textContent).toBe("삭제 중");
await act(async () => reject(new Error("네트워크 오류")));
- expect(renderer.root.findByProps({ role: "alert" }).children.join("")).toContain("네트워크 오류");
- expect(renderer.root.findByType(ExpenseScopePanel).findAllByType("li")).toHaveLength(1);
+ await settle();
+ const panel = dialogTitled("장소 비용")!;
+ expect(panel.querySelector('[role="alert"]')!.textContent).toContain("네트워크 오류");
+ expect(panel.querySelectorAll("li")).toHaveLength(1);
});
@@ -952,31 +1034,32 @@ it("requires reviewing the refreshed scoped expense before deleting after a conf
mocks.remove.mockReset();
mocks.remove.mockRejectedValueOnce(new ExpenseApiError(409, "EXPENSE_CONFLICT", "changed"));
mocks.list.mockResolvedValue([{ ...linked, version: 3, totalAmount: "200" }]);
- await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
- const deleteButton = () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!;
- await act(async () => deleteButton().props.onClick());
- await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick());
+ await click(entryButton());
+ await click(deleteButton()!);
+ await click(buttonLabelled("비용 삭제 확인")!);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(mocks.remove).toHaveBeenCalledTimes(1);
- expect(renderer.root.findAllByProps({ "aria-label": "비용 삭제 확인" })).toHaveLength(0);
- expect(deleteButton().props["aria-label"]).toContain("200 KRW");
+ expect(confirmation()).toBeNull();
+ expect(deleteButton()!.getAttribute("aria-label")).toContain("200 KRW");
mocks.remove.mockResolvedValue(undefined);
mocks.list.mockResolvedValue([]);
- await act(async () => deleteButton().props.onClick());
- await act(async () => renderer.root.findByProps({ "aria-label": "비용 삭제 확인" }).props.onClick());
+ await click(deleteButton()!);
+ await click(buttonLabelled("비용 삭제 확인")!);
expect(mocks.remove).toHaveBeenLastCalledWith("r", 10, 3);
});
it("hides scoped deletion controls when management permission is lost", async () => {
await mountPlaceExpenseButton([{ ...record, scheduleId: 2, scheduleItemId: 3 }]);
- await act(async () => renderer.root.findByType("button").props.onClick({ stopPropagation() {} }));
- await act(async () => renderer.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 삭제"))!.props.onClick());
+ await click(entryButton());
+ await click(deleteButton()!);
+ expect(confirmation()).not.toBeNull();
await act(async () => {
client.setQueryData(expenseKeys.members("r"), { members: [] });
await new Promise(resolve => setTimeout(resolve, 20));
});
- expect(renderer.root.findAllByProps({ "aria-label": "비용 삭제 확인" })).toHaveLength(0);
- expect(renderer.root.findAllByType("button").filter(b => b.props["aria-label"]?.endsWith("비용 삭제"))).toHaveLength(0);
+ expect(confirmation()).toBeNull();
+ expect(buttonLabelled("비용 삭제 확인")).toBeNull();
+ expect(deleteButton()).toBeUndefined();
});
it("emits no expense or budget analytics while pending, after rejected writes, or blocked duplicate writes", async () => {
@@ -1024,12 +1107,12 @@ async function startPendingExpenseAndBudgetWrites() {
it.each(["account", "logout", "room", "unmount", "revocation"])("does not attribute late expense and budget success after %s changes", async (change) => {
const finishWrites = await startPendingExpenseAndBudgetWrites();
await act(async () => {
- if (change === "unmount") renderer.unmount();
+ if (change === "unmount") unmount();
else if (change === "revocation") getExpenseRecovery(client, "r").revoke();
else {
if (change === "account") analytics.userId = 2;
if (change === "logout") analytics.userId = undefined;
- renderer.update(
+ draw(
);
}
@@ -1041,7 +1124,7 @@ it.each(["account", "logout", "room", "unmount", "revocation"])("does not attrib
});
function panelButton(label: string) {
- return renderer.root.findAllByType("button").find(button => button.children.includes(label))!;
+ return buttons().find(button => button.textContent?.trim() === label)!;
}
async function flushQueries() {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
@@ -1049,37 +1132,39 @@ async function flushQueries() {
it("counts displayed settlement once per opening, never initial reads, rerenders, or refetches", async () => {
await mountMutations({ panel: true });
expect(analytics.send).not.toHaveBeenCalled();
- await act(async () => panelButton("정산 요약").props.onClick());
- expect(renderer.root.findByType("dialog")).toBeDefined();
- expect(analytics.send.mock.calls).toEqual([["event", "settlement_summary_viewed", { room_id: "r" }]]);
+ await click(panelButton("정산 요약"));
+ expect(dialogTitled("정산 요약")).toBeDefined();
+ expect(analytics.send.mock.calls).toEqual([sentEvent("settlement_summary_viewed")]);
await act(async () => { await context.refresh(); });
await flushQueries();
expect(analytics.send).toHaveBeenCalledTimes(1);
- await act(async () => panelButton("확인").props.onClick());
- await act(async () => panelButton("정산 요약").props.onClick());
+ await click(panelButton("확인"));
+ expect(dialogTitled("정산 요약")).toBeUndefined();
+ await click(panelButton("정산 요약"));
expect(analytics.send.mock.calls).toEqual([
- ["event", "settlement_summary_viewed", { room_id: "r" }], ["event", "settlement_summary_viewed", { room_id: "r" }],
+ sentEvent("settlement_summary_viewed"), sentEvent("settlement_summary_viewed"),
]);
});
it("waits for successful settlement data after loading and error before counting a view", async () => {
let reject!: (error: Error) => void;
await mountMutations({ panel: true, summary: () => new Promise((_, fail) => { reject = fail; }) });
- await act(async () => panelButton("정산 요약").props.onClick());
+ await click(panelButton("정산 요약"));
expect(analytics.send).not.toHaveBeenCalled();
await act(async () => reject(new Error("offline")));
await flushQueries();
- expect(JSON.stringify(renderer.toJSON())).toContain("정산 조회에 실패했어요.");
+ expect(dialogTitled("정산 요약")!.textContent).toContain("정산 조회에 실패했어요.");
expect(analytics.send).not.toHaveBeenCalled();
mocks.summary.mockResolvedValue({ currencies: [] });
await act(async () => { await context.refresh(); });
await flushQueries();
- expect(analytics.send.mock.calls).toEqual([["event", "settlement_summary_viewed", { room_id: "r" }]]);
+ expect(analytics.send.mock.calls).toEqual([sentEvent("settlement_summary_viewed")]);
});
it("does not count a settlement that finishes loading after the dialog closes", async () => {
let finish!: (value: unknown) => void;
await mountMutations({ panel: true, summary: () => new Promise(resolve => { finish = resolve; }) });
- await act(async () => panelButton("정산 요약").props.onClick());
- await act(async () => panelButton("확인").props.onClick());
+ await click(panelButton("정산 요약"));
+ await click(panelButton("확인"));
+ expect(dialogTitled("정산 요약")).toBeUndefined();
await act(async () => finish({ currencies: [] }));
await flushQueries();
expect(analytics.send).not.toHaveBeenCalled();
@@ -1088,7 +1173,7 @@ it("does not count a settlement that finishes loading after the dialog closes",
it.each(["account", "logout", "room", "room-loss", "account-roundtrip", "room-roundtrip"])("synchronously fences pending expense and budget analytics on %s before React rerenders", async (change) => {
const finishWrites = await startPendingExpenseAndBudgetWrites();
- // These are the actual synchronous session mutations. No renderer.update or
+ // These are the actual synchronous session mutations. No rerender or
// hook identity change occurs before the in-flight REST writes resolve.
await act(async () => {
if (change === "logout") tearDownClientSession({ queryClient: client });
@@ -1112,7 +1197,7 @@ it.each(["account", "logout", "room", "room-loss", "account-roundtrip", "room-ro
await context.saveBudget({ budgetKrw: "200", expectedVersion: 1 });
});
expect(analytics.send.mock.calls.filter(([command]) => command === "event")).toEqual([
- ["event", "expense_updated", { room_id: "r" }], ["event", "expense_budget_saved", { room_id: "r" }],
+ sentEvent("expense_updated"), sentEvent("expense_budget_saved"),
]);
}
});
@@ -1126,18 +1211,19 @@ it.each([
])("place summary $label opens the complete scoped original-currency list", async ({ rows, label }) => {
const records = rows.map(([currency, totalAmount], id) => ({ ...record, id, currency, totalAmount, scheduleId: 2, scheduleItemId: 3 }));
await mountPlaceExpenseButton([...records, { ...record, id: 100, scheduleId: 9, scheduleItemId: 3 }]);
- const button = renderer.root.findByType("button");
- expect(button.findByType("span").children.join("")).toBe(label);
- expect(button.props["aria-label"]).toBe("장소 비용 목록 열기");
- const descriptionId = button.props["aria-describedby"];
+ const button = entryButton();
+ expect(button.querySelector("span")!.textContent).toBe(label);
+ expect(button.getAttribute("aria-label")).toBe("장소 비용 목록 열기");
+ const descriptionId = button.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
- expect(textOf(renderer.root.findByProps({ id: descriptionId }))).toBe(label);
- await act(async () => button.props.onClick({ stopPropagation() {} }));
- expect(renderer.root.findAllByType(ExpenseEditor)).toHaveLength(0);
- const panel = renderer.root.findByType(ExpenseScopePanel);
- expect(panel.findAllByType("li")).toHaveLength(rows.length);
- for (const { currency, totalAmount } of records) {
- expect(textOf(panel)).toContain(currency);
- expect(panel.props.expenses).toContainEqual(expect.objectContaining({ currency, totalAmount }));
+ expect(document.getElementById(descriptionId!)!.textContent).toBe(label);
+ await click(button);
+ expect(editors()).toHaveLength(0);
+ const panel = dialogTitled("장소 비용")!;
+ const rowsShown = [...panel.querySelectorAll("li")];
+ expect(rowsShown).toHaveLength(rows.length);
+ // 행마다 원래 통화 금액 그대로 보인다(환산 없음)
+ for (const [index, { currency, totalAmount }] of records.entries()) {
+ expect(rowsShown[index].textContent).toContain(`${formatExpenseAmount(totalAmount)} ${currency}`);
}
});
diff --git a/src/components/expenses/ExpenseScopePanel.test.tsx b/src/components/expenses/ExpenseScopePanel.test.tsx
index 3f86aa20..e934f1ab 100644
--- a/src/components/expenses/ExpenseScopePanel.test.tsx
+++ b/src/components/expenses/ExpenseScopePanel.test.tsx
@@ -20,29 +20,47 @@ function Harness({ canManage = true }: { canManage?: boolean }) {
isPending={false} isError={false} canManage={canManage} busy={false} onAdd={() => {}} onEdit={() => {}}
onDelete={async () => { await remove(); setExpenses([]); }} onRetry={() => {}} onClose={close} />;
}
-const button = (label: string) => host.querySelector(`[aria-label="${label}"]`)!;
-const confirmation = () => host.querySelector('dialog[role="alertdialog"]');
+// 목록 대화상자·확인창은 Base UI가 document.body로 포털한다
+const button = (label: string) => document.querySelector(`[aria-label="${label}"]`)!;
+const listDialog = () => document.querySelector('[role="dialog"]');
+const confirmation = () => document.querySelector('[role="alertdialog"]');
+const listHeading = () => [...document.querySelectorAll("h3")].find((heading) => heading.textContent === "내역");
+async function settle() {
+ // 닫힘 전환·finalFocus(microtask)·Base UI의 requestAnimationFrame을 기다린다
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+}
+async function pressEscape() {
+ await act(async () => {
+ (document.activeElement ?? document.body).dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
+ );
+ });
+ await settle();
+}
async function openDelete() {
- const trigger = host.querySelector('[aria-label$="USD 비용 삭제"]')!;
+ const trigger = document.querySelector('[aria-label$="USD 비용 삭제"]')!;
trigger.focus();
await act(async () => trigger.click());
+ await settle();
return trigger;
}
beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- HTMLDialogElement.prototype.showModal = function () { this.open = true; };
- HTMLDialogElement.prototype.close = function () { this.open = false; };
close.mockReset(); remove.mockReset(); remove.mockResolvedValue(undefined);
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root.render( ));
+ await settle();
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
it("opens a separate modal and restores the delete trigger after cancel", async () => {
const trigger = await openDelete();
- expect(confirmation()?.open).toBe(true);
+ expect(confirmation()).not.toBeNull();
+ expect(confirmation()!.textContent).toContain("비용을 삭제할까요?");
+ expect(listDialog()).not.toBeNull();
expect(document.activeElement).toBe(button("비용 삭제 취소"));
await act(async () => button("비용 삭제 취소").click());
+ await settle();
expect(confirmation()).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(remove).not.toHaveBeenCalled();
@@ -51,9 +69,10 @@ it("opens a separate modal and restores the delete trigger after cancel", async
it("Escape cancels only the confirmation and preserves the expense list", async () => {
const trigger = await openDelete();
expect(confirmation()).not.toBeNull();
- await act(async () => confirmation()!.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true })));
+ await pressEscape();
expect(close).not.toHaveBeenCalled();
expect(confirmation()).toBeNull();
+ expect(listDialog()).not.toBeNull();
expect(document.activeElement).toBe(trigger);
});
@@ -63,22 +82,32 @@ it("blocks dismissal during deletion and restores focus to the list heading afte
await openDelete();
expect(confirmation()).not.toBeNull();
await act(async () => button("비용 삭제 확인").click());
- await act(async () => confirmation()!.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true })));
- expect(confirmation()?.open).toBe(true);
- expect(button("비용 삭제 취소").disabled).toBe(true);
+ const confirm = confirmation()!.querySelector("[aria-busy='true']")!;
+ expect(confirm.getAttribute("aria-disabled")).toBe("true");
+ expect(document.getElementById(confirm.getAttribute("aria-labelledby")!)!.textContent).toBe("삭제 중");
+ await pressEscape();
+ expect(confirmation()).not.toBeNull();
+ const cancel = button("비용 삭제 취소");
+ expect(cancel.disabled).toBe(true);
+ await act(async () => cancel.click());
+ await settle();
+ expect(confirmation()).not.toBeNull();
expect(close).not.toHaveBeenCalled();
+ expect(remove).toHaveBeenCalledOnce();
await act(async () => finish());
+ await settle();
expect(confirmation()).toBeNull();
- expect(document.activeElement).toBe(host.querySelector("h2"));
- expect(host.querySelectorAll("li")).toHaveLength(0);
+ expect(document.activeElement).toBe(listHeading());
+ expect(listDialog()!.querySelectorAll("li")).toHaveLength(0);
});
it("allows closing the list after management permission is lost during confirmation", async () => {
await openDelete();
await act(async () => root.render( ));
+ await settle();
expect(confirmation()).toBeNull();
- await act(async () => host.querySelector("dialog")!.dispatchEvent(new Event("cancel", { cancelable: true })));
+ await pressEscape();
expect(close).toHaveBeenCalledOnce();
expect(remove).not.toHaveBeenCalled();
});
diff --git a/src/components/expenses/ExpenseScopePanel.tsx b/src/components/expenses/ExpenseScopePanel.tsx
index ea26e561..f4b92c10 100644
--- a/src/components/expenses/ExpenseScopePanel.tsx
+++ b/src/components/expenses/ExpenseScopePanel.tsx
@@ -1,12 +1,31 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ ResponsiveDialog,
+ ResponsiveDialogBody,
+ ResponsiveDialogContent,
+ ResponsiveDialogDescription,
+ ResponsiveDialogFooter,
+ ResponsiveDialogHeader,
+ ResponsiveDialogTitle,
+} from "@/components/ui/responsive-dialog";
+import { Spinner } from "@/components/ui/spinner";
import { useMobileView } from "@/contexts/MobileViewContext";
-import { useSheetDrag } from "@/components/mobile/useSheetDrag";
-import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle";
import { expenseTitle } from "@/lib/expenses/expense-name";
-import { Pencil, Plus, Trash2, X } from "lucide-react";
-import { useEffect, useId, useRef, useState } from "react";
+import { PlusIcon } from "@/assets/icons";
+import { Pencil, Trash2 } from "lucide-react";
+import { useRef, useState } from "react";
import { ExpenseApiError, type Expense } from "@/lib/api/rooms/expenses";
import {
@@ -50,7 +69,6 @@ export function ExpenseScopePanel({
const [error, setError] = useState("");
const deleteLock = useRef(false);
const disabled = busy || deleting;
- const confirming = selected !== null && canManage;
async function confirmDelete() {
if (!selected || !canManage || busy || deleteLock.current) return;
deleteLock.current = true;
@@ -76,74 +94,38 @@ export function ExpenseScopePanel({
deleteLock.current = false;
}
}
- const sheetDrag = useSheetDrag(onClose, disabled || confirming);
const { isMobileDevice } = useMobileView();
- // 바텀시트는 기준 시트처럼 딤을 눌러 닫는다. 시트 안에서 시작한 드래그가 딤에서 끝나면 닫지 않는다.
- const backdropPress = useRef(false);
- const dialog = useRef(null);
- const title = useRef(null);
- const titleId = useId();
+ // 확인창이 닫히면 연 삭제 버튼으로, 그 행이 지워졌거나 버튼이 막혀 있으면 '내역' 제목으로 포커스를 돌린다(옛 동작)
+ const deleteOpener = useRef(null);
+ const listHeading = useRef(null);
const scoped = expensesInScope(expenses, scope);
const totals = totalsByCurrency(scoped);
- useEffect(() => {
- const element = dialog.current;
- const opener = element?.ownerDocument.activeElement;
- element?.showModal();
- title.current?.focus();
- return () => {
- element?.close();
- if (opener && opener instanceof HTMLElement && opener.isConnected) opener.focus();
- };
- }, []);
-
return (
- {
- event.preventDefault();
- if (!disabled && !confirming) onClose();
- }}
- onPointerDown={(event) => {
- backdropPress.current = event.button === 0 && event.target === event.currentTarget;
+ {
+ if (!open) onClose();
}}
- onPointerUp={(event) => {
- const startedOnBackdrop = backdropPress.current;
- backdropPress.current = false;
- if (isMobileDevice && !disabled && !confirming && startedOnBackdrop && event.target === event.currentTarget) onClose();
- }}
- onPointerCancel={() => {
- backdropPress.current = false;
- }}
- className="fixed inset-auto left-1/2 top-1/2 m-0 max-h-[85dvh] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl border border-gray-border bg-white p-0 text-text shadow-2xl backdrop:bg-black/40 mobile:backdrop:bg-[#0f1724]/35 mobile:backdrop:animate-in mobile:backdrop:fade-in-0 mobile:backdrop:duration-200 mobile:bottom-0 mobile:left-0 mobile:max-h-[60dvh] mobile:top-auto mobile:w-full mobile:max-w-none mobile:translate-x-0 mobile:translate-y-0 mobile:rounded-b-none mobile:rounded-t-[20px] mobile:animate-in mobile:slide-in-from-bottom mobile:duration-200 mobile:border-x-0 mobile:border-b-0"
+ // 확인창이 열려 있는 동안 Esc·딤은 Base UI가 위 확인창에만 준다
+ closeDisabled={disabled}
+ // PC는 딤을 눌러도 닫지 않고, 시트는 딤을 눌러 닫는다(지금 동작)
+ disablePointerDismissal={!isMobileDevice}
>
-
-
-
-
-
-
- {scope.label} 비용
-
- {scope.subtitle ? (
-
{scope.subtitle}
- ) : null}
-
-
-
-
-
-
+
+
+ {scope.label} 비용
+ {scope.subtitle ? (
+ {scope.subtitle}
+ ) : null}
+
-
+
총 비용 · {scoped.length}건
{totals.length ? totals.map(({ currency, amount }) => (
@@ -152,16 +134,16 @@ export function ExpenseScopePanel({
{error && {error}
}
- 내역
+ 내역
{isPending && !scoped.length ? (
-
+
) : isError && !scoped.length ? (
비용 조회에 실패했어요.
{ void Promise.resolve(onRetry()).catch(() => setError("비용 조회에 실패했어요. 다시 시도해 주세요.")); }} className="cursor-pointer text-primary-strong underline">다시 시도
) : scoped.length ? (
-
+
{scoped.map((expense) => (
@@ -197,7 +179,7 @@ export function ExpenseScopePanel({
aria-label={`${expenseTitle(expense)} ${formatExpenseAmount(expense.totalAmount)} ${expense.currency} 비용 삭제`}
title="비용 삭제"
disabled={disabled}
- onClick={() => { setError(""); setSelected(expense); }}
+ onClick={(event) => { deleteOpener.current = event.currentTarget; setError(""); setSelected(expense); }}
className="flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-lg text-dark-gray transition-colors enabled:hover:bg-gray-50 enabled:hover:text-text focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -210,90 +192,76 @@ export function ExpenseScopePanel({
) : (
아직 등록된 비용이 없어요.
)}
-
+
{canManage ? (
-
+
+
+ ) : null}
+ {/* 확인창은 목록 대화상자의 React 자식으로 둔다. Base UI가 위 레이어(확인창)에만 Esc·포커스를 준다 */}
+ {selected && canManage ? (
+
setSelected(null)}
+ onConfirm={() => void confirmDelete()}
+ finalFocus={() => {
+ // Base UI는 닫히는 커밋 도중 이 함수를 불러, 그때는 삭제 버튼의 disabled(deleting)가 아직 풀리지 않았다.
+ // 커밋이 끝난 뒤(microtask) DOM을 보고 고르고, false를 돌려 Base UI는 포커스를 옮기지 않게 한다(옛 동작)
+ const opener = deleteOpener.current;
+ const heading = listHeading.current;
+ queueMicrotask(() => {
+ if (opener?.isConnected && !opener.matches(":disabled")) opener.focus();
+ else heading?.focus();
+ });
+ return false;
+ }}
+ />
) : null}
-
- {selected && canManage ? (
- setSelected(null)}
- onConfirm={() => void confirmDelete()}
- fallbackFocus={title}
- />
- ) : null}
-
+
+
);
}
-function ExpenseDeleteDialog({ expense, pending, onCancel, onConfirm, fallbackFocus }: Readonly<{
+function ExpenseDeleteDialog({ expense, pending, onCancel, onConfirm, finalFocus }: Readonly<{
expense: Expense;
pending: boolean;
onCancel: () => void;
onConfirm: () => void;
- fallbackFocus: { current: HTMLHeadingElement | null };
+ finalFocus: () => boolean;
}>) {
- const dialog = useRef
(null);
- const cancel = useRef(null);
- const titleId = useId();
- const descriptionId = useId();
-
- useEffect(() => {
- const element = dialog.current;
- const trigger = element?.ownerDocument.activeElement;
- const fallback = fallbackFocus.current;
- element?.showModal();
- cancel.current?.focus();
- return () => {
- element?.close();
- // Wait for the parent to re-enable its controls or remove the deleted row.
- queueMicrotask(() => {
- if (trigger && trigger instanceof HTMLElement && trigger.isConnected && !trigger.matches(":disabled")) {
- trigger.focus();
- } else if (fallback?.isConnected) {
- fallback.focus();
- }
- });
- };
- }, [fallbackFocus]);
-
return (
- {
- event.preventDefault();
- event.stopPropagation();
- if (!pending) onCancel();
+ {
+ if (!open) onCancel();
}}
- className="fixed inset-0 m-auto max-h-[85dvh] w-[calc(100%-2rem)] max-w-sm overflow-y-auto rounded-2xl border border-gray-border bg-white p-5 text-text shadow-2xl backdrop:bg-black/30"
+ closeDisabled={pending}
>
- 비용을 삭제할까요?
- 삭제한 내역은 복구할 수 없어요.
-
- {expenseTitle(expense)}
- {formatExpenseAmount(expense.totalAmount)} {expense.currency}
-
-
- 취소
- {pending ? "삭제 중…" : "비용 삭제"}
-
-
+ {/* 딤을 눌러 닫지 않는다(지금 확인창과 같음). 처음 포커스는 취소 버튼(AlertDialogContent 기본) */}
+
+
+ 비용을 삭제할까요?
+ 삭제한 내역은 복구할 수 없어요.
+
+
+ {expenseTitle(expense)}
+ {formatExpenseAmount(expense.totalAmount)} {expense.currency}
+
+
+ 취소
+
+ 비용 삭제
+
+
+
+
);
}
diff --git a/src/components/expenses/ExpenseSelect.test.tsx b/src/components/expenses/ExpenseSelect.test.tsx
index 2804a857..4306503b 100644
--- a/src/components/expenses/ExpenseSelect.test.tsx
+++ b/src/components/expenses/ExpenseSelect.test.tsx
@@ -1,63 +1,123 @@
-import { act, create, type ReactTestRenderer } from "react-test-renderer";
-import { afterEach, expect, it, vi } from "vitest";
+// @vitest-environment jsdom
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ExpenseSelect } from "./ExpenseSelect";
-let renderer: ReactTestRenderer;
+const state = vi.hoisted(() => ({ mobile: false }));
+vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: state.mobile }) }));
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+let host: HTMLDivElement;
+let root: Root;
const change = vi.fn();
const options = [
{ value: "FOOD", label: "식사" },
{ value: "FLIGHT", label: "항공" },
];
+beforeEach(() => {
+ state.mobile = false;
+ change.mockClear();
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
afterEach(async () => {
- if (renderer) await act(async () => renderer.unmount());
+ await act(async () => root.unmount());
+ host.remove();
});
-async function mount(disabled = false) {
- change.mockClear();
- await act(async () => {
- renderer = create(
- ,
- );
- });
+async function render(node: ReactNode) {
+ await act(async () => root.render(node));
}
-const trigger = () => renderer.root.findByProps({ role: "combobox" });
-async function key(key: string) {
- const event = {
- key,
- preventDefault: vi.fn(),
- stopPropagation: vi.fn(),
- nativeEvent: { isComposing: false },
- };
- await act(async () => trigger().props.onKeyDown(event));
+async function mount(props: { disabled?: boolean; mobileSheet?: boolean } = {}) {
+ await render( );
+}
+const trigger = () => document.querySelector('[role="combobox"]')!;
+const optionNodes = () => [...document.querySelectorAll('[role="option"]')];
+// Base UI는 열고 닫을 때 microtask·timeout으로 포커스·목록을 옮긴다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+async function key(target: Element, key: string) {
+ const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
+ await act(async () => { target.dispatchEvent(event); });
+ await flush();
return event;
}
-it("selects custom options by keyboard without submitting the containing form", async () => {
- await mount();
- await key("ArrowDown");
- expect(renderer.root.findAllByProps({ role: "option" })).toHaveLength(2);
- await key("End");
- expect((await key("Enter")).preventDefault).toHaveBeenCalled();
+
+it("selects options by keyboard without submitting the containing form", async () => {
+ const submit = vi.fn();
+ await render(
+ { event.preventDefault(); submit(); }}>
+
+ ,
+ );
+ expect(trigger().textContent).toBe("선택");
+ await act(async () => trigger().focus());
+ await key(trigger(), "ArrowDown");
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
+ expect(optionNodes().map((option) => option.textContent)).toEqual(["식사", "항공"]);
+ await key(document.activeElement!, "End");
+ expect(document.activeElement?.textContent).toBe("항공");
+ await key(document.activeElement!, "Enter");
expect(change).toHaveBeenCalledWith("FLIGHT");
- expect(trigger().props["aria-expanded"]).toBe(false);
+ expect(submit).not.toHaveBeenCalled();
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
});
-it("closes on Escape without propagating to the expense dialog", async () => {
+
+it("closes on Escape without changing the value and returns focus to the trigger", async () => {
await mount();
- await act(async () => trigger().props.onClick());
- const event = await key("Escape");
- expect(event.preventDefault).toHaveBeenCalled();
- expect(event.stopPropagation).toHaveBeenCalled();
+ await act(async () => trigger().click());
+ await flush();
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
+ await key(document.activeElement!, "Escape");
expect(change).not.toHaveBeenCalled();
- expect(trigger().props["aria-expanded"]).toBe(false);
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+ expect(document.activeElement).toBe(trigger());
});
+
it("does not open or select while disabled", async () => {
- await mount(true);
- await act(async () => trigger().props.onClick());
- await key("ArrowDown");
- expect(trigger().props["aria-expanded"]).toBe(false);
+ await mount({ disabled: true });
+ await act(async () => trigger().click());
+ await act(async () => trigger().focus());
+ await key(trigger(), "ArrowDown");
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
expect(change).not.toHaveBeenCalled();
});
+
+it("shows the label of an empty-value option instead of the placeholder", async () => {
+ await render(
+ ,
+ );
+ expect(trigger().textContent).toBe("장소 연결 없음");
+ await act(async () => trigger().click());
+ await flush();
+ await act(async () => optionNodes().find((option) => option.textContent === "경복궁")!.click());
+ expect(change).toHaveBeenCalledWith("100");
+});
+
+it("marks the trigger invalid only while aria-invalid is set", async () => {
+ await render( );
+ expect(trigger().getAttribute("aria-invalid")).toBe("true");
+ await render( );
+ expect(trigger().hasAttribute("aria-invalid")).toBe(false);
+});
+
+it("picks from a bottom sheet radio list on mobile devices when mobileSheet is set", async () => {
+ state.mobile = true;
+ await mount({ mobileSheet: true });
+ expect(trigger().getAttribute("aria-haspopup")).toBe("dialog");
+ await act(async () => trigger().click());
+ await flush();
+ const sheet = document.querySelector('[role="dialog"]')!;
+ expect(sheet.textContent).toContain("카테고리");
+ const radios = [...sheet.querySelectorAll('[role="radio"]')];
+ expect(radios).toHaveLength(2);
+ await act(async () => radios[1].click());
+ await flush();
+ expect(change).toHaveBeenCalledWith("FLIGHT");
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+});
diff --git a/src/components/expenses/ExpenseSelect.tsx b/src/components/expenses/ExpenseSelect.tsx
index 64b07bdc..30254d41 100644
--- a/src/components/expenses/ExpenseSelect.tsx
+++ b/src/components/expenses/ExpenseSelect.tsx
@@ -1,12 +1,27 @@
"use client";
-import { useEffect, useId, useRef, useState } from "react";
-import { cn } from "@/lib/utils";
-import { ExpenseDialog } from "./ExpenseDialog";
-import { useMobileView } from "@/contexts/MobileViewContext";
-import { Check } from "lucide-react";
+import { useId, useState } from "react";
+
import { ChevronDownIcon } from "@/assets/icons";
+import {
+ Drawer,
+ DrawerBody,
+ DrawerContent,
+ DrawerDescription,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useMobileView } from "@/contexts/MobileViewContext";
+import { cn } from "@/lib/utils";
+
+type ExpenseSelectOption = { value: string; label: string };
+/**
+ * 가계부 선택 칸. 빈 문자열 값은 '선택 없음'이다(카테고리 미선택, 장소 연결 없음).
+ * 목록은 ui Select이고, mobileSheet이면 모바일 기기에서만 아래 시트 라디오 목록(Drawer)으로 고른다.
+ */
export function ExpenseSelect({
label,
name,
@@ -17,183 +32,137 @@ export function ExpenseSelect({
disabled = false,
mobileSheet = false,
compact = false,
+ id,
+ "aria-invalid": ariaInvalid,
}: {
label: string;
name?: string;
value: string;
- options: { value: string; label: string }[];
+ options: ExpenseSelectOption[];
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
+ /** 모바일 기기에서 아래 시트 라디오 목록으로 고른다(가계부 카테고리 필터) */
mobileSheet?: boolean;
+ /** 시트 트리거만 작게 그린다. ui Select 트리거는 디자인 시스템 크기 하나다 */
compact?: boolean;
+ /** 보이는 라벨(FieldLabel htmlFor)과 잇는 트리거 id */
+ id?: string;
+ /** 칸 오류. 트리거에 Text Field 오류 모양을 그린다 */
+ "aria-invalid"?: boolean;
}) {
const { isMobileDevice } = useMobileView();
- const [sheet, setSheet] = useState(false);
- const [open, setOpen] = useState(false);
- const [active, setActive] = useState(0);
- const root = useRef(null);
- const trigger = useRef(null);
- const list = useRef(null);
- const id = useId();
- const isOpen = open && !disabled;
- const activeIndex = Math.min(active, options.length - 1);
- const selected = options.find((option) => option.value === value);
- function show() {
- setSheet(mobileSheet && isMobileDevice);
- setActive(
- Math.max(
- 0,
- options.findIndex((option) => option.value === value),
- ),
+ if (mobileSheet && isMobileDevice) {
+ return (
+
);
- setOpen(true);
}
- function choose(next: string) {
- if (disabled) return;
- onChange(next);
- setOpen(false);
- trigger.current?.focus();
- }
- useEffect(() => {
- const element = root.current;
- if (!isOpen || !element || sheet) return;
- const outside = (event: PointerEvent) => {
- if (!element.contains(event.target as Node)) setOpen(false);
- };
- element.ownerDocument.addEventListener("pointerdown", outside);
- return () =>
- element.ownerDocument.removeEventListener("pointerdown", outside);
- }, [isOpen, sheet]);
- useEffect(() => {
- if (isOpen)
- list.current?.children[activeIndex]?.scrollIntoView({ block: "nearest" });
- }, [isOpen, activeIndex]);
+ // Base UI Select는 null만 '선택 없음'으로 본다. 빈 문자열을 그대로 넘기면 '장소 연결 없음' 같은 빈 값 항목의 라벨 대신 placeholder가 보인다
+ const items = options.map((option) => ({ value: option.value === "" ? null : option.value, label: option.label }));
return (
- {
- if (!sheet && !event.currentTarget.contains(event.relatedTarget))
- setOpen(false);
- }}
+
onChange(next ?? "")}
+ name={name}
+ disabled={disabled}
>
+
+
+
+
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ );
+}
+
+// 모바일 기기 카테고리 시트. 트리거는 지금 시트 트리거 모양 그대로, 목록은 Drawer + 라디오다
+function ExpenseSelectSheet({
+ label,
+ name,
+ value,
+ options,
+ onChange,
+ placeholder,
+ disabled,
+ compact,
+}: Readonly<{
+ label: string;
+ name?: string;
+ value: string;
+ options: ExpenseSelectOption[];
+ onChange: (value: string) => void;
+ placeholder: string;
+ disabled: boolean;
+ compact: boolean;
+}>) {
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
+ const sheetId = useId();
+ const isOpen = isSheetOpen && !disabled;
+ const selected = options.find((option) => option.value === value);
+ return (
+
{
- if (!disabled) {
- if (isOpen) setOpen(false);
- else show();
- }
- }}
- onKeyDown={(event) => {
- if (disabled || event.nativeEvent.isComposing) return;
- if (event.key === "Escape" && isOpen) {
- event.preventDefault();
- event.stopPropagation();
- setOpen(false);
- } else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
- event.preventDefault();
- if (!isOpen) show();
- else
- setActive(
- Math.max(
- 0,
- Math.min(
- options.length - 1,
- activeIndex + (event.key === "ArrowDown" ? 1 : -1),
- ),
- ),
- );
- } else if (isOpen && (event.key === "Home" || event.key === "End")) {
- event.preventDefault();
- setActive(event.key === "Home" ? 0 : options.length - 1);
- } else if (isOpen && (event.key === "Enter" || event.key === " ")) {
- event.preventDefault();
- if (options[activeIndex]) choose(options[activeIndex].value);
- }
- }}
+ onClick={() => setIsSheetOpen(true)}
className={cn("flex min-h-12 w-full min-w-0 items-center justify-between gap-3 rounded-lg border border-border bg-fill-subtle px-3.5 py-2 text-left text-[16px] leading-6 font-normal cursor-pointer transition-colors enabled:hover:border-primary/40 enabled:hover:bg-gray-50 aria-expanded:border-primary/50 aria-expanded:bg-primary/5 focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50", compact && "min-h-10 gap-2 px-3 text-[14px] leading-5 mobile:min-h-11 max-sm:min-h-11")}
>
-
+
{selected?.label ?? placeholder}
- {isOpen && sheet && (
- setOpen(false)}>
-
- 선택한 카테고리를 보여줘요.
-
-
+
+
+ 카테고리
+ 선택한 카테고리를 보여줘요.
+
+
+ {
+ onChange(String(next));
+ setIsSheetOpen(false);
+ }}
+ >
{options.map((option) => (
{option.value === "ALL" ? "전체" : option.label}
- choose(option.value)}
- className="size-4 cursor-pointer accent-primary"
- />
+
))}
-
-
- )}
- {isOpen && !sheet && (
-
- {options.map((option, index) => (
- event.preventDefault()}
- onClick={() => choose(option.value)}
- className={cn("flex min-h-12 w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-[16px] leading-6 font-normal cursor-pointer transition-colors hover:bg-primary/10", compact && "text-[14px] leading-5", index === activeIndex && "bg-primary/5")}
- >
- {option.label}
- {value === option.value && (
-
- )}
-
- ))}
-
- )}
-
+
+
+
+
);
}
diff --git a/src/components/expenses/ExpenseViews.test.tsx b/src/components/expenses/ExpenseViews.test.tsx
index 8ac874a0..ffeb2868 100644
--- a/src/components/expenses/ExpenseViews.test.tsx
+++ b/src/components/expenses/ExpenseViews.test.tsx
@@ -1,12 +1,39 @@
+// @vitest-environment jsdom
import { renderToStaticMarkup } from "react-dom/server";
+import { act, type ReactNode } from "react";
+import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
-import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
ExpenseList,
ExpenseSummaryView,
ExpenseAnalysisView,
ExpenseRolePicker,
} from "./ExpenseViews";
+// 메뉴·탭은 Base UI라 react-dom으로 그리고 document에서 찾는다(메뉴 목록은 body로 포털된다)
+async function renderInDom(node: ReactNode) {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ await act(async () => root.render(node));
+ return {
+ host,
+ rerender: (next: ReactNode) => act(async () => root.render(next)),
+ cleanup: async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ vi.unstubAllGlobals();
+ },
+ };
+}
+// Base UI는 메뉴를 닫은 뒤 다음 틱에 목록을 지운다
+const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+const menuItem = (label: string) =>
+ document.querySelector(`[role="menuitem"][aria-label="${label}"]`);
+async function press(element: HTMLElement) {
+ await act(async () => element.click());
+ await flush();
+}
const expense = {
id: 10,
expenseGroup: "PREPARATION" as const,
@@ -296,59 +323,56 @@ it("scales analysis bars against the largest amount and handles zero totals", ()
});
it("shows only the selected analysis graph and preserves exact daily amounts", async () => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- let renderer: ReactTestRenderer | undefined;
+ const view = await renderInDom( );
try {
- await act(async () => {
- renderer = create( );
- });
- const graphs = () => renderer!.root.findAllByType("figure");
- expect(graphs()).toHaveLength(1);
- expect(graphs()[0].props["aria-label"]).toBe("USD 카테고리별 비용 그래프");
- await act(async () => renderer!.root.findAllByType("button").find(b => b.children.includes("일차별"))!.props.onClick());
- expect(graphs()).toHaveLength(1);
- expect(graphs()[0].props["aria-label"]).toBe("USD 준비·일차별 비용 그래프");
- expect(JSON.stringify(renderer!.toJSON())).toContain("여행 준비 · 1,234,567.00 USD");
- await act(async () => renderer!.root.findAllByType("button").find(b => b.children.includes("카테고리별"))!.props.onClick());
- expect(graphs()).toHaveLength(1);
- expect(graphs()[0].props["aria-label"]).toBe("USD 카테고리별 비용 그래프");
+ const graphs = () => [...view.host.querySelectorAll("figure")].map(figure => figure.getAttribute("aria-label"));
+ // 분석 기준은 Tabs(role="tab", aria-selected)다
+ const tab = (label: string) => [...view.host.querySelectorAll('[role="tab"]')].find(t => t.textContent === label)!;
+ expect(view.host.querySelector('[role="tablist"]')?.getAttribute("aria-label")).toBe("비용 분석 기준");
+ expect(tab("카테고리별").getAttribute("aria-selected")).toBe("true");
+ expect(graphs()).toEqual(["USD 카테고리별 비용 그래프"]);
+ await press(tab("일차별"));
+ expect(tab("일차별").getAttribute("aria-selected")).toBe("true");
+ expect(tab("카테고리별").getAttribute("aria-selected")).toBe("false");
+ expect(graphs()).toEqual(["USD 준비·일차별 비용 그래프"]);
+ expect(view.host.innerHTML).toContain("여행 준비 · 1,234,567.00 USD");
+ await press(tab("카테고리별"));
+ expect(graphs()).toEqual(["USD 카테고리별 비용 그래프"]);
} finally {
- await act(async () => renderer?.unmount());
- vi.unstubAllGlobals();
+ await view.cleanup();
}
});
it("opens editing from the expense card and keeps deletion as a separate action", async () => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- let renderer: ReactTestRenderer | undefined;
const onEdit = vi.fn();
const onDelete = vi.fn();
const render = (canManage: boolean, busy = false) => ;
+ const view = await renderInDom(render(true));
try {
- await act(async () => { renderer = create(render(true)); });
- expect(renderer!.root.findAllByType("details")).toHaveLength(0);
- expect(JSON.stringify(renderer!.toJSON())).toContain("결제");
- expect(JSON.stringify(renderer!.toJSON())).toContain("분담");
- const more = () => renderer!.root.findAllByType("button").find(b => b.props["aria-label"]?.endsWith("비용 더보기"))!;
- await act(async () => more().props.onClick());
- await act(async () => renderer!.root.findByProps({ "aria-label": "비용 수정" }).props.onClick());
+ expect(view.host.querySelectorAll("details")).toHaveLength(0);
+ expect(view.host.textContent).toContain("결제");
+ expect(view.host.textContent).toContain("분담");
+ const more = () => [...view.host.querySelectorAll("button")].find(b => b.getAttribute("aria-label")?.endsWith("비용 더보기"))!;
+ await press(more());
+ await press(menuItem("비용 수정")!);
expect(onEdit).toHaveBeenCalledWith(expense);
onEdit.mockClear();
- await act(async () => more().props.onClick());
- await act(async () => renderer!.root.findByProps({ "aria-label": "비용 삭제" }).props.onClick());
+ await press(more());
+ await press(menuItem("비용 삭제")!);
expect(onDelete).toHaveBeenCalledWith(expense);
expect(onEdit).not.toHaveBeenCalled();
- await act(async () => renderer!.update(render(true, true)));
- expect(renderer!.root.findAllByType("button").every(b => b.props.disabled)).toBe(true);
- await act(async () => renderer!.update(render(false)));
- expect(renderer!.root.findAllByType("button")).toHaveLength(0);
+ await view.rerender(render(true, true));
+ const buttons = [...view.host.querySelectorAll("button")];
+ expect(buttons.length).toBeGreaterThan(0);
+ expect(buttons.every(b => b.disabled)).toBe(true);
+ await view.rerender(render(false));
+ expect(view.host.querySelectorAll("button")).toHaveLength(0);
} finally {
- await act(async () => renderer?.unmount());
- vi.unstubAllGlobals();
+ await view.cleanup();
}
});
@@ -360,18 +384,17 @@ it("announces unavailable identity before an empty personal settlement", () => {
});
it("opens a more disclosure before delete and uses category instead of memo as the name", async () => {
- vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- let renderer: ReactTestRenderer | undefined;
const onDelete = vi.fn();
+ const view = await renderInDom( );
try {
- await act(async () => { renderer = create( ); });
- const buttons = () => renderer!.root.findAllByType("button");
- expect(buttons().find(b => b.props["aria-label"] === "비용 삭제")).toBeUndefined();
- const more = buttons().find(b => b.props["aria-label"] === "기타 비용 더보기");
- expect(more).toBeDefined();
- await act(async () => more!.props.onClick());
- await act(async () => buttons().find(b => b.props["aria-label"] === "비용 삭제")!.props.onClick());
+ expect(menuItem("비용 삭제")).toBeNull();
+ const more = view.host.querySelector('button[aria-label="기타 비용 더보기"]');
+ expect(more).not.toBeNull();
+ await press(more!);
+ await press(menuItem("비용 삭제")!);
expect(onDelete).toHaveBeenCalledWith(expense);
- expect(buttons().find(b => b.props["aria-label"] === "비용 삭제")).toBeUndefined();
- } finally { await act(async () => renderer?.unmount()); vi.unstubAllGlobals(); }
+ expect(menuItem("비용 삭제")).toBeNull();
+ } finally {
+ await view.cleanup();
+ }
});
diff --git a/src/components/expenses/ExpenseViews.tsx b/src/components/expenses/ExpenseViews.tsx
index 8f4ec007..6f7ad929 100644
--- a/src/components/expenses/ExpenseViews.tsx
+++ b/src/components/expenses/ExpenseViews.tsx
@@ -1,5 +1,9 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Spinner } from "@/components/ui/spinner";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ExpenseKrwAmount, ExpenseRowAmount } from "./ExpenseKrw";
import { expenseTitle } from "@/lib/expenses/expense-name";
@@ -25,12 +29,8 @@ import {
roleOptions,
type MemberQueryStatus,
} from "@/lib/expenses/expense-policy";
-import { ChatMemberAvatarRing } from "@/components/chat/messages/ChatMemberAvatarRing";
+import { UserAvatar } from "@/components/user/UserAvatar";
-export const expenseButtonClass =
- "min-h-10 rounded-lg border border-gray-border px-3 py-2 text-label-m-emphasis mobile:text-label-s-emphasis font-semibold cursor-pointer transition-colors enabled:hover:border-primary/40 enabled:hover:bg-primary/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50";
-export const expenseInputClass =
- "mt-1 min-h-12 w-full min-w-0 rounded-lg border border-border bg-fill-subtle px-3 py-2 text-body-s-regular focus:outline-primary";
// Group the integer string directly so large amounts and trailing decimals stay exact.
import { formatExpenseAmount } from "@/lib/expenses/format-expense-amount";
@@ -70,12 +70,13 @@ export function ExpensePerson({
title={person.label}
className="inline-flex min-w-0 max-w-full items-center gap-1.5 align-middle"
>
- {person.imageUrl ? : {person.label.slice(0, 1)} }
+ {/* tone이 대체를 정한다: 사진을 못 받으면 사람 아이콘, 사진이 없으면 라벨 첫 글자 */}
+
@@ -103,6 +104,7 @@ export function ExpenseRolePicker({
currentUserId,
selected,
original,
+ disabled,
onChange,
}: Readonly<{
title: string;
@@ -110,6 +112,8 @@ export function ExpenseRolePicker({
currentUserId?: number;
selected: number[];
original: number[];
+ // 감싼 는 Base UI Checkbox(span)에 전달되지 않아 따로 받는다
+ disabled?: boolean;
onChange: (ids: number[]) => void;
}>) {
const titleId = useId();
@@ -140,19 +144,19 @@ export function ExpenseRolePicker({
{options.map((option) => (
-
+ disabled={disabled}
+ onCheckedChange={(checked) =>
onChange(
- e.target.checked
+ checked
? [...selected, option.userId]
: selected.filter((id) => id !== option.userId),
)
}
- className="size-5 shrink-0 cursor-pointer appearance-none bg-[url(/expenses/checkbox.svg)] bg-contain bg-center bg-no-repeat checked:bg-[url(/expenses/checkbox-checked.svg)] disabled:cursor-not-allowed"
/>
승인 대기
-
onChange(selected.filter((id) => id !== member.userId))
}
>
제외
-
+
))}
{pendingMembers.length > 0 && (
@@ -292,9 +297,9 @@ export function ExpenseList({
expenseDayLabel("TRIP_DAY", Number(key), schedules))}
{day && (
-
+
{day.dayNumber}일차
-
+
)}
key === "PREPARATION" ? d.expenseGroup === "PREPARATION" : d.expenseGroup === "TRIP_DAY" && String(d.scheduleId) === key)?.total} />
{!krwSummary &&
@@ -527,7 +532,7 @@ function SettlementContent({
}: Readonly) {
if (scope === "mine" && currentUserId === undefined) {
return (
-
+
);
}
if (!summary.currencies.length) {
@@ -672,27 +677,13 @@ export function ExpenseSummaryView({
{showDetails &&
정산 범위
-
- {(
- [
- ["mine", "내 정산"],
- ["all", "전체 정산"],
- ] as const
- ).map(([value, label]) => (
- onScopeChange?.(value)}
- className={`min-h-10 cursor-pointer border-b-2 px-3 py-2 text-label-m-emphasis mobile:text-label-s-emphasis font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-primary ${scope === value ? "border-primary text-primary-strong" : "border-transparent text-dark-gray hover:text-primary-strong"}`}
- >
- {label}
-
- ))}
-
+ {/* 정산 내용 범위를 바꾸는 하위 전환이라 Tabs Chip(Outline md)을 쓴다. 내용은 아래 SettlementContent가 scope로 그려 패널을 두지 않는다 */}
+ onScopeChange?.(next)}>
+
+ 내 정산
+ 전체 정산
+
+
}
{krwSummary && (scope !== "all" || !summary.currencies.length) &&
원화 환산 결제 합계
@@ -788,27 +779,13 @@ export function ExpenseAnalysisView({
const [groupBy, setGroupBy] = useState<"category" | "day">("category");
return (
-
- {(
- [
- ["category", "카테고리별"],
- ["day", "일차별"],
- ] as const
- ).map(([value, label]) => (
- setGroupBy(value)}
- className={`min-h-10 cursor-pointer border-b-2 px-3 py-2 text-label-m-emphasis mobile:text-label-s-emphasis font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-primary ${groupBy === value ? "border-primary text-primary-strong" : "border-transparent text-dark-gray hover:text-primary-strong"}`}
- >
- {label}
-
- ))}
-
+ {/* 분석 묶음 기준을 바꾸는 하위 전환이라 Tabs Chip(Outline md). 통화마다 그래프 하나를 groupBy로 고르므로 패널을 두지 않는다 */}
+
setGroupBy(next)}>
+
+ 카테고리별
+ 일차별
+
+
{!summary.currencies.length && (
분석할 비용이 없어요.
diff --git a/src/components/filter-dropdown.tsx b/src/components/filter-dropdown.tsx
deleted file mode 100644
index 4c50bd05..00000000
--- a/src/components/filter-dropdown.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client";
-
-import { useEffect, useId, useRef, useState } from "react";
-
-import { createPortal } from "react-dom";
-import { MapSearchIcon } from "@/components/map/MapSearchIcon";
-import { cn } from "@/lib/utils";
-
-import {
- mapFilterChipActiveRingClassName,
- mapFilterChipInactiveClassName,
-} from "@/components/map/map-chip-button";
-
-export type FilterOption = { label: string; value: T };
-
-interface FilterDropdownProps {
- label: string;
- options: FilterOption[];
- value: T;
- onChange: (v: T) => void;
- onOpenChange?: (open: boolean) => void;
- menuClassName?: string;
-}
-
-export function FilterDropdown({
- label,
- options,
- value,
- onChange,
- onOpenChange,
- menuClassName,
-}: FilterDropdownProps) {
- const menuId = useId();
- const focusMenuOnOpen = useRef(false);
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
- const menuRef = useRef(null);
- const triggerRef = useRef(null);
- const [position, setPosition] = useState({ top: 0, left: 0 });
-
- useEffect(() => {
- onOpenChange?.(open);
- }, [open, onOpenChange]);
-
- useEffect(() => {
- if (!open) return;
- if (focusMenuOnOpen.current) menuRef.current?.querySelector("button")?.focus();
- const dismissOutside = (event: MouseEvent) => {
- if (!ref.current?.contains(event.target as Node) && !menuRef.current?.contains(event.target as Node)) setOpen(false);
- };
- const dismissOnMove = (event: Event) => {
- if (!menuRef.current?.contains(event.target as Node)) setOpen(false);
- };
- const escape = (event: KeyboardEvent) => {
- if (event.key === "Escape") { setOpen(false); triggerRef.current?.focus(); }
- };
- document.addEventListener("mousedown", dismissOutside);
- document.addEventListener("keydown", escape);
- window.addEventListener("scroll", dismissOnMove, true);
- window.addEventListener("resize", dismissOnMove);
- return () => {
- document.removeEventListener("mousedown", dismissOutside);
- document.removeEventListener("keydown", escape);
- window.removeEventListener("scroll", dismissOnMove, true);
- window.removeEventListener("resize", dismissOnMove);
- };
- }, [open]);
-
- const isActive = value !== "all";
- const selectedLabel = options.find((o) => o.value === value)?.label ?? label;
-
- return (
-
- {
- if (event.key === "ArrowDown" && !open) { event.preventDefault(); triggerRef.current?.click(); }
- }}
- aria-label={`${label} 필터`}
- onClick={(event) => {
- focusMenuOnOpen.current = event.detail === 0;
- const rect = triggerRef.current?.getBoundingClientRect();
- if (rect) setPosition({ top: rect.bottom + 4, left: Math.max(8, Math.min(rect.left, window.innerWidth - 144)) });
- setOpen((prev) => !prev);
- }}
- className={cn(
- "flex items-center gap-1",
- isActive
- ? mapFilterChipActiveRingClassName()
- : mapFilterChipInactiveClassName(),
- )}
- >
- {isActive ? selectedLabel : label}
-
-
-
- {open && createPortal(
- , document.body,
- )}
-
- );
-}
diff --git a/src/components/layout/HeaderBar.tsx b/src/components/layout/HeaderBar.tsx
index 6bece7e0..5b90cd03 100644
--- a/src/components/layout/HeaderBar.tsx
+++ b/src/components/layout/HeaderBar.tsx
@@ -3,11 +3,12 @@
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import { ChevronLeftIcon } from "@/assets/icons";
import { BrandLogo } from "@/components/BrandLogo";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useMobileView } from "@/contexts/MobileViewContext";
import { useSessionPromptVisible } from "@/hooks/useSessionPromptVisible";
import { FEEDBACK_FORM_URL } from "@/lib/contact";
@@ -51,13 +52,10 @@ const HeaderBar = ({
const [editOpen, setEditOpen] = useState(false);
const pathname = usePathname();
const [menuState, setMenuState] = useState({ path: pathname, open: false });
- const menuRef = useRef(null);
- const menuButtonRef = useRef(null);
const closeEdit = useCallback(() => setEditOpen(false), []);
const { isMobileDevice } = useMobileView();
if (menuState.path !== pathname) setMenuState({ path: pathname, open: false });
const menuOpen = menuState.path === pathname && menuState.open;
- const closeMenu = () => setMenuState({ path: pathname, open: false });
const isMapPanel = isMobileMapPathname(pathname ?? "");
const showMapShortcut = isPlanPathname(pathname ?? "") || isMapPanel || isChatPathname(pathname ?? "") || pathname === "/bookmark" || pathname === "/cost" || isPackingPath(pathname ?? "");
const { dismiss: dismissFeedbackPrompt } = useSessionPromptVisible(FEEDBACK_FORM_CLICKED_KEY);
@@ -103,24 +101,6 @@ const HeaderBar = ({
);
- useEffect(() => {
- if (!menuOpen) return;
- function closeOnOutside(event: PointerEvent) {
- if (!menuRef.current?.contains(event.target as Node)) setMenuState({ path: pathname, open: false });
- }
- function closeOnEscape(event: KeyboardEvent) {
- if (event.key !== "Escape") return;
- setMenuState({ path: pathname, open: false });
- menuButtonRef.current?.focus();
- }
- document.addEventListener("pointerdown", closeOnOutside);
- document.addEventListener("keydown", closeOnEscape);
- return () => {
- document.removeEventListener("pointerdown", closeOnOutside);
- document.removeEventListener("keydown", closeOnEscape);
- };
- }, [menuOpen, pathname]);
-
return (
{isMobileDevice ? (
@@ -144,34 +124,30 @@ const HeaderBar = ({
{showMapShortcut ?
: null}
-
+
+ {/* 지금 위치: 44px 버튼 아래 끝에서 6px 위(top 38px), 오른쪽 끝 맞춤 */}
+
+ setEditOpen(true)}>
+ 방 정보 수정
+
+ }>
+ 초대하기
+
+
+ 피드백
+
+
+ 버그 제보
+
+
+
: null}
) : (
diff --git a/src/components/layout/HeaderMemberPreview.tsx b/src/components/layout/HeaderMemberPreview.tsx
index 2d5a4047..93c36e92 100644
--- a/src/components/layout/HeaderMemberPreview.tsx
+++ b/src/components/layout/HeaderMemberPreview.tsx
@@ -1,12 +1,15 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
import { JoinRequestsSection } from "@/app/(main)/member-settings/_components/JoinRequestsSection";
import { MemberList } from "@/app/(main)/member-settings/_components/MemberList";
import { useMemberActions } from "@/app/(main)/member-settings/_components/useMemberActions";
import { ChevronDownIcon, SendIcon } from "@/assets/icons";
import { InviteRoomDialog } from "@/components/rooms/InviteRoomDialog";
+import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { UserAvatar } from "@/components/user/UserAvatar";
import { useHostJoinRequestsBadgeCount } from "@/hooks/useHostJoinRequestsBadgeCount";
import { selectHeaderMemberPreview } from "@/lib/rooms/member-preview";
@@ -44,109 +47,78 @@ export function HeaderMemberPreview() {
const pendingJoinRequestsCount = useHostJoinRequestsBadgeCount();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
- const dropdownRef = useRef(null);
const { visible, restCount } = selectHeaderMemberPreview(members, user?.id);
- useEffect(() => {
- if (!dropdownOpen) return;
- function handleClickOutside(event: MouseEvent) {
- const target = event.target as Element;
- // 구성원 옵션 메뉴는 body에 떠 있으므로 드롭다운 바깥으로 보지 않는다
- if (dropdownRef.current?.contains(target) || target.closest("[data-floating-menu]")) return;
- setDropdownOpen(false);
- }
- function handleEscape(event: KeyboardEvent) {
- if (event.key === "Escape") setDropdownOpen(false);
- }
- document.addEventListener("mousedown", handleClickOutside);
- document.addEventListener("keydown", handleEscape);
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- document.removeEventListener("keydown", handleEscape);
- };
- }, [dropdownOpen]);
-
return (
{visible.length > 0 ? (
-
-
+ setDropdownOpen((open) => !open)}
className="isolate flex cursor-pointer items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
-
+
{visible.map(({ member, isOnline }, index) => (
- 0 ? "relative -ml-1.5" : "relative"}
- >
-
-
+ />
))}
{restCount > 0 ? (
-
+
+{restCount}
-
+
) : null}
-
+ {/* 묶음(isolate) 밖에 두면 마지막 아바타를 덮으므로 안에서 가장 아래 층에 둔다 */}
+
{pendingJoinRequestsCount > 0 ? : null}
-
-
-
- {dropdownOpen ? (
-
- {isHost && roomId ?
: null}
-
-
- 구성원
- {memberCount}
-
- {
- setDropdownOpen(false);
- requestKick(member);
- }}
- onTransfer={(member) => {
- setDropdownOpen(false);
- requestTransfer(member);
- }}
- className={cn(MEMBER_LIST_MAX_HEIGHT_CLASS, "-mx-1 overflow-y-auto overscroll-contain px-1 [&>li]:py-2 [&>li:first-child]:pt-0 [&>li:last-child]:pb-0")}
- />
-
-
-
{
+
+
+
+ {isHost && roomId ? : null}
+
+
+ 구성원
+ {memberCount}
+
+ {
+ setDropdownOpen(false);
+ requestKick(member);
+ }}
+ onTransfer={(member) => {
setDropdownOpen(false);
- openLeaveDialog();
+ requestTransfer(member);
}}
- className="h-12 w-full cursor-pointer rounded-lg border border-border bg-fill-subtle text-label-l-emphasis text-text transition-colors enabled:hover:bg-fill enabled:active:bg-fill-strong disabled:cursor-not-allowed disabled:bg-fill disabled:text-text-disabled"
- >
- 방 나가기
-
-
- ) : null}
-
+ className={cn(MEMBER_LIST_MAX_HEIGHT_CLASS, "-mx-1 overflow-y-auto overscroll-contain px-1 [&>li]:py-2 [&>li:first-child]:pt-0 [&>li:last-child]:pb-0")}
+ />
+
+
+
{
+ setDropdownOpen(false);
+ openLeaveDialog();
+ }}
+ className="w-full"
+ >
+ 방 나가기
+
+
+
) : null}
{
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
async function rerender() { await act(async () => root.render( )); }
+// 메뉴는 Base UI Menu라 document.body로 포털된다. 항목은 role="menuitem"(방 정보 수정은 div, 이동 항목은 a)
+const menu = () => document.querySelector('[role="menu"][aria-label="여행방 메뉴"]');
+const menuButton = () => host.querySelector('button[aria-haspopup="menu"]')!;
it("shows the room details and map action in the mobile top navigation", () => {
expect(host.querySelector("header")?.textContent).toContain("가을 여행");
@@ -47,18 +50,20 @@ it("uses the back top navigation for a nested bookmark screen", async () => {
});
it("opens the mobile menu with working destinations and closes it with Escape", async () => {
- const menuButton = host.querySelector('button[aria-label="메뉴 열기"]')!;
- expect(menuButton).not.toBeNull();
- expect(host.querySelector('[aria-label="여행방 메뉴"]')).toBeNull();
- await act(async () => menuButton.click());
- const menu = host.querySelector('[aria-label="여행방 메뉴"]')!;
- expect(menu).not.toBeNull();
- expect(menu.querySelector('a[href="/member-settings"]')?.textContent).toContain("초대하기");
- expect(menu.querySelectorAll("a[href^='http']")).toHaveLength(2);
- expect(menu.querySelector('button')?.textContent).toContain("방 정보 수정");
+ const trigger = host.querySelector('button[aria-label="메뉴 열기"]')!;
+ expect(trigger).not.toBeNull();
+ expect(menu()).toBeNull();
+ await act(async () => trigger.click());
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ expect(trigger.getAttribute("aria-label")).toBe("메뉴 닫기");
+ const items = [...menu()!.querySelectorAll('[role="menuitem"]')];
+ expect(items.map(item => item.textContent)).toEqual(["방 정보 수정", "초대하기", "피드백", "버그 제보"]);
+ expect(menu()!.querySelector('a[role="menuitem"][href="/member-settings"]')?.textContent).toContain("초대하기");
+ expect(menu()!.querySelectorAll("a[role='menuitem'][href^='http']")).toHaveLength(2);
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
- expect(host.querySelector('[aria-label="여행방 메뉴"]')).toBeNull();
- expect(document.activeElement).toBe(menuButton);
+ expect(menu()).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ expect(trigger.getAttribute("aria-label")).toBe("메뉴 열기");
});
it.each(["/bookmark", "/cost", "/packing/12345678-1234-1234-1234-123456789abc"])("shows the map shortcut on %s", async pathname => {
@@ -88,23 +93,32 @@ it("keeps the map shortcut hidden on search", async () => {
});
it("closes the menu on an outside pointer event", async () => {
- await act(async () => host.querySelector('button[aria-label="메뉴 열기"]')!.click());
- expect(host.querySelector('[aria-label="여행방 메뉴"]')).not.toBeNull();
+ await act(async () => menuButton().click());
+ expect(menu()).not.toBeNull();
await act(async () => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
- expect(host.querySelector('[aria-label="여행방 메뉴"]')).toBeNull();
+ expect(menu()).toBeNull();
});
it("closes the open menu when the route changes", async () => {
- await act(async () => host.querySelector('button[aria-label="메뉴 열기"]')!.click());
+ await act(async () => menuButton().click());
+ expect(menu()).not.toBeNull();
state.pathname = "/cost";
await rerender();
- expect(host.querySelector('[aria-label="여행방 메뉴"]')).toBeNull();
+ expect(menu()).toBeNull();
});
it("closes the menu when a destination is selected", async () => {
- await act(async () => host.querySelector('button[aria-label="메뉴 열기"]')!.click());
- await act(async () => host.querySelector('a[href="/member-settings"]')!.click());
- expect(host.querySelector('[aria-label="여행방 메뉴"]')).toBeNull();
+ await act(async () => menuButton().click());
+ await act(async () => menu()!.querySelector('a[href="/member-settings"]')!.click());
+ expect(menu()).toBeNull();
+});
+
+it("opens the trip edit dialog from the menu item and closes the menu", async () => {
+ await act(async () => menuButton().click());
+ const edit = [...menu()!.querySelectorAll('[role="menuitem"]')].find(item => item.textContent === "방 정보 수정")!;
+ await act(async () => edit.click());
+ expect(menu()).toBeNull();
+ expect(host.querySelector('[role="dialog"]')?.textContent).toBe("정보 수정");
});
it("keeps the folder's second back link hidden on mobile", () => {
diff --git a/src/components/layout/planning-chrome.test.tsx b/src/components/layout/planning-chrome.test.tsx
index ee3be481..05dc1e9c 100644
--- a/src/components/layout/planning-chrome.test.tsx
+++ b/src/components/layout/planning-chrome.test.tsx
@@ -1,4 +1,6 @@
// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
@@ -44,11 +46,23 @@ describe("planning chrome", () => {
expect(link?.getAttribute("title")).toBe("버그 제보");
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
- it("uses the creation label in the bookmark tab and modal", () => {
+ it("uses the creation label in the bookmark tab and modal", async () => {
const html = renderToStaticMarkup( );
expect(html).toContain("새 북마크 생성");
expect(html).not.toContain("새 북마크 추가");
- const modal = renderToStaticMarkup( );
- expect(modal).toContain("새 북마크 생성");
+ // 대화상자는 Base UI Dialog라 document.body로 포털된다(서버 렌더 문자열에는 없다)
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ try {
+ await act(async () => root.render( ));
+ const dialog = document.querySelector('[role="dialog"]')!;
+ expect(dialog).not.toBeNull();
+ expect(document.getElementById(dialog.getAttribute("aria-labelledby")!)?.textContent).toBe("새 북마크 생성");
+ } finally {
+ await act(async () => root.unmount());
+ host.remove();
+ }
});
});
diff --git a/src/components/loading/LoadingIndicator.tsx b/src/components/loading/LoadingIndicator.tsx
deleted file mode 100644
index af97ac6a..00000000
--- a/src/components/loading/LoadingIndicator.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { LoaderCircle } from "lucide-react";
-import { cn } from "@/lib/utils";
-
-/** A compact visual indicator with a screen-reader-only loading announcement. */
-export function LoadingIndicator({
- label = "불러오는 중",
- className,
- size = 20,
-}: Readonly<{
- label?: string;
- className?: string;
- size?: number;
-}>) {
- return (
-
-
-
- );
-}
diff --git a/src/components/map/Map.search-rank.test.tsx b/src/components/map/Map.search-rank.test.tsx
index 70238ffe..1760aa39 100644
--- a/src/components/map/Map.search-rank.test.tsx
+++ b/src/components/map/Map.search-rank.test.tsx
@@ -508,7 +508,10 @@ it.each(["whole", "specific"] as const)("%s search resets discover filters witho
await act(async () => button("음식점").click());
await act(async () => button("영업 중").click());
await act(async () => host.querySelector('[aria-label="평점 필터"]')!.click());
- await act(async () => button("4.5 이상").click());
+ // 평점 목록은 ui Select(Base UI)라 document.body로 포털된 role="option"이다
+ const ratingOption = (text: string) => [...document.querySelectorAll('[role="option"]')].find(o => o.textContent === text)!;
+ await act(async () => ratingOption("4.5 이상").click());
+ expect(host.querySelector('[aria-label="평점 필터"]')?.textContent).toBe("4.5 이상");
await act(async () => { useMapPinsFocusStore.getState().claimFocus("discover"); });
boundary.page.mockResolvedValue({ items, nextPageToken: null });
await act(async () => {
diff --git a/src/components/map/MapCategoryChips.tsx b/src/components/map/MapCategoryChips.tsx
index a55789ff..e3c565b7 100644
--- a/src/components/map/MapCategoryChips.tsx
+++ b/src/components/map/MapCategoryChips.tsx
@@ -1,11 +1,9 @@
"use client";
-import { MapSearchIcon } from "./MapSearchIcon";
-
import { cn } from "@/lib/utils";
+import { MapChipToggle } from "./MapChipToggle";
import { MAP_PLACE_CATEGORIES } from "./map-place-categories";
-import { mapChipButtonClassName } from "./map-chip-button";
type MapCategoryChipsProps = {
selectedCategoryId: string | null;
@@ -19,11 +17,6 @@ export function MapCategoryChips({
onSelectCategory,
className,
}: MapCategoryChipsProps) {
- function handleChipClick(categoryId: string) {
- if (selectedCategoryId === categoryId) onSelectCategory(null);
- else onSelectCategory(categoryId);
- }
-
return (
- {MAP_PLACE_CATEGORIES.map((cat) => {
- const active = selectedCategoryId === cat.id;
- return (
- handleChipClick(cat.id)}
- aria-pressed={active}
- className={cn(
- "flex items-center gap-1.5",
- mapChipButtonClassName(active),
- )}
- >
-
- {cat.label}
-
- );
- })}
+ {MAP_PLACE_CATEGORIES.map((cat) => (
+ onSelectCategory(pressed ? cat.id : null)}
+ >
+ {/* 카테고리 아이콘은 선택과 상관없이 파란색이다(교체 전 지도 칩 아이콘 #005bd9) */}
+
+ {cat.label}
+
+ ))}
);
}
diff --git a/src/components/map/MapChipToggle.tsx b/src/components/map/MapChipToggle.tsx
new file mode 100644
index 00000000..fc0f829d
--- /dev/null
+++ b/src/components/map/MapChipToggle.tsx
@@ -0,0 +1,27 @@
+import { Toggle } from "@base-ui/react/toggle";
+import type { ReactNode } from "react";
+
+import { Chip } from "@/components/ui/chip";
+
+type Props = {
+ pressed: boolean;
+ onPressedChange: (pressed: boolean) => void;
+ children: ReactNode;
+};
+
+/**
+ * 지도 툴바에서 고르는 칩(카테고리·영업 중). Figma Chip Outline md(36px)이고, Base UI Toggle이라 선택 상태가 aria-pressed로 읽힌다.
+ * 모바일에서는 보이는 크기는 두고 누르는 영역만 가상 요소로 44px까지 넓힌다. 배경이 보이는 칩이라 padding으로 넓히면 모양이 커진다
+ */
+export function MapChipToggle({ pressed, onPressedChange, children }: Readonly) {
+ return (
+ }
+ className="relative [&_svg]:pointer-events-none mobile:before:absolute mobile:before:inset-x-0 mobile:before:top-1/2 mobile:before:h-11 mobile:before:-translate-y-1/2"
+ >
+ {children}
+
+ );
+}
diff --git a/src/components/map/MapDiscoverToolbar.test.tsx b/src/components/map/MapDiscoverToolbar.test.tsx
index db0cba58..cd6460f5 100644
--- a/src/components/map/MapDiscoverToolbar.test.tsx
+++ b/src/components/map/MapDiscoverToolbar.test.tsx
@@ -27,19 +27,26 @@ it("keeps the selected category, applies rating/open filters, and reset clears a
expect(button("음식점").getAttribute("aria-pressed")).toBe("true");
expect(host.textContent).not.toContain("호텔");
await act(async () => button("영업 중").click());
- await act(async () => host.querySelector('[aria-label="평점 필터"]')!.click());
- const trigger = host.querySelector('[aria-label="평점 필터"]')!;
- expect(document.getElementById(trigger.getAttribute("aria-controls")!)).not.toBeNull();
+ // 평점 필터는 ui Select(Base UI)라 트리거는 combobox, 목록은 document.body로 포털된 listbox·option이다
+ const trigger = host.querySelector('[role="combobox"][aria-label="평점 필터"]')!;
+ expect(trigger.textContent).toBe("평점");
+ await act(async () => trigger.click());
+ await act(async () => { await new Promise((r) => requestAnimationFrame(() => r(null))); });
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ const listbox = document.getElementById(trigger.getAttribute("aria-controls")!)!;
+ expect(listbox.getAttribute("role")).toBe("listbox");
+ expect(host.contains(listbox)).toBe(false);
+ expect(document.activeElement?.getAttribute("role")).toBe("option");
expect(document.activeElement?.textContent).toBe("전체");
- // The menu is portalled out of the horizontal scroll clip, but its choices must still work.
- const ratingChoice = [...document.querySelectorAll("button")].find(b => b.textContent?.includes("4.0"))!;
- await act(async () => {
- ratingChoice.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
- ratingChoice.click();
- });
- expect(JSON.parse(host.querySelector("output")!.textContent!)).toMatchObject({ category: "restaurant", open: "open" });
- expect(JSON.parse(host.querySelector("output")!.textContent!).rating).not.toBe("all");
+ expect(document.activeElement?.getAttribute("aria-selected")).toBe("true");
+ // The list is portalled out of the horizontal scroll clip, but its choices must still work.
+ const ratingChoice = [...listbox.querySelectorAll('[role="option"]')].find(o => o.textContent === "4.0 이상")!;
+ await act(async () => ratingChoice.click());
+ expect(JSON.parse(host.querySelector("output")!.textContent!)).toEqual({ category: "restaurant", rating: "4.0", open: "open" });
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(trigger.textContent).toBe("4.0 이상");
await act(async () => host.querySelector('[aria-label="카테고리 필터 초기화"]')!.click());
expect(JSON.parse(host.querySelector("output")!.textContent!)).toEqual({ category: null, rating: "all", open: "all" });
+ expect(host.querySelector('[aria-label="평점 필터"]')).toBeNull();
expect(button("호텔")).toBeDefined();
});
diff --git a/src/components/map/MapDiscoverToolbar.tsx b/src/components/map/MapDiscoverToolbar.tsx
index 35cf6e45..65a13e5b 100644
--- a/src/components/map/MapDiscoverToolbar.tsx
+++ b/src/components/map/MapDiscoverToolbar.tsx
@@ -3,7 +3,8 @@
import { type ReactNode } from "react";
import { flushSync } from "react-dom";
-import { CircleCancelIcon, SearchIcon } from "@/assets/icons";
+import { CircleCancelIcon, CloseIcon, SearchIcon } from "@/assets/icons";
+import { Chip } from "@/components/ui/chip";
import { useMobileView } from "@/contexts/MobileViewContext";
import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store";
@@ -14,9 +15,8 @@ import {
import { cn } from "@/lib/utils";
import MapFilter from "./MapFilter";
-import { MapSearchIcon } from "./MapSearchIcon";
+import { MapChipToggle } from "./MapChipToggle";
import { MAP_PLACE_CATEGORIES } from "./map-place-categories";
-import { mapChipButtonClassName } from "./map-chip-button";
import { MapCategoryChips } from "./MapCategoryChips";
import type { OpenValue, RatingValue } from "./map-filters";
@@ -57,17 +57,18 @@ export function MapDiscoverToolbar({
{isMobileDevice ? : null}
-
+ {/* Figma Chip에는 그림자가 없어 지도 위에 뜨는 그림자를 칩마다 주지 않고 이 줄에 준다. 색은 검색 말풍선과 같은 #0000001a, drop-shadow 5px는 box-shadow 10px와 같은 퍼짐이다 */}
+
{category ? (
-
- {category.label}
-
+
+ {category.label}
+
-
-
-
+ }>
+
+
) :
}
diff --git a/src/components/map/MapFilter.tsx b/src/components/map/MapFilter.tsx
index 5c4592ee..046419a8 100644
--- a/src/components/map/MapFilter.tsx
+++ b/src/components/map/MapFilter.tsx
@@ -1,16 +1,17 @@
-import { FilterDropdown } from "@/components/filter-dropdown";
-import { MAP_FILTER_DROPDOWN_MENU_Z_CLASS } from "@/lib/layout/mapDetailPanelLayout";
-import {
- mapFilterChipActiveFilledClassName,
- mapFilterChipInactiveClassName,
-} from "./map-chip-button";
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { useEffect, useRef, useState } from "react";
+
+import { Chip } from "@/components/ui/chip";
+import { Select, SelectContent, SelectItem } from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { MapChipToggle } from "./MapChipToggle";
+import { MapSearchIcon } from "./MapSearchIcon";
import {
RATING_OPTIONS,
type OpenValue,
type RatingValue,
} from "./map-filters";
-import { cn } from "@/lib/utils";
type MapFilterProps = {
rating: RatingValue;
@@ -33,27 +34,85 @@ export default function MapFilter({
return (
-
- setOpenNow(isOpenOnly ? "all" : "open")}
- className={cn(
- "inline-flex items-center gap-1",
- isOpenOnly
- ? mapFilterChipActiveFilledClassName()
- : mapFilterChipInactiveClassName(),
- )}
+
+ setOpenNow(pressed ? "open" : "all")}
>
영업 중
-
+
);
}
+
+/**
+ * 평점 하나를 고르는 Select. 트리거는 옆 칩들과 같은 Chip Outline md이고, 평점을 고르면 선택 칩 모양으로 그 라벨을 보인다("전체"면 "평점").
+ * Select 트리거에는 aria-pressed가 없어 Chip이 선택 모양을 그리지 못하므로 selected로 준다.
+ * 목록은 ui Select 모양(트리거 아래 4px, 늘 아래로)이고, 폭은 칩보다 넓게 지금 값(최소 136px)으로 라벨 폭에 맞춘다
+ */
+function RatingSelect({
+ value,
+ onChange,
+ onOpenChange,
+}: Readonly<{
+ value: RatingValue;
+ onChange: (value: RatingValue) => void;
+ onOpenChange?: (open: boolean) => void;
+}>) {
+ const [isOpen, setIsOpen] = useState(false);
+ const popupRef = useRef
(null);
+ const isActive = value !== "all";
+ const triggerLabel = isActive ? (RATING_OPTIONS.find((option) => option.value === value)?.label ?? "평점") : "평점";
+
+ function changeOpen(open: boolean) {
+ setIsOpen(open);
+ onOpenChange?.(open);
+ }
+
+ // 지금 동작 유지: 열린 동안 페이지·툴바 가로 스크롤이 움직이거나 창 크기가 바뀌면 닫는다(목록 안 스크롤은 제외).
+ // Base UI 목록은 닫지 않고 위치만 따라간다
+ useEffect(() => {
+ if (!isOpen) return;
+ const closeOnMove = (event: Event) => {
+ if (event.target instanceof Node && popupRef.current?.contains(event.target)) return;
+ setIsOpen(false);
+ onOpenChange?.(false);
+ };
+ window.addEventListener("scroll", closeOnMove, true);
+ window.addEventListener("resize", closeOnMove);
+ return () => {
+ window.removeEventListener("scroll", closeOnMove, true);
+ window.removeEventListener("resize", closeOnMove);
+ };
+ }, [isOpen, onOpenChange]);
+
+ return (
+ {
+ if (next) onChange(next);
+ }}
+ open={isOpen}
+ onOpenChange={changeOpen}
+ >
+ }
+ >
+ {triggerLabel}
+
+
+
+
+
+ {RATING_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx
index d543bb1d..32954c3e 100644
--- a/src/components/map/MapPlaceSearch.tsx
+++ b/src/components/map/MapPlaceSearch.tsx
@@ -5,7 +5,7 @@ import { flushSync } from "react-dom";
import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store";
import { useSearchParams } from "next/navigation";
import { MapPlaceSearchInput } from "@/components/search/MapPlaceSearchInput";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
+import { Spinner } from "@/components/ui/spinner";
import { usePlaceSearchStore } from "@/stores/place-search-store";
import type { SearchConfirmation } from "@/lib/places/search-session";
import { useMapPlaceSearch } from "./useMapPlaceSearch";
@@ -101,7 +101,7 @@ function SearchFeedback({ status, error, hasItems, retry }: Readonly<{
hasItems: boolean;
retry: () => void;
}>) {
- if (status === "pending") return ;
+ if (status === "pending") return ;
if (status === "error") return (
{error}
diff --git a/src/components/map/MapSearchIcon.tsx b/src/components/map/MapSearchIcon.tsx
index 0cafbdff..6b49986b 100644
--- a/src/components/map/MapSearchIcon.tsx
+++ b/src/components/map/MapSearchIcon.tsx
@@ -2,9 +2,7 @@ import Image from "next/image";
/** Original vector exports from Figma 6153:8257; explicit dimensions preserve the glyphs. */
export function MapSearchIcon({ name, size = 16, className }: Readonly<{
- name: "restaurant" | "hotel" | "attraction" | "museum" | "transit" |
- "chevron-down" | "reset" | "search" | "search-white" | "location" |
- "clear" | "close" | "star" | "arrow-right";
+ name: "chevron-down" | "search" | "search-white" | "location" | "close" | "star" | "arrow-right";
size?: number;
className?: string;
}>) {
diff --git a/src/components/map/map-chip-button.ts b/src/components/map/map-chip-button.ts
deleted file mode 100644
index eb7c4d93..00000000
--- a/src/components/map/map-chip-button.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { cn } from "@/lib/utils";
-
-const mapChipButtonBaseClass =
- "inline-flex h-9 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border bg-fill-elevate px-4 text-label-m-emphasis shadow-[0_2px_10px_#0000001a] transition-colors hover:bg-fill active:bg-fill-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
-
-export function mapFilterChipInactiveClassName() {
- return cn(mapChipButtonBaseClass, "border-border text-text");
-}
-
-export function mapFilterChipActiveRingClassName() {
- return cn(mapChipButtonBaseClass, "border-primary-strong text-primary-strong");
-}
-
-export function mapFilterChipActiveFilledClassName() {
- return mapFilterChipActiveRingClassName();
-}
-
-/** Chip / Outline / md, including the selected category. */
-export function mapChipButtonClassName(active: boolean) {
- return active ? mapFilterChipActiveRingClassName() : mapFilterChipInactiveClassName();
-}
diff --git a/src/components/map/map-filters.ts b/src/components/map/map-filters.ts
index 109f25f7..1d4b1834 100644
--- a/src/components/map/map-filters.ts
+++ b/src/components/map/map-filters.ts
@@ -1,9 +1,7 @@
-import type { FilterOption } from "@/components/filter-dropdown";
-
export type RatingValue = "all" | "3.0" | "3.5" | "4.0" | "4.5";
export type OpenValue = "all" | "open";
-export const RATING_OPTIONS: FilterOption
[] = [
+export const RATING_OPTIONS: { label: string; value: RatingValue }[] = [
{ label: "전체", value: "all" },
{ label: "3.0 이상", value: "3.0" },
{ label: "3.5 이상", value: "3.5" },
diff --git a/src/components/map/map-place-categories.ts b/src/components/map/map-place-categories.ts
index c51cae17..e84b296e 100644
--- a/src/components/map/map-place-categories.ts
+++ b/src/components/map/map-place-categories.ts
@@ -1,5 +1,13 @@
-import type { ComponentProps } from "react";
-import type { MapSearchIcon } from "./MapSearchIcon";
+import type { ComponentType } from "react";
+
+import {
+ TravelHotelIcon,
+ TravelMuseumIcon,
+ TravelRestaurantIcon,
+ TravelThemeparkIcon,
+ TravelTrainIcon,
+ type IconProps,
+} from "@/assets/icons";
/**
* Google Maps Place types (Table A).
@@ -11,7 +19,7 @@ export type MapPlaceCategory = {
label: string;
/** 레거시/검색 문맥용 쿼리 문자열 */
searchQuery: string;
- icon: ComponentProps["name"];
+ icon: ComponentType;
/** Nearby Search `includedPrimaryTypes` */
googlePlaceTypeHint: string;
};
@@ -21,35 +29,35 @@ export const MAP_PLACE_CATEGORIES: MapPlaceCategory[] = [
id: "restaurant",
label: "음식점",
searchQuery: "음식점",
- icon: "restaurant",
+ icon: TravelRestaurantIcon,
googlePlaceTypeHint: "restaurant",
},
{
id: "lodging",
label: "호텔",
searchQuery: "호텔",
- icon: "hotel",
+ icon: TravelHotelIcon,
googlePlaceTypeHint: "lodging",
},
{
id: "things_to_do",
label: "즐길거리",
searchQuery: "관광명소 즐길거리",
- icon: "attraction",
+ icon: TravelThemeparkIcon,
googlePlaceTypeHint: "tourist_attraction",
},
{
id: "museum",
label: "박물관",
searchQuery: "박물관",
- icon: "museum",
+ icon: TravelMuseumIcon,
googlePlaceTypeHint: "museum",
},
{
id: "transit",
label: "대중교통",
searchQuery: "지하철역 버스정류장",
- icon: "transit",
+ icon: TravelTrainIcon,
googlePlaceTypeHint: "transit_station",
},
];
diff --git a/src/components/mobile/BottomSheetDragHandle.tsx b/src/components/mobile/BottomSheetDragHandle.tsx
deleted file mode 100644
index e31f4b30..00000000
--- a/src/components/mobile/BottomSheetDragHandle.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-"use client";
-import type { ReactNode } from "react";
-import { cn } from "@/lib/utils";
-import type { useSheetDrag } from "./useSheetDrag";
-
-/** Shared gesture surface; form controls and the scrolling body stay outside it. */
-export function BottomSheetDragHandle({ drag, className, children }: Readonly<{
- drag: ReturnType;
- className?: string;
- children: ReactNode;
-}>) {
- return {children}
;
-}
diff --git a/src/components/mobile/MobileBottomSheet.test.tsx b/src/components/mobile/MobileBottomSheet.test.tsx
deleted file mode 100644
index 2d4fd86c..00000000
--- a/src/components/mobile/MobileBottomSheet.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-// @vitest-environment jsdom
-import { act } from 'react';
-import { createRoot } from 'react-dom/client';
-import { afterEach, expect, it, vi } from 'vitest';
-import { MobileBottomSheet } from './MobileBottomSheet';
-
-afterEach(() => vi.unstubAllGlobals());
-it('dismisses by dragging the header, preserves controls and blocks dismissal while saving', () => {
- vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);
- HTMLElement.prototype.setPointerCapture=vi.fn();
- HTMLElement.prototype.hasPointerCapture=()=>true;
- HTMLElement.prototype.releasePointerCapture=vi.fn();
- const host=document.createElement('div');const root=createRoot(host);const close=vi.fn();
- const render=(disabled=false,open=true)=>root.render( );
- const pointer=(target:Element,type:string,y:number)=>{const event=new Event(type,{bubbles:true});Object.assign(event,{pointerId:1,isPrimary:true,button:0,clientX:20,clientY:y});act(()=>target.dispatchEvent(event));};
- const drag=(target:Element,end='pointerup')=>{pointer(target,'pointerdown',20);pointer(target,'pointermove',150);pointer(target,end,150);};
- try {
- act(()=>render());
- drag(document.querySelector('h2')!);
- expect(close).toHaveBeenCalledTimes(1);
- close.mockClear();
- drag(document.querySelector('h2')!,'pointercancel');
- expect(close).not.toHaveBeenCalled();
- drag(document.querySelector('input')!);
- expect(close).not.toHaveBeenCalled();
- act(()=>render(true));drag(document.querySelector('h2')!);
- expect(close).not.toHaveBeenCalled();
- act(()=>render(false));pointer(document.querySelector('h2')!,'pointerdown',20);pointer(document.querySelector('h2')!,'pointermove',150);
- act(()=>render(false,false));act(()=>render());
- expect(document.querySelector('dialog')?.style.transform).toBe('');
- } finally {act(()=>root.unmount());host.remove();}
-});
diff --git a/src/components/mobile/MobileBottomSheet.tsx b/src/components/mobile/MobileBottomSheet.tsx
deleted file mode 100644
index 34d82f69..00000000
--- a/src/components/mobile/MobileBottomSheet.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-"use client";
-
-import { useEffect, useId, type ReactNode } from "react";
-import { createPortal } from "react-dom";
-
-import { CloseIcon, type IconProps } from "@/assets/icons";
-import { useSheetDrag } from "./useSheetDrag";
-import { BottomSheetDragHandle } from "./BottomSheetDragHandle";
-import { cn } from "@/lib/utils";
-
-type MobileBottomSheetProps = Readonly<{
- open: boolean;
- onClose: () => void;
- title: string;
- /** 제목 아래 보조 문구 — 예: 장소명 */
- subtitle?: string;
- /** `menu`: 항목 목록(제목 20px, 간격 8px) / `form`: 입력 폼(제목 18px, 간격 16px) */
- variant?: "menu" | "form";
- /** 저장 중 등 닫기를 막아야 할 때 */
- closeDisabled?: boolean;
- children: ReactNode;
-}>;
-
-/** 모바일 하단 시트 — 딤 배경, 핸들, 제목·닫기 줄로 구성된다 */
-export function MobileBottomSheet(props: MobileBottomSheetProps) {
- if (!props.open || typeof document === "undefined") return null;
- return ;
-}
-
-function MobileBottomSheetContent({
- open,
- onClose,
- title,
- subtitle,
- variant = "menu",
- closeDisabled = false,
- children,
-}: MobileBottomSheetProps) {
- const titleId = useId();
- const drag = useSheetDrag(onClose, closeDisabled, false);
-
- useEffect(() => {
- if (!open) return;
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape" && !closeDisabled) onClose();
- }
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [closeDisabled, onClose, open]);
-
- if (!open || typeof document === "undefined") return null;
-
- return createPortal(
-
-
{
- if (!closeDisabled) onClose();
- }}
- />
-
-
- {/* 핸들 위·아래 12px, 그 아래 제목 줄(Figma) — 닫기 버튼 터치 영역(44)은 위아래로 겹쳐 제목 줄 높이를 키우지 않는다 */}
-
-
-
-
-
- {title}
-
-
-
-
-
- {subtitle ? (
- {subtitle}
- ) : null}
-
- {children}
-
-
,
- document.body,
- );
-}
-
-type MobileSheetMenuItemProps = Readonly<{
- icon: (props: IconProps) => ReactNode;
- label: string;
- onClick: () => void;
- /** 삭제 등 위험한 동작 */
- danger?: boolean;
- disabled?: boolean;
- /** 오른쪽에 붙는 보조 내용 — 예: 비용 합계 */
- trailing?: ReactNode;
-}>;
-
-/** 바텀시트 메뉴 한 줄 — 아이콘 20px + 라벨 */
-export function MobileSheetMenuItem({
- icon: Icon,
- label,
- onClick,
- danger = false,
- disabled = false,
- trailing,
-}: MobileSheetMenuItemProps) {
- return (
-
-
-
- {label}
-
- {trailing ? (
- {trailing}
- ) : null}
-
- );
-}
diff --git a/src/components/mobile/useSheetDrag.ts b/src/components/mobile/useSheetDrag.ts
deleted file mode 100644
index f2952043..00000000
--- a/src/components/mobile/useSheetDrag.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-"use client";
-
-import { SHEET_DISMISS_DISTANCE } from "./sheet-gesture";
-import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
-
-/**
- * Keep dragging on the fixed header so scrolling and form controls remain native.
- * 바텀시트는 모바일 기기에서만 쓰므로 화면 폭과 관계없이 `is-mobile-device`일 때만 끈다. 모바일 전용 시트는 `mobileOnly=false`.
- */
-export function useSheetDrag(onClose: () => void, disabled = false, mobileOnly = true) {
- const gesture = useRef<{ id: number; x: number; y: number; distance: number } | null>(null);
- const [offset, setOffset] = useState(0);
- const [dragging, setDragging] = useState(false);
- const [hasDragged, setHasDragged] = useState(false);
- const [reduceMotion, setReduceMotion] = useState(false);
- function finish(event: PointerEvent
, cancelled: boolean) {
- const current = gesture.current;
- if (!current || current.id !== event.pointerId) return;
- gesture.current = null;
- setOffset(0);
- setDragging(false);
- if (event.currentTarget.hasPointerCapture?.(event.pointerId))
- event.currentTarget.releasePointerCapture(event.pointerId);
- if (!cancelled && !disabled && current.distance >= SHEET_DISMISS_DISTANCE) onClose();
- }
- return {
- surfaceStyle: {
- transform: !disabled && offset > 0 ? `translateY(${offset}px)` : undefined,
- transition: dragging || reduceMotion ? "none" : "transform 180ms ease-out",
- animation: hasDragged ? "none" : undefined,
- } satisfies CSSProperties,
- handleProps: {
- onPointerDown(event: PointerEvent) {
- if (disabled || !event.isPrimary || event.button !== 0 || gesture.current) return;
- if (mobileOnly && !document.documentElement.classList.contains("is-mobile-device")) return;
- if ((event.target as Element).closest("button, input, textarea, select, a, [role='button'], [contenteditable='true']")) return;
- gesture.current = { id: event.pointerId, x: event.clientX, y: event.clientY, distance: 0 };
- event.currentTarget.setPointerCapture(event.pointerId);
- setHasDragged(true);
- setReduceMotion(Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches));
- setDragging(true);
- },
- onPointerMove(event: PointerEvent) {
- const current = gesture.current;
- if (!current || current.id !== event.pointerId) return;
- if (disabled) { finish(event, true); return; }
- const distance = event.clientY - current.y;
- if (Math.abs(event.clientX - current.x) > Math.max(12, Math.abs(distance))) {
- finish(event, true);
- return;
- }
- current.distance = Math.max(0, distance);
- setOffset(current.distance);
- },
- onPointerUp(event: PointerEvent) { finish(event, false); },
- onPointerCancel(event: PointerEvent) { finish(event, true); },
- onLostPointerCapture(event: PointerEvent) { finish(event, true); },
- },
- handleClassName: `shrink-0 mobile:touch-none mobile:select-none ${dragging ? "mobile:cursor-grabbing" : "mobile:cursor-grab"}`,
- };
-}
diff --git a/src/components/packing/PackingPage.dialog.test.tsx b/src/components/packing/PackingPage.dialog.test.tsx
index 831599ed..b4a00e37 100644
--- a/src/components/packing/PackingPage.dialog.test.tsx
+++ b/src/components/packing/PackingPage.dialog.test.tsx
@@ -6,25 +6,39 @@ import {PackingPage} from './PackingPage';
const mocks=vi.hoisted(()=>({context:null as unknown}));
vi.mock('@/hooks/usePackingList',()=>({usePackingList:()=>mocks.context}));
afterEach(()=>{document.body.replaceChildren();vi.unstubAllGlobals();});
-it('traps category dialog focus, closes with Escape, and restores its opener',async()=>{
+// Base UI는 첫 포커스·포커스 복귀를 다음 프레임에, 닫힘 정리(언마운트)를 애니메이션 확인 뒤에 한다
+const settle=()=>act(async()=>{await new Promise(resolve=>requestAnimationFrame(()=>resolve(null)));await new Promise(resolve=>setTimeout(resolve,0));});
+const escape=(target:EventTarget=document)=>act(async()=>{target.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));});
+function accessibleName(node:Element){
+ const labelledBy=node.getAttribute('aria-labelledby');
+ if(labelledBy) return labelledBy.split(' ').map(id=>document.getElementById(id)?.textContent ?? '').join(' ');
+ return node.getAttribute('aria-label') ?? node.textContent ?? '';
+}
+const buttonIn=(scope:ParentNode,name:string)=>Array.from(scope.querySelectorAll('button')).find(node=>accessibleName(node)===name)!;
+const openDialog=()=>document.querySelector('[role="dialog"][data-open]');
+const openAlert=()=>document.querySelector('[role="alertdialog"][data-open]');
+const deleteState=(status='ready')=>({data:{id:1,roomId:'room',ownerUserId:1,version:1,initializedAt:'2026-01-01T00:00:00Z',parts:[{id:1,name:'여권·예약·결제',column:0,position:0,items:[{id:11,partId:1,name:'여권',checked:false,position:0,memo:null,tips:[]}]}]},status,message:null,confirmation:{kind:'item',id:11,version:1,name:'여권'} as {kind:'item';id:number;version:number;name:string}|null});
+it('opens the category dialog as a modal, closes with Escape, and restores its opener',async()=>{
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);
mocks.context={scopeKey:'room:user',state:{data:{id:1,roomId:'room',ownerUserId:1,version:1,initializedAt:'2026-01-01T00:00:00Z',parts:[]},status:'ready',message:null,confirmation:null},coordinator:{execute:vi.fn(),prepareDelete:vi.fn(),refresh:vi.fn()}};
const main=document.createElement('main');document.body.append(main);const root=createRoot(main);
try{
await act(async()=>root.render( ));
- const opener=main.querySelector('button')!;opener.focus();
+ const opener=main.querySelector('[aria-label="카테고리 추가"]')!;opener.focus();
await act(async()=>opener.click());
- const dialog=document.querySelector('dialog')!;
- expect(dialog.getAttribute('aria-modal')).toBe('true');
- expect(main.hasAttribute('inert')).toBe(true);
- const controls=Array.from(dialog.querySelectorAll('button,input'));
- expect(document.activeElement).toBe(controls[0]);
- controls.at(-1)!.focus();
- const tab=new KeyboardEvent('keydown',{key:'Tab',bubbles:true,cancelable:true});document.dispatchEvent(tab);
- expect(tab.defaultPrevented).toBe(true);expect(document.activeElement).toBe(controls[0]);
- await act(async()=>document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true})));
- expect(document.querySelector('dialog')).toBeNull();
- expect(main.hasAttribute('inert')).toBe(false);
+ await settle();
+ const dialog=openDialog()!;
+ expect(dialog).not.toBeNull();
+ expect(accessibleName(dialog)).toBe('카테고리 추가');
+ // 바깥 페이지는 Base UI가 aria-hidden으로 가리고, 첫 포커스는 첫 입력 칸이다
+ expect(main.getAttribute('aria-hidden')).toBe('true');
+ expect(document.activeElement).toBe(dialog.querySelector('input[aria-label="카테고리 이름"]'));
+ // 닫기 X는 없다(취소·추가만)
+ expect(Array.from(dialog.querySelectorAll('button')).map(accessibleName)).toEqual(['취소','추가']);
+ await escape();
+ await settle();
+ expect(openDialog()).toBeNull();
+ expect(main.hasAttribute('aria-hidden')).toBe(false);
expect(document.activeElement).toBe(opener);
}finally{await act(async()=>root.unmount());}
});
@@ -32,18 +46,28 @@ it('returns focus to the category menu trigger after closing a menu dialog',asyn
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);
mocks.context={scopeKey:'room:user',state:{data:{id:1,roomId:'room',ownerUserId:1,version:1,initializedAt:'2026-01-01T00:00:00Z',parts:[{id:1,name:'여권·예약·결제',column:0,position:0,items:[]}]},status:'ready',message:null,confirmation:null},coordinator:{execute:vi.fn(),prepareDelete:vi.fn(),refresh:vi.fn()}};
const main=document.createElement('main');document.body.append(main);const root=createRoot(main);
+ const menu=()=>document.querySelector('[role="menu"]');
+ const rename=()=>document.querySelector('[role="menuitem"][aria-label="카테고리 이름 수정"]')!;
try{
await act(async()=>root.render( ));
const trigger=main.querySelector('[aria-label="카테고리 메뉴: 여권·예약·결제"]')!;
+ trigger.focus();
await act(async()=>trigger.click());
- let rename=main.querySelector('[role="menuitem"]:nth-child(2)')!;
- rename.focus();await act(async()=>rename.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true})));
- expect(main.querySelector('[role="menu"]')).toBeNull();expect(document.activeElement).toBe(trigger);
+ await settle();
+ expect(Array.from(document.querySelectorAll('[role="menuitem"]')).map(node=>node.getAttribute('aria-label'))).toEqual(['준비물 추가','카테고리 이름 수정','카테고리 삭제']);
+ rename().focus();await escape(rename());
+ await settle();
+ expect(menu()).toBeNull();expect(document.activeElement).toBe(trigger);
await act(async()=>trigger.click());
- rename=main.querySelector('[role="menuitem"]:nth-child(2)')!;
- rename.focus();await act(async()=>rename.click());
- expect(document.querySelector('dialog')).not.toBeNull();
- await act(async()=>document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true})));
+ await settle();
+ rename().focus();await act(async()=>rename().click());
+ await settle();
+ expect(openDialog()).not.toBeNull();
+ expect(menu()).toBeNull();
+ expect((openDialog()!.querySelector('input[aria-label="카테고리 이름"]') as HTMLInputElement).value).toBe('여권·예약·결제');
+ await escape();
+ await settle();
+ expect(openDialog()).toBeNull();
expect(document.activeElement).toBe(trigger);
}finally{await act(async()=>root.unmount());}
});
@@ -52,18 +76,40 @@ it('does not cancel a delete confirmation after its request has started',async()
let finish!:(value:{kind:string})=>void;
const confirmDelete=vi.fn(()=>new Promise<{kind:string}>(resolve=>{finish=resolve;}));
const cancelConfirmation=vi.fn();
- const state={data:{id:1,roomId:'room',ownerUserId:1,version:1,initializedAt:'2026-01-01T00:00:00Z',parts:[{id:1,name:'여권·예약·결제',column:0,position:0,items:[{id:11,partId:1,name:'여권',checked:false,position:0,memo:null,tips:[]}]}]},status:'ready',message:null,confirmation:{kind:'item',id:11,version:1,name:'여권'} as {kind:'item';id:number;version:number;name:string}|null};
+ const state=deleteState();
mocks.context={scopeKey:'room:user',state,coordinator:{execute:vi.fn(),prepareDelete:vi.fn(),confirmDelete,cancelConfirmation,refresh:vi.fn()}};
const main=document.createElement('main');document.body.append(main);const root=createRoot(main);
try{
await act(async()=>root.render( ));
- const dialog=document.querySelector('dialog')!;
- const action=Array.from(dialog.querySelectorAll('button')).find(button=>button.textContent==='삭제')!;
- await act(async()=>action.click());
- const cancel=Array.from(dialog.querySelectorAll('button')).find(button=>button.textContent==='취소')!;
- expect(cancel.disabled).toBe(true);
- await act(async()=>document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true})));
+ const dialog=openAlert()!;
+ expect(dialog.textContent).toContain('준비물을 삭제할까요?');
+ await act(async()=>buttonIn(dialog,'삭제').click());
+ expect(confirmDelete).toHaveBeenCalledOnce();
+ // 삭제 중: 확인 버튼은 progress, 취소는 비활성, Esc·딤으로 닫히지 않는다
+ expect(buttonIn(dialog,'처리 중').getAttribute('aria-busy')).toBe('true');
+ expect(buttonIn(dialog,'취소').disabled).toBe(true);
+ await escape();
+ await act(async()=>buttonIn(document,'준비물을 삭제할까요? 배경 닫기').click());
expect(cancelConfirmation).not.toHaveBeenCalled();
+ expect(openAlert()).not.toBeNull();
await act(async()=>{finish({kind:'success'});state.confirmation=null;root.render( );});
}finally{await act(async()=>root.unmount());}
});
+it.each(['writing','sync-error'])('disables the delete confirmation while the list status is %s',async status=>{
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);
+ const confirmDelete=vi.fn();
+ const cancelConfirmation=vi.fn();
+ mocks.context={scopeKey:'room:user',state:deleteState(status),coordinator:{execute:vi.fn(),prepareDelete:vi.fn(),confirmDelete,cancelConfirmation,refresh:vi.fn()}};
+ const main=document.createElement('main');document.body.append(main);const root=createRoot(main);
+ try{
+ await act(async()=>root.render( ));
+ const dialog=openAlert()!;
+ const action=buttonIn(dialog,'삭제');
+ expect(action.disabled).toBe(true);
+ await act(async()=>action.click());
+ expect(confirmDelete).not.toHaveBeenCalled();
+ // 취소는 그대로 된다
+ await act(async()=>buttonIn(dialog,'취소').click());
+ expect(cancelConfirmation).toHaveBeenCalledOnce();
+ }finally{await act(async()=>root.unmount());}
+});
diff --git a/src/components/packing/PackingPage.module.css b/src/components/packing/PackingPage.module.css
index 2f716b7a..8c21ee0a 100644
--- a/src/components/packing/PackingPage.module.css
+++ b/src/components/packing/PackingPage.module.css
@@ -8,9 +8,9 @@
color:var(--color-text-default);
letter-spacing:-0.02em;
}
-.page button:enabled,.dialogActions button:enabled {cursor:pointer}
-.page button:disabled,.dialogActions button:disabled {cursor:not-allowed;opacity:0.5}
-.page button:focus-visible,.page textarea:focus-visible,.page input:focus-visible,.dialog button:focus-visible,.dialog input:focus-visible {outline:2px solid var(--color-primary-default);outline-offset:2px}
+/* ui Button(data-slot="button")은 커서·포커스를 스스로 그린다 */
+.page button:not([data-slot="button"]):enabled {cursor:pointer}
+.page button:not([data-slot="button"]):focus-visible {outline:2px solid var(--color-primary-default);outline-offset:2px}
.icon {position:relative;display:inline-block;flex:none;vertical-align:middle;pointer-events:none}
.icon img {display:block;max-width:none;transform-origin:top left}
.boardPane {flex:1;min-width:0;min-height:0;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--color-border-default) transparent}
@@ -22,7 +22,7 @@
.progress strong {color:var(--color-primary-default);font-weight:700}
.addCategory {grid-column:2;grid-row:1 / 3;align-self:center;display:inline-flex;align-items:center;justify-content:center;width:152px;min-height:36px;border-radius:8px;background:var(--color-primary-default);padding:8px 10px;color:var(--color-text-inverse);font-size:14px;line-height:20px;font-weight:700;white-space:nowrap}
.addCategory span:last-child {padding:0 6px}
-.addCategory:enabled:hover,.primary:enabled:hover {background:var(--color-primary-strong)}
+.addCategory:enabled:hover {background:var(--color-primary-strong)}
.columns {display:flex;align-items:flex-start;gap:20px;overflow-x:auto;padding-bottom:16px;scrollbar-width:thin;scrollbar-color:var(--color-border-default) transparent}
.part {position:relative;display:flex;flex:0 0 324px;flex-direction:column;gap:12px;min-width:0;border-radius:12px;background:var(--color-fill-default);padding:12px}
.partHeader {position:relative;display:flex;align-items:center;gap:8px;min-width:0;min-height:36px}
@@ -31,19 +31,14 @@
.partProgress {flex:none;font-size:12px;line-height:16px;color:var(--color-text-subtle)}
.menuTrigger {display:flex;flex:none;align-items:center;justify-content:center;width:20px;height:36px;margin-left:auto;border-radius:4px}
.menuTrigger:hover,.iconButton:hover {background:var(--color-fill-strong)}
-.menu {position:absolute;z-index:20;top:100%;right:0;width:220px;overflow:hidden;border:1px solid var(--color-border-subtle);border-radius:8px;background:var(--color-fill-elevate, #fff);box-shadow:0 2px 10px #0000001a}
-.menu button {display:flex;align-items:center;gap:10px;width:100%;min-height:44px;border-bottom:0.5px solid var(--color-border-subtle);padding:0 14px;text-align:left;font-size:14px;line-height:20px;font-weight:700;white-space:nowrap}
-.menu button:last-child {border-bottom:0}
-.menu button:hover,.menu button:focus-visible {background:var(--color-fill-default)}
-.menuDanger {color:var(--color-status-negative)}
+/* 카테고리 메뉴 폭(지금 값). 표면·항목 모양은 ui DropdownMenu */
+.menuContent {width:220px}
.items {display:flex;flex-direction:column;gap:12px;margin:0;padding:0;list-style:none}
.item {position:relative;min-width:0;border:1px solid var(--color-border-subtle);border-radius:8px;background:var(--color-fill-elevate, #fff)}
.item:hover {border-color:var(--color-border-default)}
.item[data-selected="true"] {border-color:var(--color-primary-default);background:var(--color-primary-subtle)}
.checkbox {position:relative;display:flex;flex:none;align-items:center;justify-content:center;width:24px;height:24px;cursor:pointer}
-.checkbox input {position:absolute;z-index:1;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
-.checkbox input:disabled {cursor:not-allowed}
-.checkbox input:focus-visible+.icon {outline:2px solid var(--color-primary-default);outline-offset:2px;border-radius:4px}
+.checkbox:has([aria-disabled="true"]) {cursor:not-allowed}
.item>.checkbox {position:absolute;z-index:1;top:18px;left:16px}
.itemSelect {display:block;width:100%;min-width:0;border-radius:inherit;padding:16px;text-align:left}
.itemSelect strong {display:flex;align-items:center;min-height:28px;margin-left:32px;overflow-wrap:anywhere;font-size:16px;line-height:22px;font-weight:500}
@@ -55,46 +50,25 @@
.detail {position:relative;display:flex;flex:0 0 440px;flex-direction:column;min-width:0;min-height:0;border-left:1px solid var(--color-border-subtle);background:var(--color-fill-default)}
.detail[hidden] {display:none}
.detailScroll {display:flex;flex:1;flex-direction:column;gap:24px;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:0 32px 32px;scrollbar-width:thin}
-.detailDragHeader {padding:32px 32px 24px}
-.detailBackdrop,.sheetHandle {display:none}
+.detailTop {padding:32px 32px 24px}
.detailHeader {display:flex;align-items:center;gap:12px;min-height:40px}
-.detailHeader h2 {min-width:0;flex:1;overflow-wrap:anywhere;font-size:24px;line-height:34px;font-weight:700}
+.detailTitle {min-width:0;flex:1;overflow-wrap:anywhere;font-size:24px;line-height:34px;font-weight:700}
.detailActions {display:flex;flex:none;gap:4px}
-.iconButton {display:flex;align-items:center;justify-content:center;flex:none;width:32px;height:32px;border-radius:8px}
+/* 모바일 시트는 .page 밖(포털)이라 커서·포커스를 여기서도 준다 */
+.iconButton {display:flex;align-items:center;justify-content:center;flex:none;width:32px;height:32px;border-radius:8px;cursor:pointer}
+.iconButton:focus-visible {outline:2px solid var(--color-primary-default);outline-offset:2px}
.collapseDetail {position:absolute;top:337px;left:-1px;display:flex;align-items:center;justify-content:center;width:20px;height:104px;background:var(--color-icon-subtle)}
.collapseDetail:hover {background:var(--color-icon-default)}
.memoPanel {min-width:0}
.sectionTitle {display:block;margin:0 0 12px;font-size:14px;line-height:20px;font-weight:700}
-.memoField {display:block;width:100%;height:96px;min-height:96px;resize:none;border:1px solid var(--color-border-default);border-radius:8px;background:var(--color-fill-subtle);padding:12px 14px;font-size:16px;line-height:24px;overflow-wrap:anywhere}
-.memoField::placeholder {color:var(--color-text-subtle)}
-.memoFooter {height:16px;margin-top:4px;text-align:right;color:var(--color-text-subtle);font-size:11px;line-height:16px}
-.dialogActions {display:flex;justify-content:flex-end;gap:8px;margin-top:24px}
-.actionButton {display:inline-flex;align-items:center;justify-content:center;min-width:80px;height:36px;border:1px solid var(--color-border-default);border-radius:8px;background:var(--color-fill-subtle);padding:8px 16px;color:var(--color-text-default);font-size:14px;line-height:20px;font-weight:700;white-space:nowrap}
-/* 다른 항목의 저장을 기다리는 잠금은 버튼의 명도를 바꾸지 않는다. */
-.actionButton:disabled[data-waiting="true"] {opacity:1;cursor:wait}
-.actionButton:not(.primary):not(.danger):enabled:hover {background:var(--color-fill-default)}
-.primary {border-color:transparent;background:var(--color-primary-default);color:var(--color-text-inverse)}
-.danger {border-color:transparent;background:var(--color-status-negative);color:var(--color-text-inverse)}
-.danger:enabled:hover {filter:brightness(0.94)}
-.memoActions {height:40px;margin-top:12px}
-.memoActions .actionButton {min-width:72px}
+.memoActions {display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
.tips {border-top:1px solid var(--color-border-subtle);padding-top:24px}
.tipCard {border-radius:8px;background:var(--color-fill-elevate, #fff);padding:16px;font-size:14px;line-height:20px}
.tipCard h4 {margin-bottom:16px;font-weight:700;overflow-wrap:anywhere}
.tipCard ul {display:flex;flex-direction:column;gap:16px;margin:0;padding:0;list-style:none}
.tipCard li {color:var(--color-text-subtle);overflow-wrap:anywhere}
-.fieldLabel {display:block;font-size:12px;line-height:16px;font-weight:500}
-.field {display:block;width:100%;height:48px;margin-top:4px;border:1px solid var(--color-border-default);border-radius:8px;background:var(--color-fill-subtle);padding:12px 14px;font-size:16px;line-height:24px;font-weight:400}
-.error {margin-top:8px;color:var(--color-status-negative);font-size:12px;line-height:18px}
-.deleteCopy {margin:0;color:var(--color-text-subtle);font-size:16px;line-height:24px;overflow-wrap:anywhere}
-.alert {margin-bottom:16px;border:1px solid var(--color-status-cautionary);border-radius:8px;background:var(--color-fill-subtle);padding:12px;font-size:14px;line-height:20px}
-.alert button {margin-left:8px;text-decoration:underline}
-.dialogOverlay {padding:20px;background:#00000059;backdrop-filter:none}
-.dialog {max-width:448px;border-radius:12px;background:var(--color-fill-elevate, #fff);padding:32px;color:var(--color-text-default);letter-spacing:-0.02em;scrollbar-gutter:auto;box-shadow:0 4px 8px #0000001f}
-.dialog>div:first-child {margin-bottom:24px}
-.dialog h2 {font-size:18px;line-height:26px;font-weight:700}
-/* 좁은 화면의 페이지 배치 — 상세 바텀시트는 화면 폭과 관계없이 모바일 기기에서만(아래 .is-mobile-device) */
+/* 좁은 화면의 페이지 배치 — 상세 바텀시트는 화면 폭과 관계없이 모바일 기기에서만(PackingPage의 useMobileView) */
@media (max-width:700px) {
.content {padding:20px}
.pageHeader {grid-template-rows:44px auto;gap:8px;min-height:96px;margin-bottom:20px}
@@ -105,18 +79,13 @@
.part {width:100%;flex:auto;gap:8px}
.partHeader {min-height:44px}
.menuTrigger {width:44px;height:44px}
- .menu {width:208px;padding:4px}
- .menu button {border:0;border-radius:4px;padding:0 10px}
+ .menuContent {width:208px}
.items {gap:8px}
.item>.checkbox {top:8px;left:8px;width:44px;height:44px}
.itemSelect {padding:8px}
.itemSelect strong {min-height:44px;margin-left:48px}
.itemSelect small {margin-top:4px}
.addItem {min-height:44px}
- .dialogActions .actionButton {flex:1;height:48px;font-size:16px;line-height:22px}
- .dialog {max-width:353px;padding:24px}
- .dialog h2 {font-size:20px;line-height:28px}
- .deleteCopy {font-size:14px;line-height:20px}
}
:global(.is-mobile-device) .content {padding:20px}
@@ -128,33 +97,20 @@
:global(.is-mobile-device) .part {width:100%;flex:auto;gap:8px}
:global(.is-mobile-device) .partHeader {min-height:44px}
:global(.is-mobile-device) .menuTrigger {width:44px;height:44px}
-:global(.is-mobile-device) .menu {width:208px;padding:4px}
-:global(.is-mobile-device) .menu button {border:0;border-radius:4px;padding:0 10px}
+:global(.is-mobile-device) .menuContent {width:208px}
:global(.is-mobile-device) .items {gap:8px}
:global(.is-mobile-device) .item>.checkbox {top:8px;left:8px;width:44px;height:44px}
:global(.is-mobile-device) .itemSelect {padding:8px}
:global(.is-mobile-device) .itemSelect strong {min-height:44px;margin-left:48px}
:global(.is-mobile-device) .itemSelect small {margin-top:4px}
:global(.is-mobile-device) .addItem {min-height:44px}
-:global(.is-mobile-device) .detailBackdrop {position:fixed;z-index:190;inset:0;display:block;background:#0f172459;animation:sheetBackdropIn 200ms ease-out}
-:global(.is-mobile-device) .detail {position:fixed;z-index:200;right:0;bottom:0;left:0;width:100%;height:76.53dvh;max-height:calc(100dvh - 80px);border:0;border-radius:20px 20px 0 0;background:var(--color-fill-elevate, #fff);animation:sheetIn 200ms ease-out}
-:global(.is-mobile-device) .sheetHandle {display:block;flex:none;width:36px;height:4px;margin:12px auto 4px;border-radius:2px;background:var(--color-border-subtle)}
-:global(.is-mobile-device) .detailDragHeader {padding:0 20px 20px;touch-action:none;user-select:none}
-:global(.is-mobile-device) .detailScroll {gap:20px;padding:0 20px max(20px, env(safe-area-inset-bottom))}
:global(.is-mobile-device) .detailHeader {gap:8px;min-height:44px}
:global(.is-mobile-device) .detailHeader .checkbox,:global(.is-mobile-device) .iconButton {width:44px;height:44px}
-:global(.is-mobile-device) .detailHeader h2 {font-size:20px;line-height:28px}
:global(.is-mobile-device) .detailActions {gap:8px}
-:global(.is-mobile-device) .collapseDetail {display:none}
-:global(.is-mobile-device) .memoActions {height:48px}
-:global(.is-mobile-device) .dialogActions .actionButton {flex:1;height:48px;font-size:16px;line-height:22px}
:global(.is-mobile-device) .tips {padding-top:20px}
:global(.is-mobile-device) .tipCard {background:var(--color-fill-default)}
:global(.is-mobile-device) .tipCard h4 {margin-bottom:12px}
:global(.is-mobile-device) .tipCard ul {gap:12px}
-:global(.is-mobile-device) .dialog {max-width:353px;padding:24px}
-:global(.is-mobile-device) .dialog h2 {font-size:20px;line-height:28px}
-:global(.is-mobile-device) .deleteCopy {font-size:14px;line-height:20px}
/* Keep the initial loading layout aligned with the populated board. */
.loadingAction {background:var(--color-fill-strong)}
@@ -164,7 +120,3 @@
.loadingCheck {flex:none;width:24px;height:24px;border-radius:4px;background:var(--color-fill-strong)}
.loadingLine {width:55%;height:16px;border-radius:4px;background:var(--color-fill-strong)}
.loadingAdd {height:40px}
-
-/* 모바일 준비물 상세 시트 등장 — 기준 바텀시트(딤 서서히·시트 아래에서)와 같은 200ms */
-@keyframes sheetIn {from {transform:translateY(100%)}}
-@keyframes sheetBackdropIn {from {opacity:0}}
diff --git a/src/components/packing/PackingPage.test.tsx b/src/components/packing/PackingPage.test.tsx
index 897a13a9..76d181e1 100644
--- a/src/components/packing/PackingPage.test.tsx
+++ b/src/components/packing/PackingPage.test.tsx
@@ -1,233 +1,383 @@
// @vitest-environment jsdom
-import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
-import type { ReactNode } from 'react';
import type { PackingItem } from '@/lib/packing/types';
import { PackingPage } from './PackingPage';
-const mocks = vi.hoisted(() => ({context: null as unknown}));
+const mocks = vi.hoisted(() => ({context: null as unknown, mobile: false}));
vi.mock('@/hooks/usePackingList', () => ({usePackingList: () => mocks.context}));
-vi.mock('@/components/settings/SettingsDialog', () => ({SettingsDialog: ({title, children}: {title:string; children:ReactNode}) => {children} }));
+// PC는 오른쪽 상세 패널(aside), 모바일 기기는 아래 시트(Drawer)를 그린다
+vi.mock('@/contexts/MobileViewContext', async importOriginal => ({
+ ...await importOriginal(),
+ useMobileView: () => ({isMobileDevice: mocks.mobile}),
+}));
const item: PackingItem = {id:11,partId:1,name:'여권',checked:false,position:0,memo:{id:11,itemId:11,content:'기존 메모'},tips:['유효기간을 확인하세요.','사본을 준비하세요.']};
const second: PackingItem = {id:12,partId:1,name:'항공권 예약 내역',checked:true,position:1,memo:null,tips:[]};
const data = {id:1,roomId:'room',ownerUserId:1,version:1,initializedAt:'2026-01-01T00:00:00Z',parts:[{id:1,name:'여권·예약·결제',column:0,position:0,items:[item,second]},{id:2,name:'통신·전자기기',column:0,position:1,items:[]},{id:3,name:'옷·가방',column:1,position:2,items:[]},{id:4,name:'세면·건강',column:2,position:3,items:[]}]};
let state: {data: typeof data|null; status:string; message:string|null; confirmation: null|{kind:'item'|'part';id:number;version:number;name:string}};
let coordinator: {execute:ReturnType;prepareDelete:ReturnType;confirmDelete:ReturnType;cancelConfirmation:ReturnType;refresh:ReturnType};
-let renderer:ReactTestRenderer;
-beforeEach(()=>{vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);state={data:structuredClone(data),status:'ready',message:null,confirmation:null};coordinator={execute:vi.fn().mockResolvedValue({kind:'success'}),prepareDelete:vi.fn(),confirmDelete:vi.fn(),cancelConfirmation:vi.fn(),refresh:vi.fn()};mocks.context={state,coordinator,scopeKey:'room:user'};});
-afterEach(()=>{if(renderer)act(()=>renderer.unmount());vi.unstubAllGlobals();document.documentElement.classList.remove('is-mobile-device');});
-async function mount(){await act(async()=>{renderer=create( );});}
-function button(label:string){const scope=renderer.root.findAllByType('dialog').at(-1) ?? renderer.root;return scope.findAllByType('button').find(node=>node.props['aria-label']===label || node.children.join('')===label)!;}
-async function selectFirstItem(){await act(async()=>button('준비물 선택: 여권').props.onClick());}
-function input(label:string){return renderer.root.findAllByType('input').find(node=>node.props['aria-label']===label)!;}
+let host: HTMLDivElement;
+let root: Root;
+beforeEach(()=>{
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);
+ mocks.mobile=false;
+ state={data:structuredClone(data),status:'ready',message:null,confirmation:null};
+ coordinator={execute:vi.fn().mockResolvedValue({kind:'success'}),prepareDelete:vi.fn(),confirmDelete:vi.fn(),cancelConfirmation:vi.fn(),refresh:vi.fn()};
+ mocks.context={state,coordinator,scopeKey:'room:user'};
+ host=document.createElement('div');document.body.append(host);root=createRoot(host);
+});
+afterEach(async()=>{await act(async()=>root.unmount());document.body.replaceChildren();vi.unstubAllGlobals();});
+
+// Base UI는 첫 포커스·닫힘 정리를 다음 프레임 이후에 한다
+const settle=()=>act(async()=>{await new Promise(resolve=>requestAnimationFrame(()=>resolve(null)));await new Promise(resolve=>setTimeout(resolve,0));});
+async function mount(){await act(async()=>root.render( ));await settle();}
+async function rerender(){await act(async()=>root.render( ));await settle();}
+function accessibleName(node:Element){
+ const labelledBy=node.getAttribute('aria-labelledby');
+ if(labelledBy) return labelledBy.split(' ').map(id=>document.getElementById(id)?.textContent ?? '').join(' ');
+ return node.getAttribute('aria-label') ?? node.textContent ?? '';
+}
+const openDialogs=()=>Array.from(document.querySelectorAll('[role="dialog"][data-open],[role="alertdialog"][data-open]'));
+// 열린 대화상자·시트가 있으면 맨 위 것 안에서, 없으면 화면 전체에서 찾는다(모달 뒤 요소는 누를 수 없다)
+function button(label:string){
+ const scope:ParentNode=openDialogs().at(-1) ?? document;
+ const found=Array.from(scope.querySelectorAll('button')).find(node=>accessibleName(node)===label);
+ if(!found) throw new Error(`button not found: ${label}`);
+ return found;
+}
+const menuItem=(label:string)=>document.querySelector(`[role="menuitem"][aria-label="${label}"]`)!;
+const checkbox=(label:string)=>document.querySelector(`[role="checkbox"][aria-label="${label}"]`)!;
+const input=(label:string)=>document.querySelector(`input[aria-label="${label}"]`);
+const memo=()=>document.querySelector('textarea[aria-label="준비물 메모"]');
+const detail=()=>document.querySelector('aside[aria-label="준비물 상세"]')!;
+const selectedName=()=>document.querySelector('[aria-label="선택한 준비물 이름"]')!.textContent;
+const sheet=()=>document.querySelector('[role="dialog"][data-open]');
+const detailOpen=()=>mocks.mobile ? sheet()!==null : !detail().hidden;
+const text=()=>document.body.textContent ?? '';
+async function click(element:HTMLElement){await act(async()=>element.click());await settle();}
+async function type(element:HTMLInputElement|HTMLTextAreaElement,value:string){
+ const prototype=element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ await act(async()=>{
+ Object.getOwnPropertyDescriptor(prototype,'value')!.set!.call(element,value);
+ element.dispatchEvent(new Event('input',{bubbles:true}));
+ });
+}
+async function escape(target:EventTarget=document){
+ await act(async()=>{target.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));});
+ await settle();
+}
+async function selectFirstItem(){await click(button('준비물 선택: 여권'));}
+async function openMenu(part:string){await click(button(`카테고리 메뉴: ${part}`));}
+function deferred(){let finish!:(value:{kind:string})=>void;coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));return (value:{kind:string})=>act(async()=>{finish(value);await Promise.resolve();});}
+
it('starts on the full board without selecting an item or showing detail controls', async () => {
await mount();
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(true);
- expect(renderer.root.findAllByType('textarea')).toHaveLength(0);
- expect(button('준비물 선택: 여권').props['aria-pressed']).toBe(false);
+ expect(detail().hidden).toBe(true);
+ expect(memo()).toBeNull();
+ expect(button('준비물 선택: 여권').getAttribute('aria-pressed')).toBe('false');
});
it('collapses the selected detail and preserves its memo draft when reopened', async () => {
await mount();
- await act(async () => button('준비물 선택: 여권').props.onClick());
- await act(async () => renderer.root.findByType('textarea').props.onChange({target:{value:'작성 중인 메모'}}));
- await act(async () => button('준비물 상세 접기').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(true);
- await act(async () => button('준비물 선택: 여권').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(false);
- expect(renderer.root.findByType('textarea').props.value).toBe('작성 중인 메모');
+ await selectFirstItem();
+ await type(memo()!,'작성 중인 메모');
+ await click(button('준비물 상세 접기'));
+ expect(detail().hidden).toBe(true);
+ await selectFirstItem();
+ expect(detail().hidden).toBe(false);
+ expect(memo()!.value).toBe('작성 중인 메모');
});
it('changes the selected item check state from the detail panel', async () => {
await mount();
- await act(async () => button('준비물 선택: 여권').props.onClick());
- const detail = renderer.root.findByProps({'aria-label':'준비물 상세'});
- const checkbox = detail.findByProps({type:'checkbox'});
- await act(async () => checkbox.props.onChange({target:{checked:true}}));
+ await selectFirstItem();
+ await click(detail().querySelector('[role="checkbox"][aria-label="상세: 여권 준비 완료"]')!);
expect(coordinator.execute).toHaveBeenCalledWith({type:'checkItem', id:11, checked:true});
- expect(renderer.root.findByProps({'aria-label':'선택한 준비물 이름'}).children.join('')).toBe('여권');
+ expect(selectedName()).toBe('여권');
+});
+it('shows four ordered category columns and authoritative progress',async()=>{
+ await mount();
+ const board=document.querySelector('[aria-label="준비물 카테고리"]')!;
+ expect(Array.from(board.querySelectorAll('section')).map(node=>node.getAttribute('aria-label'))).toEqual(['여권·예약·결제','통신·전자기기','옷·가방','세면·건강']);
+ expect(document.querySelector('[aria-label="전체 준비 현황"] strong')!.textContent).toContain('1 / 2');
});
-it('shows four ordered category columns and authoritative progress',async()=>{await mount();expect(renderer.root.findByProps({'aria-label':'준비물 카테고리'}).findAllByType('section').map(node=>node.props['aria-label'])).toEqual(['여권·예약·결제','통신·전자기기','옷·가방','세면·건강']);expect(renderer.root.findByProps({'aria-label':'전체 준비 현황'}).findByType('strong').children.join('')).toContain('1 / 2');});
it('renders added categories after the four defaults even when they share backend column 2',async()=>{
state.data!.parts.push({id:5,name:'추가 준비 1',column:2,position:8,items:[]},{id:6,name:'추가 준비 2',column:2,position:9,items:[]});
await mount();
- expect(renderer.root.findByProps({'aria-label':'준비물 카테고리'}).findAllByType('section').map(node=>node.props['aria-label'])).toEqual([
+ const board=document.querySelector('[aria-label="준비물 카테고리"]')!;
+ expect(Array.from(board.querySelectorAll('section')).map(node=>node.getAttribute('aria-label'))).toEqual([
'여권·예약·결제','통신·전자기기','옷·가방','세면·건강','추가 준비 1','추가 준비 2',
]);
});
-it('selects an item and shows only its server supplied tips',async()=>{await mount();await selectFirstItem();expect(JSON.stringify(renderer.toJSON())).toContain('유효기간을 확인하세요.');expect(JSON.stringify(renderer.toJSON())).toContain('사본을 준비하세요.');await act(async()=>button('준비물 선택: 항공권 예약 내역').props.onClick());expect(JSON.stringify(renderer.toJSON())).not.toContain('유효기간을 확인하세요.');expect(JSON.stringify(renderer.toJSON())).not.toContain('우때의 여행 팁');});
-it('keeps checkbox independent from item selection',async()=>{await mount();await selectFirstItem();await act(async()=>input('항공권 예약 내역 준비 완료').props.onChange({target:{checked:false}}));expect(coordinator.execute).toHaveBeenCalledWith({type:'checkItem',id:12,checked:false});expect(renderer.root.findByProps({'aria-label':'선택한 준비물 이름'}).children.join('')).toBe('여권');});
-it('opens the category menu and sends validated create and rename commands',async()=>{await mount();await act(async()=>button('카테고리 메뉴: 여권·예약·결제').props.onClick());await act(async()=>button('카테고리 이름 수정').props.onClick());await act(async()=>input('카테고리 이름').props.onChange({target:{value:'새 카테고리'}}));await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));expect(coordinator.execute).toHaveBeenCalledWith({type:'renamePart',id:1,name:'새 카테고리'});});
-it('closes a category menu with Escape',async()=>{await mount();const trigger=button('카테고리 메뉴: 여권·예약·결제');await act(async()=>trigger.props.onClick());const menu=renderer.root.findByProps({role:'menu'});await act(async()=>menu.props.onKeyDown({key:'Escape',preventDefault(){},stopPropagation(){}}));expect(renderer.root.findAllByProps({role:'menu'})).toHaveLength(0);});
-it('opens an item add dialog for the selected category',async()=>{await mount();await act(async()=>button('준비물 추가: 여권·예약·결제').props.onClick());await act(async()=>input('준비물 이름').props.onChange({target:{value:'비상 연락처'}}));await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));expect(coordinator.execute).toHaveBeenCalledWith({type:'createItem',partId:1,name:'비상 연락처'});});
-it('creates a category without replacing the existing list',async()=>{await mount();await act(async()=>button('카테고리 추가').props.onClick());await act(async()=>input('카테고리 이름').props.onChange({target:{value:'추가 준비'}}));await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));expect(coordinator.execute).toHaveBeenCalledWith({type:'createPart',name:'추가 준비'});expect(state.data!.parts).toHaveLength(4);});
-it('retains a failed name draft and clears it on scope change',async()=>{coordinator.execute.mockResolvedValue({kind:'error'});await mount();await act(async()=>button('카테고리 추가').props.onClick());await act(async()=>input('카테고리 이름').props.onChange({target:{value:'개인용'}}));await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));expect(input('카테고리 이름').props.value).toBe('개인용');mocks.context={state,coordinator,scopeKey:'other:user'};await act(async()=>renderer.update( ));expect(renderer.root.findAllByProps({'aria-label':'카테고리 이름'})).toHaveLength(0);});
+it('selects an item and shows only its server supplied tips',async()=>{
+ await mount();await selectFirstItem();
+ expect(text()).toContain('유효기간을 확인하세요.');
+ expect(text()).toContain('사본을 준비하세요.');
+ await click(button('준비물 선택: 항공권 예약 내역'));
+ expect(text()).not.toContain('유효기간을 확인하세요.');
+ expect(text()).not.toContain('우때의 여행 팁');
+});
+it('keeps checkbox independent from item selection',async()=>{
+ await mount();await selectFirstItem();
+ await click(checkbox('항공권 예약 내역 준비 완료'));
+ expect(coordinator.execute).toHaveBeenCalledWith({type:'checkItem',id:12,checked:false});
+ expect(selectedName()).toBe('여권');
+});
+it('opens the category menu and sends validated create and rename commands',async()=>{
+ await mount();
+ await openMenu('여권·예약·결제');
+ await click(menuItem('카테고리 이름 수정'));
+ expect(input('카테고리 이름')!.value).toBe('여권·예약·결제');
+ await type(input('카테고리 이름')!,'새 카테고리');
+ await click(button('저장'));
+ expect(coordinator.execute).toHaveBeenCalledWith({type:'renamePart',id:1,name:'새 카테고리'});
+});
+it('closes a category menu with Escape',async()=>{
+ await mount();
+ await openMenu('여권·예약·결제');
+ expect(document.querySelector('[role="menu"]')).not.toBeNull();
+ await escape(menuItem('준비물 추가'));
+ expect(document.querySelector('[role="menu"]')).toBeNull();
+});
+it('opens an item add dialog for the selected category',async()=>{
+ await mount();
+ await click(button('준비물 추가: 여권·예약·결제'));
+ expect(accessibleName(openDialogs().at(-1)!)).toBe('준비물 추가');
+ await type(input('준비물 이름')!,'비상 연락처');
+ await click(button('추가'));
+ expect(coordinator.execute).toHaveBeenCalledWith({type:'createItem',partId:1,name:'비상 연락처'});
+});
+it('creates a category without replacing the existing list',async()=>{
+ await mount();
+ await click(button('카테고리 추가'));
+ await type(input('카테고리 이름')!,'추가 준비');
+ await click(button('추가'));
+ expect(coordinator.execute).toHaveBeenCalledWith({type:'createPart',name:'추가 준비'});
+ expect(state.data!.parts).toHaveLength(4);
+});
+it('retains a failed name draft and clears it on scope change',async()=>{
+ coordinator.execute.mockResolvedValue({kind:'error'});
+ await mount();
+ await click(button('카테고리 추가'));
+ await type(input('카테고리 이름')!,'개인용');
+ await click(button('추가'));
+ expect(input('카테고리 이름')!.value).toBe('개인용');
+ mocks.context={state,coordinator,scopeKey:'other:user'};
+ await rerender();
+ expect(input('카테고리 이름')).toBeNull();
+});
it('does not submit a Korean name while IME composition is active',async()=>{
- await mount();await act(async()=>button('카테고리 추가').props.onClick());
- const name=input('카테고리 이름');await act(async()=>name.props.onChange({target:{value:'여권'}}));
- name.props.onCompositionStart();
- const preventDefault=vi.fn();name.props.onKeyDown({key:'Enter',nativeEvent:{isComposing:false},keyCode:229,preventDefault});
- expect(preventDefault).toHaveBeenCalledOnce();
- await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));
+ await mount();await click(button('카테고리 추가'));
+ const name=input('카테고리 이름')!;
+ await type(name,'여권');
+ await act(async()=>{name.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true}));});
+ const enter=new KeyboardEvent('keydown',{key:'Enter',keyCode:229,bubbles:true,cancelable:true});
+ await act(async()=>{name.dispatchEvent(enter);});
+ expect(enter.defaultPrevented).toBe(true);
+ await click(button('추가'));
expect(coordinator.execute).not.toHaveBeenCalled();
});
it('rejects an overlong raw name and permits a duplicate after correction',async()=>{
- await mount();await act(async()=>button('준비물 추가: 여권·예약·결제').props.onClick());
- await act(async()=>input('준비물 이름').props.onChange({target:{value:' '.repeat(100)+'x'}}));
- await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));
- expect(coordinator.execute).not.toHaveBeenCalled();expect(renderer.root.findAllByProps({role:'alert'})).toHaveLength(1);
- await act(async()=>input('준비물 이름').props.onChange({target:{value:'여권'}}));
- await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));
+ await mount();await click(button('준비물 추가: 여권·예약·결제'));
+ await type(input('준비물 이름')!,' '.repeat(100)+'x');
+ await click(button('추가'));
+ expect(coordinator.execute).not.toHaveBeenCalled();
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
+ expect(input('준비물 이름')!.getAttribute('aria-invalid')).toBe('true');
+ await type(input('준비물 이름')!,'여권');
+ await click(button('추가'));
expect(coordinator.execute).toHaveBeenCalledWith({type:'createItem',partId:1,name:'여권'});
});
it('keeps later name edits when an earlier save succeeds',async()=>{
- let finish!:(value:{kind:string})=>void;coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));await mount();await selectFirstItem();
- await act(async()=>button('준비물 이름 수정: 여권').props.onClick());
- await act(async()=>input('준비물 이름').props.onChange({target:{value:'첫 번째 이름'}}));
- let pending!:Promise;act(()=>{pending=renderer.root.findByType('form').props.onSubmit({preventDefault(){}});});
- await act(async()=>input('준비물 이름').props.onChange({target:{value:'두 번째 이름'}}));
- await act(async()=>{finish({kind:'success'});await pending;});
- expect(input('준비물 이름').props.value).toBe('두 번째 이름');
-});
-it('does not close a reopened name dialog when an old save resolves',async()=>{
- let finish!:(value:{kind:string})=>void;coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));await mount();await selectFirstItem();
- await act(async()=>button('준비물 이름 수정: 여권').props.onClick());
- let pending!:Promise;act(()=>{pending=renderer.root.findByType('form').props.onSubmit({preventDefault(){}});});
- await act(async()=>button('취소').props.onClick());
- await act(async()=>button('준비물 이름 수정: 여권').props.onClick());
- await act(async()=>input('준비물 이름').props.onChange({target:{value:'새 초안'}}));
- await act(async()=>{finish({kind:'success'});await pending;});
- expect(input('준비물 이름').props.value).toBe('새 초안');
+ const finish=deferred();await mount();await selectFirstItem();
+ await click(button('준비물 이름 수정: 여권'));
+ await type(input('준비물 이름')!,'첫 번째 이름');
+ await click(button('저장'));
+ await type(input('준비물 이름')!,'두 번째 이름');
+ await finish({kind:'success'});
+ expect(input('준비물 이름')!.value).toBe('두 번째 이름');
+});
+it('shows save progress and keeps the name dialog open until the save finishes',async()=>{
+ const finish=deferred();await mount();await selectFirstItem();
+ await click(button('준비물 이름 수정: 여권'));
+ await click(button('저장'));
+ // 저장 중: 저장 버튼은 progress, 취소·Esc로 닫히지 않는다(closeDisabled)
+ expect(button('저장 중').getAttribute('aria-busy')).toBe('true');
+ await click(button('취소'));
+ await escape();
+ expect(input('준비물 이름')).not.toBeNull();
+ await finish({kind:'success'});
+ await settle();
+ expect(input('준비물 이름')).toBeNull();
+ expect(coordinator.execute).toHaveBeenCalledTimes(1);
});
it('closes a name dialog if its target disappeared while saving',async()=>{
- coordinator.execute.mockResolvedValue({kind:'missing'});await mount();await selectFirstItem();await act(async()=>button('준비물 이름 수정: 여권').props.onClick());
- await act(async()=>renderer.root.findByType('form').props.onSubmit({preventDefault(){}}));
- expect(renderer.root.findAllByProps({'aria-label':'준비물 이름'})).toHaveLength(0);
+ coordinator.execute.mockResolvedValue({kind:'missing'});await mount();await selectFirstItem();
+ await click(button('준비물 이름 수정: 여권'));
+ await click(button('저장'));
+ expect(input('준비물 이름')).toBeNull();
+});
+it('opens item rename and category delete from their respective controls',async()=>{
+ await mount();await selectFirstItem();
+ await click(button('준비물 이름 수정: 여권'));
+ expect(input('준비물 이름')!.value).toBe('여권');
+ await click(button('취소'));
+ expect(input('준비물 이름')).toBeNull();
+ await openMenu('여권·예약·결제');
+ await click(menuItem('카테고리 삭제'));
+ expect(coordinator.prepareDelete).toHaveBeenCalledWith('part',1);
+});
+it('requires a coordinator confirmation before deleting an item',async()=>{
+ await mount();await selectFirstItem();
+ await click(button('준비물 삭제: 여권'));
+ expect(coordinator.prepareDelete).toHaveBeenCalledWith('item',11);
+ expect(coordinator.confirmDelete).not.toHaveBeenCalled();
+ state.confirmation={kind:'item',id:11,version:1,name:'여권'};
+ await rerender();
+ expect(openDialogs().at(-1)!.getAttribute('role')).toBe('alertdialog');
+ await click(button('삭제'));
+ expect(coordinator.confirmDelete).toHaveBeenCalledOnce();
+});
+it('saves the selected item memo without changing its checked state',async()=>{
+ await mount();await selectFirstItem();
+ await type(memo()!,'새 메모');
+ await click(button('저장'));
+ expect(coordinator.execute).toHaveBeenCalledWith({type:'saveMemo',id:11,content:'새 메모'});
+ expect(state.data!.parts[0].items[0].checked).toBe(false);
});
-it('opens item rename and category delete from their respective controls',async()=>{await mount();await selectFirstItem();await act(async()=>button('준비물 이름 수정: 여권').props.onClick());expect(input('준비물 이름').props.value).toBe('여권');await act(async()=>button('취소').props.onClick());await act(async()=>button('카테고리 메뉴: 여권·예약·결제').props.onClick());await act(async()=>button('카테고리 삭제').props.onClick());expect(coordinator.prepareDelete).toHaveBeenCalledWith('part',1);});
-it('requires a coordinator confirmation before deleting an item',async()=>{await mount();await selectFirstItem();await act(async()=>button('준비물 삭제: 여권').props.onClick());expect(coordinator.prepareDelete).toHaveBeenCalledWith('item',11);expect(coordinator.confirmDelete).not.toHaveBeenCalled();state.confirmation={kind:'item',id:11,version:1,name:'여권'};await act(async()=>renderer.update( ));await act(async()=>button('삭제').props.onClick());expect(coordinator.confirmDelete).toHaveBeenCalledOnce();});
-it('saves the selected item memo without changing its checked state',async()=>{await mount();await selectFirstItem();await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:'새 메모'}}));await act(async()=>button('저장').props.onClick());expect(coordinator.execute).toHaveBeenCalledWith({type:'saveMemo',id:11,content:'새 메모'});expect(state.data!.parts[0].items[0].checked).toBe(false);});
it('keeps memo markup as text and sends blank memo save without deleting the item',async()=>{
state.data!.parts[0].items[0]={...item,memo:{id:11,itemId:11,content:''}};
- await mount();await selectFirstItem();expect(renderer.root.findAllByType('script')).toHaveLength(0);
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:' '}}));
- await act(async()=>button('저장').props.onClick());
+ await mount();await selectFirstItem();
+ expect(document.querySelectorAll('script')).toHaveLength(0);
+ expect(memo()!.value).toBe('');
+ await type(memo()!,' ');
+ await click(button('저장'));
expect(coordinator.execute).toHaveBeenCalledWith({type:'saveMemo',id:11,content:' '});
- expect(renderer.root.findByProps({'aria-label':'준비물 카테고리'}).findAllByProps({type:'checkbox'})).toHaveLength(2);
+ expect(document.querySelector('[aria-label="준비물 카테고리"]')!.querySelectorAll('[role="checkbox"]')).toHaveLength(2);
});
it('preserves newer memo typing when a pending save publishes the submitted text',async()=>{
- let finish!:(value:{kind:string})=>void;coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));await mount();await selectFirstItem();
- const textarea=()=>renderer.root.findByType('textarea');
- await act(async()=>textarea().props.onChange({target:{value:'보낸 메모'}}));
- let pending!:Promise;act(()=>{pending=button('저장').props.onClick();});
- await act(async()=>textarea().props.onChange({target:{value:'나중에 쓴 메모'}}));
+ const finish=deferred();await mount();await selectFirstItem();
+ await type(memo()!,'보낸 메모');
+ await click(button('저장'));
+ await type(memo()!,'나중에 쓴 메모');
state.data!.parts[0].items[0]={...item,memo:{id:11,itemId:11,content:'보낸 메모'}};
- await act(async()=>renderer.update( ));
- expect(textarea().props.value).toBe('나중에 쓴 메모');
- await act(async()=>{finish({kind:'success'});await pending;});
- expect(textarea().props.value).toBe('나중에 쓴 메모');
+ await rerender();
+ expect(memo()!.value).toBe('나중에 쓴 메모');
+ await finish({kind:'success'});
+ expect(memo()!.value).toBe('나중에 쓴 메모');
});
it('preserves new memo typing when a pending blank memo save clears the memo',async()=>{
- let finish!:(value:{kind:string})=>void;coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));await mount();await selectFirstItem();
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:''}}));
- let pending!:Promise;act(()=>{pending=button('저장').props.onClick();});
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:'삭제 중 새 메모'}}));
+ const finish=deferred();await mount();await selectFirstItem();
+ await type(memo()!,'');
+ await click(button('저장'));
+ await type(memo()!,'삭제 중 새 메모');
state.data!.parts[0].items[0]={...item,memo:null};
- await act(async()=>renderer.update( ));
- await act(async()=>{finish({kind:'success'});await pending;});
- expect(renderer.root.findByType('textarea').props.value).toBe('삭제 중 새 메모');
+ await rerender();
+ await finish({kind:'success'});
+ expect(memo()!.value).toBe('삭제 중 새 메모');
+});
+it('preserves the loading state without fabricated progress or a list',async()=>{
+ state.data=null;state.status='loading';
+ await mount();
+ // 로딩 안내는 화면에 보이지 않는 status(output)로만 전한다
+ expect(document.querySelector('output')!.textContent).toContain('불러오는 중');
+ expect(document.querySelectorAll('h2')).toHaveLength(0);
+ expect(document.querySelector('[aria-label="전체 준비 현황"]')).toBeNull();
+});
+it('returns to the board when the selected item is removed',async()=>{
+ await mount();
+ await click(button('준비물 선택: 항공권 예약 내역'));
+ expect(detail().getAttribute('data-open')).toBe('true');
+ state.data!.parts[0].items=[item];
+ await rerender();
+ expect(detail().getAttribute('data-open')).toBe('false');
});
-it('preserves the loading state without fabricated progress or a list',async()=>{state.data=null;state.status='loading';await mount();expect(JSON.stringify(renderer.toJSON())).toContain('불러오는 중');expect(renderer.root.findAllByType('h2')).toHaveLength(0);expect(renderer.root.findAllByProps({'aria-label':'전체 준비 현황'})).toHaveLength(0);});
-it('returns to the board when the selected item is removed',async()=>{await mount();await act(async()=>button('준비물 선택: 항공권 예약 내역').props.onClick());expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props['data-open']).toBe(true);state.data!.parts[0].items=[item];await act(async()=>renderer.update( ));expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props['data-open']).toBe(false);});
it('opens a dismissible mobile detail sheet above the board',async()=>{
- await mount();await act(async()=>button('준비물 선택: 항공권 예약 내역').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props['data-open']).toBe(true);
- await act(async()=>button('준비물 상세 닫기').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props['data-open']).toBe(false);
+ mocks.mobile=true;
+ await mount();
+ expect(document.querySelector('aside[aria-label="준비물 상세"]')).toBeNull();
+ await click(button('준비물 선택: 항공권 예약 내역'));
+ expect(sheet()).not.toBeNull();
+ expect(accessibleName(sheet()!)).toBe('항공권 예약 내역');
+ await click(button('준비물 상세 닫기'));
+ expect(sheet()).toBeNull();
+ expect(button('준비물 선택: 항공권 예약 내역').getAttribute('aria-pressed')).toBe('false');
});
-it('cancels memo edits and closes the detail on desktop and mobile',async()=>{
+it.each([false,true])('cancels memo edits and closes the detail (mobile: %s)',async(mobile)=>{
+ mocks.mobile=mobile;
await mount();await selectFirstItem();
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:'취소할 초안'}}));
- await act(async()=>button('취소').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(true);
+ await type(memo()!,'취소할 초안');
+ await click(button('취소'));
+ expect(detailOpen()).toBe(false);
expect(coordinator.execute).not.toHaveBeenCalled();
await selectFirstItem();
- expect(renderer.root.findByType('textarea').props.value).toBe('기존 메모');
+ expect(memo()!.value).toBe('기존 메모');
});
it('closes detail only after the memo save succeeds',async()=>{
- let finish!:(value:{kind:string})=>void;
- coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));
+ const finish=deferred();
await mount();await selectFirstItem();
- await act(async()=>button('저장').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(false);
- await act(async()=>finish({kind:'success'}));
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(true);
+ await click(button('저장'));
+ expect(detail().hidden).toBe(false);
+ await finish({kind:'success'});
+ expect(detail().hidden).toBe(true);
});
it.each(['error','blocked'])('keeps the memo draft open when saving returns %s',async(kind)=>{
coordinator.execute.mockResolvedValue({kind});
await mount();await selectFirstItem();
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:'보존할 초안'}}));
- await act(async()=>button('저장').props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(false);
- expect(renderer.root.findByType('textarea').props.value).toBe('보존할 초안');
+ await type(memo()!,'보존할 초안');
+ await click(button('저장'));
+ expect(detail().hidden).toBe(false);
+ expect(memo()!.value).toBe('보존할 초안');
});
it('keeps detail open for edits typed after the memo save started',async()=>{
- let finish!:(value:{kind:string})=>void;
- coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));
+ const finish=deferred();
await mount();await selectFirstItem();
- await act(async()=>button('저장').props.onClick());
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:'나중에 입력한 메모'}}));
- await act(async()=>finish({kind:'success'}));
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(false);
- expect(renderer.root.findByType('textarea').props.value).toBe('나중에 입력한 메모');
+ await click(button('저장'));
+ await type(memo()!,'나중에 입력한 메모');
+ await finish({kind:'success'});
+ expect(detail().hidden).toBe(false);
+ expect(memo()!.value).toBe('나중에 입력한 메모');
});
it('does not close another item when the previous item memo save finishes',async()=>{
- let finish!:(value:{kind:string})=>void;
- coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));
+ const finish=deferred();
await mount();await selectFirstItem();
- await act(async()=>button('저장').props.onClick());
- await act(async()=>button('준비물 선택: 항공권 예약 내역').props.onClick());
- await act(async()=>finish({kind:'success'}));
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(false);
- expect(renderer.root.findByProps({'aria-label':'선택한 준비물 이름'}).children.join('')).toBe('항공권 예약 내역');
+ await click(button('저장'));
+ await click(button('준비물 선택: 항공권 예약 내역'));
+ await finish({kind:'success'});
+ expect(detail().hidden).toBe(false);
+ expect(selectedName()).toBe('항공권 예약 내역');
});
-it.each(['취소','준비물 상세 닫기'])('does not close a reopened detail when a save finishes after %s',async(close)=>{
- let finish!:(value:{kind:string})=>void;
- coordinator.execute.mockReturnValue(new Promise(resolve=>{finish=resolve;}));
+// 모바일 시트의 X는 저장 중(status writing)에 막히므로, 저장 중 닫기는 PC 접기·메모 취소로 본다
+it.each([[false,'취소'],[false,'준비물 상세 접기'],[true,'취소']] as const)('does not close a reopened detail when a save finishes (mobile: %s, close: %s)',async(mobile,close)=>{
+ mocks.mobile=mobile;
+ const finish=deferred();
await mount();await selectFirstItem();
- await act(async()=>button('저장').props.onClick());
- await act(async()=>button(close).props.onClick());
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(true);
+ await click(button('저장'));
+ await click(button(close));
+ expect(detailOpen()).toBe(false);
await selectFirstItem();
- await act(async()=>finish({kind:'success'}));
- expect(renderer.root.findByProps({'aria-label':'준비물 상세'}).props.hidden).toBe(false);
+ await finish({kind:'success'});
+ expect(detailOpen()).toBe(true);
});
-it('dismisses the mobile detail with the shared header drag and retains its memo draft',async()=>{
- vi.stubGlobal('matchMedia',()=>({matches:true}));
- document.documentElement.classList.add('is-mobile-device');
+it('keeps the mobile memo draft after closing the sheet and does not close it while writing',async()=>{
+ mocks.mobile=true;
await mount();await selectFirstItem();
- await act(async()=>renderer.root.findByType('textarea').props.onChange({target:{value:'드래그 후에도 유지할 메모'}}));
- const detail=()=>renderer.root.findByProps({'aria-label':'준비물 상세'});
- const handle=()=>detail().findAllByType('div').find(node=>node.props.onPointerDown);
- expect(handle()).toBeDefined();
- const target=document.createElement('div');
- target.setPointerCapture=vi.fn();target.hasPointerCapture=()=>true;target.releasePointerCapture=vi.fn();
- const event=(y:number)=>({pointerId:1,isPrimary:true,button:0,clientX:20,clientY:y,target,currentTarget:target});
- await act(async()=>handle()!.props.onPointerDown(event(20)));
- await act(async()=>handle()!.props.onPointerMove(event(150)));
- expect(detail().props.style.transform).toBe('translateY(130px)');
- await act(async()=>handle()!.props.onPointerUp(event(150)));
- expect(detail().props.hidden).toBe(true);
+ await type(memo()!,'닫은 뒤에도 유지할 메모');
+ await click(button('준비물 상세 닫기'));
+ expect(sheet()).toBeNull();
await selectFirstItem();
- expect(renderer.root.findByType('textarea').props.value).toBe('드래그 후에도 유지할 메모');
- state.status='writing';await act(async()=>renderer.update( ));
- await act(async()=>handle()!.props.onPointerDown(event(20)));
- await act(async()=>handle()!.props.onPointerMove(event(150)));
- await act(async()=>handle()!.props.onPointerUp(event(150)));
- expect(detail().props.hidden).toBe(false);
+ expect(memo()!.value).toBe('닫은 뒤에도 유지할 메모');
+ // 저장 중에는 X·Esc로 닫히지 않는다(closeDisabled)
+ state.status='writing';await rerender();
+ expect(button('준비물 상세 닫기').disabled).toBe(true);
+ await click(button('준비물 상세 닫기'));
+ await escape();
+ expect(sheet()).not.toBeNull();
+ expect(memo()!.value).toBe('닫은 뒤에도 유지할 메모');
});
diff --git a/src/components/packing/PackingPage.tsx b/src/components/packing/PackingPage.tsx
index ae3bdada..6112fa74 100644
--- a/src/components/packing/PackingPage.tsx
+++ b/src/components/packing/PackingPage.tsx
@@ -1,11 +1,20 @@
"use client";
-import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import Image from "next/image";
+import { useForm, useStore } from "@tanstack/react-form";
import { usePackingList } from "@/hooks/usePackingList";
-import { useSheetDrag } from "@/components/mobile/useSheetDrag";
-import { BottomSheetDragHandle } from "@/components/mobile/BottomSheetDragHandle";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
+import { ConfirmDialog } from "@/components/settings/ConfirmDialog";
+import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Drawer, DrawerBody, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Field, FieldCount, FieldError, FieldFooter, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useMobileView } from "@/contexts/MobileViewContext";
import { SetSectionSideDetailWidth } from "@/contexts/SectionWidthContext";
import { PACKING_DETAIL_WIDTH_PX } from "@/lib/layout-tokens";
import { renderTextWithLinks } from "@/lib/text/renderTextWithLinks";
@@ -16,9 +25,29 @@ import styles from "./PackingPage.module.css";
type Context = ReturnType;
type Editor = {kind:"createPart"} | {kind:"renamePart"|"createItem"|"renameItem";id:number;name:string};
-const actionButton = styles.actionButton;
const linked = (value:string) => renderTextWithLinks(value,{linkClassName:"text-primary underline [overflow-wrap:anywhere]",onLinkClick:event=>event.stopPropagation()});
+// 이름·메모 대화상자나 삭제 확인창이 열려 있으면 Esc는 그쪽 몫이다. Base UI 표면은 이다
+const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-open], [role="alertdialog"][data-open]';
+
+function nameError(value:string, kind:"part"|"item") {
+ try {
+ normalizePackingName(value, kind);
+ return undefined;
+ } catch (error) {
+ return error instanceof Error ? error.message : "이름을 확인해 주세요.";
+ }
+}
+
+function memoError(value:string) {
+ try {
+ normalizePackingMemo(value);
+ return undefined;
+ } catch (error) {
+ return error instanceof Error ? error.message : "메모를 확인해 주세요.";
+ }
+}
+
function PackingIcon({name, size = 24}: {name:keyof typeof packingIcons; size?:number}) {
const sourceSize = name === "plusWhite" ? 16 : name === "collapse" || name.startsWith("menu") && name !== "menu" ? 20 : 24;
return
@@ -29,145 +58,269 @@ function PackingIcon({name, size = 24}: {name:keyof typeof packingIcons; size?:n
function PackingCheckbox({item,context,detail = false}: {item:PackingItem;context:Context;detail?:boolean}) {
return
- void context.coordinator?.execute({type:"checkItem",id:item.id,checked:event.target.checked})}/>
-
+ onCheckedChange={checked=>void context.coordinator?.execute({type:"checkItem",id:item.id,checked})}/>
;
}
-function NameDialog({editor,context,onClose}: {editor:Editor;context:Context;onClose:()=>void}) {
- const [value,setValue]=useState("name" in editor ? editor.name : "");
- const latestValue=useRef(value);
+/**
+ * 카테고리·준비물 이름 추가/수정. 열린 채 마운트되고 닫기 요청(Esc·딤·취소)은 onClose다.
+ * 다른 항목을 저장하는 동안(status writing)은 제출만 막고 버튼 모양은 그대로 둔다(체크할 때마다 깜빡이지 않게).
+ * 동기화 오류처럼 저장할 수 없는 상태(ready·writing 밖)에서는 버튼을 비활성으로 보인다(옛 동작)
+ */
+function NameDialog({editor,context,finalFocus,onClose}: Readonly<{editor:Editor;context:Context;finalFocus:()=>HTMLElement|null;onClose:()=>void}>) {
+ const formId=useId();
const mounted=useRef(true);
const composing=useRef(false);
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
- const [error,setError]=useState("");
- const [saving,setSaving]=useState(false);
const kind=editor.kind.includes("Part") ? "part" : "item";
const noun=kind==="part" ? "카테고리" : "준비물";
- const title=`${noun} ${editor.kind.startsWith("create") ? "추가" : "이름 수정"}`;
- async function submit(event:FormEvent) {
- event.preventDefault();
- if(saving || composing.current || context.state.status!=="ready" || !context.coordinator) return;
- try {normalizePackingName(value,kind);} catch(error) {setError(error instanceof Error ? error.message : "이름을 확인해 주세요.");return;}
- setSaving(true);
- const command=editor.kind==="createPart" ? {type:"createPart" as const,name:value} : editor.kind==="renamePart" ? {type:"renamePart" as const,id:editor.id,name:value} : editor.kind==="createItem" ? {type:"createItem" as const,partId:editor.id,name:value} : {type:"renameItem" as const,id:editor.id,name:value};
- try {const result=await context.coordinator.execute(command);if(mounted.current && (result.kind==="missing" || (result.kind==="success" && latestValue.current===value))) onClose();} finally {if(mounted.current)setSaving(false);}
- }
- return
- {noun} 이름 {latestValue.current=event.target.value;setValue(event.target.value);setError("");}} onCompositionStart={()=>{composing.current=true;}} onCompositionEnd={()=>{composing.current=false;}} onKeyDown={event=>{if(event.key==="Enter" && (composing.current || event.nativeEvent.isComposing || event.keyCode===229)) event.preventDefault();}} className={styles.field}/>
- {error && {error}
}
- 취소 {editor.kind.startsWith("create") ? "추가" : "저장"}
- ;
+ const isCreate=editor.kind.startsWith("create");
+ const form=useForm({
+ defaultValues:{name:"name" in editor ? editor.name : ""},
+ onSubmit:async({value,formApi})=>{
+ const coordinator=context.coordinator;
+ if(!coordinator) return;
+ const command = (() => {
+ switch (editor.kind) {
+ case "createPart": return {type:"createPart" as const,name:value.name};
+ case "renamePart": return {type:"renamePart" as const,id:editor.id,name:value.name};
+ case "createItem": return {type:"createItem" as const,partId:editor.id,name:value.name};
+ case "renameItem": return {type:"renameItem" as const,id:editor.id,name:value.name};
+ }
+ })();
+ const result=await coordinator.execute(command);
+ // 저장하는 동안 다시 고친 이름은 남기고, 대화상자를 닫았다 다시 연 뒤 도착한 응답은 새 대화상자를 닫지 않는다
+ if(mounted.current && (result.kind==="missing" || (result.kind==="success" && formApi.getFieldValue("name")===value.name))) onClose();
+ },
+ });
+ const isSaving=useStore(form.store,state=>state.isSubmitting);
+ const blocked=context.state.status!=="ready" && context.state.status!=="writing";
+ return {if(!open)onClose();}} closeDisabled={isSaving}>
+
+ {noun} {isCreate ? "추가" : "이름 수정"}
+ {
+ event.preventDefault();
+ if(form.state.isSubmitting || composing.current || context.state.status!=="ready" || !context.coordinator) return;
+ void form.handleSubmit();
+ }}>
+ nameError(value,kind)}}>
+ {field=>{
+ const error=field.state.meta.errors[0];
+ return
+ {noun} 이름
+ field.handleChange(event.target.value)} onBlur={field.handleBlur}
+ onCompositionStart={()=>{composing.current=true;}} onCompositionEnd={()=>{composing.current=false;}}
+ onKeyDown={event=>{if(event.key==="Enter" && (composing.current || event.nativeEvent.isComposing || event.keyCode===229)) event.preventDefault();}}/>
+ {error}
+ ;
+ }}
+
+
+
+ }>취소
+ {isCreate ? "추가" : "저장"}
+
+
+ ;
}
function DeleteDialog({context,onClose}: {context:Context;onClose:()=>void}) {
const pending=useRef(false);
const [deleting,setDeleting]=useState(false);
- const close=useCallback(()=>{if(!pending.current)onClose();},[onClose]);
const confirmation=context.state.confirmation;
if(!confirmation) return null;
const part=confirmation.kind==="part";
+ function close(){if(!pending.current)onClose();}
async function confirm(){if(pending.current || context.state.status!=="ready" || !context.coordinator)return;pending.current=true;setDeleting(true);try{await context.coordinator.confirmDelete();}finally{pending.current=false;setDeleting(false);}}
const count=context.state.data?.parts.find(value=>value.id===confirmation.id)?.items.length ?? 0;
- return
- ‘{confirmation.name}’{part ? `의 준비물 ${count}개와` : " 준비물과"} 작성한 메모가 함께 삭제됩니다.
- 취소 void confirm()}>삭제
- ;
+ const deletionSubject=part ? `의 준비물 ${count}개와` : " 준비물과";
+ return void confirm()} onCancel={close}/>;
}
-function MemoPanel({item,context,open,onComplete}: {item:PackingItem;context:Context;open:boolean;onComplete:()=>void}) {
- const [value,setValue]=useState(item.memo?.content ?? "");
- const [saving,setSaving]=useState(false);
- const [error,setError]=useState("");
- const latestValue=useRef(value);
+/**
+ * 선택한 준비물의 메모 폼. 상세를 닫아도 초안이 남아야 해서(다시 열면 이어 쓰기) 모바일 시트처럼 닫히면 내용이 언마운트되는 껍데기 바깥,
+ * 준비물마다 key로 다시 마운트되는 컴포넌트에서 부른다
+ */
+function useMemoForm(item:PackingItem, context:Context, open:boolean, onComplete:()=>void) {
const mounted=useRef(true);
const completionVersion=useRef(0);
const source=useRef(item.memo?.content ?? "");
const submitted=useRef(null);
+ // 열린 동안 서버 메모가 바뀌어도 기본값은 연 순간 값으로 둔다. 칸 값은 아래 effect가 규칙대로 맞춘다
+ const [defaultValues]=useState(()=>({memo:item.memo?.content ?? ""}));
+ const form=useForm({
+ defaultValues,
+ onSubmit:async({value,formApi})=>{
+ submitted.current=value.memo;
+ const version=completionVersion.current;
+ try {
+ const result=await context.coordinator?.execute({type:"saveMemo",id:item.id,content:value.memo});
+ if(result?.kind==="success" && mounted.current && version===completionVersion.current && formApi.getFieldValue("memo")===value.memo) onComplete();
+ } finally {
+ submitted.current=null;
+ }
+ },
+ });
+ const isSaving=useStore(form.store,state=>state.isSubmitting);
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
useEffect(()=>{if(!open)completionVersion.current+=1;},[open]);
- useEffect(()=>{const next=item.memo?.content ?? "";if(source.current!==next){const previous=source.current;source.current=next;setValue(current=>current===previous || current===submitted.current ? next : current);}},[item.memo?.content]);
+ // 서버 메모가 바뀌면 손대지 않은 칸(이전 서버 값)이나 방금 보낸 값만 새 값으로 바꾼다. 그 뒤 새로 쓴 초안은 지키지 않는다
+ useEffect(()=>{
+ const next=item.memo?.content ?? "";
+ if(source.current===next) return;
+ const previous=source.current;
+ source.current=next;
+ const current=form.getFieldValue("memo");
+ if(current===previous || current===submitted.current) form.setFieldValue("memo",next);
+ },[item.memo?.content,form]);
function cancel() {
completionVersion.current+=1;
- latestValue.current=item.memo?.content ?? "";
- setValue(latestValue.current);
- setError("");
+ // 값을 바꾸면 칸을 다시 검사하면서 제출 오류도 지워진다
+ form.setFieldValue("memo",item.memo?.content ?? "");
onComplete();
}
- async function save() {
- if(saving || context.state.status!=="ready") return;
- try {normalizePackingMemo(value);} catch(e) {setError(e instanceof Error ? e.message : "메모를 확인해 주세요.");return;}
- setSaving(true);
- submitted.current=value;
- const version=completionVersion.current;
- try {
- const result=await context.coordinator?.execute({type:"saveMemo",id:item.id,content:value});
- if(result?.kind==="success" && mounted.current && version===completionVersion.current && latestValue.current===value) onComplete();
- } finally {
- submitted.current=null;
- if(mounted.current)setSaving(false);
- }
+ function save() {
+ if(form.state.isSubmitting || context.state.status!=="ready") return;
+ void form.handleSubmit();
}
+ // 저장할 수 없는 상태(동기화 오류 등)면 저장 버튼을 비활성으로 보인다. writing은 제출만 막는다(NameDialog와 같음)
+ const blocked=context.state.status!=="ready" && context.state.status!=="writing";
+ return {form,isSaving,blocked,cancel,save};
+}
+
+function MemoPanel({item,memo}: Readonly<{item:PackingItem;memo:ReturnType}>) {
+ const {isMobileDevice}=useMobileView();
+ const errorId=useId();
+ const {form}=memo;
return
내 메모
- {latestValue.current=event.target.value;setValue(event.target.value);setError("");}}
- placeholder="준비물에 대한 메모를 남겨 주세요." maxLength={2000} rows={3} className={styles.memoField}/>
- ({value.length}/2000)
- {error && {error}
}
-
-
취소
-
void save()}>저장
+
memoError(value)}}>
+ {field=>{
+ const error=field.state.meta.errors[0];
+ return
+ field.handleChange(event.target.value)} onBlur={field.handleBlur}
+ placeholder="준비물에 대한 메모를 남겨 주세요." maxLength={2000} rows={3}/>
+ {error} ({field.state.value.length}/2000)
+ ;
+ }}
+
+ {/* 상세 안 인라인 버튼: PC 패널은 sm, 모바일 기기 시트는 md로 폭을 나눈다 */}
+
+ 취소
+ 저장
;
}
-function Detail({item,context,onClose,onRename,onDelete,open}: {item:PackingItem|null;context:Context;onClose:()=>void;onRename:()=>void;onDelete:()=>void;open:boolean}) {
- const sheetDrag = useSheetDrag(onClose, !open || context.state.status === "writing");
+function DetailSections({item,memo}: Readonly<{item:PackingItem;memo:ReturnType
}>) {
+ return <>
+
+ {item.tips.length>0 &&
+ 우때의 여행 팁 💡
+
+
{item.name} 준비 전 확인해 주세요
+
{item.tips.map((tip,index)=>{linked(tip)} )}
+
+ }
+ >;
+}
+
+/** 체크·이름·수정/삭제 줄. 이름 요소(children)는 PC 패널은 h2, 모바일 시트는 시트 제목(DrawerTitle)이다 */
+function DetailHeader({item,context,onRename,onDelete,children}: Readonly<{item:PackingItem;context:Context;onRename:()=>void;onDelete:()=>void;children:ReactNode}>) {
+ return ;
+}
+
+type DetailProps = {item:PackingItem|null;context:Context;open:boolean;onClose:()=>void;onRename:()=>void;onDelete:()=>void};
+type ItemDetailProps = Omit & {item:PackingItem};
+
+/** PC: 목록 오른쪽 비모달 패널. 닫혀도 언마운트하지 않아 메모 초안이 남는다 */
+function DetailPanel({item,context,open,onClose,onRename,onDelete}: Readonly) {
useEffect(()=>{
if(!open) return;
function handleEscape(event:KeyboardEvent) {
- if(event.key!=="Escape" || event.defaultPrevented || document.querySelector("dialog[open]")) return;
+ if(event.key!=="Escape" || event.defaultPrevented || document.querySelector(OPEN_DIALOG_SELECTOR)) return;
event.preventDefault();
onClose();
}
document.addEventListener("keydown",handleEscape);
return()=>document.removeEventListener("keydown",handleEscape);
},[open,onClose]);
+ return ;
+}
+
+function DetailPanelBody({item,context,open,onClose,onRename,onDelete}: ItemDetailProps) {
+ const memo=useMemoForm(item,context,open,onClose);
return <>
- {open && }
- {/* 닫혀도 언마운트되지 않아, 드래그 후에도 열 때마다 CSS 등장 애니메이션이 다시 돌도록 애니메이션 해제는 쓰지 않는다 */}
-
-
-
- {item && }
-
-
- {item && <>
-
- {item.tips.length>0 &&
- 우때의 여행 팁 💡
-
-
{item.name} 준비 전 확인해 주세요
-
{item.tips.map((tip,index)=>{linked(tip)} )}
-
- }
- >}
-
-
-
+
+
+ {item.name}
+
+
+
>;
}
+/**
+ * 모바일 기기: 아래 시트(Drawer). 저장 중(status writing)에는 Esc·딤·X·스와이프로 닫히지 않는다.
+ * 시트 내용은 닫히면 언마운트되므로 메모 폼은 시트 바깥(DetailSheetBody)이 들고 있다
+ */
+function DetailSheet({item,context,open,onClose,onRename,onDelete}: Readonly) {
+ return {if(!next)onClose();}} closeDisabled={context.state.status==="writing"}>
+ {item && }
+ ;
+}
+
+function DetailSheetBody({item,context,open,onClose,onRename,onDelete}: ItemDetailProps) {
+ const memo=useMemoForm(item,context,open,onClose);
+ // 높이는 지금 시트 값(화면의 76.53%, 위 80px은 남김)
+ return
+ {/* 오른쪽 닫기 X의 누르는 영역(음수 margin)이 삭제 버튼 영역과 겹치지 않게 띄운다 */}
+
+
+ {item.name}
+
+
+
+ ;
+}
+
+/** 카테고리 메뉴. 항목에서 연 이름 대화상자는 닫힐 때 이 메뉴 버튼으로 포커스를 돌려준다(항목은 메뉴와 함께 사라진다) */
+function PartMenu({part,onEdit,onDelete}: Readonly<{part:PackingPart;onEdit:(editor:Editor,returnFocus:HTMLElement|null)=>void;onDelete:()=>void}>) {
+ const trigger=useRef(null);
+ return
+
+
+
+
+ onEdit({kind:"createItem",id:part.id,name:""},trigger.current)}>
+ 준비물 추가
+
+ onEdit({kind:"renamePart",id:part.id,name:part.name},trigger.current)}>
+ 카테고리 수정
+
+
+ 카테고리 삭제
+
+
+ ;
+}
+
function PackingLoadingBoard() {
return <>
준비물을 불러오는 중…
@@ -187,21 +340,24 @@ function PackingLoadingBoard() {
function PackingContent({context}: {context:Context}) {
const {state,coordinator}=context;
+ const {isMobileDevice}=useMobileView();
const loading = !state.data && state.status === "loading";
const emptyBoard = loading ? : 준비물을 확인할 수 없어요. ;
const [selectedId,setSelectedId]=useState(null);
const [detailOpen,setDetailOpen]=useState(false);
- const [menuId,setMenuId]=useState(null);
const [editor,setEditor]=useState(null);
- const menuTrigger=useRef(null);
+ // 메뉴 항목에서 연 이름 대화상자의 포커스 복귀 대상. 그 밖에서 열면 null(Base UI 기본: 연 버튼으로 복귀)
+ const editorReturnFocus=useRef(null);
const parts=[...(state.data?.parts ?? [])].sort((a,b)=>a.column-b.column || a.position-b.position);
const items=parts.flatMap(part=>part.items);
const selected=items.find(item=>item.id===selectedId) ?? null;
const selectedTrigger=useRef(null);
const closeDetail=useCallback(()=>{setDetailOpen(false);selectedTrigger.current?.focus({preventScroll:true});},[]);
- function closeMenu(){menuTrigger.current?.focus({preventScroll:true});setMenuId(null);}
- function prepareDelete(kind:"part"|"item",id:number){if(menuId!==null)closeMenu();void coordinator?.prepareDelete(kind,id);}
- useEffect(()=>{if(menuId===null)return;function close(event:PointerEvent){if(!(event.target instanceof Node) || !menuTrigger.current?.parentElement?.contains(event.target))setMenuId(null);}document.addEventListener("pointerdown",close);return()=>document.removeEventListener("pointerdown",close);},[menuId]);
+ function openEditor(next:Editor,returnFocus:HTMLElement|null=null){editorReturnFocus.current=returnFocus;setEditor(next);}
+ function prepareDelete(kind:"part"|"item",id:number){void coordinator?.prepareDelete(kind,id);}
+ const detailProps:DetailProps={item:selected,context,open:detailOpen && selected!==null,onClose:closeDetail,
+ onRename:()=>{if(selected)openEditor({kind:"renameItem",id:selected.id,name:selected.name});},
+ onDelete:()=>{if(selected)prepareDelete("item",selected.id);}};
return
{/* 상세가 열리면 목록 400을 지킨 채 상세 폭만큼 데스크톱 최소 폭을 넓힌다 */}
@@ -209,7 +365,7 @@ function PackingContent({context}: {context:Context}) {
준비물
- {state.data && setEditor({kind:"createPart"})}>
+ {state.data && openEditor({kind:"createPart"})}>
카테고리 추가
}
{loading && }
@@ -221,39 +377,17 @@ function PackingContent({context}: {context:Context}) {
{loading && }
- {state.message &&
- {state.message}
- {["sync-error","uncertain","error"].includes(state.status) && void coordinator?.refresh(true)}>다시 확인 }
-
}
+ {state.message &&
+ {state.message}
+ {["sync-error","uncertain","error"].includes(state.status) && void coordinator?.refresh(true)}>다시 확인 }
+ }
{!state.data ? emptyBoard : (
{parts.map((part:PackingPart)=>
{part.name}
{part.items.filter(item=>item.checked).length}/{part.items.length}
-
setMenuId(menuId===part.id?null:part.id)}>
- {menuId===part.id &&
{
- if(event.key==="Escape"){
- event.preventDefault();event.stopPropagation();closeMenu();
- }else if(event.key==="ArrowDown"||event.key==="ArrowUp"){
- event.preventDefault();
- const choices=[...event.currentTarget.querySelectorAll
('button')];
- const index=choices.indexOf(document.activeElement as HTMLButtonElement);
- choices[(index+(event.key==="ArrowDown"?1:choices.length-1))%choices.length]?.focus();
- }
- }}>
- {closeMenu();setEditor({kind:"createItem",id:part.id,name:""});}}>
- 준비물 추가
-
- {closeMenu();setEditor({kind:"renamePart",id:part.id,name:part.name});}}>
- 카테고리 수정
-
- prepareDelete("part",part.id)}>
- 카테고리 삭제
-
- }
+
prepareDelete("part",part.id)}/>
{[...part.items].sort((a,b)=>a.position-b.position).map(item=>
)}
- setEditor({kind:"createItem",id:part.id,name:""})} aria-label={`준비물 추가: ${part.name}`}>
+ openEditor({kind:"createItem",id:part.id,name:""})} aria-label={`준비물 추가: ${part.name}`}>
준비물 추가
)}
@@ -275,10 +409,8 @@ function PackingContent({context}: {context:Context}) {
)}
- selected&&setEditor({kind:"renameItem",id:selected.id,name:selected.name})}
- onDelete={()=>selected&&prepareDelete("item",selected.id)}/>
- {editor && setEditor(null)}/>}
+ {isMobileDevice ? : }
+ {editor && editorReturnFocus.current} onClose={()=>setEditor(null)}/>}
{state.confirmation && coordinator?.cancelConfirmation()}/>}
;
}
diff --git a/src/components/place/AddToBookmarkModal.tsx b/src/components/place/AddToBookmarkModal.tsx
index 5018d60b..a3637852 100644
--- a/src/components/place/AddToBookmarkModal.tsx
+++ b/src/components/place/AddToBookmarkModal.tsx
@@ -1,12 +1,21 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
-
import { Check, Plus } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { createPortal } from "react-dom";
+import { useCallback, useMemo, useState, type SyntheticEvent } from "react";
import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
+
import { messageForBookmarkCategorySaveError } from "@/lib/api/errors";
import { AddBookmarkModal } from "@/app/(main)/bookmark/_components/AddBookmarkModal";
import { FolderRibbonIcon } from "@/app/(main)/bookmark/_components/FolderRibbonIcon";
@@ -24,6 +33,16 @@ import {
import { cn } from "@/lib/utils";
import { useSessionStore } from "@/stores/session-store";
+// 포털 안 클릭·터치가 React 트리를 따라 부모(지도 상세 패널·시트 드래그, 검색 결과 카드)로 올라가지 않게 포털 표면과 배경에서 끊는다.
+// 딤은 표면 밖이므로 표면과 배경 각각에서 차단한다. 키보드는 Base UI에 그대로 전달한다
+const stopPropagation = (event: SyntheticEvent) => event.stopPropagation();
+const pointerBoundary = {
+ onClick: stopPropagation,
+ onMouseDown: stopPropagation,
+ onPointerDown: stopPropagation,
+ onTouchStart: stopPropagation,
+};
+
type Props = {
googlePlaceId: string;
placeCategory?: string;
@@ -69,15 +88,6 @@ export function AddToBookmarkModal({
setSubmitFeedback(null);
}, []);
- useEffect(() => {
- if (createFolderModalOpen) return;
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [onClose, createFolderModalOpen]);
-
const openCreateFolderModal = () => {
setCreateFolderModalKey((k) => k + 1);
setCreateFolderModalOpen(true);
@@ -208,39 +218,24 @@ export function AddToBookmarkModal({
return null;
}
- /* 부모(지도 상세 패널·시트)의 transform 때문에 fixed가 패널 기준이 되지 않도록 body로 포털 */
- return createPortal(
- {
- ev.stopPropagation();
- if (ev.target === ev.currentTarget) onClose();
+ return (
+
{
+ if (!open) onClose();
}}
- onClick={(ev) => ev.stopPropagation()}
- onPointerDown={(ev) => ev.stopPropagation()}
- onTouchStart={(ev) => ev.stopPropagation()}
>
- e.stopPropagation()}
- className="flex max-h-[min(85vh,560px)] w-full max-w-sm flex-col overflow-hidden rounded-2xl border border-gray-border bg-white pb-3 shadow-lg"
- >
-
-
- 북마크에 추가
-
-
- 북마크를 선택한 뒤 추가해 주세요.
-
-
+
+
+ 북마크에 추가
+ 북마크를 선택한 뒤 추가해 주세요.
+
-
+ {/* 제목 줄·버튼은 고정하고 목록만 스크롤한다. 행 구분선·hover 배경이 표면 끝까지 닿도록 표면 padding만큼 넓힌다 */}
+
-
+
)}
@@ -344,45 +339,35 @@ export function AddToBookmarkModal({
)}
-
-
- 닫기
-
-
+ }>닫기
+
- {isAddingBookmarks ? "추가 중…" : "추가"}
-
-
-
+ 추가
+
+
- {createFolderModalOpen && (
- c.name),
- )}
- onClose={() => setCreateFolderModalOpen(false)}
- onSave={handleCreateFolderSave}
- />
- )}
- ,
- document.body,
+ {createFolderModalOpen && (
+ c.name),
+ )}
+ onClose={() => setCreateFolderModalOpen(false)}
+ onSave={handleCreateFolderSave}
+ />
+ )}
+
+
);
}
diff --git a/src/components/place/AddToScheduleModal.tsx b/src/components/place/AddToScheduleModal.tsx
index 5519352f..5f1a6a71 100644
--- a/src/components/place/AddToScheduleModal.tsx
+++ b/src/components/place/AddToScheduleModal.tsx
@@ -1,12 +1,22 @@
"use client";
-import { LoadingIndicator } from "@/components/loading/LoadingIndicator";
-
import { useQueryClient } from "@tanstack/react-query";
-import { useEffect } from "react";
-import { createPortal } from "react-dom";
+import type { SyntheticEvent } from "react";
import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
+
import { useCreateScheduleItem, useRoomSchedules } from "@/hooks/useRooms";
import { bucketItemCount } from "@/lib/analytics/context";
import {
@@ -23,6 +33,16 @@ import {
} from "@/lib/plan/tripRange";
import { useSessionStore } from "@/stores/session-store";
+// 포털 안 클릭·터치가 React 트리를 따라 부모(지도 상세 패널·시트 드래그, 검색 결과 카드)로 올라가지 않게 포털 표면과 배경에서 끊는다.
+// 딤은 표면 밖이므로 표면과 배경 각각에서 차단한다. 키보드는 Base UI에 그대로 전달한다
+const stopPropagation = (event: SyntheticEvent) => event.stopPropagation();
+const pointerBoundary = {
+ onClick: stopPropagation,
+ onMouseDown: stopPropagation,
+ onPointerDown: stopPropagation,
+ onTouchStart: stopPropagation,
+};
+
type Props = {
googlePlaceId: string;
placeCategory?: string;
@@ -55,14 +75,6 @@ export function AddToScheduleModal({
const sorted = schedules ? sortRoomSchedules(schedules) : [];
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [onClose]);
-
async function pickDay(scheduleId: number) {
if (!rid || isAdding) return;
try {
@@ -91,39 +103,22 @@ export function AddToScheduleModal({
}
}
- /* 부모(지도 상세 패널·시트)의 transform 때문에 fixed가 패널 기준이 되지 않도록 body로 포털 */
- return createPortal(
- {
- ev.stopPropagation();
- if (ev.target === ev.currentTarget) onClose();
+ return (
+
{
+ if (!open) onClose();
}}
- onClick={(ev) => ev.stopPropagation()}
- onPointerDown={(ev) => ev.stopPropagation()}
- onTouchStart={(ev) => ev.stopPropagation()}
>
- e.stopPropagation()}
- >
-
-
- 일정에 추가
-
-
- 담을 여행 일차를 선택하세요.
-
-
-
-
+
+
+ 일정에 추가
+ 담을 여행 일차를 선택하세요.
+
+
+
{!rid ? (
참여 중인 방이 없어요. 방에 입장한 뒤 다시 시도해 주세요.
@@ -132,7 +127,7 @@ export function AddToScheduleModal({
{rid && schedulesLoading ? (
-
+
) : null}
@@ -181,19 +176,12 @@ export function AddToScheduleModal({
))}
) : null}
-
-
-
-
- 닫기
-
-
-
- ,
- document.body,
+
+
+
+ }>닫기
+
+
+
);
}
diff --git a/src/components/place/HeroSection.tsx b/src/components/place/HeroSection.tsx
index 6af46348..d1d71bd1 100644
--- a/src/components/place/HeroSection.tsx
+++ b/src/components/place/HeroSection.tsx
@@ -1,11 +1,12 @@
"use client";
+import { Skeleton } from "@/components/ui/skeleton";
import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl";
import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl";
import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh";
export function HeroSkeleton() {
- return
;
+ return ;
}
export function HeroImage({
diff --git a/src/components/place/PlaceDetailPanel.tsx b/src/components/place/PlaceDetailPanel.tsx
index 7acebf3c..d3e923a9 100644
--- a/src/components/place/PlaceDetailPanel.tsx
+++ b/src/components/place/PlaceDetailPanel.tsx
@@ -4,6 +4,7 @@ import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
+import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
import { useAnalyticsRoomId } from "@/hooks/useAnalyticsRoomId";
import { useMobileView } from "@/contexts/MobileViewContext";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
@@ -195,9 +196,13 @@ export function PlaceDetailPanel({
: undefined;
const tabs = isDetailError && !detailData ? (
-
-
장소 정보를 불러오지 못했어요.
-
void refetch()} className="mt-2 text-primary underline">다시 시도
+
+
+ 장소 정보를 불러오지 못했어요.
+
+ void refetch()} className="text-primary underline">다시 시도
+
+
) : (
;
-}
+import { Skeleton } from "@/components/ui/skeleton";
+/** 장소 상세 본문 로딩 스켈레톤 — `PlaceSummaryHeader`·탭·홈 탭 배치를 따른다 */
export function PlaceDetailSkeleton() {
return (
{/* 이름·카테고리·평점·액션 버튼 */}
{/* 탭 */}
-
-
+
+
{/* 홈 탭 본문 */}
-
-
-
-
+
+
+
+
);
@@ -40,13 +38,13 @@ export function PlaceSheetSummarySkeleton() {
return (
);
diff --git a/src/components/place/PlaceDetailTabs.tsx b/src/components/place/PlaceDetailTabs.tsx
index 3c5c8fd6..b45e039b 100644
--- a/src/components/place/PlaceDetailTabs.tsx
+++ b/src/components/place/PlaceDetailTabs.tsx
@@ -1,12 +1,17 @@
"use client";
-import { useState, type ComponentProps } from "react";
+import type { ComponentProps } from "react";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HomeTab } from "./HomeTab";
import { ReviewsTab } from "./ReviewsTab";
-import { TABS, type Tab } from "./types";
+import { TABS } from "./types";
-/** 장소 상세의 홈·리뷰 탭 — 장소 상세 패널·시트와 지도 경로 보기 카드가 함께 쓴다 */
+/**
+ * 장소 상세의 홈·리뷰 탭 — 장소 상세 패널·시트와 지도 경로 보기 카드가 함께 쓴다.
+ * 숨은 탭 패널은 언마운트된다(Tabs 기본 keepMounted false — 지금처럼 탭을 바꾸면 패널 state·스크롤이 처음부터)
+ */
export function PlaceDetailTabs({
home,
reviews,
@@ -14,30 +19,21 @@ export function PlaceDetailTabs({
home: ComponentProps
;
reviews: ComponentProps;
}>) {
- const [activeTab, setActiveTab] = useState("홈");
-
return (
- <>
- {/* Tab navigation */}
-
+
+
{TABS.map((tab) => (
- setActiveTab(tab)}
- className={`flex-1 py-2.5 text-label-s-regular mobile:text-label-xs-regular font-medium transition-colors ${
- activeTab === tab
- ? "border-b-2 border-primary text-primary"
- : "text-dark-gray hover:text-[#364153]"
- }`}
- >
+
{tab}
-
+
))}
-
-
- {/* Tab content */}
- {activeTab === "홈" && }
- {activeTab === "리뷰" && }
- >
+
+
+
+
+
+
+
+
);
}
diff --git a/src/components/place/ReviewsTab.tsx b/src/components/place/ReviewsTab.tsx
index a3081638..47e44faf 100644
--- a/src/components/place/ReviewsTab.tsx
+++ b/src/components/place/ReviewsTab.tsx
@@ -1,5 +1,7 @@
import { ExternalLink } from "lucide-react";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+
import { StarRow } from "./StarRow";
import type { PlaceReview } from "./types";
@@ -73,9 +75,9 @@ export function ReviewsTab({
{reviews.map((review, i) => (
-
- {review.authorDisplayName?.charAt(0)}
-
+
+ {review.authorDisplayName?.charAt(0)}
+
diff --git a/src/components/place/room-analytics.test.tsx b/src/components/place/room-analytics.test.tsx
index 78bd0299..2e0e484d 100644
--- a/src/components/place/room-analytics.test.tsx
+++ b/src/components/place/room-analytics.test.tsx
@@ -184,3 +184,27 @@ it("keeps the extracted mobile delete sheet's room after its pending request com
room_id: "room-a", item_count_bucket: "0",
});
});
+
+// Popup/backdrop containment must hold even when the modal belongs to a clickable place card.
+it.each(["bookmark", "schedule"])("contains %s modal pointer events and keeps backdrop dismissal", async kind => {
+ const outer = vi.fn();
+ const close = vi.fn();
+ const hostEvents = { onClick: outer, onMouseDown: outer, onPointerDown: outer, onTouchStart: outer };
+ await render(
+ {kind === "bookmark"
+ ?
+ :
}
+
);
+ const content = document.querySelector('[data-slot="dialog-content"]')!;
+ for (const type of ["mousedown", "pointerdown", "touchstart", "click"]) {
+ await act(async () => content.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })));
+ }
+ expect(outer).not.toHaveBeenCalled();
+ expect(close).not.toHaveBeenCalled();
+ const backdrop = document.querySelector('[data-slot="dialog-overlay"]')!;
+ for (const type of ["mousedown", "pointerdown", "touchstart", "click"]) {
+ await act(async () => backdrop.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })));
+ }
+ expect(outer).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalled();
+});
diff --git a/src/components/place/types.ts b/src/components/place/types.ts
index 182351f4..efc09e28 100644
--- a/src/components/place/types.ts
+++ b/src/components/place/types.ts
@@ -1,5 +1,4 @@
export const TABS = ["홈", "리뷰"] as const;
-export type Tab = (typeof TABS)[number];
export type { PlaceReview } from "@/lib/api/places";
import type { PlaceReview } from "@/lib/api/places";
diff --git a/src/components/rooms/DestinationChipsField.tsx b/src/components/rooms/DestinationChipsField.tsx
index d40eaa85..dba323e5 100644
--- a/src/components/rooms/DestinationChipsField.tsx
+++ b/src/components/rooms/DestinationChipsField.tsx
@@ -1,22 +1,21 @@
"use client";
-import { X } from "lucide-react";
import { useState } from "react";
import { DestinationSearchInput } from "@/components/search/DestinationSearchInput";
+import { Chip, ChipRemove } from "@/components/ui/chip";
type Props = {
values: string[];
onChange: (next: string[]) => void;
- readOnly?: boolean;
maxCount: number;
maxLength: number;
};
+/** 목적지 검색 칸 → 고른 칩 → 경고 순서. 새 여행 만들기(`TripFormFields`)와 여행 정보 수정(`RoomTripEditForm`)이 같이 쓰고, 간격은 감싸는 Field가 준다 */
export function DestinationChipsField({
values,
onChange,
- readOnly = false,
maxCount,
maxLength,
}: Props) {
@@ -50,48 +49,10 @@ export function DestinationChipsField({
setDraft("");
}
- if (readOnly) {
- if (!values.length) {
- return —
;
- }
- return (
-
- {values.map((v, i) => (
-
- {v}
-
- ))}
-
- );
- }
-
return (
-
- {values.length > 0 ? (
-
- {values.map((v, i) => (
-
- {v}
- remove(i)}
- aria-label={`${v} 삭제`}
- className="rounded-full p-0.5 transition hover:bg-primary/20"
- >
-
-
-
- ))}
-
- ) : null}
+ <>
{atMax ? (
-
+
최대 {maxCount}개까지 추가할 수 있어요
) : (
@@ -99,13 +60,24 @@ export function DestinationChipsField({
value={draft}
onChange={setDraft}
onResolvedPlace={handleResolved}
+ placeholder="여행지를 검색해 선택하세요"
selectionOnly
- leadingIconType="search"
+ appearance="field"
/>
)}
+ {values.length > 0 ? (
+
+ {values.map((v, i) => (
+
+ {v}
+ remove(i)} />
+
+ ))}
+
+ ) : null}
{inlineWarning ? (
{inlineWarning}
) : null}
-
+ >
);
}
diff --git a/src/components/rooms/InviteRoomDialog.tsx b/src/components/rooms/InviteRoomDialog.tsx
index 3c91d4ec..4178c872 100644
--- a/src/components/rooms/InviteRoomDialog.tsx
+++ b/src/components/rooms/InviteRoomDialog.tsx
@@ -1,9 +1,14 @@
"use client";
+import type { ComponentProps } from "react";
+
import { AddMemberPanel } from "@/app/(main)/member-settings/_components/AddMemberPanel";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-/** 초대하기 모달 — PC 헤더와 홈 방 카드 메뉴가 같이 쓴다. 불러오기·오류·권한 안내는 `AddMemberPanel`이 맡는다 */
+/**
+ * 초대하기 모달 — PC 헤더와 홈 방 카드 메뉴가 같이 쓴다. 불러오기·오류·권한 안내는 `AddMemberPanel`이 맡는다.
+ * 마운트되면 열린 상태라 호출부가 조건부로 그린다. 메뉴 항목처럼 여는 요소가 닫힌 뒤 사라지면 finalFocus로 돌아갈 곳을 준다
+ */
export function InviteRoomDialog({
roomId,
inviteCode,
@@ -13,6 +18,7 @@ export function InviteRoomDialog({
isRoomDetailError,
onRetryRoomDetail,
onClose,
+ finalFocus,
}: Readonly<{
roomId: string;
inviteCode: string | null | undefined;
@@ -22,32 +28,45 @@ export function InviteRoomDialog({
isRoomDetailError: boolean;
onRetryRoomDetail?: () => void;
onClose: () => void;
+ finalFocus?: ComponentProps["finalFocus"];
}>) {
return (
-
- 링크를 공유해 함께 여행을 계획해 보세요.
-
-
- {isRoomDetailError && onRetryRoomDetail ? (
-
- 다시 시도
-
- ) : null}
-
-
+ {
+ if (!open) onClose();
+ }}
+ >
+
+
+ 초대하기
+
+
+
링크를 공유해 함께 여행을 계획해 보세요.
+
+
+ {isRoomDetailError && onRetryRoomDetail ? (
+
+ 다시 시도
+
+ ) : null}
+
+
+
+
);
}
diff --git a/src/components/rooms/RoomTripEditDialog.test.tsx b/src/components/rooms/RoomTripEditDialog.test.tsx
index ca3089a1..29eafa08 100644
--- a/src/components/rooms/RoomTripEditDialog.test.tsx
+++ b/src/components/rooms/RoomTripEditDialog.test.tsx
@@ -27,7 +27,17 @@ import HeaderBar from "@/components/layout/HeaderBar";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let host: HTMLDivElement;
let root: Root;
-const dialog = () => document.querySelector("dialog[open]");
+// Base UI 대화상자는 document.body로 포털된다. 여행 정보 수정 대화상자는 제목(aria-labelledby)으로 찾는다
+const dialogs = () => [...document.querySelectorAll('[role="dialog"]')];
+const editDialogs = () =>
+ dialogs().filter((node) => document.getElementById(node.getAttribute("aria-labelledby") ?? "")?.textContent === "여행 정보 수정");
+const dialog = () => editDialogs()[0] ?? null;
+// 열림·닫힘 처리와 처음 포커스는 Base UI가 다음 animation frame에 마친다
+async function nextFrame() {
+ await act(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ });
+}
const button = (label: string, scope: ParentNode = document) => {
const node = [...scope.querySelectorAll("button")].find(
(item) => item.getAttribute("aria-label") === label || item.textContent === label,
@@ -51,10 +61,12 @@ async function open() {
const trigger = button("여행 정보 수정", host);
trigger.focus();
await click("여행 정보 수정", host);
+ await nextFrame();
return trigger;
}
const dateField = (edge: "start" | "end") => dialog()!.querySelector(`#trip-room-${edge}`)!;
-const picker = () => dialog()!.querySelector('[role="dialog"][aria-label="날짜 선택"]');
+// 날짜 선택 팝업도 body로 포털돼 여행 정보 수정 대화상자 밖에 그려진다
+const picker = () => document.querySelector('[role="dialog"][aria-label="날짜 선택"]');
const day = (ymd: string) => picker()!.querySelector(`button[data-ymd="${ymd}"]`)!;
/** 날짜 칸을 눌러 달력을 열고, 날짜를 차례로 누른 뒤 확인한다 */
async function pickDates(edge: "start" | "end", ...days: string[]) {
@@ -75,10 +87,12 @@ afterEach(async () => { await act(async () => root.unmount()); host.remove(); })
it("opens one named modal beside the title and closing discards its draft with focus restoration", async () => {
const trigger = await open();
expect(trigger.parentElement?.textContent).toContain("가을 여행");
- expect(document.querySelectorAll("dialog[open]")).toHaveLength(1);
+ expect(dialogs()).toHaveLength(1);
+ expect(editDialogs()).toHaveLength(1);
expect(document.getElementById(dialog()!.getAttribute("aria-labelledby")!)?.textContent).toBe("여행 정보 수정");
await change('input[type="text"]', "임시 제목");
await click("여행 정보 수정 닫기", dialog()!);
+ await nextFrame();
expect(dialog()).toBeNull(); expect(document.activeElement).toBe(trigger);
await open();
expect(dialog()!.querySelector('input[type="text"]')!.value).toBe("가을 여행");
@@ -97,6 +111,23 @@ it("uses the existing trimmed mutation payload and closes only after save succee
expect(dialog()).toBeNull();
});
+it("shows the title error under the title field and locks apply until a title is entered", async () => {
+ await open();
+ const title = () => dialog()!.querySelector('input[type="text"]')!;
+ expect(title().hasAttribute("aria-invalid")).toBe(false);
+ await change('input[type="text"]', " ");
+ expect(title().getAttribute("aria-invalid")).toBe("true");
+ const error = document.getElementById(title().getAttribute("aria-describedby")!);
+ expect(error?.getAttribute("role")).toBe("alert");
+ expect(error?.textContent).toBe("여행 제목을 입력해 주세요.");
+ expect(button("적용하기", dialog()!).disabled).toBe(true);
+ await change('input[type="text"]', "새 제목");
+ expect(title().hasAttribute("aria-invalid")).toBe(false);
+ expect(dialog()!.textContent).not.toContain("여행 제목을 입력해 주세요.");
+ expect(button("적용하기", dialog()!).disabled).toBe(false);
+ expect(state.update).not.toHaveBeenCalled();
+});
+
it("allows an earlier start date and keeps date ordering and required destination validation", async () => {
await open();
// 저장된 시작일보다 이른 날짜로도 시작일을 앞당길 수 있다
@@ -110,6 +141,7 @@ it("allows an earlier start date and keeps date ordering and required destinatio
expect(dateField("end").getAttribute("aria-label")).toContain("2026년 10월 12일");
await click("서울 삭제", dialog()!);
expect(button("적용하기", dialog()!).disabled).toBe(true);
+ expect([...dialog()!.querySelectorAll('[role="alert"]')].map((node) => node.textContent)).toContain("여행지를 하나 이상 선택해 주세요.");
expect(state.update).not.toHaveBeenCalled();
});
@@ -168,20 +200,21 @@ it.each(["loading", "missing"])("does not offer editing for %s room data", async
if (mode === "missing") expect(dialog()!.textContent).toContain("여행 정보를 불러오지 못했어요.");
});
-it("contains shrink confirmation focus and Escape dismisses only confirmation, preserving the draft", async () => {
+// Tab 순환(포커스 가두기)은 Base UI 확인창이 맡는다. 여기서는 처음 포커스·Esc 범위·포커스 복귀를 본다
+it("focuses the shrink confirmation and Escape dismisses only confirmation, preserving the draft", async () => {
await open();
await pickDates("end", "2026-10-10");
button("적용하기", dialog()!).focus();
await click("적용하기", dialog()!);
- const confirm = document.querySelector('[role="alertdialog"]')!;
+ await nextFrame();
+ const confirm = document.querySelector('[role="alertdialog"]')!;
expect(confirm).not.toBeNull();
- expect(confirm.contains(document.activeElement)).toBe(true);
- await act(async () => {
- button("계속하기", confirm).focus();
- document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
- });
+ expect(document.getElementById(confirm.getAttribute("aria-labelledby")!)?.textContent).toBe("여행 기간을 줄일까요?");
+ // 확인창의 처음 포커스는 취소다(Enter 한 번에 삭제되는 계속하기가 실행되지 않게)
expect(document.activeElement).toBe(button("취소", confirm));
+ expect(state.update).not.toHaveBeenCalled();
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
+ await nextFrame();
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
expect(dialog()).not.toBeNull();
expect(dateField("end").getAttribute("aria-label")).toContain("2026년 10월 10일");
@@ -189,6 +222,55 @@ it("contains shrink confirmation focus and Escape dismisses only confirmation, p
expect(state.update).not.toHaveBeenCalled();
});
+it("saves the shortened period from the confirmation and keeps it open while saving", async () => {
+ await open();
+ await pickDates("end", "2026-10-10");
+ await click("적용하기", dialog()!);
+ await nextFrame();
+ let confirm = document.querySelector('[role="alertdialog"]')!;
+ await click("계속하기", confirm);
+ expect(state.update).toHaveBeenCalledWith(expect.objectContaining({
+ roomId: "room", data: expect.objectContaining({ startDate: "2026-10-10", endDate: "2026-10-10" }),
+ }), expect.any(Object));
+ // 저장 중에는 확인창이 Esc로 닫히지 않고 계속하기가 진행 표시(저장 중)로 바뀐다
+ state.pending = true;
+ await act(async () => root.render( ));
+ confirm = document.querySelector('[role="alertdialog"]')!;
+ const proceed = [...confirm.querySelectorAll("button")].find((node) => node.getAttribute("aria-busy") === "true")!;
+ expect(proceed).toBeDefined();
+ expect(document.getElementById(proceed.getAttribute("aria-labelledby")!)?.textContent).toBe("저장 중");
+ await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
+ await nextFrame();
+ expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
+ expect(dialog()).not.toBeNull();
+ state.pending = false;
+ await act(async () => state.update.mock.calls[0][1].onSuccess());
+ await nextFrame();
+ expect(document.querySelector('[role="alertdialog"]')).toBeNull();
+ expect(dialog()).toBeNull();
+});
+
+it("does not close the edit dialog by Escape or X while saving", async () => {
+ await open();
+ await change("#trip-room-title", "도쿄 2");
+ await click("적용하기", dialog()!);
+ expect(state.update).toHaveBeenCalledTimes(1);
+ state.pending = true;
+ await act(async () => root.render( ));
+ await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
+ await nextFrame();
+ expect(dialog()).not.toBeNull();
+ const close = dialog()!.querySelector('[aria-label="여행 정보 수정 닫기"]')!;
+ await act(async () => close.click());
+ await nextFrame();
+ expect(dialog()).not.toBeNull();
+ state.pending = false;
+ await act(async () => root.render( ));
+ await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
+ await nextFrame();
+ expect(dialog()).toBeNull();
+});
+
it("names the existing title, destination and date fields", async () => {
await open();
const modal = dialog()!;
diff --git a/src/components/rooms/RoomTripEditDialog.tsx b/src/components/rooms/RoomTripEditDialog.tsx
index 59025560..b640ac83 100644
--- a/src/components/rooms/RoomTripEditDialog.tsx
+++ b/src/components/rooms/RoomTripEditDialog.tsx
@@ -4,18 +4,37 @@ import {
RoomTripDeleteButton,
RoomTripSettingsSection,
} from "@/components/rooms/RoomTripSettingsSection";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { useUpdateRoom } from "@/hooks/useRooms";
+/**
+ * 여행 정보 수정 대화상자. 마운트되면 열린 상태라 HeaderBar가 조건부로 그린다.
+ * 기간 축소 확인(RoomTripEditForm)·여행 삭제 확인(RoomTripDeleteButton)은 이 대화상자의 React 자식이라 위 레이어만 Esc·포커스를 받는다.
+ * 저장 요청을 여기서 만들어 폼에 내려 주고, 저장 중에는 Esc·딤·X로 닫히지 않게 한다(대화상자 공통 규칙)
+ */
export function RoomTripEditDialog({ onClose }: { onClose: () => void }) {
+ const update = useUpdateRoom();
return (
-
-
-
-
- {/* 여행 삭제는 적용하기 아래 모달 오른쪽 끝 — 방장이 아니면 버튼이 없어 줄째 숨긴다 */}
-
-
-
-
+ {
+ if (!open) onClose();
+ }}
+ >
+ {/* 지금 폭 600은 lg(640)로 붙였다(ui/dialog.tsx size 주석) */}
+
+
+ 여행 정보 수정
+
+
+
+ {/* 여행 삭제는 적용하기 아래 오른쪽 끝 — 방장이 아니면 버튼이 없어 줄째 숨긴다 */}
+
+
+
+
+
+
);
}
diff --git a/src/components/rooms/RoomTripEditFields.tsx b/src/components/rooms/RoomTripEditFields.tsx
deleted file mode 100644
index 7309df9c..00000000
--- a/src/components/rooms/RoomTripEditFields.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-"use client";
-
-import { useState, type ReactNode } from "react";
-
-import { CloseIcon } from "@/assets/icons";
-import { DateRangeField } from "@/components/date-picker/DateRangeField";
-import { DestinationSearchInput } from "@/components/search/DestinationSearchInput";
-import {
- isTripScheduleDayLimitExceeded,
- TRIP_SCHEDULE_DAY_LIMIT_MESSAGE,
-} from "@/lib/plan/schedulePolicy";
-import {
- isTripDateRangeInvalid,
- ROOM_TRIP_TITLE_MAX_LENGTH,
- TRIP_DATE_RANGE_INVALID_MESSAGE,
- TRIP_DESTINATION_MAX_LENGTH,
- TRIP_DESTINATIONS_MAX_COUNT,
- type TripFormValues,
-} from "@/lib/rooms/trip-form";
-import { cn } from "@/lib/utils";
-
-const FIELD_BOX_CLASS =
- "flex h-12 w-full min-w-0 items-center rounded-lg border border-border bg-fill-subtle pl-3.5 pr-2.5 text-body-m-regular mobile:text-body-s-regular text-text-subtle";
-const MESSAGE_CLASS = "text-body-s-regular mobile:text-body-xs-regular text-primary";
-
-type Props = {
- idPrefix: string;
- values: TripFormValues;
- readOnly?: boolean;
- onTitleChange: (value: string) => void;
- onDestinationsChange: (next: string[]) => void;
- onStartDateChange: (value: string) => void;
- onEndDateChange: (value: string) => void;
-};
-
-function Field({ label, htmlFor, className, children }: Readonly<{ label: string; htmlFor?: string; className?: string; children: ReactNode }>) {
- const LabelTag = htmlFor ? "label" : "p";
- return (
-
-
- {label}
-
- {children}
-
- );
-}
-
-/** 여행 정보 수정 모달 전용 입력 필드 — 새 여행 만들기(`TripFormFields`)와 분리 */
-export function RoomTripEditFields({
- idPrefix,
- values,
- readOnly = false,
- onTitleChange,
- onDestinationsChange,
- onStartDateChange,
- onEndDateChange,
-}: Readonly) {
- const { title, destinations, startDate, endDate } = values;
- const [destinationDraft, setDestinationDraft] = useState("");
- const [destinationWarning, setDestinationWarning] = useState(null);
- const destinationsFull = destinations.length >= TRIP_DESTINATIONS_MAX_COUNT;
- const dateRangeInvalid = isTripDateRangeInvalid(startDate, endDate);
- const scheduleDayLimitExceeded = isTripScheduleDayLimitExceeded(startDate, endDate);
- let dateMessage = null;
- if (dateRangeInvalid) dateMessage = TRIP_DATE_RANGE_INVALID_MESSAGE;
- else if (scheduleDayLimitExceeded) dateMessage = TRIP_SCHEDULE_DAY_LIMIT_MESSAGE;
-
- function addDestination(place: { description: string; placeId: string } | null) {
- if (!place) return;
- const next = place.description.trim().slice(0, TRIP_DESTINATION_MAX_LENGTH);
- setDestinationDraft("");
- if (!next) return;
- if (destinations.includes(next)) {
- setDestinationWarning("이미 추가한 여행지예요");
- return;
- }
- if (destinationsFull) {
- setDestinationWarning(`여행지는 최대 ${TRIP_DESTINATIONS_MAX_COUNT}개까지 추가할 수 있어요`);
- return;
- }
- setDestinationWarning(null);
- onDestinationsChange([...destinations, next]);
- }
-
- if (readOnly) {
- return (
-
-
- {title.trim() || "—"}
-
-
- {destinations.join(", ") || "—"}
-
-
{}}
- />
-
- );
- }
-
- return (
-
-
- onTitleChange(event.target.value.slice(0, ROOM_TRIP_TITLE_MAX_LENGTH))}
- placeholder="예: 봄 일본 여행, 하와이 신혼여행"
- className={cn(FIELD_BOX_CLASS, "outline-none placeholder:text-text-disabled focus:border-primary")}
- />
-
-
-
- {destinationsFull ? (
-
- 최대 {TRIP_DESTINATIONS_MAX_COUNT}개까지 추가할 수 있어요
-
- ) : (
-
- )}
- {destinations.length > 0 ? (
-
- {destinations.map((destination, index) => (
-
- {destination}
- {
- onDestinationsChange(destinations.filter((_, i) => i !== index));
- setDestinationWarning(null);
- }}
- aria-label={`${destination} 삭제`}
- className="rounded-full p-0.5 transition-colors hover:bg-primary/20"
- >
-
-
-
- ))}
-
- ) : null}
- {destinationWarning ? {destinationWarning}
: null}
-
-
-
- {/* 스크롤되는 모달이고 칸 폭(약 176px)에 맞추면 달력이 절반 크기가 되어 팝업으로 띄운다 */}
-
{
- onStartDateChange(next.start);
- onEndDateChange(next.end);
- }}
- />
- {dateMessage ? {dateMessage}
: null}
-
-
- );
-}
diff --git a/src/components/rooms/RoomTripEditForm.test.tsx b/src/components/rooms/RoomTripEditForm.test.tsx
index ec489a1d..be2e5424 100644
--- a/src/components/rooms/RoomTripEditForm.test.tsx
+++ b/src/components/rooms/RoomTripEditForm.test.tsx
@@ -3,18 +3,28 @@ import { act, type ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { RoomTripEditForm } from "./RoomTripEditForm";
-import type { RoomTripEditFields } from "./RoomTripEditFields";
+import type { DestinationChipsField } from "./DestinationChipsField";
+import type { DateRangeField } from "@/components/date-picker/DateRangeField";
const state = vi.hoisted(() => ({
- fields: null as ComponentProps | null,
+ destinations: null as ComponentProps | null,
+ dates: null as ComponentProps | null,
update: vi.fn(),
}));
vi.mock("@/hooks/useRooms", () => ({
useRoomSchedules: () => ({ data: [{}, {}, {}] }),
- useUpdateRoom: () => ({ mutate: state.update, isPending: false }),
}));
-vi.mock("./RoomTripEditFields", () => ({
- RoomTripEditFields: (props: ComponentProps) => {
- state.fields = props;
+// 저장 요청은 대화상자(RoomTripEditDialog)가 만들어 내려 준다
+const update = { mutate: state.update, isPending: false, error: null } as unknown as ComponentProps["update"];
+// 목적지 검색(구글 장소)과 날짜 달력은 칸이 받은 값·onChange만 본다. 제목은 실제 입력 칸을 쓴다
+vi.mock("./DestinationChipsField", () => ({
+ DestinationChipsField: (props: ComponentProps) => {
+ state.destinations = props;
+ return null;
+ },
+}));
+vi.mock("@/components/date-picker/DateRangeField", () => ({
+ DateRangeField: (props: ComponentProps) => {
+ state.dates = props;
return null;
},
}));
@@ -22,6 +32,20 @@ vi.mock("./TripDateShrinkConfirmModal", () => ({
TripDateShrinkConfirmModal: () =>
,
}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const titleInput = (container: HTMLElement) => container.querySelector("input")!;
+const applyButton = (container: HTMLElement) => container.querySelector("button")!;
+async function typeTitle(container: HTMLElement, value: string) {
+ const input = titleInput(container);
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+async function changeDates(start: string, end: string) {
+ await act(async () => state.dates!.onChange({ start, end }));
+}
+
it("preserves drafts on same-room refresh; reopening and room switch reset correctly", async () => {
const container = document.createElement("div");
const root = createRoot(container);
@@ -33,26 +57,22 @@ it("preserves drafts on same-room refresh; reopening and room switch reset corre
endDate: "2026-10-12",
};
const render = async (value: typeof room) => {
- await act(async () => root.render( ));
+ await act(async () => root.render( ));
};
await render(room);
- await act(async () => state.fields!.onTitleChange("draft"));
+ await typeTitle(container, "draft");
await render({ ...room, destinations: [...room.destinations] });
- expect(state.fields!.values.title).toBe("draft");
+ expect(titleInput(container).value).toBe("draft");
await act(async () => root.render(null));
await render(room);
- expect(state.fields!.values.title).toBe("A");
- await act(async () => {
- state.fields!.onStartDateChange("2026-10-11");
- state.fields!.onEndDateChange("2026-10-11");
- });
+ expect(titleInput(container).value).toBe("A");
+ await changeDates("2026-10-11", "2026-10-11");
await act(async () => root.render(null));
await render(room);
- expect(state.fields!.values.startDate).toBe("2026-10-10");
- expect(state.fields!.values.endDate).toBe("2026-10-12");
+ expect(state.dates!.value).toEqual({ start: "2026-10-10", end: "2026-10-12" });
await render({ ...room, startDate: "2026-10-11" });
- await act(async () => state.fields!.onEndDateChange("2026-10-11"));
- await act(async () => container.querySelector("button")!.click());
+ await changeDates(state.dates!.value.start, "2026-10-11");
+ await act(async () => applyButton(container).click());
expect(container.querySelector("[data-confirm]")).not.toBeNull();
await render({
...room,
@@ -61,7 +81,7 @@ it("preserves drafts on same-room refresh; reopening and room switch reset corre
startDate: "2026-11-10",
endDate: "2026-11-12",
});
- expect(state.fields!.values.title).toBe("B");
+ expect(titleInput(container).value).toBe("B");
expect(container.querySelector("[data-confirm]")).toBeNull();
expect(state.update).not.toHaveBeenCalled();
await act(async () => root.unmount());
@@ -71,17 +91,17 @@ it("keeps the values from when it opened while the server changes and saves the
state.update.mockClear();
const container = document.createElement("div");
const root = createRoot(container);
- const apply = () => container.querySelector("button")!;
const room = { id: "r", title: "A", destinations: ["서울"], startDate: "2027-01-01", endDate: "2027-01-03" };
- await act(async () => root.render( ));
+ await act(async () => root.render( ));
// 열어 둔 사이 다른 멤버가 제목과 종료일을 바꿨다
const changedByOther = { ...room, title: "B", endDate: "2027-01-05" };
- await act(async () => root.render( ));
- expect(state.fields!.values).toMatchObject({ title: "A", endDate: "2027-01-03" });
- expect(apply().disabled).toBe(true);
+ await act(async () => root.render( ));
+ expect(titleInput(container).value).toBe("A");
+ expect(state.dates!.value.end).toBe("2027-01-03");
+ expect(applyButton(container).disabled).toBe(true);
// 서버 최신 기간(01-01~01-05)에서 잘리는 일차가 있으면 축소 확인을 띄운다
- await act(async () => state.fields!.onEndDateChange("2027-01-04"));
- await act(async () => apply().click());
+ await changeDates(state.dates!.value.start, "2027-01-04");
+ await act(async () => applyButton(container).click());
expect(container.querySelector("[data-confirm]")).not.toBeNull();
expect(state.update).not.toHaveBeenCalled();
await act(async () => root.unmount());
@@ -89,11 +109,11 @@ it("keeps the values from when it opened while the server changes and saves the
// A가 28일로 연 사이 B가 시작일을 30일로 바꾸고, A가 제목만 고쳐 저장하면 A 화면의 28일이 저장된다
const second = createRoot(container);
const opened = { id: "r", title: "A", destinations: ["서울"], startDate: "2027-01-28", endDate: "2027-01-31" };
- await act(async () => second.render( ));
- await act(async () => second.render( ));
- expect(state.fields!.values.startDate).toBe("2027-01-28");
- await act(async () => state.fields!.onTitleChange("C"));
- await act(async () => apply().click());
+ await act(async () => second.render( ));
+ await act(async () => second.render( ));
+ expect(state.dates!.value.start).toBe("2027-01-28");
+ await typeTitle(container, "C");
+ await act(async () => applyButton(container).click());
expect(state.update).toHaveBeenCalledWith(
{
roomId: "r",
@@ -111,10 +131,10 @@ it("allows moving the start date earlier than the saved start date", async () =>
const container = document.createElement("div");
const root = createRoot(container);
const room = { id: "r", title: "A", destinations: ["서울"], startDate: "2027-01-04", endDate: "2027-01-11" };
- await act(async () => root.render( ));
- expect("startDateMin" in state.fields!).toBe(false);
- await act(async () => state.fields!.onStartDateChange("2027-01-02"));
- await act(async () => container.querySelector("button")!.click());
+ await act(async () => root.render( ));
+ expect(state.dates!.min).toBeUndefined();
+ await changeDates("2027-01-02", state.dates!.value.end);
+ await act(async () => applyButton(container).click());
expect(state.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ startDate: "2027-01-02", endDate: "2027-01-11" }),
@@ -129,14 +149,51 @@ it("does not submit draft edits and submits once for rapid apply clicks", async
const container = document.createElement("div");
const root = createRoot(container);
const room = { id: "r", title: "old", destinations: ["서울"], startDate: "2027-01-01", endDate: "2027-01-03" };
- await act(async () => root.render( ));
- await act(async () => state.fields!.onTitleChange("cancelled"));
+ await act(async () => root.render( ));
+ await typeTitle(container, "cancelled");
expect(state.update).not.toHaveBeenCalled();
- await act(async () => state.fields!.onTitleChange("new"));
+ await typeTitle(container, "new");
await act(async () => {
- container.querySelector("button")!.click();
- container.querySelector("button")!.click();
+ applyButton(container).click();
+ applyButton(container).click();
});
expect(state.update).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
+
+it("shows each field's error under that field and locks apply until it is fixed", async () => {
+ state.update.mockClear();
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const room = { id: "r", title: "A", destinations: ["서울"], startDate: "2027-01-01", endDate: "2027-01-03" };
+ await act(async () => root.render( ));
+ expect(container.textContent).not.toContain("입력해 주세요");
+
+ // 제목을 비우면 제목 칸이 오류 상태가 되고 칸 아래에 문장이 붙는다
+ await typeTitle(container, " ");
+ expect(titleInput(container).getAttribute("aria-invalid")).toBe("true");
+ const titleError = container.querySelector(`#${titleInput(container).getAttribute("aria-describedby")}`);
+ expect(titleError?.textContent).toBe("여행 제목을 입력해 주세요.");
+ expect(applyButton(container).disabled).toBe(true);
+ await typeTitle(container, "B");
+ expect(titleInput(container).hasAttribute("aria-invalid")).toBe(false);
+ expect(container.textContent).not.toContain("여행 제목을 입력해 주세요.");
+ expect(applyButton(container).disabled).toBe(false);
+
+ // 목적지를 모두 지우면 목적지 아래에 문장이 붙는다
+ await act(async () => state.destinations!.onChange([]));
+ expect(container.textContent).toContain("여행지를 하나 이상 선택해 주세요.");
+ expect(applyButton(container).disabled).toBe(true);
+ await act(async () => state.destinations!.onChange(["부산"]));
+ expect(container.textContent).not.toContain("여행지를 하나 이상 선택해 주세요.");
+
+ // 종료일이 시작일보다 앞서면 기간 아래에 문장이 붙는다
+ await changeDates("2027-01-05", "2027-01-03");
+ expect(container.textContent).toContain("종료일은 시작일 이후여야 해요.");
+ expect(applyButton(container).disabled).toBe(true);
+ await changeDates("2027-01-01", "2027-01-03");
+ expect(container.textContent).not.toContain("종료일은 시작일 이후여야 해요.");
+ expect(applyButton(container).disabled).toBe(false);
+ expect(state.update).not.toHaveBeenCalled();
+ await act(async () => root.unmount());
+});
diff --git a/src/components/rooms/RoomTripEditForm.tsx b/src/components/rooms/RoomTripEditForm.tsx
index 80c680d5..87848168 100644
--- a/src/components/rooms/RoomTripEditForm.tsx
+++ b/src/components/rooms/RoomTripEditForm.tsx
@@ -1,30 +1,35 @@
"use client";
+import { useForm, useStore } from "@tanstack/react-form";
import { useRef, useState } from "react";
import { toast } from "sonner";
-import {
- SettingsActionButton,
- SettingsActionButtonRow,
-} from "@/components/settings/SettingsActionButton";
+import { DateRangeField } from "@/components/date-picker/DateRangeField";
+import { DestinationChipsField } from "@/components/rooms/DestinationChipsField";
+import { RoomTripReadOnlyFields } from "@/components/rooms/RoomTripReadOnlyFields";
import { TripDateShrinkConfirmModal } from "@/components/rooms/TripDateShrinkConfirmModal";
-import { RoomTripEditFields } from "@/components/rooms/RoomTripEditFields";
-import { useRoomSchedules, useUpdateRoom } from "@/hooks/useRooms";
-import {
- countSchedulesTrimmedByDateChange,
- isTripScheduleDayLimitExceeded,
-} from "@/lib/plan/schedulePolicy";
+import { Button } from "@/components/ui/button";
+import { Field, FieldError, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { useRoomSchedules, type useUpdateRoom } from "@/hooks/useRooms";
+import { countSchedulesTrimmedByDateChange } from "@/lib/plan/schedulePolicy";
import {
areDestinationsEqual,
- isTripDateRangeInvalid,
- isTripDestinationsValid,
ROOM_TRIP_TITLE_MAX_LENGTH,
toTripFormValues,
+ TRIP_DESTINATION_MAX_LENGTH,
+ TRIP_DESTINATIONS_MAX_COUNT,
+ tripDateRangeError,
+ tripDestinationsError,
+ tripTitleError,
type RoomTripFormSource,
+ type TripFormValues,
} from "@/lib/rooms/trip-form";
type Props = {
room: RoomTripFormSource;
+ /** 여행 정보 저장 요청. 대화상자가 저장 중에 닫히지 않게 그 isPending을 쓰므로 대화상자가 만들어 내려 준다 */
+ update: ReturnType;
readOnly?: boolean;
onSaved?: () => void;
};
@@ -33,61 +38,34 @@ export function RoomTripEditForm(props: Props) {
return ;
}
-function RoomTripEditSession({ room, readOnly = false, onSaved }: Props) {
+function RoomTripEditSession({ room, update, readOnly = false, onSaved }: Readonly) {
/** 서버 최신 값 — 다른 멤버가 바꾸면 따라 바뀐다. 읽기 전용 표시와 저장 직전 기준(기간 축소 확인·이전 기간)에 쓴다 */
// 저장은 화면에 보이는 폼 값 그대로 보낸다 — 열어 둔 사이 다른 멤버가 바꾼 값보다 마지막에 저장한 사람의 화면 값이 남는다
const latest = toTripFormValues(room);
/**
- * 모달을 연 순간의 값 — 열려 있는 동안 서버 값이 바뀌어도 입력 칸을 다시 채우지 않는다.
+ * 모달을 연 순간의 값 — 폼의 defaultValues다. 열려 있는 동안 서버 값이 바뀌어도 입력 칸을 다시 채우지 않는다.
* 바뀐 값은 사이드 패널·헤더에 먼저 보이고, 모달을 다시 열면 그때의 최신 값으로 시작한다.
*/
const [opened, setOpened] = useState(latest);
-
- const [title, setTitle] = useState(opened.title);
- const [destinations, setDestinations] = useState(opened.destinations);
- const [startDate, setStartDate] = useState(opened.startDate);
- const [endDate, setEndDate] = useState(opened.endDate);
const [shrinkConfirmOpen, setShrinkConfirmOpen] = useState(false);
+ const idPrefix = `trip-${room.id}`;
const { data: schedules } = useRoomSchedules(room.id);
- const { mutate: updateRoom, isPending, error } = useUpdateRoom();
-
- const dateRangeInvalid = isTripDateRangeInvalid(startDate, endDate);
- const scheduleDayLimitExceeded = isTripScheduleDayLimitExceeded(
- startDate,
- endDate,
- );
+ const { mutate: updateRoom, isPending, error } = update;
const scheduleCount = schedules?.length ?? 0;
- /** 연 순간과 비교한다 — 서버 값이 바뀌어도 사용자가 바꾼 게 없으면 적용하기를 켜지 않는다 */
- const isDirty =
- title !== opened.title ||
- !areDestinationsEqual(destinations, opened.destinations) ||
- startDate !== opened.startDate ||
- endDate !== opened.endDate;
-
- const canApply =
- title.trim() &&
- isTripDestinationsValid(destinations) &&
- startDate &&
- endDate &&
- !dateRangeInvalid &&
- !scheduleDayLimitExceeded &&
- !isPending &&
- isDirty;
-
const submitInFlight = useRef(false);
- function submitUpdate() {
+ function submitUpdate(value: TripFormValues) {
if (submitInFlight.current || readOnly) return;
submitInFlight.current = true;
updateRoom(
{
roomId: room.id,
data: {
- title: title.trim().slice(0, ROOM_TRIP_TITLE_MAX_LENGTH),
- destinations: destinations.map((d) => d.trim()),
- startDate,
- endDate,
+ title: value.title.trim().slice(0, ROOM_TRIP_TITLE_MAX_LENGTH),
+ destinations: value.destinations.map((d) => d.trim()),
+ startDate: value.startDate,
+ endDate: value.endDate,
},
previousStartDate: latest.startDate,
previousEndDate: latest.endDate,
@@ -97,7 +75,8 @@ function RoomTripEditSession({ room, readOnly = false, onSaved }: Props) {
onSuccess: () => {
setShrinkConfirmOpen(false);
// 저장한 값이 새 기준 — 모달이 남아 있어도 다시 바꾼 게 없으면 적용하기가 꺼진다
- setOpened({ title, destinations, startDate, endDate });
+ setOpened(value);
+ form.reset(value);
toast.success("여행 정보가 수정되었어요");
onSaved?.();
},
@@ -105,37 +84,114 @@ function RoomTripEditSession({ room, readOnly = false, onSaved }: Props) {
);
}
- function handleApply() {
- if (!canApply) return;
+ const form = useForm({
+ defaultValues: opened,
+ onSubmit: ({ value }) => {
+ // 서버는 지금 기간에서 잘라내므로, 열 때가 아니라 서버 최신 기간과 저장할 폼 기간을 비교한다
+ const trimCount = countSchedulesTrimmedByDateChange(
+ scheduleCount,
+ latest.startDate,
+ latest.endDate,
+ value.startDate,
+ value.endDate,
+ );
+ if (trimCount > 0) {
+ setShrinkConfirmOpen(true);
+ return;
+ }
+ submitUpdate(value);
+ },
+ });
+ const values = useStore(form.store, (state) => state.values);
+ const { title, destinations, startDate, endDate } = values;
- // 서버는 지금 기간에서 잘라내므로, 열 때가 아니라 서버 최신 기간과 저장할 폼 기간을 비교한다
- const trimCount = countSchedulesTrimmedByDateChange(
- scheduleCount,
- latest.startDate,
- latest.endDate,
- startDate,
- endDate,
- );
-
- if (trimCount > 0) {
- setShrinkConfirmOpen(true);
- return;
- }
+ /**
+ * 연 순간과 비교한다 — 서버 값이 바뀌어도 사용자가 바꾼 게 없으면 적용하기를 켜지 않는다.
+ * TanStack isDirty는 한 번 바뀌면 남아서, 되돌리면 다시 꺼지는 지금 기준을 그대로 쓴다
+ */
+ const isDirty =
+ title !== opened.title ||
+ !areDestinationsEqual(destinations, opened.destinations) ||
+ startDate !== opened.startDate ||
+ endDate !== opened.endDate;
- submitUpdate();
- }
+ // 칸 오류는 각 form.Field의 validators가 입력할 때마다 칸 아래에 보인다. 적용하기는 같은 검증 함수로 잠근다
+ // (validators는 건드린 칸에서만 돌아서, 연 순간부터 비어 있던 칸(기간 없는 방 등)도 잠그려면 값으로 다시 본다)
+ const canApply =
+ !tripTitleError(title) &&
+ !tripDestinationsError(destinations) &&
+ !tripDateRangeError(startDate, endDate) &&
+ !isPending &&
+ isDirty;
return (
-
+ {readOnly ? (
+
+ ) : (
+
+
tripTitleError(value) }}>
+ {(field) => {
+ const fieldError = field.state.meta.errors[0];
+ return (
+
+ 여행 제목
+ field.handleChange(event.target.value.slice(0, ROOM_TRIP_TITLE_MAX_LENGTH))}
+ onBlur={field.handleBlur}
+ placeholder="예: 봄 일본 여행, 하와이 신혼여행"
+ />
+ {fieldError}
+
+ );
+ }}
+
+
+
tripDestinationsError(value) }}>
+ {(field) => (
+
+ {/* 검색 입력이 DestinationSearchInput 안에 있어 label로 잇지 못한다. 입력 이름은 aria-label '목적지 검색'이다 */}
+ 목적지
+
+ {field.state.meta.errors[0]}
+
+ )}
+
+
+ {/* 기간은 칸 하나(DateRangeField)가 두 값을 바꾼다. 오류는 끝 날짜 칸이 시작 날짜와 함께 검증해 기간 아래에 보인다 */}
+
tripDateRangeError(fieldApi.form.getFieldValue("startDate"), value) }}
+ >
+ {(field) => (
+
+ {/* 스크롤되는 모달이고 칸 폭(약 176px)에 맞추면 달력이 절반 크기가 되어 팝업으로 띄운다 */}
+ {
+ form.setFieldValue("startDate", next.start);
+ field.handleChange(next.end);
+ }}
+ />
+ {field.state.meta.errors[0]}
+
+ )}
+
+
+ )}
{error && !readOnly && (
@@ -145,24 +201,28 @@ function RoomTripEditSession({ room, readOnly = false, onSaved }: Props) {
)}
+ {/* form 요소로 감싸지 않는다 — 지금처럼 제목 칸 Enter로 저장되지 않게 둔다 */}
{!readOnly && (
-
-
- {isPending ? "저장 중…" : "적용하기"}
-
-
+
{
+ if (!canApply || form.state.isSubmitting) return;
+ void form.handleSubmit();
+ }}
+ >
+ 적용하기
+
)}
+ {/* 여행 정보 수정 대화상자의 React 자식으로 둬야 Base UI가 위 레이어(확인창)만 Esc·포커스를 처리한다 */}
{shrinkConfirmOpen ? (
setShrinkConfirmOpen(false)}
- onConfirm={submitUpdate}
+ onConfirm={() => submitUpdate(form.state.values)}
/>
) : null}
diff --git a/src/components/rooms/RoomTripReadOnlyFields.tsx b/src/components/rooms/RoomTripReadOnlyFields.tsx
new file mode 100644
index 00000000..7f0c67b7
--- /dev/null
+++ b/src/components/rooms/RoomTripReadOnlyFields.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { DateRangeField } from "@/components/date-picker/DateRangeField";
+import type { TripFormValues } from "@/lib/rooms/trip-form";
+import { cn } from "@/lib/utils";
+
+const FIELD_BOX_CLASS =
+ "flex h-12 w-full min-w-0 items-center rounded-lg border border-border bg-fill-subtle pl-3.5 pr-2.5 text-body-m-regular mobile:text-body-s-regular text-text-subtle";
+
+function Field({ label, className, children }: Readonly<{ label: string; className?: string; children: ReactNode }>) {
+ return (
+
+ );
+}
+
+/** 여행 정보 수정 권한이 없을 때 보이는 읽기 전용 칸. 고칠 수 있을 때의 칸은 `RoomTripEditForm`이 form.Field로 그린다 */
+export function RoomTripReadOnlyFields({ idPrefix, values }: Readonly<{ idPrefix: string; values: TripFormValues }>) {
+ const { title, destinations, startDate, endDate } = values;
+ return (
+
+
+ {title.trim() || "—"}
+
+
+ {destinations.join(", ") || "—"}
+
+
{}} />
+
+ );
+}
diff --git a/src/components/rooms/RoomTripSettingsSection.tsx b/src/components/rooms/RoomTripSettingsSection.tsx
index 98473854..235654cc 100644
--- a/src/components/rooms/RoomTripSettingsSection.tsx
+++ b/src/components/rooms/RoomTripSettingsSection.tsx
@@ -1,10 +1,11 @@
"use client";
-import { useState } from "react";
+import { useState, type ComponentProps } from "react";
import { useRouter } from "next/navigation";
import { DeleteConfirmModal } from "@/app/home/_components/DeleteConfirmModal";
import { RoomTripEditForm } from "@/components/rooms/RoomTripEditForm";
+import { Spinner } from "@/components/ui/spinner";
import { useCurrentRoomMembership } from "@/hooks/useCurrentRoomMembership";
import { isRoomMemberRole } from "@/lib/rooms";
import { useSessionStore } from "@/stores/session-store";
@@ -41,7 +42,13 @@ export function RoomTripDeleteButton() {
);
}
-export function RoomTripSettingsSection({ onSaved }: { onSaved?: () => void }) {
+export function RoomTripSettingsSection({
+ update,
+ onSaved,
+}: Readonly<{
+ update: ComponentProps["update"];
+ onSaved?: () => void;
+}>) {
const { roomId, roomSource, isLoading } = useCurrentRoomMembership();
const canEdit = isRoomMemberRole(roomSource?.role);
const description = !isLoading && roomSource && roomId && !canEdit
@@ -54,14 +61,14 @@ export function RoomTripSettingsSection({ onSaved }: { onSaved?: () => void }) {
{isLoading ? (
) : !roomSource || !roomId ? (
여행 정보를 불러오지 못했어요.
) : (
-
+
)}
);
diff --git a/src/components/rooms/TripDateShrinkConfirmModal.tsx b/src/components/rooms/TripDateShrinkConfirmModal.tsx
index fc6f01ee..6bd3769d 100644
--- a/src/components/rooms/TripDateShrinkConfirmModal.tsx
+++ b/src/components/rooms/TripDateShrinkConfirmModal.tsx
@@ -1,12 +1,15 @@
"use client";
-import { useEffect, useRef } from "react";
-import { CalendarRange } from "lucide-react";
-
import {
- SettingsActionButton,
- SettingsActionButtonRow,
-} from "@/components/settings/SettingsActionButton";
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
type Props = {
isPending: boolean;
@@ -14,81 +17,42 @@ type Props = {
onConfirm: () => void;
};
+const TITLE = "여행 기간을 줄일까요?";
+
+/**
+ * 여행 기간 축소 확인(Figma Alert Dialog). 마운트되면 열린 상태라 RoomTripEditForm이 조건부로 그린다.
+ * 여행 정보 수정 대화상자의 React 자식이라 Esc·포커스 가두기는 이 확인창만 받고, 닫히면 수정 중인 입력이 그대로 남는다.
+ * 딤·Esc·취소는 onClose, 저장 중에는 모두 막힌다. 버튼 이름은 ConfirmDialog와 달리 저장 진행("저장 중")을 읽어 직접 조립한다
+ */
export function TripDateShrinkConfirmModal({
isPending,
onClose,
onConfirm,
-}: Props) {
- const dialogRef = useRef
(null);
- useEffect(() => {
- const trigger = document.activeElement;
- dialogRef.current?.querySelector("button")?.focus();
- return () => {
- queueMicrotask(() => {
- if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus();
- });
- };
- }, []);
-
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape" && !isPending) onClose();
- };
- document.addEventListener("keydown", handler);
- return () => document.removeEventListener("keydown", handler);
- }, [isPending, onClose]);
-
+}: Readonly) {
return (
- {
- if (e.target === e.currentTarget && !isPending) onClose();
+
{
+ if (!open) onClose();
}}
>
- e.stopPropagation()}
- >
-
-
-
-
-
- 여행 기간을 줄일까요?
-
-
- 여행 기간을 줄이면 제거되는 일차의 모든 장소와 비용도 함께 삭제돼요.
- 계속할까요?
-
-
-
-
- 취소
-
-
- {isPending ? "저장 중…" : "계속하기"}
-
-
-
-
+ {/* 지금 확인창처럼 딤을 눌러도 닫힌다 */}
+
+
+ {TITLE}
+
+ 여행 기간을 줄이면 제거되는 일차의 모든 장소와 비용도 함께 삭제돼요. 계속할까요?
+
+
+
+ 취소
+
+ 계속하기
+
+
+
+
);
}
diff --git a/src/components/rooms/TripFormFields.tsx b/src/components/rooms/TripFormFields.tsx
index c7dea099..57f23a91 100644
--- a/src/components/rooms/TripFormFields.tsx
+++ b/src/components/rooms/TripFormFields.tsx
@@ -2,6 +2,8 @@
import { DateRangeField } from "@/components/date-picker/DateRangeField";
import { DestinationChipsField } from "@/components/rooms/DestinationChipsField";
+import { Field, FieldHeader, FieldLabel, FieldSuffix } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
import {
isTripScheduleDayLimitExceeded,
TRIP_SCHEDULE_DAY_LIMIT_MESSAGE,
@@ -13,8 +15,6 @@ import {
TRIP_DATE_RANGE_INVALID_MESSAGE,
TRIP_DESTINATION_MAX_LENGTH,
TRIP_DESTINATIONS_MAX_COUNT,
- TRIP_FORM_FIELD_CLASS,
- TRIP_FORM_FIELD_READ_ONLY_CLASS,
TRIP_START_BEFORE_MIN_MESSAGE,
type TripFormValues,
} from "@/lib/rooms/trip-form";
@@ -22,7 +22,6 @@ import {
type Props = {
idPrefix: string;
values: TripFormValues;
- readOnly?: boolean;
autoFocusTitle?: boolean;
startDateMin?: string;
onTitleChange: (value: string) => void;
@@ -31,10 +30,10 @@ type Props = {
onEndDateChange: (value: string) => void;
};
+/** 새 여행 만들기 입력 필드. 칸 모양과 배치는 여행 정보 수정(`RoomTripEditForm`)과 같은 Figma Text Field이고, 글자 수만 Header Suffix로 더 보인다 */
export function TripFormFields({
idPrefix,
values,
- readOnly = false,
autoFocusTitle = false,
startDateMin,
onTitleChange,
@@ -43,93 +42,60 @@ export function TripFormFields({
onEndDateChange,
}: Props) {
const { title, destinations, startDate, endDate } = values;
- const dateRangeInvalid = isTripDateRangeInvalid(startDate, endDate);
- const startBeforeMin =
- startDateMin != null && isTripStartBeforeMin(startDate, startDateMin);
- const scheduleDayLimitExceeded = isTripScheduleDayLimitExceeded(
- startDate,
- endDate,
- );
- const fieldClassName = readOnly
- ? TRIP_FORM_FIELD_READ_ONLY_CLASS
- : TRIP_FORM_FIELD_CLASS;
+ let dateMessage = null;
+ if (startDateMin != null && isTripStartBeforeMin(startDate, startDateMin)) dateMessage = TRIP_START_BEFORE_MIN_MESSAGE;
+ else if (isTripDateRangeInvalid(startDate, endDate)) dateMessage = TRIP_DATE_RANGE_INVALID_MESSAGE;
+ else if (isTripScheduleDayLimitExceeded(startDate, endDate)) dateMessage = TRIP_SCHEDULE_DAY_LIMIT_MESSAGE;
return (
-
-
-
-
여행 제목
- {!readOnly && (
-
- {title.length}/{ROOM_TRIP_TITLE_MAX_LENGTH}
-
- )}
-
- {readOnly ? (
-
{title.trim() || "—"}
- ) : (
-
- onTitleChange(e.target.value.slice(0, ROOM_TRIP_TITLE_MAX_LENGTH))
- }
- placeholder="예: 봄 일본 여행, 하와이 신혼여행"
- className="w-full text-body-m-regular text-dark-gray outline-none placeholder:text-light-gray"
- autoFocus={autoFocusTitle}
- />
- )}
-
+
+
+
+ 여행 제목
+
+ {title.length}/{ROOM_TRIP_TITLE_MAX_LENGTH}
+
+
+ onTitleChange(event.target.value.slice(0, ROOM_TRIP_TITLE_MAX_LENGTH))}
+ placeholder="예: 봄 일본 여행, 하와이 신혼여행"
+ autoFocus={autoFocusTitle}
+ />
+
-
-
-
목적지
- {!readOnly && (
-
- {destinations.length}/{TRIP_DESTINATIONS_MAX_COUNT}
-
- )}
-
+
+
+ {/* 검색 입력이 DestinationSearchInput 안에 있어 label로 잇지 못한다. 입력 이름은 aria-label '목적지 검색'이다 */}
+ 목적지
+
+ {destinations.length}/{TRIP_DESTINATIONS_MAX_COUNT}
+
+
-
+
-
+
{
onStartDateChange(next.start);
onEndDateChange(next.end);
}}
/>
- {!readOnly && startBeforeMin && (
-
- {TRIP_START_BEFORE_MIN_MESSAGE}
-
- )}
- {!readOnly && !startBeforeMin && dateRangeInvalid && (
-
- {TRIP_DATE_RANGE_INVALID_MESSAGE}
-
- )}
- {!readOnly &&
- !startBeforeMin &&
- !dateRangeInvalid &&
- scheduleDayLimitExceeded && (
-
- {TRIP_SCHEDULE_DAY_LIMIT_MESSAGE}
-
- )}
+ {dateMessage ? (
+ {dateMessage}
+ ) : null}
);
diff --git a/src/components/search/DestinationSearchInput.test.tsx b/src/components/search/DestinationSearchInput.test.tsx
new file mode 100644
index 00000000..1b6849b3
--- /dev/null
+++ b/src/components/search/DestinationSearchInput.test.tsx
@@ -0,0 +1,63 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vitest";
+import { DestinationSearchInput } from "./DestinationSearchInput";
+import { Label } from "@/components/ui/label";
+
+vi.mock("@vis.gl/react-google-maps", () => ({ useMapsLibrary: () => null }));
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+it.each(["default", "field"] as const)("preserves clear behavior and focus for %s appearance", async (appearance) => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ const change = vi.fn();
+ const resolved = vi.fn();
+ try {
+ await act(async () => root.render(
));
+ const input = host.querySelector("input")!;
+ const clear = host.querySelector
('button[aria-label="지우기"]')!;
+ expect(input.value).toBe("서울");
+ await act(async () => clear.click());
+ expect(input.value).toBe("");
+ expect(change).toHaveBeenCalledExactlyOnceWith("");
+ expect(resolved).toHaveBeenCalledExactlyOnceWith(null);
+ expect(document.activeElement).toBe(input);
+ expect(host.querySelector("button")).toBeNull();
+ } finally {
+ await act(async () => root.unmount());
+ host.remove();
+ }
+});
+
+it("retains the selection-only hint association and does not refocus when cleared", async () => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ try {
+ await act(async () => root.render( ));
+ const input = host.querySelector("input")!;
+ const hint = document.getElementById(input.getAttribute("aria-describedby")!);
+ expect(input.getAttribute("role")).toBe("combobox");
+ expect(hint?.textContent).toContain("목록에서 선택");
+ await act(async () => host.querySelector('button[aria-label="선택 지우기"]')!.click());
+ expect(document.activeElement).not.toBe(input);
+ expect(input.value).toBe("");
+ } finally {
+ await act(async () => root.unmount());
+ host.remove();
+ }
+});
+
+it("forwards the label control association and children", async () => {
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ try {
+ await act(async () => root.render(<>목적지 >));
+ expect(host.querySelector("label")?.control).toBe(host.querySelector("input"));
+ expect(host.querySelector("label")?.textContent).toBe("목적지");
+ } finally {
+ await act(async () => root.unmount());
+ }
+});
diff --git a/src/components/search/DestinationSearchInput.tsx b/src/components/search/DestinationSearchInput.tsx
index 09a16311..1355b051 100644
--- a/src/components/search/DestinationSearchInput.tsx
+++ b/src/components/search/DestinationSearchInput.tsx
@@ -9,6 +9,7 @@ import {
DESTINATION_INCLUDED_PRIMARY_TYPES,
PlacePredictionInlineDescription,
} from "@/lib/placesAutocompleteSuggest";
+import { CloseIcon, SearchIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
const DESTINATION_SELECTION_HINT =
@@ -33,12 +34,49 @@ type Props = {
appearance?: "default" | "field";
};
-function DestinationLeadingIcon({ show, type }: Readonly<{ show: boolean; type: "search" | "map-pin" }>) {
+function DestinationLeadingIcon({ show, type, appearance }: Readonly<{ show: boolean; type: "search" | "map-pin"; appearance: "default" | "field" }>) {
if (!show) return null;
+ if (appearance === "field") return ;
const Icon = type === "search" ? Search : MapPin;
return ;
}
+function DestinationClearButton({ appearance, selectionOnly, onClick }: Readonly<{
+ appearance: "default" | "field";
+ selectionOnly: boolean;
+ onClick: React.MouseEventHandler;
+}>) {
+ const isField = appearance === "field";
+ return (
+
+ {isField ? : }
+
+ );
+}
+
+function DestinationSelectionHint({ id, appearance }: Readonly<{
+ id: string;
+ appearance: "default" | "field";
+}>) {
+ return (
+
+ {DESTINATION_SELECTION_HINT}
+
+ );
+}
+
/**
* Google Places Autocomplete Data API 기반 목적지 검색.
* `includedPrimaryTypes`로 지역·행정구역·국가 등(레거시 `(regions)`에 대응).
@@ -55,7 +93,7 @@ export function DestinationSearchInput({
appearance = "default",
}: Readonly) {
const isField = appearance === "field";
- const showHint = selectionOnly && !isField;
+ const showHint = selectionOnly;
const placesLib = useMapsLibrary("places");
const buildRequest = useCallback(
@@ -163,7 +201,9 @@ export function DestinationSearchInput({
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
- e.preventDefault();
+ // 추천 목록이 열려 있을 때만 Esc를 여기서 쓴다. 감싼 대화상자(ui/dialog)는 preventDefault된 Esc면 닫지 않고,
+ // 목록이 없으면 Esc가 대화상자 닫기로 이어진다
+ if (isOpen && predictions.length > 0) e.preventDefault();
clearSuggestionUi();
return;
}
@@ -193,12 +233,14 @@ export function DestinationSearchInput({
-
+
{showClear ? (
-
-
-
+
) : null}
@@ -270,14 +306,7 @@ export function DestinationSearchInput({
) : null}
- {showHint ? (
-
- ) : null}
+ {showHint ? : null}
);
}
diff --git a/src/components/search/MapMobileSearchScreen.tsx b/src/components/search/MapMobileSearchScreen.tsx
index d3b6bb16..2c9e315a 100644
--- a/src/components/search/MapMobileSearchScreen.tsx
+++ b/src/components/search/MapMobileSearchScreen.tsx
@@ -7,6 +7,7 @@ import {
LocationIcon,
SearchIcon,
} from "@/assets/icons";
+import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
import { useSessionUser } from "@/hooks/useSessionUser";
import {
addMapSearchHistory,
@@ -141,18 +142,19 @@ export function MapMobileSearchScreen({
/>
{error ? (
-
-
{error}
-
inputRef.current?.focus()}
- >
- 다시 시도
-
+
+
+ {error}
+
+ inputRef.current?.focus()}
+ >
+ 다시 시도
+
+
+
) : null}
{!query.trim() ? (
diff --git a/src/components/search/MapPlaceSearchInput.tsx b/src/components/search/MapPlaceSearchInput.tsx
index cb688dd8..8604e6b8 100644
--- a/src/components/search/MapPlaceSearchInput.tsx
+++ b/src/components/search/MapPlaceSearchInput.tsx
@@ -10,7 +10,8 @@ import {
} from "react";
import { flushSync } from "react-dom";
import { MapSearchIcon } from "@/components/map/MapSearchIcon";
-import { ChevronLeftIcon, CircleCancelIcon, SearchIcon } from "@/assets/icons";
+import { SearchIcon } from "@/assets/icons";
+import { SearchBar, SearchBarBack, SearchBarClear, SearchBarIcon, SearchBarInput } from "@/components/ui/search-bar";
import { AutocompleteSearchSession, MAP_AUTOCOMPLETE_DEBOUNCE_MS } from "@/lib/places/autocomplete-session";
import type { PlacePrediction, PlaceSession, PredictionText } from "@/lib/api/place-sessions";
import type { SearchConfirmation } from "@/lib/places/search-session";
@@ -44,18 +45,10 @@ const INPUT_STYLES = {
mobile: {
container: "flex min-h-0 w-full flex-col",
form: "shrink-0 px-5 pb-2 pt-4",
- field: "h-12 gap-2 rounded-lg pl-3.5 pr-2.5",
- text: "text-body-m-regular",
- clear: "h-full w-8",
- iconSize: 20,
},
desktop: {
container: "w-[353px] max-w-full",
form: undefined,
- field: "h-12 gap-2 rounded-lg pl-3.5 pr-2.5",
- text: "text-body-m-regular",
- clear: "size-8",
- iconSize: 20,
},
} as const;
@@ -271,16 +264,15 @@ export function MapPlaceSearchInput({
void confirm(active);
}}
>
-
+
{onBack ? (
-
-
-
- ) : mobile ? : }
-
+ ) : (
+
+
+
+ )}
+
{value ? (
- {
update("");
if (mobile) fieldRef.current?.focus();
else fieldRef.current?.blur();
}}
- className={cn("flex shrink-0 cursor-pointer items-center justify-center rounded hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary", appearance.clear, !mobile && "peer-focus:hidden")}
- >
- {mobile ? : }
-
+ className={cn(!mobile && "peer-focus:hidden")}
+ />
) : null}
-
+
{showDropdown ?
: null}
diff --git a/src/components/search/MobilePlaceSearchScreen.tsx b/src/components/search/MobilePlaceSearchScreen.tsx
index 2b86add9..62b86f37 100644
--- a/src/components/search/MobilePlaceSearchScreen.tsx
+++ b/src/components/search/MobilePlaceSearchScreen.tsx
@@ -13,14 +13,13 @@ import { createPortal } from "react-dom";
import { useMapsLibrary } from "@vis.gl/react-google-maps";
import {
- ChevronLeftIcon,
- CircleCancelIcon,
CloseIcon,
LocationErrorIcon,
LocationIcon,
SearchIcon,
type IconProps,
} from "@/assets/icons";
+import { SearchBar, SearchBarBack, SearchBarClear, SearchBarInput } from "@/components/ui/search-bar";
import { useSessionUser } from "@/hooks/useSessionUser";
import {
addMapSearchHistory,
@@ -247,16 +246,9 @@ function LegacyMobilePlaceSearchScreen({
)}
>
-
-
-
-
-
+
+ updateInput(e.target.value)}
placeholder="떠나고 싶은 지역을 입력해주세요"
autoComplete="off"
- className="min-w-0 flex-1 bg-transparent pr-1 text-body-m-regular text-text outline-none placeholder:text-text-subtle [&::-webkit-search-cancel-button]:hidden"
/>
- {inputValue ? (
-
-
-
- ) : null}
-
+ {inputValue ? : null}
+
diff --git a/src/components/search/PlacesSearchInput.tsx b/src/components/search/PlacesSearchInput.tsx
index fbcac4d3..3c661e04 100644
--- a/src/components/search/PlacesSearchInput.tsx
+++ b/src/components/search/PlacesSearchInput.tsx
@@ -8,9 +8,11 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
-import { Search, MapPin, X } from "lucide-react";
+import { MapPin } from "lucide-react";
import { useMapsLibrary } from "@vis.gl/react-google-maps";
+import { LocationIcon, SearchIcon } from "@/assets/icons";
+import { SearchBar, SearchBarClear, SearchBarIcon, SearchBarInput } from "@/components/ui/search-bar";
import { useDebouncedPlacePredictions } from "@/hooks/useDebouncedPlacePredictions";
import {
circleLocationBias,
@@ -294,19 +296,11 @@ export function PlacesSearchInput({
return (
-
- {leadingIcon === "pin" ? (
-
- ) : (
-
- )}
-
+
+ {leadingIcon === "pin" ? : }
+
+ predictions.length > 0 && setIsOpen(true)}
placeholder={placeholder}
autoComplete="off"
- className="w-full rounded-lg border border-gray-border bg-white py-2 pl-9 pr-8 text-body-m-regular outline-none placeholder:text-[#99A1AF] focus:border-primary focus:ring-2 focus:ring-primary/30 focus:ring-inset disabled:cursor-not-allowed disabled:opacity-60"
/>
- {inputValue ? (
-
-
-
- ) : null}
-
+ {inputValue ? : null}
+
{!pickOnly ? (
[...document.querySelectorAll("button")]
- .find(node => node.textContent === name || node.getAttribute("aria-label") === name)!;
-async function render(pending = false) {
+// 접근성 이름: aria-labelledby(Button loading이면 loadingLabel span) → aria-label → 글자 순서
+function accessibleName(node: Element) {
+ const labelledBy = node.getAttribute("aria-labelledby");
+ if (labelledBy) return labelledBy.split(" ").map(id => document.getElementById(id)?.textContent ?? "").join(" ");
+ return node.getAttribute("aria-label") ?? node.textContent ?? "";
+}
+const buttons = () => [...document.querySelectorAll("button")];
+const button = (name: string) => buttons().find(node => accessibleName(node) === name)!;
+// Base UI는 첫 포커스·포커스 복귀를 다음 프레임에 한다
+const nextFrame = () => act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))); });
+const escape = () => act(async () => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
+});
+async function render({ pending = false, confirmDisabled = false } = {}) {
await act(async () => root.render(
-
+
,
));
}
@@ -36,7 +47,15 @@ it.each(["click", "mousedown", "pointerdown", "touchstart"])("contains %s from p
expect(outer).not.toHaveBeenCalled();
});
-it.each(["확인", "취소", "삭제 닫기", "삭제 배경 닫기"])("preserves the %s action", async name => {
+it("is a modal alert dialog without a close X", async () => {
+ await render();
+ const dialog = document.querySelector('[role="alertdialog"]')!;
+ expect(dialog.hasAttribute("data-open")).toBe(true);
+ expect(accessibleName(dialog)).toBe("삭제");
+ expect(buttons().map(accessibleName)).not.toContain("삭제 닫기");
+});
+
+it.each(["확인", "취소", "삭제 배경 닫기"])("preserves the %s action", async name => {
await render();
await act(async () => button(name).click());
expect(confirm).toHaveBeenCalledTimes(name === "확인" ? 1 : 0);
@@ -44,49 +63,48 @@ it.each(["확인", "취소", "삭제 닫기", "삭제 배경 닫기"])("preserve
expect(outer).not.toHaveBeenCalled();
});
-it("keeps document Escape and Tab handling, contains focus and restores the trigger", async () => {
+it("focuses cancel first, hides the page behind it, closes with document Escape and restores the trigger", async () => {
const trigger = document.createElement("button");
document.body.append(trigger); trigger.focus();
await render();
- expect(document.activeElement).toBe(button("삭제 닫기"));
- expect(host.hasAttribute("inert")).toBe(true);
- await act(async () => {
- button("확인").focus();
- button("확인").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
- });
- expect(document.activeElement).toBe(button("삭제 닫기"));
- await act(async () => button("삭제 닫기").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true })));
- expect(document.activeElement).toBe(button("확인"));
- await act(async () => trigger.focus());
- expect(document.activeElement).toBe(button("삭제 닫기"));
- await act(async () => button("취소").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })));
+ await nextFrame();
+ // 첫 포커스는 취소(Enter 한 번에 확인이 실행되지 않게). 바깥 요소는 Base UI가 aria-hidden으로 가린다
+ expect(document.activeElement).toBe(button("취소"));
+ expect(host.getAttribute("aria-hidden")).toBe("true");
+ expect(trigger.getAttribute("aria-hidden")).toBe("true");
+ await escape();
expect(cancel).toHaveBeenCalledTimes(1);
await act(async () => root.render(null));
- expect(host.hasAttribute("inert")).toBe(false);
+ await nextFrame();
+ expect(host.hasAttribute("aria-hidden")).toBe(false);
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
-it("uses current pending state without resetting focus and blocks all pending actions", async () => {
- await render(); button("취소").focus();
- await render(true);
- expect(document.activeElement).toBe(button("취소"));
- for (const name of ["처리 중…", "취소", "삭제 닫기", "삭제 배경 닫기"]) {
+it("shows progress while pending and blocks confirm, cancel, backdrop and Escape", async () => {
+ await render(); await nextFrame(); button("확인").focus();
+ await render({ pending: true });
+ await nextFrame();
+ // 누른 확인 버튼에 포커스가 남는다(progress 중에도 focusable, 첫 포커스로 되돌리지 않음)
+ const action = button("처리 중");
+ expect(document.activeElement).toBe(action);
+ expect(action).toBeDefined();
+ expect(action.getAttribute("aria-busy")).toBe("true");
+ expect(action.getAttribute("aria-disabled")).toBe("true");
+ expect(buttons().map(accessibleName)).not.toContain("확인");
+ for (const name of ["처리 중", "취소", "삭제 배경 닫기"]) {
await act(async () => button(name).click());
}
- await act(async () => button("삭제 닫기").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
+ await escape();
expect(confirm).not.toHaveBeenCalled(); expect(cancel).not.toHaveBeenCalled();
+ expect(document.querySelector('[role="alertdialog"]')?.hasAttribute("data-open")).toBe(true);
});
-it.each([false, true])("SettingsDialog portal containment is opt-in (%s)", async enabled => {
- await act(async () => root.render(
-
-
- 실행
-
-
,
- ));
- await act(async () => button("실행").click());
- expect(confirm).toHaveBeenCalledTimes(1);
- expect(outer).toHaveBeenCalledTimes(enabled ? 0 : 1);
+it("disables only the confirm button when confirmDisabled", async () => {
+ await render({ confirmDisabled: true });
+ expect(button("확인").disabled).toBe(true);
+ await act(async () => button("확인").click());
+ expect(confirm).not.toHaveBeenCalled();
+ await act(async () => button("취소").click());
+ expect(cancel).toHaveBeenCalledTimes(1);
});
diff --git a/src/components/settings/ConfirmDialog.tsx b/src/components/settings/ConfirmDialog.tsx
index 2c0d7dfb..b45ebc8b 100644
--- a/src/components/settings/ConfirmDialog.tsx
+++ b/src/components/settings/ConfirmDialog.tsx
@@ -1,119 +1,101 @@
"use client";
-import { useCallback, useLayoutEffect, useRef } from "react";
+import type { SyntheticEvent } from "react";
import {
- alertDialogButtonClass,
- SettingsActionButton,
- SettingsActionButtonRow,
-} from "@/components/settings/SettingsActionButton";
-import { SettingsDialog } from "@/components/settings/SettingsDialog";
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
type Props = {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
+ /** 확인 동작 처리 중. Esc·딤·취소를 모두 막고 확인 버튼은 progress로 바뀐다 */
isPending?: boolean;
+ /** 지금은 확인할 수 없는 상태(예: 동기화 오류). 확인 버튼만 비활성으로 보이고 취소·닫기는 그대로 된다 */
+ confirmDisabled?: boolean;
+ /** 삭제·탈퇴·나가기 같은 위험 동작이면 true(확인 버튼 Critical Solid) */
destructive?: boolean;
- appearance?: "default" | "ledger" | "alert";
- /** 확인 동작이 실패했을 때 버튼 아래에 보여줄 한 줄 안내 — `alert`에서만 쓴다 */
+ /** 확인 동작이 실패했을 때 버튼 아래에 보여줄 한 줄 안내 */
errorMessage?: string | null;
onConfirm: () => void;
onCancel: () => void;
};
+// 포털 표면과 배경에서만 카드 클릭·드래그 호스트로 가는 포인터 이벤트를 끊는다.
+// 키 입력은 Base UI가 document에서도 받으므로 막지 않는다.
+const stopPropagation = (event: SyntheticEvent) => event.stopPropagation();
+const pointerBoundary = {
+ onClick: stopPropagation,
+ onMouseDown: stopPropagation,
+ onPointerDown: stopPropagation,
+ onTouchStart: stopPropagation,
+};
+
+/**
+ * 확인창(Figma Alert Dialog 5621:3002). 마운트되면 열린 상태라 호출부는 조건부로 렌더한다.
+ * 닫기 요청(Esc·딤·취소)은 모두 onCancel이고, 처리 중에는 모두 막힌다. 성공 뒤 닫기는 호출부가 언마운트로 한다.
+ *
+ * @example
+ * {target ? (
+ * setTarget(null)} />
+ * ) : null}
+ */
export function ConfirmDialog({
title,
description,
confirmLabel = "확인",
cancelLabel = "취소",
isPending = false,
+ confirmDisabled = false,
destructive = false,
- appearance = "default",
errorMessage,
onConfirm,
onCancel,
}: Readonly) {
- // SettingsDialog는 onClose가 바뀔 때마다 포커스·inert를 다시 잡으므로 안정된 함수를 넘긴다
- const latest = useRef({ onCancel, isPending });
- useLayoutEffect(() => {
- latest.current = { onCancel, isPending };
- });
- const handleClose = useCallback(() => {
- if (!latest.current.isPending) latest.current.onCancel();
- }, []);
-
- const confirmText = isPending ? "처리 중…" : confirmLabel;
- const alertConfirmClass = alertDialogButtonClass[destructive ? "critical" : "brand"];
- const ledgerButtonClass = appearance === "ledger" ? "h-12 rounded-lg text-[16px]" : undefined;
- const descriptionClass = appearance === "ledger"
- ? "whitespace-pre-line text-[14px] leading-5 text-text-subtle"
- : "text-body-m-regular mobile:text-body-s-regular leading-relaxed text-dark-gray";
-
- if (appearance === "alert") {
- return (
-
- {description ? {description}
: null}
-
-
- {cancelLabel}
-
-
- {confirmText}
-
-
- {errorMessage ? (
-
- {errorMessage}
-
- ) : null}
-
- );
- }
-
return (
- {
+ if (!open) onCancel();
+ }}
>
- {description ? (
-
- {description}
-
- ) : null}
-
-
- {cancelLabel}
-
-
- {confirmText}
-
-
-
+
+
+ {title}
+ {description ? {description} : null}
+
+
+
+ {cancelLabel}
+
+ {confirmLabel}
+
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
);
}
diff --git a/src/components/settings/SettingsActionButton.tsx b/src/components/settings/SettingsActionButton.tsx
deleted file mode 100644
index cf17ba5a..00000000
--- a/src/components/settings/SettingsActionButton.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import { type ButtonHTMLAttributes, type ReactNode } from "react";
-
-import { cn } from "@/lib/utils";
-
-/** `RoomTripEditForm` 하단 액션 버튼과 동일 */
-export const settingsActionButtonVariantClass = {
- secondary:
- "rounded-full border border-gray-border bg-transparent py-2.5 text-label-l-emphasis mobile:text-label-m-emphasis font-semibold text-dark-gray transition hover:bg-bubble-gray disabled:cursor-not-allowed disabled:opacity-40",
- primary:
- "rounded-full border border-transparent bg-primary py-2.5 text-label-l-emphasis mobile:text-label-m-emphasis font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
-} as const;
-
-/** Figma Alert Dialog 버튼 — Secondary와 Primary(Neutral·Brand Solid·Critical) */
-export const alertDialogButtonClass = {
- secondary:
- "h-12 cursor-pointer rounded-lg bg-fill px-3.5 text-label-l-emphasis text-text transition-colors enabled:hover:bg-fill-strong disabled:cursor-not-allowed disabled:text-text-disabled",
- neutral:
- "h-12 cursor-pointer rounded-lg bg-[var(--gray-800)] px-3.5 text-label-l-emphasis text-text-inverse transition-opacity enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
- brand:
- "h-12 cursor-pointer rounded-lg bg-primary px-3.5 text-label-l-emphasis text-text-inverse transition-opacity enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
- critical:
- "h-12 cursor-pointer rounded-lg bg-status-negative px-3.5 text-label-l-emphasis text-text-inverse transition-opacity enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
-} as const;
-
-export type SettingsActionButtonVariant = keyof typeof settingsActionButtonVariantClass;
-
-type SettingsActionButtonProps = ButtonHTMLAttributes & {
- variant: SettingsActionButtonVariant;
- /** 기본 `flex-1` — 모달 단일 버튼 등은 `false` + `className="w-full"` */
- flex?: boolean;
-};
-
-export function SettingsActionButton({
- variant,
- flex = true,
- className,
- type = "button",
- ...props
-}: SettingsActionButtonProps) {
- return (
-
- );
-}
-
-export function SettingsActionButtonRow({
- className,
- children,
-}: {
- className?: string;
- children: ReactNode;
-}) {
- return {children}
;
-}
diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx
deleted file mode 100644
index 4e24ae4b..00000000
--- a/src/components/settings/SettingsDialog.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-"use client";
-
-import { useEffect, useId, useRef, type ReactNode, type SyntheticEvent } from "react";
-import { createPortal } from "react-dom";
-import { X } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-// 포털의 이벤트가 React 트리의 카드 클릭·드래그 호스트로 전파되는 것만 막는다.
-// 사용자 동작은 내부 버튼이 담당하며, 키 입력은 document의 포커스·Escape 처리로 전달한다.
-const stopPropagation = (event: SyntheticEvent) => event.stopPropagation();
-const portalEventBoundary = {
- onClick: stopPropagation,
- onMouseDown: stopPropagation,
- onPointerDown: stopPropagation,
- onTouchStart: stopPropagation,
-};
-
-function restoreDialogFocus(trigger: Element | null) {
- if (trigger instanceof HTMLElement && trigger !== document.body && trigger.isConnected) {
- trigger.focus();
- return;
- }
- const fallback = document.querySelector("[data-cookie-settings-trigger]");
- if (fallback) {
- fallback.focus();
- return;
- }
- const main = document.querySelector("main, [role=main]");
- if (!main) return;
- const previousTabIndex = main.getAttribute("tabindex");
- main.setAttribute("tabindex", "-1");
- main.focus({ preventScroll: true });
- if (previousTabIndex === null) main.removeAttribute("tabindex");
- else main.setAttribute("tabindex", previousTabIndex);
-}
-
-type DialogAppearance = "default" | "ledger" | "alert";
-type DialogSize = "default" | "medium" | "compact";
-
-const dialogWidthClass: Record = {
- compact: "max-w-md",
- medium: "max-w-[600px]",
- default: "max-w-[640px]",
-};
-const dialogTitleClass: Record = {
- alert: "text-title-l",
- ledger: "text-[20px] leading-7 font-bold",
- default: "text-title-l mobile:text-title-m font-bold",
-};
-
-function getDialogSurfaceClass(appearance: DialogAppearance, size: DialogSize) {
- if (appearance === "alert") {
- return "relative m-0 max-h-[calc(100dvh-2rem)] w-full min-w-0 max-w-[400px] overflow-y-auto overscroll-contain rounded-[10px] border-0 bg-fill-elevate p-5 text-text shadow-xl [scrollbar-gutter:auto]";
- }
- const isLedger = appearance === "ledger";
- return cn(
- "relative m-0 border-0 max-h-[calc(100dvh-2rem)] w-full min-w-0 overflow-y-auto overscroll-contain bg-white p-6 text-neutral-900 shadow-xl",
- dialogWidthClass[size],
- isLedger ? "rounded-xl mobile:max-w-[353px] max-sm:max-w-[353px]" : "rounded-3xl",
- isLedger ? "[scrollbar-gutter:auto]" : "[scrollbar-gutter:stable_both-edges] sm:px-8 sm:py-6",
- );
-}
-
-/** 기존 설정 모달의 표면·간격을 공유하는 키보드 접근 가능한 다이얼로그. */
-export function SettingsDialog({
- title,
- onClose,
- children,
- size = "default",
- appearance = "default",
- stopPortalEventPropagation = false,
- className = "",
- overlayClassName = "",
- showCloseButton = true,
- titleAccessory,
-}: Readonly<{
- title: string;
- onClose: () => void;
- children: ReactNode;
- size?: DialogSize;
- /** `alert` — Figma Alert Dialog(폭 400, 여백 20, 제목 title/lg) */
- appearance?: DialogAppearance;
- stopPortalEventPropagation?: boolean;
- className?: string;
- overlayClassName?: string;
- showCloseButton?: boolean;
- /** 제목 바로 옆에 붙는 보조 액션 — 예: 여행 삭제 */
- titleAccessory?: ReactNode;
-}>) {
- const titleId = useId();
- const overlayRef = useRef(null);
- const dialogRef = useRef(null);
-
- useEffect(() => {
- const trigger = document.activeElement;
- const dialog = dialogRef.current!;
- const background = [...document.body.children].filter(
- (node) => node !== overlayRef.current,
- );
- const previousInert = background.map((node) => node.getAttribute("inert"));
- background.forEach((node) => node.setAttribute("inert", ""));
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- const focusScope = () => dialog.querySelector('[role="alertdialog"][aria-modal="true"]') ?? dialog;
- const focusable = () => [
- ...focusScope().querySelectorAll(
- 'button:not(:disabled), a[href], summary, input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
- ),
- ];
- // 제목 옆 위험 액션 등 `data-skip-autofocus`는 첫 포커스에서 건너뛴다
- (focusable().find((node) => node.dataset.skipAutofocus === undefined) ?? focusable()[0])?.focus();
-
- function handleKey(event: KeyboardEvent) {
- if (event.defaultPrevented) return;
- if (event.key === "Escape" && focusScope() === dialog) {
- event.preventDefault();
- onClose();
- }
- if (event.key !== "Tab") return;
- const items = focusable();
- const first = items[0];
- const last = items.at(-1);
- if (event.shiftKey && document.activeElement === first) {
- event.preventDefault();
- last?.focus();
- } else if (!event.shiftKey && document.activeElement === last) {
- event.preventDefault();
- first?.focus();
- }
- }
- function containFocus(event: FocusEvent) {
- if (!focusScope().contains(event.target as Node)) focusable()[0]?.focus();
- }
- document.addEventListener("keydown", handleKey);
- document.addEventListener("focusin", containFocus);
- return () => {
- document.removeEventListener("keydown", handleKey);
- document.removeEventListener("focusin", containFocus);
- document.body.style.overflow = previousOverflow;
- background.forEach((node, index) => {
- const value = previousInert[index];
- if (value === null) node.removeAttribute("inert");
- else node.setAttribute("inert", value);
- });
- restoreDialogFocus(trigger);
- };
- }, [onClose]);
-
- const isAlert = appearance === "alert";
- const isCompactSurface = appearance === "ledger" || isAlert;
-
- return createPortal(
-
-
-
-
-
-
- {title}
-
- {titleAccessory}
-
- {showCloseButton &&
-
- }
-
- {children}
-
-
,
- document.body,
- );
-}
diff --git a/src/components/settings/WithdrawAccountConfirmModal.tsx b/src/components/settings/WithdrawAccountConfirmModal.tsx
index 42a0f389..595d94ea 100644
--- a/src/components/settings/WithdrawAccountConfirmModal.tsx
+++ b/src/components/settings/WithdrawAccountConfirmModal.tsx
@@ -1,12 +1,15 @@
"use client";
-import { useEffect } from "react";
-import { UserX } from "lucide-react";
-
import {
- SettingsActionButton,
- SettingsActionButtonRow,
-} from "@/components/settings/SettingsActionButton";
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
type Props = {
onClose: () => void;
@@ -14,70 +17,47 @@ type Props = {
isPending: boolean;
};
+const TITLE = "정말 탈퇴하시겠어요?";
+
+/** 회원 탈퇴 확인창(Figma Alert Dialog, Destructive). 마운트되면 열린 상태라 호출부가 조건부로 렌더한다 */
export function WithdrawAccountConfirmModal({
onClose,
onConfirm,
isPending,
}: Props) {
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape" && !isPending) onClose();
- };
- document.addEventListener("keydown", handler);
- return () => document.removeEventListener("keydown", handler);
- }, [onClose, isPending]);
-
return (
- {
- if (e.target === e.currentTarget && !isPending) onClose();
+
{
+ if (!open) onClose();
}}
- role="presentation"
>
- e.stopPropagation()}
- >
-
-
-
-
-
- 정말 탈퇴하시겠어요?
-
-
- 탈퇴 후에는 계정을 복구할 수 없습니다.
-
- 이메일·닉네임·프로필 등
-
- 개인정보는 익명화되며, 참여 중이던 여행 멤버십은 정리됩니다.
+ {/* 지금처럼 딤을 눌러도 닫힌다(처리 중에는 closeDisabled가 막는다) */}
+
+
+ {TITLE}
+
+ {"탈퇴 후에는 계정을 복구할 수 없습니다.\n이메일·닉네임·프로필 등 개인정보는 익명화되며,\n참여 중이던 여행 멤버십은 정리됩니다."}
+
+ {/* 지금 화면처럼 본문보다 작은 보조 안내 */}
+
+ 방장인 여행에 다른 멤버가 남아 있으면, 탈퇴 전 방장을 위임해야 합니다.
-
- 방장인 여행에 다른 멤버가 남아 있으면, 탈퇴 전 방장을 위임해야
- 합니다.
-
-
-
-
- 취소
-
-
+
+ 취소
+
- {isPending ? "탈퇴 처리 중…" : "탈퇴하기"}
-
-
-
-
+ 탈퇴하기
+
+
+
+
);
}
diff --git a/src/components/settings/WithdrawalDelegationRequiredModal.tsx b/src/components/settings/WithdrawalDelegationRequiredModal.tsx
index 715cfa22..dbb298f2 100644
--- a/src/components/settings/WithdrawalDelegationRequiredModal.tsx
+++ b/src/components/settings/WithdrawalDelegationRequiredModal.tsx
@@ -1,10 +1,16 @@
"use client";
-import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { AlertCircle } from "lucide-react";
-import { SettingsActionButton } from "@/components/settings/SettingsActionButton";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import type { RoomRequiringDelegation } from "@/lib/api/user";
import { useSessionStore } from "@/stores/session-store";
@@ -13,17 +19,12 @@ type Props = {
onClose: () => void;
};
+const TITLE = "방장 위임이 필요해요";
+
+/** 탈퇴 전 방장 위임 안내(Figma Alert Dialog, Notice). 마운트되면 열린 상태라 호출부가 조건부로 렌더한다 */
export function WithdrawalDelegationRequiredModal({ rooms, onClose }: Props) {
const router = useRouter();
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- document.addEventListener("keydown", handler);
- return () => document.removeEventListener("keydown", handler);
- }, [onClose]);
-
function handleRoomClick(roomId: string) {
useSessionStore.getState().setCurrentRoomId(roomId);
onClose();
@@ -31,61 +32,41 @@ export function WithdrawalDelegationRequiredModal({ rooms, onClose }: Props) {
}
return (
- {
- if (e.target === e.currentTarget) onClose();
+
{
+ if (!open) onClose();
}}
- role="presentation"
>
- e.stopPropagation()}
- >
-
-
-
- 방장 위임이 필요해요
-
-
+ {/* 지금처럼 딤을 눌러도 닫힌다 */}
+
+
+ {TITLE}
+
아래 여행에서 방장을 다른 멤버에게 넘긴 뒤 탈퇴할 수 있어요. 여행
이름을 누르면 멤버 설정으로 이동합니다.
-
- {rooms.length > 0 && (
-
- {rooms.map((room) => (
-
- handleRoomClick(room.roomId)}
- className="w-full cursor-pointer rounded-lg px-3 py-2.5 text-left text-label-l-regular mobile:text-label-m-regular font-medium text-gray-800 transition hover:bg-white"
- >
- {room.title}
-
-
- ))}
-
- )}
-
-
-
- 확인
-
-
-
-
+
+
+ {rooms.length > 0 && (
+
+ {rooms.map((room) => (
+
+ handleRoomClick(room.roomId)}
+ className="w-full cursor-pointer rounded-lg px-3 py-2.5 text-left text-label-l-regular text-text transition-colors hover:bg-fill focus-visible:outline-2 focus-visible:outline-primary mobile:text-label-m-regular"
+ >
+ {room.title}
+
+
+ ))}
+
+ )}
+
+ 확인
+
+
+
);
}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..766d55b2
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+import { cva, type VariantProps } from "class-variance-authority";
+import { useRef, type ComponentProps } from "react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+// Figma 13 Alert Dialog: Alert Dialog 5621:3002(Purpose × Action Layout). 닫기 X는 없다(6058:535).
+// Purpose는 버튼 Type으로 고른다: Notice·Confirmation 주요 = Neutral Solid(Button 기본), Destructive 주요 = Critical Solid(tone="critical"), 취소 = Neutral Weak(6058:516).
+// 바깥 클릭은 Base UI AlertDialog가 막는다(disablePointerDismissal 강제). 딤을 눌러 닫으려면 AlertDialogContent의 backdropCloseLabel을 준다.
+// 닫기 요청(Esc·취소·딤)은 모두 Root의 onOpenChange를 거친다. 처리 중에는 Root의 closeDisabled로 막는다(Dialog·Drawer와 같은 이름·의미).
+//
+// @example
+//
+//
+//
+// 여행을 삭제할까요?
+// 삭제하면 되돌릴 수 없어요.
+//
+//
+// 취소
+// deleteRoom(undefined, { onSuccess: () => setOpen(false) })}>삭제
+//
+//
+//
+
+type AlertDialogProps = AlertDialogPrimitive.Root.Props & {
+ /** 처리 중처럼 닫으면 안 될 때 true. Esc·딤 닫기 버튼·취소(AlertDialogCancel)를 모두 막는다. 화면이 open을 false로 바꾸는 것(성공 뒤 닫기)은 막지 않는다 */
+ closeDisabled?: boolean;
+};
+
+/**
+ * Base UI AlertDialog.Root. 모달·role="alertdialog"는 고정이다. 메뉴 항목 등에서 열면 open·onOpenChange로 제어한다.
+ * 안쪽 요소가 Esc를 먼저 쓰고 preventDefault했으면 닫지 않는다(Dialog와 같은 규칙).
+ */
+function AlertDialog({ closeDisabled = false, onOpenChange, ...props }: Readonly) {
+ return (
+ {
+ // Base UI는 Esc를 document keydown(버블)에서 받아 defaultPrevented를 보지 않는다. 포털 안 React onKeyDown이 먼저 실행돼 이 값이 채워져 있다
+ const escapeHandledInside = eventDetails.reason === "escape-key" && eventDetails.event.defaultPrevented;
+ if (!open && (closeDisabled || escapeHandledInside)) {
+ eventDetails.cancel();
+ return;
+ }
+ onOpenChange?.(open, eventDetails);
+ }}
+ {...props}
+ />
+ );
+}
+
+/** 여는 버튼. 우리 Button으로 그리려면 render={ } */
+function AlertDialogTrigger(props: Readonly) {
+ return ;
+}
+
+// 딤은 Alert Dialog 페이지에 정의가 없어(figma-button-dialog 3-6) 지금 앱 값 black/40을 쓴다.
+// 대화상자 안에서 연 확인창(중첩)도 딤을 그린다(forceRender). 확인창은 그림자가 없어 흰 대화상자 위에서 경계가 사라지고, 지금 확인창도 대화상자 위에 딤을 한 겹 더 깐다.
+// iOS 26 Safari는 fixed 딤이 스크롤 뒤 화면을 다 덮지 못해 absolute로 둔다(Base UI 권장, body position: relative는 globals.css)
+type BackdropEventProps = Pick;
+
+function AlertDialogOverlay({ closeLabel, backdropProps }: Readonly<{ closeLabel?: string; backdropProps?: BackdropEventProps }>) {
+ return (
+
+ {/* Base UI에 AlertDialog 바깥 닫기 옵션이 없어 딤 전체를 덮는 Close 버튼으로 연다(옛 SettingsDialog의 '배경 닫기' 버튼과 같은 구조).
+ Close라 onOpenChange(false, reason "close-press")를 거치므로 closeDisabled면 거기서 막힌다. 키보드는 Esc로 닫으니 탭 순서에서 뺀다 */}
+ {closeLabel && (
+
+ )}
+
+ );
+}
+
+type AlertDialogContentProps = Omit & {
+ /** 배치(폭)에만 쓴다 */
+ className?: string;
+ /** 주면 딤을 눌러 닫힌다(지금 확인창 동작). 값은 딤 위 숨은 닫기 버튼의 이름이다(예: "{제목} 배경 닫기"). 없으면 Esc·취소로만 닫힌다 */
+ backdropCloseLabel?: string;
+ /** 배경 닫기 버튼을 포함한 포털 이벤트의 전파 경계. 키보드는 차단하지 않는다 */
+ backdropProps?: BackdropEventProps;
+ /** 확인창의 접근성 이름. AlertDialogTitle 없이(Figma Show Title=false) 쓸 때 꼭 준다. 제목이 있으면 Base UI가 aria-labelledby로 제목을 잇는다 */
+ "aria-label"?: string;
+};
+
+// 폭: 원본 353(393 화면 − 좌우 20), 가이드 최대 400(5955:386). 좁은 화면은 좌우 20을 남기고 줄어든다.
+// padding spacing/24, 세로 버튼 배치(Vertical 5621:2999·3001)만 아래 spacing/12. radius 10은 rounded-lg, Content–Actions 간격 20, 그림자 없음(5955:386), 바탕 fill/elevation.
+// 최대 높이는 Figma 미정이라 dialog.tsx와 같은 규칙(화면 − 32px, 넘치면 안에서 스크롤)이다. 배경 스크롤이 잠겨 있어 가로 화면·글자 확대에서 버튼을 못 누르는 것을 막는다.
+// z-50은 다른 Base UI 팝업(dialog·drawer·select 등)과 같은 값이다. 같은 z라 나중에 연 확인창이 대화상자·시트 위에 뜬다.
+// 열림·닫힘은 shadcn 원본(fade + zoom 95%, 100ms)을 Base UI 전환 속성으로 옮긴 것이다
+
+/**
+ * 포털·딤·표면을 함께 그린다. 제목 없이 쓰면 aria-label을 준다.
+ * 첫 포커스는 취소 버튼이다(없으면 Base UI 기본: 첫 포커스 요소, 터치로 열면 표면). 세로 배치(주요 → 취소)에서도 Enter 한 번에 파괴적 동작이 실행되지 않게 한다.
+ * 바꾸려면 initialFocus를 넘긴다.
+ */
+function AlertDialogContent({ className, backdropCloseLabel, backdropProps, initialFocus, ...props }: Readonly) {
+ const popupRef = useRef(null);
+ return (
+
+
+
+ interactionType === "touch"
+ ? popupRef.current
+ : (popupRef.current?.querySelector(
+ "[data-slot=alert-dialog-cancel]:not([data-disabled])",
+ ) ?? true))
+ }
+ className={cn(
+ "fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[min(400px,calc(100vw-40px))] -translate-x-1/2 -translate-y-1/2 flex-col gap-5 overflow-y-auto overscroll-contain rounded-lg bg-fill-elevate p-6 transition-[opacity,scale] duration-100 ease-out has-[[data-slot=alert-dialog-footer][data-layout=vertical]]:pb-3 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none",
+ className,
+ )}
+ {...props}
+ />
+
+ );
+}
+
+/** Figma Content: 제목·본문 세로 간격 4, 가운데 정렬(5621:2819·2820) */
+function AlertDialogHeader({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+// title/lg · text/default(5621:2819). Figma Show Title=false면 제목 없이 본문만 둔다(5955:298).
+// 그때는 확인창 이름이 사라지므로 AlertDialogContent에 aria-label(예: "여행 삭제 확인")을 준다
+function AlertDialogTitle({ className, ...props }: Readonly) {
+ return (
+
+ );
+}
+
+// text/subtle(5621:2820). 글자는 Figma body/lg(18)가 제목에 비해 커 보여 body/md(16)로 고정한다(사용자 결정 2026-11-01).
+// 설명 문자열의 줄바꿈(\n)은 지금 확인창처럼 그대로 보인다(ConfirmDialog whitespace-pre-line)
+function AlertDialogDescription({ className, ...props }: Readonly) {
+ return (
+
+ );
+}
+
+// Figma Actions: 버튼 md 48·간격 8·꽉 채움(5955:389). 자동 전환은 없어(6058:531) 문구가 길면 화면이 vertical을 고른다.
+// 순서는 DOM 순서 그대로 보인다(포커스 순서와 같게): horizontal은 취소 → 주요(5621:2998), vertical은 주요 → 취소(5621:2999)로 적는다.
+// vertical에서도 첫 포커스는 AlertDialogContent가 취소로 보낸다(주요가 먼저 있어도 Enter로 바로 실행되지 않음).
+// 버튼이 하나(Notice · Single 5621:3000)면 horizontal에 주요 버튼만 넣는다
+const alertDialogFooterVariants = cva("flex gap-2", {
+ variants: {
+ layout: {
+ horizontal: "*:flex-1",
+ vertical: "flex-col",
+ },
+ },
+ defaultVariants: { layout: "horizontal" },
+});
+
+function AlertDialogFooter({
+ className,
+ layout = "horizontal",
+ ...props
+}: Readonly & VariantProps>) {
+ return (
+
+ );
+}
+
+type AlertDialogActionProps = Omit, "variant" | "tone" | "size"> & {
+ /** Neutral Solid(기본) · Critical Solid(삭제·탈퇴 같은 위험 동작). 브랜드 버튼은 확인창 유형에 없다(6058:517) */
+ tone?: "neutral" | "critical";
+};
+
+/**
+ * 주요 버튼. 누른다고 닫히지 않는 일반 Button이다 — 작업이 성공하면 화면이 open을 false로 바꾸고, 실패하면 열어 둔 채 오류를 보여 준다.
+ * Solid md 48로 고정이다. 처리 중에는 loading(누를 수 없음, 폭 유지 progress)
+ */
+function AlertDialogAction({ tone = "neutral", ...props }: Readonly) {
+ return ;
+}
+
+type AlertDialogCancelProps = Omit & {
+ /** 배치에만 쓴다 */
+ className?: string;
+};
+
+/**
+ * 취소 버튼(Neutral Weak md). Base UI Close라 누르면 onOpenChange(false, reason "close-press")로 닫힌다.
+ * closeDisabled면 눌러도 닫히지 않는다. 비활성으로 보이게 하는 것은 Figma가 따로 정하지 않아(6058:529 "취소를 Disabled로 만들지 않는다") 화면이 disabled로 정한다
+ */
+function AlertDialogCancel({ className, ...props }: Readonly) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 00000000..d0a0105a
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,91 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import type { ComponentProps } from "react";
+
+import { cn } from "@/lib/utils";
+
+// Figma DS에 인라인 Alert가 없어 지금 화면의 안내 상자 모양을 옮긴다.
+// lg는 로그인·약관 동의 화면 상자, md는 장소 상세·모바일 장소 검색·준비물 상자다.
+// brand의 테두리·바탕은 지금 쓰는 primary/35·primary/[0.06](합성색 #9ccfff·#f0f8ff)에 맞는 시맨틱 토큰이 없어 가장 가까운 원시 색을 쓴다
+const alertVariants = cva("group/alert flex border text-left", {
+ variants: {
+ tone: {
+ neutral: "border-border-subtle bg-fill-subtle text-text",
+ brand: "border-[var(--blue-300)] bg-[var(--blue-50)] text-primary",
+ warning: "border-status-cautionary bg-fill-subtle text-text",
+ },
+ size: {
+ lg: "rounded-xl px-4 py-3 text-body-m-regular mobile:text-body-s-regular",
+ md: "rounded-md p-3 text-body-s-regular",
+ },
+ // 모바일 장소 검색·준비물 상자는 동작이 문구 바로 옆에 있다(horizontal)
+ orientation: {
+ vertical: "flex-col gap-1",
+ horizontal: "items-center gap-2",
+ },
+ },
+ defaultVariants: { tone: "neutral", size: "md", orientation: "vertical" },
+});
+
+/**
+ * 급하지 않은 안내는 role="status"로 덮는다.
+ * 폭을 정하지 않는다. w-full에 바깥 margin을 더하면 넘치므로 간격은 부모가 주고, items-center 부모 안에서는 호출부가 self-stretch로 채운다.
+ * 아이콘 칸이 없다. 앞에 붙는 표시(로그인 '!' 원)는 호출부가 감싸서 배치한다.
+ */
+function Alert({
+ className,
+ tone = "neutral",
+ size = "md",
+ orientation = "vertical",
+ ...props
+}: Readonly & VariantProps>) {
+ return (
+
+ );
+}
+
+// md 제목은 지금 앱 안 상자처럼 본문 굵기 그대로다. 굵은 제목은 로그인·약관 상자(lg)에만 있다
+function AlertTitle({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+function AlertDescription({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+// 세로 배치에서 동작은 문구보다 8px 아래다(지금 상자의 mt-2). 부품 사이 gap 4px에 위 여백 4px을 더한다
+function AlertAction({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction };
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 00000000..edfc9aab
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,126 @@
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import { cva, type VariantProps } from "class-variance-authority";
+import type { ComponentProps } from "react";
+
+import { cn } from "@/lib/utils";
+
+// Figma DS에 Avatar 컴포넌트가 없어 지금 화면들의 아바타 모양을 옮긴다. Figma 근거는 Header Profile(5219:10776)의
+// 36px·fill/strong 원뿐이라 이것을 기본값으로 둔다. 크기는 글자 설정을 따라 커지지 않게 px로 둔다(원칙 3-2).
+// 대체 글자는 화면별 지금 크기·굵기(24 가계부, 28 헤더 미리보기, 32 리뷰, 36 구성원, 56 내 정보)에 가장 가까운 토큰이고,
+// 글자를 쓰는 곳이 없는 40은 크기의 44%(18px)다. 대체 아이콘은 지금 채팅 아바타처럼 크기의 58%에 가장 가까운 Figma 아이콘 크기다.
+const avatarVariants = cva("relative flex shrink-0 select-none items-center justify-center overflow-hidden", {
+ variants: {
+ size: {
+ 24: "size-[24px] text-body-xs-regular [&_svg]:size-[12px]",
+ 28: "size-[28px] text-label-xs-regular [&_svg]:size-[16px]",
+ 32: "size-[32px] text-label-m-regular mobile:text-label-xs-regular [&_svg]:size-[20px]",
+ 36: "size-[36px] text-label-l-regular [&_svg]:size-[20px]",
+ 40: "size-[40px] text-label-xl-regular [&_svg]:size-[24px]",
+ 56: "size-[56px] text-heading-s [&_svg]:size-[32px]",
+ },
+ shape: {
+ circle: "rounded-full",
+ rounded: "rounded-xl",
+ },
+ // 배경은 사진을 받는 동안 보이는 색이기도 하다(AvatarImage가 bg-inherit로 아래의 대체를 가린다)
+ tone: {
+ primary: "bg-primary-subtle text-primary",
+ neutral: "bg-fill text-text-subtle",
+ placeholder: "bg-fill-strong text-text-subtle",
+ // 채팅 사진 자리 bg-light-gray(#d9d9d9)에 가장 가까운 gray/300은 맞는 semantic이 없어 원시 변수로 쓴다.
+ // 대체(사람 아이콘) 칸은 지금처럼 사진 자리보다 밝다
+ icon: "bg-[var(--gray-300)] text-icon-disabled *:data-[slot=avatar-fallback]:bg-fill-strong",
+ // WOORI(AI) 그림은 흰 바탕에 둔다
+ elevate: "bg-fill-elevate",
+ },
+ ring: {
+ none: "",
+ online: "ring-2 ring-status-positive",
+ // 구성원 카드는 옆에 접속 상태 글자가 있어 옅은 1px 링, 글자가 없는 헤더 미리보기(겹친 묶음)는 굵은 링과 흑백으로 그린다.
+ // 묶음 안인지로 나눠 쓰는 쪽은 접속 상태만 정한다
+ offline:
+ "ring-1 ring-border-subtle in-data-[slot=avatar-group]:ring-2 in-data-[slot=avatar-group]:ring-border in-data-[slot=avatar-group]:grayscale in-data-[slot=avatar-group]:brightness-110 in-data-[slot=avatar-group]:contrast-75",
+ // 채팅 목록 아바타. 그림자는 지금 쓰는 shadow-sm에 가장 가까운 Figma shadow/subtle(0 1 4 #1f2229 8%)이다
+ subtle: "ring-1 ring-border-subtle shadow-[0_1px_4px_rgba(31,34,41,0.08)]",
+ // 채팅 새 메시지 미리보기. 회색 버튼 바탕(background/strong) 위라 윤곽이 border/default다
+ default: "ring-1 ring-border",
+ // 가계부에서 겹친 아바타 사이를 끊는 안쪽 흰 테두리. 지금 굵기 1.5px에 맞는 토큰이 없다
+ cutout: "border-[1.5px] border-fill-elevate",
+ },
+ },
+ // 채팅 새 메시지 미리보기(24·28)는 10px, 채팅 목록(40)은 14px 모서리다
+ compoundVariants: [{ shape: "rounded", size: [24, 28], className: "rounded-lg" }],
+ defaultVariants: { size: 36, shape: "circle", tone: "placeholder", ring: "none" },
+});
+
+// Base UI는 상태 함수 className도 받지만 cn이 버리므로 배치용 문자열만 받는다
+type AvatarProps = Omit &
+ VariantProps & { className?: string };
+
+/** 바깥 className은 배치(겹침 순서·위치)에만 쓴다. 색·크기·모양은 variant로만 바꾼다 */
+function Avatar({ className, size, shape, tone, ring, ...props }: Readonly) {
+ return (
+
+ );
+}
+
+type AvatarImageProps = Omit & { className?: string };
+
+function AvatarImage({ className, ...props }: Readonly) {
+ return (
+
+ );
+}
+
+// delay를 주면 실패한 사진의 대체도 그만큼 늦게 보인다. 받는 동안은 AvatarImage가 대체를 덮어 delay가 필요 없다
+type AvatarFallbackProps = Omit & { className?: string };
+
+function AvatarFallback({ className, ...props }: Readonly) {
+ return (
+
+ );
+}
+
+/** 버튼 안에도 두도록 span으로 그린다. 앞사람이 위에 오는 겹침 순서는 쓰는 쪽이 자식 className의 z로 정한다 */
+function AvatarGroup({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+/** 헤더 구성원 미리보기의 +N. 쓰는 곳이 28px 묶음뿐이라 크기를 고정한다 */
+function AvatarGroupCount({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 00000000..ebf12950
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,81 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+// Figma 15 Badges: Badge / Weak 5626:32, Badge / Solid 5626:58, Badge / Outline 5626:84. 비조작 라벨이라 hover·focus·transition이 없다
+// 원시 변수로 쓰는 색: Figma primary/subtle(blue/50)은 코드 primary-subtle(blue-100)과 값이 다르고, status/*/subtle·strong은 코드에 토큰이 없다(코드 status-*는 */default).
+// gray/800(Solid Neutral 배경)·green/800·orange/800(아이콘)은 Figma도 원시 변수에 바로 묶었다
+const badgeVariants = cva(
+ // radius/sm 4px은 맞는 토큰이 없다(rounded-sm = 6px)
+ "inline-flex w-fit max-w-full shrink-0 items-center gap-1 overflow-hidden rounded-[4px] whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ weak: "",
+ solid: "",
+ // Figma도 Outline만 1px 테두리가 있어 폭이 2px 넓다. Weak·Solid에 투명 테두리를 두면 Figma보다 넓어진다
+ outline: "border",
+ },
+ tone: {
+ neutral: "",
+ brand: "",
+ informative: "",
+ positive: "",
+ warning: "",
+ critical: "",
+ },
+ // 아이콘은 Figma lg 14·md 12px이고, 글자 크기 설정을 따라 커지지 않게 px로 둔다(원칙 3-2)
+ size: {
+ lg: "min-h-6 px-2 text-label-s-regular [&>svg]:size-[14px]",
+ md: "min-h-5 px-1.5 text-label-xs-regular [&>svg]:size-[12px]",
+ },
+ },
+ // Weak·Outline의 Positive·Warning은 Figma에서 아이콘만 문구보다 한 단계 진하다
+ compoundVariants: [
+ { variant: "weak", tone: "neutral", className: "bg-fill text-text" },
+ { variant: "weak", tone: "brand", className: "bg-[var(--blue-50)] text-primary-strong" },
+ { variant: "weak", tone: "informative", className: "bg-fill text-primary-strong" },
+ { variant: "weak", tone: "positive", className: "bg-[var(--lime-100)] text-[var(--green-700)] [&>svg]:text-[var(--green-800)]" },
+ { variant: "weak", tone: "warning", className: "bg-[var(--orange-100)] text-[var(--orange-700)] [&>svg]:text-[var(--orange-800)]" },
+ { variant: "weak", tone: "critical", className: "bg-[var(--red-100)] text-[var(--red-800)]" },
+ { variant: "solid", tone: "neutral", className: "bg-[var(--gray-800)] text-text-inverse" },
+ { variant: "solid", tone: "brand", className: "bg-primary-strong text-text-inverse" },
+ // Figma도 Weak / Informative와 같은 색이다(5626:38·50)
+ { variant: "solid", tone: "informative", className: "bg-fill text-primary-strong" },
+ { variant: "solid", tone: "positive", className: "bg-[var(--green-700)] text-text-inverse" },
+ { variant: "solid", tone: "warning", className: "bg-[var(--orange-700)] text-text-inverse" },
+ { variant: "solid", tone: "critical", className: "bg-[var(--red-800)] text-text-inverse" },
+ { variant: "outline", tone: "neutral", className: "border-text text-text" },
+ { variant: "outline", tone: "brand", className: "border-primary-strong text-primary-strong" },
+ { variant: "outline", tone: "informative", className: "border-border text-primary-strong" },
+ { variant: "outline", tone: "positive", className: "border-[var(--green-700)] text-[var(--green-700)] [&>svg]:text-[var(--green-800)]" },
+ { variant: "outline", tone: "warning", className: "border-[var(--orange-700)] text-[var(--orange-700)] [&>svg]:text-[var(--orange-800)]" },
+ { variant: "outline", tone: "critical", className: "border-[var(--red-800)] text-[var(--red-800)]" },
+ ],
+ defaultVariants: { variant: "weak", tone: "neutral", size: "lg" },
+ },
+);
+
+/**
+ * 아이콘(@/assets/icons)은 span으로 감싸지 않고 직계 자식으로 넣는다. 크기·색은 Badge의 [&>svg] 규칙이 정한다.
+ * 긴 라벨은 부모 폭에서 잘리기만 한다(Figma에 말줄임 정의가 없다). 말줄임표가 필요한 곳은 호출부가 라벨을 ``로 감싼다.
+ */
+function Badge({
+ className,
+ variant = "weak",
+ tone = "neutral",
+ size = "lg",
+ render,
+ ...props
+}: Readonly & VariantProps>) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">({ className: cn(badgeVariants({ variant, tone, size }), className) }, props),
+ render,
+ state: { slot: "badge", variant, tone, size },
+ });
+}
+
+export { Badge };
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 00000000..3e0fc4c4
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva } from "class-variance-authority";
+import { useId } from "react";
+
+import { Spinner } from "@/components/ui/spinner";
+import { cn } from "@/lib/utils";
+
+// Figma 01 Button: Button 5272:3019(Type × State) 안의 Button Base 5002:3814(Size)가 실제 모양을 그린다.
+// 상태는 DOM으로 그린다: Hover=:hover, Pressed=:active, Disabled=data-disabled, Loading=aria-busy. Focus는 Figma에 변형이 없어(6055:562) 다른 ui 부품과 같은 outline이다.
+// hover·active를 enabled:가 아니라 not-data-disabled:로 거는 이유: Loading은 focusableWhenDisabled라 native disabled가 아니어서 enabled:가 걸리고,
+// render로 div를 그리면 :enabled가 아예 걸리지 않는다. Base UI는 두 경우 모두 data-disabled를 단다.
+// Loading도 data-disabled라 비활성 색은 not-aria-busy:로 한정해 Loading 색과 겹치지 않게 한다. 비활성은 opacity 없이 Figma 색(6종 모두 fill/default + text/disabled, 5272:2989~3011)이다.
+// 아이콘–문구 간격은 Figma Label 좌우 padding 6px뿐이라(6055:576) 문구 양옆 6px을 버튼 padding에 더하고, 아이콘 쪽은 data-icon으로 6px을 뺀 뒤 gap 6px로 둔다
+const buttonVariants = cva(
+ "group/button relative inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap select-none transition-colors duration-120 ease-out not-data-disabled:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary motion-reduce:transition-none data-disabled:not-aria-busy:bg-fill data-disabled:not-aria-busy:text-text-disabled [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ solid: "",
+ weak: "",
+ // Figma 테두리는 바깥 1px(strokeAlign OUTSIDE)이지만 높이 안에 둔다. 높이는 같고 폭만 Figma보다 2px 넓다
+ outline: "border",
+ },
+ tone: {
+ neutral: "",
+ brand: "",
+ critical: "",
+ },
+ // Button Base Size(5002:3815·3822·3829·3836). 높이는 시안 값을 min-h로 두고(원칙 3-2) 세로 padding은 따로 주지 않는다.
+ // 가로 padding = Size padding + Label 6px. 아이콘은 크기·선 굵기 그대로 px다
+ size: {
+ // label/xl/emphasis 행간이 Figma 22·코드 토큰 24라 문구만 있으면 Figma 54px보다 2px 높다. 아이콘이 있을 때의 56px로 맞춘다.
+ // radius/lg 12px은 코드 rounded-lg(10px)와 달라 임의값이다
+ lg: "min-h-14 rounded-[12px] px-5.5 text-label-xl-emphasis has-data-[icon=inline-start]:ps-4 has-data-[icon=inline-end]:pe-4 [&_svg:not([class*='size-'])]:size-[24px]",
+ md: "min-h-12 rounded-md px-5 text-label-l-emphasis has-data-[icon=inline-start]:ps-3.5 has-data-[icon=inline-end]:pe-3.5 [&_svg:not([class*='size-'])]:size-[20px]",
+ sm: "min-h-9 rounded-md px-4 text-label-m-emphasis has-data-[icon=inline-start]:ps-2.5 has-data-[icon=inline-end]:pe-2.5 [&_svg:not([class*='size-'])]:size-[16px]",
+ xs: "min-h-8 rounded-md px-3.5 text-label-m-emphasis has-data-[icon=inline-start]:ps-2 has-data-[icon=inline-end]:pe-2 [&_svg:not([class*='size-'])]:size-[16px]",
+ },
+ },
+ // Figma 등록 Type 6종만 둔다(5864:9503 "Brand Weak·Critical Outline 등 미등록 조합을 자동 확장하지 않습니다"). 나머지 조합은 ButtonProps 타입이 막는다.
+ // 원시 변수로 쓰는 색: gray/700·800·900, blue/950, red/900, gray/300은 Figma도 원시 변수에 바로 묶었고, status/negative/strong(red/800)은 코드에 시맨틱 토큰이 없다
+ compoundVariants: [
+ // Neutral Solid: Enabled 5272:3018, Hover 3010, Pressed 2999, Loading 2993
+ {
+ variant: "solid",
+ tone: "neutral",
+ className:
+ "bg-[var(--gray-800)] text-text-inverse not-data-disabled:hover:bg-[var(--gray-700)] not-data-disabled:active:bg-[var(--gray-900)] aria-busy:bg-[var(--gray-700)]",
+ },
+ // Brand Solid: Enabled 5272:3001, Hover 2997, Pressed 3009, Loading 2994
+ {
+ variant: "solid",
+ tone: "brand",
+ className:
+ "bg-primary text-text-inverse not-data-disabled:hover:bg-primary-strong not-data-disabled:active:bg-[var(--blue-950)] aria-busy:bg-primary-strong",
+ },
+ // Critical Solid: Enabled 5272:3016, Hover 3003, Pressed 3000, Loading 3017
+ {
+ variant: "solid",
+ tone: "critical",
+ className:
+ "bg-status-negative text-text-inverse not-data-disabled:hover:bg-[var(--red-800)] not-data-disabled:active:bg-[var(--red-900)] aria-busy:bg-[var(--red-800)]",
+ },
+ // Neutral Weak: Enabled 5272:3015, Hover 3004, Pressed 3008, Loading 3006
+ {
+ variant: "weak",
+ tone: "neutral",
+ className:
+ "bg-fill text-text not-data-disabled:hover:bg-fill-strong not-data-disabled:active:bg-[var(--gray-300)] aria-busy:bg-fill-strong",
+ },
+ // Outline 배경은 투명이 아니라 fill/subtle이다(5864:9503). 테두리는 Hover에서만 border/hover이고 Pressed·Loading·Disabled는 border/default다.
+ // 마우스로 누르면 :hover도 함께 걸려서 Pressed 테두리를 다시 border/default로 둔다
+ // Brand Outline: Enabled 5272:3013, Hover 3005, Pressed 2998, Loading 3012
+ {
+ variant: "outline",
+ tone: "brand",
+ className:
+ "border-border bg-fill-subtle text-primary-strong not-data-disabled:hover:border-border-hover not-data-disabled:hover:bg-fill not-data-disabled:active:border-border not-data-disabled:active:bg-fill-strong aria-busy:bg-fill",
+ },
+ // Neutral Outline: Enabled 5272:3014, Hover 3002, Pressed 2996, Loading 2992
+ {
+ variant: "outline",
+ tone: "neutral",
+ className:
+ "border-border bg-fill-subtle text-text not-data-disabled:hover:border-border-hover not-data-disabled:hover:bg-fill not-data-disabled:active:border-border not-data-disabled:active:bg-fill-strong aria-busy:bg-fill",
+ },
+ ],
+ defaultVariants: { variant: "solid", tone: "neutral", size: "md" },
+ },
+);
+
+/**
+ * Figma 등록 Type 6종(5272:3019)만 받는다. 위계는 Solid(높음)·Weak(중간)·Outline(낮음)이고(5002:3793),
+ * Brand는 핵심 행동, Neutral은 일반 행동, Critical은 파괴적 행동이다(5864:9503).
+ * - solid: neutral(기본)·brand·critical
+ * - weak: neutral
+ * - outline: neutral·brand
+ */
+type ButtonTypeProps =
+ | { variant?: "solid"; tone?: "neutral" | "brand" | "critical" }
+ | { variant: "weak"; tone?: "neutral" }
+ | { variant: "outline"; tone?: "neutral" | "brand" };
+
+type ButtonProps = Omit &
+ ButtonTypeProps & {
+ /** 배치(폭·정렬·위치)에만 쓴다. 색·글자는 variant·tone·size로 고른다 */
+ className?: string;
+ /** Figma Size: lg 56 · md 48(기본, 대화상자 버튼) · sm 36(인라인 작은 버튼) · xs 32 */
+ size?: "lg" | "md" | "sm" | "xs";
+ /** true면 누를 수 없고(중복 실행 차단) 포커스는 남는다. 문구·아이콘은 자리만 남기고 가운데에 progress를 그린다 */
+ loading?: boolean;
+ /** loading 중 버튼의 접근성 이름. 화면에는 보이지 않는다(예: "저장 중") */
+ loadingLabel?: string;
+ };
+
+/**
+ * Base UI Button 위의 디자인 시스템 버튼. Base UI처럼 기본 type이 "button"이라 제출 버튼은 type="submit"을 적는다.
+ * 문구 옆 아이콘(@/assets/icons)은 직계 자식으로 넣고 data-icon="inline-start"·"inline-end"를 단다. 크기는 Size가 정한다.
+ * render는 링크가 아닌 요소(div 등)에만 쓰고 nativeButton={false}를 함께 준다. 이동은 Next Link를 쓴다(버튼 모양 링크는 이번 범위 밖).
+ * 아이콘만 있는 버튼은 쓰지 않는다(Icon Only 크기·필수 접근성 이름이 아직 없다).
+ * loading 중 접근성 이름은 loadingLabel이다. aria-labelledby를 loadingLabel span으로 바꾸므로 호출부 aria-label·aria-labelledby보다 우선하고, loading이 끝나면 호출부 값으로 돌아간다.
+ *
+ * @example
+ *
+ * }>취소
+ * 확인
+ *
+ */
+function Button({
+ className,
+ variant = "solid",
+ tone = "neutral",
+ size = "md",
+ loading = false,
+ loadingLabel = "처리 중",
+ disabled,
+ focusableWhenDisabled,
+ children,
+ "aria-labelledby": ariaLabelledBy,
+ ...props
+}: Readonly) {
+ const loadingLabelId = useId();
+
+ return (
+
+ {/* Loading 전후로 너비·높이를 유지한다(5002:3811). 문구·아이콘은 늘 이 span 안에 두어 loading이 바뀌어도 다시 마운트되지 않는다 */}
+