-
+
}
>
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",
)}
>
-
+
- {imageUrl ? (
+ {visibleUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- 동적 장소 사진 URL (원격 패턴 비고정)
diff --git a/src/components/chat/messages/PlaceShareCard.tsx b/src/components/chat/messages/PlaceShareCard.tsx
index 37b3ba96..e2051be1 100644
--- a/src/components/chat/messages/PlaceShareCard.tsx
+++ b/src/components/chat/messages/PlaceShareCard.tsx
@@ -7,10 +7,9 @@ import { useSessionUser } from "@/hooks/useSessionUser";
import { placePhotoUrlQueryKey } from "@/lib/place-photo-query";
import { getQueryClient } from "@/lib/query-client";
import { cn } from "@/lib/utils";
-import { chatMessageChrome } from "@/components/chat/chat-message-chrome";
import { resolveChatMessageTypography } from "@/components/chat/chat-typography";
import type { ChatMessage } from "@/types/chat";
-import { ChatMemberAvatarRing } from "@/components/chat/messages/ChatMemberAvatarRing";
+import { ChatSenderAvatar } from "@/components/chat/messages/ChatSenderAvatar";
export function PlaceShareCard({
message,
@@ -82,13 +81,7 @@ export function PlaceShareCard({
return (
-
+
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..5d2c006d 100644
--- a/src/components/map/Map.search-rank.test.tsx
+++ b/src/components/map/Map.search-rank.test.tsx
@@ -46,6 +46,7 @@ vi.mock("@/contexts/MobileViewContext", () => ({
}));
vi.mock("@/hooks/usePlacesSearch", () => ({
fetchPlacesPageWithPhotos: boundary.page,
+ fetchPlacesAreaPage: vi.fn(),
}));
vi.mock("@/lib/analytics/track", () => ({
AnalyticsEvents: { search: "view_search_results", viewPlace: "view_place" },
@@ -508,7 +509,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/Map.search-selection.test.tsx b/src/components/map/Map.search-selection.test.tsx
index 2b7ede63..9adf5e06 100644
--- a/src/components/map/Map.search-selection.test.tsx
+++ b/src/components/map/Map.search-selection.test.tsx
@@ -50,6 +50,7 @@ vi.mock("@/contexts/MobileViewContext", () => ({
}));
vi.mock("@/hooks/usePlacesSearch", () => ({
fetchPlacesPageWithPhotos: boundary.page,
+ fetchPlacesAreaPage: vi.fn(),
}));
vi.mock("@/lib/analytics/track", () => ({
AnalyticsEvents: { search: "search" },
diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx
index 3abf7dcd..78270004 100644
--- a/src/components/map/Map.tsx
+++ b/src/components/map/Map.tsx
@@ -39,6 +39,7 @@ import {
import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens";
import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth";
import { isMobileMapPathname } from "@/lib/mobile-view";
+import { settleSearchSnapshotAfterMove } from "@/lib/map-viewport-commit";
import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius";
import { useMapCenterStore } from "@/stores/map-center-store";
import { useSearchRecenterStore } from "@/stores/search-recenter-store";
@@ -194,6 +195,7 @@ function MapBootstrapSync({
mapCenter: { lat, lng },
zoom: mapZoom,
radiusMeters,
+ bounds: bounds?.toJSON() ?? null,
});
}, [map, ready, center.lat, center.lng, zoom, roomId, setMapCamera]);
@@ -291,7 +293,9 @@ function MapSearchResultPins() {
const selectedPlaceId = isMobileDevice && !selectedPlace ? searchSelectedId ?? "" : selectedPlace?.googlePlaceId?.trim() ?? "";
useEffect(() => {
- if (map && preview) panToWithinWorld(map, preview.location);
+ if (!map || !preview) return;
+ panToWithinWorld(map, preview.location);
+ return settleSearchSnapshotAfterMove(map);
}, [map, preview]);
// 전체 검색 결과가 준비되면 첫 추천 대신 실제 결과들이 보이도록 맞춘다.
@@ -305,6 +309,7 @@ function MapSearchResultPins() {
items.forEach((item) => bounds.extend(item.location));
map.fitBounds(bounds, 80);
}
+ return settleSearchSnapshotAfterMove(map);
}, [map, isMobileDevice, pinsFocus, kind, status, items]);
if (pinsFocus !== "search") return null;
@@ -396,6 +401,8 @@ export default function TripMap({
);
const [rating, setRating] = useState("all");
const [openNow, setOpenNow] = useState("all");
+ // 카테고리 검색이 실패하면 「현 위치에서 검색」 버튼이 「다시 시도」로 남는다
+ const [discoverFailed, setDiscoverFailed] = useState(false);
const [showBookmarkPins, setShowBookmarkPins] = useState(true);
// Text search takes over the map at request start, including autocomplete selection.
@@ -461,6 +468,7 @@ export default function TripMap({
mapCenter: { lat: center.lat, lng: center.lng },
zoom,
radiusMeters,
+ bounds,
});
const rid =
@@ -595,7 +603,7 @@ export default function TripMap({
{routeView ? : (
<>
-
+
@@ -649,7 +657,7 @@ export default function TripMap({
{!bootstrap.ready || routeView ? null : (
<>
- {planRouteMode ? null : }
+ {planRouteMode ? null : }
{isMobileDevice ? (
{onOpenRouteView ? (
@@ -770,7 +778,9 @@ function MobileSearchSelection() {
const lat = item?.location.lat;
const lng = item?.location.lng;
useEffect(() => {
- if (map && focus === "search" && !selectedPlace && lat != null && lng != null) panToWithinWorld(map, { lat, lng });
+ if (!map || focus !== "search" || selectedPlace || lat == null || lng == null) return;
+ panToWithinWorld(map, { lat, lng });
+ return settleSearchSnapshotAfterMove(map);
}, [map, focus, selectedPlace, selectedId, lat, lng]);
if (!item || focus !== "search" || selectedPlace) return null;
return
event.stop()}> ;
diff --git a/src/components/map/MapBookmarkPins.tsx b/src/components/map/MapBookmarkPins.tsx
index bc73ea4a..68ef26fe 100644
--- a/src/components/map/MapBookmarkPins.tsx
+++ b/src/components/map/MapBookmarkPins.tsx
@@ -1,8 +1,8 @@
"use client";
import { Bookmark } from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
-import { useQueries, useQueryClient } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useQueries } from "@tanstack/react-query";
import { AdvancedMarker } from "@vis.gl/react-google-maps";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
@@ -10,10 +10,7 @@ import { useAllRoomBookmarks, useBookmarkCategories } from "@/hooks/useRooms";
import { normalizeGooglePlaceResourceId } from "@/lib/maps";
import type { PlacePreview } from "@/lib/api/places";
-import {
- buildMapBookmarkPreviewPlan,
- settleMapBookmarkPreviewBatch,
-} from "./map-bookmark-preview-query";
+import { buildMapBookmarkPreviewPlan } from "./map-bookmark-preview-query";
import {
MAP_PIN_BORDER_STROKE,
MAP_PIN_BORDER_STROKE_WIDTH,
@@ -50,7 +47,6 @@ export function MapBookmarkPins({
enabled,
hiddenNormalizedPlaceIds,
}: MapBookmarkPinsProps) {
- const queryClient = useQueryClient();
const { selectedPlace, setSelectedPlace } = useSelectedPlace();
const { data: categories, isSuccess: categoriesReady } =
@@ -92,34 +88,11 @@ export function MapBookmarkPins({
return out;
}, [allBookmarks, categoryColorById, listsSettled]);
- const [settledPreviewIdsKey, setSettledPreviewIdsKey] = useState
(
- null,
- );
const previewPlan = useMemo(
- () =>
- buildMapBookmarkPreviewPlan(
- flattenedBookmarks,
- hiddenNormalizedPlaceIds,
- settledPreviewIdsKey,
- ),
- [flattenedBookmarks, hiddenNormalizedPlaceIds, settledPreviewIdsKey],
+ () => buildMapBookmarkPreviewPlan(flattenedBookmarks, hiddenNormalizedPlaceIds),
+ [flattenedBookmarks, hiddenNormalizedPlaceIds],
);
- const { visibleBookmarks, previewIdsKey } = previewPlan;
-
- useEffect(() => {
- if (!previewIdsKey.length) return;
-
- let cancelled = false;
- void settleMapBookmarkPreviewBatch(previewIdsKey, queryClient).then(
- (settledKey) => {
- if (!cancelled) setSettledPreviewIdsKey(settledKey);
- },
- );
-
- return () => {
- cancelled = true;
- };
- }, [previewIdsKey, queryClient]);
+ const { visibleBookmarks } = previewPlan;
const placeQueryDefs = useMemo(() => {
if (!roomId || !listsSettled || visibleBookmarks.length === 0) {
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/MapDiscoverPlaces.tsx b/src/components/map/MapDiscoverPlaces.tsx
index ef6de3b3..d8cf01df 100644
--- a/src/components/map/MapDiscoverPlaces.tsx
+++ b/src/components/map/MapDiscoverPlaces.tsx
@@ -63,6 +63,8 @@ export type MapDiscoverPlacesProps = {
selectedCategoryId: string | null;
rating: RatingValue;
openNow: OpenValue;
+ /** 검색이 실패하면 true, 새 검색을 시작하면 false. 바뀌지 않는 함수(useState의 set)를 넘긴다 */
+ onSearchFailedChange: (failed: boolean) => void;
};
function DiscoverMarkerPin({
@@ -115,6 +117,7 @@ export function MapDiscoverPlaces({
selectedCategoryId,
rating,
openNow,
+ onSearchFailedChange,
}: MapDiscoverPlacesProps) {
const map = useMap();
const placesLib = useMapsLibrary("places");
@@ -140,6 +143,7 @@ export function MapDiscoverPlaces({
writeEpoch: number,
) => {
const seq = ++searchSeqRef.current;
+ onSearchFailedChange(false);
if (!placesLib) {
if (
@@ -237,9 +241,10 @@ export function MapDiscoverPlaces({
return;
}
setMarkers([]);
+ onSearchFailedChange(true);
}
},
- [placesLib],
+ [placesLib, onSearchFailedChange],
);
/** Search가 맵 핀 소유 시 디스커버 마커 비우기·비행 응답 무효화 */
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.test.tsx b/src/components/map/MapPlaceSearch.test.tsx
index 63a13fd2..ffd51012 100644
--- a/src/components/map/MapPlaceSearch.test.tsx
+++ b/src/components/map/MapPlaceSearch.test.tsx
@@ -30,6 +30,7 @@ vi.mock("@/contexts/SelectedPlaceContext", () => ({
}));
vi.mock("@/hooks/usePlacesSearch", () => ({
fetchPlacesPageWithPhotos: boundary.page,
+ fetchPlacesAreaPage: vi.fn(),
}));
vi.mock("@/lib/analytics/track", () => ({
AnalyticsEvents: { search: "search" },
@@ -84,9 +85,7 @@ it("specific Details failure retry returns to that input with a fresh session an
(buttons[1] as HTMLButtonElement).click();
});
expect(usePlaceSearchStore.getState().status).toBe("error");
- const retry = [...host.querySelectorAll("button")].find(
- (button) => button.textContent === "다시 시도",
- )!;
+ const retry = host.querySelector('button[aria-label="다시 시도"]')!;
await act(async () => retry.click());
expect(boundary.page).not.toHaveBeenCalled();
expect(document.activeElement).toBe(input);
@@ -106,3 +105,21 @@ it("specific Details failure retry returns to that input with a fresh session an
expect(boundary.page).not.toHaveBeenCalled();
expect(usePlaceSearchStore.getState().selectedId).toBe("chosen");
});
+// 버튼으로 한 「현 위치에서 검색」의 결과 없음은 검색창 아래가 아니라 버튼 자리에서 알린다
+it("search here no results stay out of the search box", async () => {
+ useSessionStore.setState({ currentRoomId: "room" });
+ useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 } });
+ usePlaceSearchStore.getState().clear();
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ cleanup = async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ };
+ await act(async () => root.render( ));
+ await act(async () => usePlaceSearchStore.setState({ searchMode: "map_recenter", status: "done", items: [] }));
+ expect(host.textContent).not.toContain("검색 결과가 없어요");
+ await act(async () => usePlaceSearchStore.setState({ searchMode: "text" }));
+ expect(host.textContent).toContain("검색 결과가 없어요");
+});
diff --git a/src/components/map/MapPlaceSearch.tsx b/src/components/map/MapPlaceSearch.tsx
index d543bb1d..dd528959 100644
--- a/src/components/map/MapPlaceSearch.tsx
+++ b/src/components/map/MapPlaceSearch.tsx
@@ -2,10 +2,11 @@
import { useRef } from "react";
import { flushSync } from "react-dom";
+import { RefreshIcon } from "@/assets/icons";
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";
@@ -26,6 +27,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
const items = usePlaceSearchStore((s) => s.items);
const inputRef = useRef(null);
const confirmationKind = usePlaceSearchStore((s) => s.confirmationKind);
+ const searchMode = usePlaceSearchStore((s) => s.searchMode);
const query = usePlaceSearchStore((s) => s.query);
const focus = useMapPinsFocusStore((s) => s.focus);
const { selectedPlace } = useSelectedPlace();
@@ -44,8 +46,9 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
}
void confirm({ query, kind: "whole", session: null, generation: 0 });
};
- const hasFeedback = status === "pending" || status === "error" ||
- (status === "done" && !items.length);
+ // 「현 위치에서 검색」의 실패·결과 없음은 검색창이 아니라 그 버튼 자리에서 알린다(MapSearchHereButton)
+ const hasFeedback = status === "pending" || (searchMode === "text" &&
+ (status === "error" || (status === "done" && !items.length)));
const feedback = hasFeedback ? (
0} retry={retry} />
) : null;
@@ -63,7 +66,7 @@ export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
+ {/* 다시 검색하려고 검색창에 들어가면 추천 목록과 같은 자리라 안내를 숨긴다 */}
{feedback ? (
-
+
{feedback}
) : null}
@@ -101,12 +105,14 @@ function SearchFeedback({ status, error, hasItems, retry }: Readonly<{
hasItems: boolean;
retry: () => void;
}>) {
- if (status === "pending") return
;
+ if (status === "pending") return
;
if (status === "error") return (
-
-
{error}
-
- 다시 시도
+
+ {error}
+ {/* 아이콘은 20px, 누르는 영역은 44px */}
+
+
);
diff --git a/src/components/map/MapSearchHereButton.test.tsx b/src/components/map/MapSearchHereButton.test.tsx
new file mode 100644
index 00000000..2688237b
--- /dev/null
+++ b/src/components/map/MapSearchHereButton.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it } from "vitest";
+import { MapSearchHereButton } from "./MapSearchHereButton";
+import { useMapCenterStore } from "@/stores/map-center-store";
+import { usePlaceSearchStore } from "@/stores/place-search-store";
+import { useSearchRecenterStore } from "@/stores/search-recenter-store";
+
+let root: Root, host: HTMLDivElement;
+const searched = { center: { lat: 35.7, lng: 139.8 }, zoom: 15, radius: 1000 };
+
+beforeEach(async () => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ usePlaceSearchStore.getState().clear();
+ useMapCenterStore.setState({ mapCenter: searched.center, zoom: 15 });
+ useSearchRecenterStore.getState().setSearchSnapshot(searched);
+ host = document.createElement("div");
+ root = createRoot(host);
+ await act(async () => root.render( ));
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ useSearchRecenterStore.getState().clearSearchSnapshot();
+ useSearchRecenterStore.getState().clearDiscoverSnapshot();
+ usePlaceSearchStore.getState().clear();
+});
+
+const button = () => host.querySelector("button");
+const notice = () => host.querySelector('[role="status"]');
+const finishSearch = (state: Partial>) =>
+ act(async () => usePlaceSearchStore.setState(state));
+
+it("shows nothing while the map shows the searched area", () => {
+ expect(button()).toBeNull();
+});
+
+it("turns into 다시 시도 when search here fails and asks search here again", async () => {
+ await finishSearch({ searchMode: "map_recenter", status: "error" });
+ expect(button()?.textContent).toBe("다시 시도");
+ const before = useSearchRecenterStore.getState().searchRecenterRequestId;
+ await act(async () => button()!.click());
+ expect(useSearchRecenterStore.getState().searchRecenterRequestId).toBe(before + 1);
+});
+
+it("a search box failure leaves the button alone", async () => {
+ await finishSearch({ searchMode: "text", status: "error" });
+ expect(button()).toBeNull();
+});
+
+it("shows 검색 결과가 없어요 until closed, and again for the next search here", async () => {
+ await finishSearch({ searchMode: "map_recenter", status: "done", items: [] });
+ expect(notice()?.textContent).toBe("검색 결과가 없어요");
+ await act(async () => host.querySelector('button[aria-label="안내 닫기"]')!.click());
+ expect(notice()).toBeNull();
+ await act(async () => { usePlaceSearchStore.getState().invalidate(); });
+ await finishSearch({ searchMode: "map_recenter", status: "done", items: [] });
+ expect(notice()).not.toBeNull();
+});
+
+it("goes back to 현 위치에서 검색 when the map moves after a failure", async () => {
+ await finishSearch({ searchMode: "map_recenter", status: "error" });
+ await act(async () => useMapCenterStore.setState({ mapCenter: { lat: 35.8, lng: 139.8 } }));
+ expect(button()?.textContent).toBe("현 위치에서 검색");
+});
+
+// 카테고리 칩으로 찾은 뒤 「현 위치에서 검색」이 실패해도(결과 없음이 아니라 오류) 같은 자리에서 다시 시도한다
+it("turns into 다시 시도 when the category search fails and asks the category search again", async () => {
+ useSearchRecenterStore.getState().setDiscoverSnapshot({
+ ...searched,
+ bounds: { south: 35.69, west: 139.79, north: 35.71, east: 139.81 },
+ });
+ await act(async () => root.render( ));
+ expect(button()?.textContent).toBe("다시 시도");
+ const before = useSearchRecenterStore.getState().discoverRecenterRequestId;
+ await act(async () => button()!.click());
+ expect(useSearchRecenterStore.getState().discoverRecenterRequestId).toBe(before + 1);
+});
+
+it("goes back to 현 위치에서 검색 when the map moves after a category failure", async () => {
+ useSearchRecenterStore.getState().setDiscoverSnapshot({
+ ...searched,
+ bounds: { south: 35.69, west: 139.79, north: 35.71, east: 139.81 },
+ });
+ await act(async () => root.render( ));
+ await act(async () => useMapCenterStore.setState({ mapCenter: { lat: 35.8, lng: 139.8 } }));
+ expect(button()?.textContent).toBe("현 위치에서 검색");
+});
diff --git a/src/components/map/MapSearchHereButton.tsx b/src/components/map/MapSearchHereButton.tsx
index bb16838e..f4c14cb8 100644
--- a/src/components/map/MapSearchHereButton.tsx
+++ b/src/components/map/MapSearchHereButton.tsx
@@ -1,15 +1,25 @@
"use client";
-import { SearchHereFloatingButton } from "@/components/map/SearchHereFloatingButton";
+import { useState } from "react";
+import { CloseIcon } from "@/assets/icons";
+import {
+ SearchHereFloatingButton,
+ searchHerePillClass,
+} from "@/components/map/SearchHereFloatingButton";
import { usePlacesSearchHereVisible } from "@/components/map/usePlacesSearchHereVisible";
+import { cn } from "@/lib/utils";
+import { usePlaceSearchStore } from "@/stores/place-search-store";
import { useSearchRecenterStore } from "@/stores/search-recenter-store";
export type MapSearchHereButtonProps = {
discoverCategoryId: string | null;
+ /** 카테고리 검색이 실패함 — 버튼이 「다시 시도」로 남는다 */
+ discoverFailed: boolean;
};
export function MapSearchHereButton({
discoverCategoryId,
+ discoverFailed,
}: MapSearchHereButtonProps) {
const requestSearchRecenter = useSearchRecenterStore(
(s) => s.requestSearchRecenter,
@@ -17,21 +27,54 @@ export function MapSearchHereButton({
const requestDiscoverRecenter = useSearchRecenterStore(
(s) => s.requestDiscoverRecenter,
);
- const { visible, suggestSearchRecenter, suggestDiscoverRecenter } =
- usePlacesSearchHereVisible({ discoverCategoryId });
+ const generation = usePlaceSearchStore((s) => s.generation);
+ // 닫은 「검색 결과가 없어요」는 그 검색 동안만 닫혀 있다(새 검색은 generation이 바뀐다)
+ const [closedGeneration, setClosedGeneration] = useState(null);
+ const {
+ visible,
+ suggestSearchRecenter,
+ searchRecenterNotice,
+ suggestDiscoverRecenter,
+ retryDiscoverRecenter,
+ } = usePlacesSearchHereVisible({ discoverCategoryId, discoverFailed });
+ const showEmptyNotice =
+ searchRecenterNotice === "empty" && closedGeneration !== generation;
+
+ if (!visible && !showEmptyNotice) return null;
return (
- {
- if (suggestSearchRecenter) {
- requestSearchRecenter();
- return;
- }
- if (suggestDiscoverRecenter) {
- requestDiscoverRecenter();
- }
- }}
- />
+
+ {showEmptyNotice ? (
+
+ 검색 결과가 없어요
+ {/* 아이콘은 16px, 누르는 영역은 32×44px(세로는 음수 margin으로 알약 높이 유지) */}
+ setClosedGeneration(generation)}
+ className="-my-3 grid h-11 w-8 shrink-0 cursor-pointer place-items-center text-primary focus-visible:outline-2 focus-visible:outline-primary"
+ >
+
+
+
+ ) : (
+
{
+ if (suggestSearchRecenter) {
+ requestSearchRecenter();
+ return;
+ }
+ if (suggestDiscoverRecenter) {
+ requestDiscoverRecenter();
+ }
+ }}
+ />
+ )}
+
);
}
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/MobileMapSearch.tsx b/src/components/map/MobileMapSearch.tsx
index a8eb89ae..ad972197 100644
--- a/src/components/map/MobileMapSearch.tsx
+++ b/src/components/map/MobileMapSearch.tsx
@@ -61,6 +61,7 @@ export function MobileMapSearch({
usePlaceSearchStore.setState({
query: entry.name,
confirmationKind: "specific",
+ searchMode: "text",
status: "pending",
error: null,
});
@@ -89,7 +90,6 @@ export function MobileMapSearch({
expanded: false,
status: "done",
error: null,
- fallback: false,
termination: "not-requested",
resolution: null,
query: item.name,
diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx
index 7b116e40..5d7ed6f5 100644
--- a/src/components/map/PlanItineraryMapRoutes.tsx
+++ b/src/components/map/PlanItineraryMapRoutes.tsx
@@ -16,9 +16,12 @@ import {
decodeOrientedPlanItinerarySegmentPath,
normalizeGooglePlaceResourceId,
} from "@/lib/maps";
-import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery";
+import {
+ loadScheduleItemRouteForMap,
+ persistedScheduleItemRouteQueryOptions,
+} from "@/lib/plan/scheduleItemRoutePersistedQuery";
import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage";
-import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces";
+import { schedulePlanPlacesQueryOptions } from "@/lib/plan/scheduleItemPlaces";
import {
scheduleIdsToRouteColors,
sortedScheduleIdsForRouteColors,
@@ -30,14 +33,7 @@ import {
import {
flattenPlanItinerarySegmentsFromPlaces,
} from "@/lib/plan/planItineraryMapSegments";
-import {
- planItinerarySegmentMapPathQueryKey,
- scheduleItemsQueryKey,
-} from "@/lib/query-keys";
-import {
- planMapSegmentEpochStoreKey,
- usePlanMapDirectionsEpochStore,
-} from "@/stores/plan-map-directions-epoch-store";
+import { planItinerarySegmentMapPathQueryKey } from "@/lib/query-keys";
import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store";
import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store";
import { useSessionStore } from "@/stores/session-store";
@@ -49,7 +45,6 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin";
* 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다.
* `routeDay`가 있으면(모바일 경로 보기) 토글과 관계없이 그 일차만 그립니다.
* 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다.
- * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고.
*/
/** 모바일 경로 보기 — 고른 일차 하나만 그린다 */
export type PlanItineraryRouteDay = {
@@ -116,13 +111,6 @@ export function PlanItineraryMapRoutes({
[singleDay, singleDayScheduleId, visibleByScheduleId],
);
- const directionsEpoch = usePlanMapDirectionsEpochStore((s) =>
- rid.length > 0 ? (s.epochByRoomId[rid] ?? 0) : 0,
- );
- const epochBySegmentKey = usePlanMapDirectionsEpochStore(
- (s) => s.epochBySegmentKey,
- );
-
const orderedScheduleIdsForQueries = useMemo(
() => [...visibleScheduleIds].sort((a, b) => a - b),
[visibleScheduleIds],
@@ -143,14 +131,11 @@ export function PlanItineraryMapRoutes({
const placesQueries = useQueries({
queries: orderedScheduleIdsForQueries.map((scheduleId) => ({
- queryKey: scheduleItemsQueryKey(rid || null, scheduleId),
- queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId),
+ ...schedulePlanPlacesQueryOptions(queryClient, rid, scheduleId),
enabled:
rid.length > 0 &&
orderedScheduleIdsForQueries.length > 0 &&
schedulesHydrated,
- staleTime: Infinity,
- refetchOnMount: false,
refetchOnWindowFocus: false,
})),
});
@@ -176,54 +161,43 @@ export function PlanItineraryMapRoutes({
);
const pathsPerSegmentQueries = useQueries({
- queries: segments.map((seg) => {
- const epochKey = planMapSegmentEpochStoreKey(
+ queries: segments.map((seg) => ({
+ queryKey: planItinerarySegmentMapPathQueryKey(
rid,
seg.scheduleId,
seg.segmentSourceItemId,
- );
- const segmentEpoch = epochBySegmentKey[epochKey] ?? 0;
-
- return {
- queryKey: planItinerarySegmentMapPathQueryKey(
- rid,
- seg.scheduleId,
- seg.segmentSourceItemId,
- seg.originPlaceId,
- seg.destPlaceId,
- seg.travelModeCanon,
- directionsEpoch,
- segmentEpoch,
- ),
- /**
- * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. `fetchQuery`에 맡겨야
- * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다.
- */
- queryFn: async () => {
- const route = await queryClient.fetchQuery(
- persistedScheduleItemRouteQueryOptions(
- rid,
- seg.scheduleId,
- seg.segmentSourceItemId,
- seg.travelModeCanon,
- fingerprintByScheduleId.get(seg.scheduleId) ?? "",
- { segmentReady: true },
- ),
- );
- return {
- path: await decodeOrientedPlanItinerarySegmentPath(
- seg,
- route?.encodedPolyline,
- ),
- };
- },
- enabled: rid.length > 0 && segments.length > 0,
- staleTime: Infinity,
- refetchOnMount: false,
- refetchOnWindowFocus: false,
- retry: false,
- };
- }),
+ seg.destinationItemId,
+ seg.originPlaceId,
+ seg.destPlaceId,
+ seg.travelModeCanon,
+ ),
+ /** 구간 경로는 일정 리스트와 같은 쿼리로 가져와, 같은 구간을 받는 중이면 그 요청에 합류한다 */
+ queryFn: async () => {
+ const route = await loadScheduleItemRouteForMap(
+ queryClient,
+ persistedScheduleItemRouteQueryOptions(
+ rid,
+ seg.scheduleId,
+ seg.segmentSourceItemId,
+ seg.destinationItemId,
+ seg.travelModeCanon,
+ fingerprintByScheduleId.get(seg.scheduleId) ?? "",
+ { segmentReady: true },
+ ),
+ );
+ return {
+ path: await decodeOrientedPlanItinerarySegmentPath(
+ seg,
+ route?.encodedPolyline,
+ ),
+ };
+ },
+ enabled: rid.length > 0 && segments.length > 0,
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })),
});
const polylines: Array = [];
@@ -231,13 +205,8 @@ export function PlanItineraryMapRoutes({
const query = pathsPerSegmentQueries[segIdx];
if (!query || query.isPending || query.isFetching) return;
const pts = query.data?.path ?? [];
-
- const segEpochKey = planMapSegmentEpochStoreKey(
- rid,
- seg.scheduleId,
- seg.segmentSourceItemId,
- );
- const segEpoch = epochBySegmentKey[segEpochKey] ?? 0;
+ // 도착 항목이 바뀌면 다른 선이라 새로 그린다
+ const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destinationItemId}-${seg.travelModeCanon}`;
const routeColor =
routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke;
@@ -245,7 +214,7 @@ export function PlanItineraryMapRoutes({
if (pts.length) {
polylines.push(
{
polylines.push(
const focusPlace = usePlanMapFocusStore((s) => s.focusPlace);
const scheduleId = focus?.scheduleId ?? null;
- // 지도 경로(`PlanItineraryMapRoutes`)와 같은 일정 항목 캐시를 읽는다 — 따로 조회하지 않는다
+ // 일정 목록·지도 경로(`PlanItineraryMapRoutes`)와 같은 일차 조회다 — 캐시가 있으면 따로 받지 않는다
const { data: places = [] } = useQuery({
- queryKey: scheduleItemsQueryKey(rid || null, scheduleId),
- queryFn: () => (scheduleId === null ? [] : readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId)),
+ ...schedulePlanPlacesQueryOptions(queryClient, rid, scheduleId),
enabled: rid.length > 0 && scheduleId !== null && schedulesHydrated,
- staleTime: Infinity,
- refetchOnMount: false,
refetchOnWindowFocus: false,
});
diff --git a/src/components/map/SearchHereFloatingButton.tsx b/src/components/map/SearchHereFloatingButton.tsx
index 5ece72c0..f7d898b0 100644
--- a/src/components/map/SearchHereFloatingButton.tsx
+++ b/src/components/map/SearchHereFloatingButton.tsx
@@ -1,32 +1,33 @@
"use client";
import { RefreshCw } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/** 지도 하단 「현 위치에서 검색」 알약 모양. 같은 자리의 「검색 결과가 없어요」 안내도 이 모양을 쓴다 */
+export const searchHerePillClass =
+ "pointer-events-auto inline-flex items-center gap-1 rounded-full border-3 border-primary bg-white px-3 py-2 text-label-m-emphasis mobile:text-label-s-emphasis font-semibold text-dark-gray shadow-md";
type SearchHereFloatingButtonProps = {
- visible: boolean;
+ label: string;
onPress: () => void;
};
export function SearchHereFloatingButton({
- visible,
+ label,
onPress,
}: SearchHereFloatingButtonProps) {
- if (!visible) return null;
-
return (
-
-
-
- 현 위치에서 검색
-
-
+
+
+ {label}
+
);
}
diff --git a/src/components/map/map-bookmark-preview-query.test.ts b/src/components/map/map-bookmark-preview-query.test.ts
index 66210e16..9c9aef30 100644
--- a/src/components/map/map-bookmark-preview-query.test.ts
+++ b/src/components/map/map-bookmark-preview-query.test.ts
@@ -1,98 +1,27 @@
import { describe, expect, it, vi } from "vitest";
-import { QueryClient } from "@tanstack/react-query";
vi.mock("@/lib/places/place-queries", () => ({
placePreviewQueryOptions: (googlePlaceId: string) => ({
queryKey: ["places", "preview", googlePlaceId],
queryFn: async () => ({ googlePlaceId }),
enabled: googlePlaceId.trim().length > 0,
- staleTime: 60_000,
- refetchOnWindowFocus: false,
- retry: 1,
}),
}));
-vi.mock("@/lib/places/place-batch-cache", () => ({
- fetchAndSeedPlacePreviews: vi.fn(),
-}));
-
-import {
- buildMapBookmarkPreviewPlan,
- mapBookmarkPreviewQueryOptions,
- settleMapBookmarkPreviewBatch,
-} from "./map-bookmark-preview-query";
-
-describe("mapBookmarkPreviewQueryOptions", () => {
- it("벌크 preview 시딩이 끝나기 전에는 개별 preview 요청을 비활성화한다", () => {
- const options = mapBookmarkPreviewQueryOptions("ChIJ-bookmark", false);
-
- expect(options.enabled).toBe(false);
- });
-
- it("벌크 preview 시딩이 끝난 뒤에는 캐시 miss 개별 조회를 허용한다", () => {
- const options = mapBookmarkPreviewQueryOptions("ChIJ-bookmark", true);
-
- expect(options.enabled).toBe(true);
- });
+import { buildMapBookmarkPreviewPlan } from "./map-bookmark-preview-query";
- it("숨길 북마크를 제외하고 벌크가 끝날 때까지 개별 조회를 차단한다", () => {
+describe("buildMapBookmarkPreviewPlan", () => {
+ it("숨길 북마크를 빼고 남은 북마크의 미리보기 쿼리를 바로 연다", () => {
const rows = [
{ bookmarkId: 1, googlePlaceId: "places/ChIJ-hidden" },
{ bookmarkId: 2, googlePlaceId: "ChIJ-visible" },
];
- const pendingPlan = buildMapBookmarkPreviewPlan(
- rows,
- new Set(["ChIJ-hidden"]),
- null,
- );
+ const plan = buildMapBookmarkPreviewPlan(rows, new Set(["ChIJ-hidden"]));
- expect(pendingPlan.visibleBookmarks).toEqual([rows[1]]);
- expect(pendingPlan.previewIdsKey).toBe("ChIJ-visible");
- expect(pendingPlan.queryOptions.map((options) => options.enabled)).toEqual([
- false,
+ expect(plan.visibleBookmarks).toEqual([rows[1]]);
+ expect(plan.queryOptions.map((options) => [options.queryKey, options.enabled])).toEqual([
+ [["places", "preview", "ChIJ-visible"], true],
]);
-
- const settledPlan = buildMapBookmarkPreviewPlan(
- rows,
- new Set(["ChIJ-hidden"]),
- pendingPlan.previewIdsKey,
- );
- expect(settledPlan.queryOptions.map((options) => options.enabled)).toEqual([
- true,
- ]);
- });
-
- it("현재 ID 벌크가 정착한 뒤에만 해당 key를 반환하고 실패해도 fallback을 연다", async () => {
- const queryClient = new QueryClient();
- let resolveBatch: (() => void) | undefined;
- const loader = vi.fn(
- () =>
- new Promise((resolve) => {
- resolveBatch = resolve;
- }),
- );
-
- const settlement = settleMapBookmarkPreviewBatch(
- "ChIJ-a\u0000ChIJ-b",
- queryClient,
- loader,
- );
- let settled = false;
- void settlement.then(() => {
- settled = true;
- });
-
- await Promise.resolve();
- expect(loader).toHaveBeenCalledWith(["ChIJ-a", "ChIJ-b"], queryClient);
- expect(settled).toBe(false);
-
- resolveBatch?.();
- await expect(settlement).resolves.toBe("ChIJ-a\u0000ChIJ-b");
-
- const failedLoader = vi.fn().mockRejectedValue(new Error("batch failed"));
- await expect(
- settleMapBookmarkPreviewBatch("ChIJ-c", queryClient, failedLoader),
- ).resolves.toBe("ChIJ-c");
});
});
diff --git a/src/components/map/map-bookmark-preview-query.ts b/src/components/map/map-bookmark-preview-query.ts
index 2bc8891d..ae2170c4 100644
--- a/src/components/map/map-bookmark-preview-query.ts
+++ b/src/components/map/map-bookmark-preview-query.ts
@@ -1,68 +1,24 @@
-import type { QueryClient } from "@tanstack/react-query";
-
-import { fetchAndSeedPlacePreviews } from "@/lib/places/place-batch-cache";
import { placePreviewQueryOptions } from "@/lib/places/place-queries";
import { filterVisibleMapBookmarks } from "./filter-visible-map-bookmarks";
-const PREVIEW_IDS_KEY_SEPARATOR = "\u0000";
-
type MapBookmarkRow = {
googlePlaceId: string;
};
-type PreviewBatchLoader = (
- googlePlaceIds: readonly string[],
- queryClient?: QueryClient | null,
-) => Promise;
-
-export function mapBookmarkPreviewQueryOptions(
- googlePlaceId: string,
- batchSettled: boolean,
-) {
- const options = placePreviewQueryOptions(googlePlaceId);
- return {
- ...options,
- enabled: batchSettled && options.enabled,
- };
-}
-
+/** 핀으로 그릴 북마크와 그 미리보기 쿼리 — 같은 커밋에 마운트된 미리보기 요청은 batch 하나로 모인다 */
export function buildMapBookmarkPreviewPlan(
rows: readonly T[],
hiddenNormalizedPlaceIds: ReadonlySet | undefined,
- settledPreviewIdsKey: string | null,
) {
const visibleBookmarks = filterVisibleMapBookmarks(
rows,
hiddenNormalizedPlaceIds,
);
- const previewIds = visibleBookmarks.map((row) => row.googlePlaceId);
- const previewIdsKey = previewIds.join(PREVIEW_IDS_KEY_SEPARATOR);
- const batchSettled =
- previewIds.length === 0 || settledPreviewIdsKey === previewIdsKey;
-
return {
visibleBookmarks,
- previewIdsKey,
queryOptions: visibleBookmarks.map((row) =>
- mapBookmarkPreviewQueryOptions(row.googlePlaceId, batchSettled),
+ placePreviewQueryOptions(row.googlePlaceId),
),
};
}
-
-export async function settleMapBookmarkPreviewBatch(
- previewIdsKey: string,
- queryClient: QueryClient,
- loader: PreviewBatchLoader = fetchAndSeedPlacePreviews,
-): Promise {
- const previewIds = previewIdsKey.length
- ? previewIdsKey.split(PREVIEW_IDS_KEY_SEPARATOR)
- : [];
-
- try {
- await loader(previewIds, queryClient);
- } catch {
- // 벌크 실패 시 key를 정착시켜 React Query의 개별 조회 fallback을 허용합니다.
- }
- return previewIdsKey;
-}
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/map/useMapPlaceSearch.test.tsx b/src/components/map/useMapPlaceSearch.test.tsx
index 0eb27186..fd2ccb51 100644
--- a/src/components/map/useMapPlaceSearch.test.tsx
+++ b/src/components/map/useMapPlaceSearch.test.tsx
@@ -12,8 +12,10 @@ import { registerQueryClient } from "@/lib/query-client";
import { placeDetailQueryKey } from "@/lib/places/place-queries";
import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store";
import { useSessionStore } from "@/stores/session-store";
+import { useSearchRecenterStore } from "@/stores/search-recenter-store";
const network = vi.hoisted(() => ({
page: vi.fn(),
+ area: vi.fn(),
detail: vi.fn(),
select: vi.fn(),
track: vi.fn(),
@@ -22,6 +24,7 @@ const network = vi.hoisted(() => ({
}));
vi.mock("@/hooks/usePlacesSearch", () => ({
fetchPlacesPageWithPhotos: network.page,
+ fetchPlacesAreaPage: network.area,
}));
vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() }));
vi.mock("@/contexts/SelectedPlaceContext", () => ({
@@ -70,6 +73,7 @@ beforeEach(async () => {
if (String(url).endsWith("/places/resolve")) return Response.json({ detail: rawDetail, source: "GOOGLE", cacheStatus: "MISS", googleDetailsRequested: true, termination: "SUCCEEDED" });
throw new Error(`Unexpected HTTP ${String(url)}`);
});
+ network.area.mockReset().mockResolvedValue({ items: [], nextPageToken: null });
network.page.mockReset().mockResolvedValue({
items: [
{
@@ -95,7 +99,7 @@ beforeEach(async () => {
network.track.mockReset();
useSessionStore.setState({ currentRoomId: "room" });
usePlaceSearchStore.getState().clear();
- useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12 });
+ useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 12, radiusMeters: null, bounds: null });
host = document.createElement("div");
root = createRoot(host);
await act(async () => root.render( ));
@@ -110,9 +114,11 @@ it("web whole search publishes only Text Search pins without selecting a detail
expect(usePlaceSearchStore.getState().selectedId).toBeNull();
expect(usePlaceSearchStore.getState().items.map(x => x.googlePlaceId)).toEqual(["other"]);
expect(usePlaceSearchStore.getState().items[0].rating).toBe(4.2);
- expect(client.getQueryData(placeDetailQueryKey("first"))).toEqual(rawDetail);
+ expect(network.page).toHaveBeenCalledWith(expect.objectContaining({ latitude: 1, longitude: 2, radius: 5000 }));
+ expect(vi.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/places/resolve"))).toHaveLength(0);
+ expect(client.getQueryData(placeDetailQueryKey("first"))).toBeUndefined();
});
-it("mobile whole search keeps cards collapsed and reuses resolved full detail without GET supplement", async () => {
+it("mobile whole search keeps cards collapsed without detail requests", async () => {
await act(async () => root.render( ));
await act(async () => {
await api.confirm(confirmation());
@@ -139,6 +145,40 @@ it("mobile specific reuses chosen resolved detail without Text Search", async ()
expect.objectContaining({ analyticsSource: "search", itinerarySource: "search" }),
);
});
+// 센소지를 찾은 뒤 지도를 옮겨 재검색할 때 위치 우선(bias)으로 찾으면 Google이 센소지를 다시 돌려줬다
+it.each([
+ ["shows what Google finds inside the visible map", ["inside"]],
+ ["shows no results when Google finds nothing inside the visible map", []],
+] as const)("search here asks Google for the visible map only and %s", async (_, expected) => {
+ await act(async () => { await api.confirm(confirmation()); });
+ const bounds = { south: 0, west: 1, north: 2, east: 3 };
+ useMapCenterStore.setState({ mapCenter: { lat: 1, lng: 2 }, zoom: 15, radiusMeters: 1234, bounds });
+ const inside = { googlePlaceId: "inside", name: "화면 안", location: { lat: 1.5, lng: 2.5 } };
+ network.area.mockResolvedValueOnce({ items: expected.length ? [inside] : [], nextPageToken: null });
+ await act(async () => useSearchRecenterStore.getState().requestSearchRecenter());
+ await vi.waitFor(() => expect(usePlaceSearchStore.getState().status).toBe("done"));
+ expect(usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId)).toEqual(expected);
+ expect(network.area).toHaveBeenCalledExactlyOnceWith({ query: "원래 검색어", area: bounds, pageSize: expect.any(Number) });
+ expect(network.page).toHaveBeenCalledTimes(1);
+});
+it("search box failure shows one message instead of the raw error", async () => {
+ network.page.mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ await act(async () => { await api.confirm(confirmation()); });
+ expect(usePlaceSearchStore.getState()).toMatchObject({ status: "error", error: "검색에 실패했어요.", searchMode: "text" });
+});
+// 「현 위치에서 검색」 실패는 검색창 문구 없이 그 버튼이 「다시 시도」로 알리고, 보이던 결과는 그대로 둔다
+it("search here failure keeps the shown results and leaves the message to the button", async () => {
+ await act(async () => { await api.confirm(confirmation()); });
+ useMapCenterStore.setState({
+ mapCenter: { lat: 1, lng: 2 }, zoom: 15, radiusMeters: 1234,
+ bounds: { south: 0, west: 1, north: 2, east: 3 },
+ });
+ network.area.mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ await act(async () => useSearchRecenterStore.getState().requestSearchRecenter());
+ await vi.waitFor(() => expect(usePlaceSearchStore.getState().status).toBe("error"));
+ expect(usePlaceSearchStore.getState()).toMatchObject({ searchMode: "map_recenter", error: null });
+ expect(usePlaceSearchStore.getState().items.map((x) => x.googlePlaceId)).toEqual(["other"]);
+});
it("editing input suppresses a late response and analytics", async () => {
let finish!: (v: unknown) => void;
network.page.mockReturnValue(
diff --git a/src/components/map/useMapPlaceSearch.ts b/src/components/map/useMapPlaceSearch.ts
index 85b22916..98875cc6 100644
--- a/src/components/map/useMapPlaceSearch.ts
+++ b/src/components/map/useMapPlaceSearch.ts
@@ -4,6 +4,7 @@ import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { useSessionUser } from "@/hooks/useSessionUser";
import {
+ fetchPlacesAreaPage,
fetchPlacesPageWithPhotos,
type PlaceSearchResult,
} from "@/hooks/usePlacesSearch";
@@ -21,6 +22,7 @@ import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { getQueryClient } from "@/lib/query-client";
import { placeDetailQueryKey } from "@/lib/places/place-queries";
import { PLACES_SEARCH_PAGE_SIZE } from "@/lib/places/placesSearchPageSize";
+import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius";
import {
confirmPlaceSearch,
type ResolvedSearchAnchor,
@@ -32,10 +34,15 @@ import {
useMapPinsFocusStore,
} from "@/stores/map-pins-focus-store";
import { useMobileMapSearchStore } from "@/stores/mobile-map-search-store";
-import { usePlaceSearchStore } from "@/stores/place-search-store";
+import {
+ usePlaceSearchStore,
+ type PlaceSearchMode,
+} from "@/stores/place-search-store";
import { useSearchRecenterStore } from "@/stores/search-recenter-store";
import { useSessionStore } from "@/stores/session-store";
+const SEARCH_BIAS_RADIUS_METERS = 5000;
+
export function useMapPlaceSearch(mobile: boolean) {
const roomId = useSessionStore((s) => s.currentRoomId);
const userId = useSessionUser().data?.id;
@@ -102,7 +109,7 @@ export function useMapPlaceSearch(mobile: boolean) {
function applySearchResult(
result: NonNullable>>>,
confirmation: SearchConfirmation,
- mode: "text" | "map_recenter",
+ mode: PlaceSearchMode,
) {
const searchRankBuckets: Record =
confirmation.kind === "whole"
@@ -126,7 +133,6 @@ export function useMapPlaceSearch(mobile: boolean) {
searchRankBuckets,
selectedId,
status: "done",
- fallback: result.fallback,
termination: result.termination,
});
setActiveSearchMapPins(placeSearchResultsToMapPins(items));
@@ -155,7 +161,7 @@ export function useMapPlaceSearch(mobile: boolean) {
async function confirm(
confirmation: SearchConfirmation,
- mode: "text" | "map_recenter" = "text",
+ mode: PlaceSearchMode = "text",
): Promise {
const query = confirmation.query.trim();
if (!query) return false;
@@ -167,10 +173,14 @@ export function useMapPlaceSearch(mobile: boolean) {
usePlaceSearchStore.getState().scope === scopeKey &&
useSessionStore.getState().currentRoomId === roomId &&
searchPinWriteStillValid(epoch);
- const mapCenter = useMapCenterStore.getState().mapCenter;
+ const { mapCenter, radiusMeters, bounds } = useMapCenterStore.getState();
+ // 「현 위치에서 검색」은 Google에 지금 화면 사각형 안만 찾게 한다(locationRestriction).
+ // 위치 우선(locationBias)으로 찾으면 화면 밖 결과가 섞여 이동 전 장소로 지도가 되돌아간다.
+ const searchArea = mode === "map_recenter" ? bounds : null;
usePlaceSearchStore.setState({
query,
confirmationKind: confirmation.kind,
+ searchMode: mode,
status: "pending",
error: null,
expanded: false,
@@ -186,6 +196,10 @@ export function useMapPlaceSearch(mobile: boolean) {
const result = await confirmPlaceSearch({
...confirmation,
mapCenter,
+ // 화면 검색의 반경은 버튼이 다시 뜨는 이동 거리 기준(searchSnapshot)으로만 쓴다
+ radius: searchArea
+ ? clampPlacesSearchRadiusMeters(radiusMeters ?? SEARCH_BIAS_RADIUS_METERS)
+ : SEARCH_BIAS_RADIUS_METERS,
isCurrent,
onResolved: (resolved) => {
usePlaceSearchStore.setState({
@@ -204,11 +218,17 @@ export function useMapPlaceSearch(mobile: boolean) {
zoom: useMapCenterStore.getState().zoom,
radius: params.radius,
});
- const page = await fetchPlacesPageWithPhotos({
- ...params,
- pageSize: PLACES_SEARCH_PAGE_SIZE,
- pageToken: undefined,
- });
+ const page = searchArea
+ ? await fetchPlacesAreaPage({
+ query: params.query,
+ area: searchArea,
+ pageSize: PLACES_SEARCH_PAGE_SIZE,
+ })
+ : await fetchPlacesPageWithPhotos({
+ ...params,
+ pageSize: PLACES_SEARCH_PAGE_SIZE,
+ pageToken: undefined,
+ });
return page.items;
},
});
@@ -220,10 +240,7 @@ export function useMapPlaceSearch(mobile: boolean) {
usePlaceSearchStore.setState({
status: "error",
termination: failedTermination(confirmation),
- error:
- error instanceof Error
- ? error.message
- : "검색에 실패했어요. 다시 시도해주세요.",
+ error: searchFailureMessage(confirmation, mode, error),
});
return false;
}
@@ -275,6 +292,18 @@ function toAnchor(
};
}
+// 「현 위치에서 검색」 실패는 그 버튼이 「다시 시도」로 알리므로 검색창 문구가 없다.
+// 모두검색 실패의 원문(네트워크 오류면 "Failed to fetch")은 사용자에게 뜻이 없어 한 문구로 보여 준다
+function searchFailureMessage(
+ confirmation: SearchConfirmation,
+ mode: PlaceSearchMode,
+ error: unknown,
+) {
+ if (mode === "map_recenter") return null;
+ if (confirmation.kind === "whole") return "검색에 실패했어요.";
+ return error instanceof Error ? error.message : "검색에 실패했어요. 다시 시도해주세요.";
+}
+
function failedTermination(confirmation: SearchConfirmation) {
const termination = usePlaceSearchStore.getState().termination;
if (termination === "SUCCEEDED" || termination === "NOT_TERMINATED") return termination;
diff --git a/src/components/map/usePlacesSearchHereVisible.ts b/src/components/map/usePlacesSearchHereVisible.ts
index b57ea784..7bafd5c0 100644
--- a/src/components/map/usePlacesSearchHereVisible.ts
+++ b/src/components/map/usePlacesSearchHereVisible.ts
@@ -2,20 +2,38 @@
import { shouldSuggestPlacesSearchRecenter } from "@/lib/maps";
import { useMapCenterStore } from "@/stores/map-center-store";
+import { usePlaceSearchStore } from "@/stores/place-search-store";
import type { DiscoverMapSnapshot } from "@/stores/search-recenter-store";
import { useSearchRecenterStore } from "@/stores/search-recenter-store";
import type { MapCenterCoords } from "@/stores/map-center-store";
type UsePlacesSearchHereVisibleParams = {
discoverCategoryId: string | null;
+ /** 카테고리 검색(MapDiscoverPlaces)이 실패함 */
+ discoverFailed: boolean;
};
+/** 「현 위치에서 검색」 결과를 버튼 자리에서 알린다: 실패는 「다시 시도」, 결과 0개는 「검색 결과가 없어요」 */
+export type SearchRecenterNotice = "failed" | "empty";
+
export type PlacesSearchHereVisibility = {
visible: boolean;
suggestSearchRecenter: boolean;
+ searchRecenterNotice: SearchRecenterNotice | null;
suggestDiscoverRecenter: boolean;
+ /** 실패한 카테고리 검색을 같은 화면에서 다시 시도 */
+ retryDiscoverRecenter: boolean;
};
+function searchRecenterNoticeOf(
+ status: "idle" | "pending" | "done" | "error",
+ hasResults: boolean,
+): SearchRecenterNotice | null {
+ if (status === "error") return "failed";
+ if (status === "done" && !hasResults) return "empty";
+ return null;
+}
+
function chipsDiscoverRecenterEligible(
discoverCategoryId: string | null,
discoverSnapshot: DiscoverMapSnapshot | null,
@@ -45,21 +63,27 @@ function chipsDiscoverRecenterEligible(
*/
export function usePlacesSearchHereVisible({
discoverCategoryId,
+ discoverFailed,
}: UsePlacesSearchHereVisibleParams): PlacesSearchHereVisibility {
const mapCenter = useMapCenterStore((s) => s.mapCenter);
const zoom = useMapCenterStore((s) => s.zoom);
const searchSnapshot = useSearchRecenterStore((s) => s.searchSnapshot);
const discoverSnapshot = useSearchRecenterStore((s) => s.discoverSnapshot);
+ const searchMode = usePlaceSearchStore((s) => s.searchMode);
+ const searchStatus = usePlaceSearchStore((s) => s.status);
+ const hasResults = usePlaceSearchStore((s) => s.items.length > 0);
if (!mapCenter) {
return {
visible: false,
suggestSearchRecenter: false,
+ searchRecenterNotice: null,
suggestDiscoverRecenter: false,
+ retryDiscoverRecenter: false,
};
}
- const suggestSearchRecenter =
+ const movedFromSearch =
searchSnapshot != null
&& shouldSuggestPlacesSearchRecenter({
mapCenter,
@@ -68,17 +92,30 @@ export function usePlacesSearchHereVisible({
zoom,
snapshotZoom: searchSnapshot.zoom,
});
+ // 실패·결과 없음은 검색한 그 화면에서만 알린다. 지도를 옮기면 새 화면 검색이라 「현 위치에서 검색」으로 돌아간다
+ const searchRecenterNotice =
+ movedFromSearch || searchMode !== "map_recenter"
+ ? null
+ : searchRecenterNoticeOf(searchStatus, hasResults);
+ const suggestSearchRecenter =
+ movedFromSearch || searchRecenterNotice === "failed";
- const suggestDiscoverRecenter = chipsDiscoverRecenterEligible(
+ const movedFromDiscover = chipsDiscoverRecenterEligible(
discoverCategoryId,
discoverSnapshot,
mapCenter,
zoom,
);
+ // 카테고리 검색이 실패하면 그 화면에서 「다시 시도」, 지도를 옮기면 새 화면 검색이라 「현 위치에서 검색」
+ const retryDiscoverRecenter =
+ discoverFailed && discoverSnapshot != null && !movedFromDiscover;
+ const suggestDiscoverRecenter = movedFromDiscover || retryDiscoverRecenter;
return {
visible: suggestSearchRecenter || suggestDiscoverRecenter,
suggestSearchRecenter,
+ searchRecenterNotice,
suggestDiscoverRecenter,
+ retryDiscoverRecenter,
};
}
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)=>