+ {/* 통화 이름이 숨는 조건(ExpenseCurrencyPicker의 mobile·max-sm)에서는 통화 칸을 코드 3글자 + ▼ 폭으로 줄여 금액 칸을 넓힌다 */}
+
Date: Tue, 13 Oct 2026 22:46:07 +0900
Subject: [PATCH 17/18] =?UTF-8?q?fix(responsive):=20Sonar=20=EC=A7=80?=
=?UTF-8?q?=EC=A0=81=EA=B3=BC=20=EB=B9=84=EC=9A=A9=20=EC=8B=9C=ED=8A=B8=20?=
=?UTF-8?q?=EB=B0=B0=EA=B2=BD=20=EC=A0=9C=EC=8A=A4=EC=B2=98=20=EC=A0=95?=
=?UTF-8?q?=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Context 값을 메모이제이션하고 지도 스타일 조건 분리
- 비용 시트의 배경 포인터 해제·취소 처리와 기존 닫기 보호 유지
- 배경 제스처 및 Escape 회귀 테스트 추가
---
.../expenses/ExpenseBackdrop.test.tsx | 107 ++++++++++++++++++
src/components/expenses/ExpenseEditor.tsx | 11 +-
src/components/expenses/ExpenseScopePanel.tsx | 11 +-
src/components/map/MapWithDetailPanel.tsx | 4 +-
src/contexts/SectionWidthContext.tsx | 7 +-
5 files changed, 132 insertions(+), 8 deletions(-)
create mode 100644 src/components/expenses/ExpenseBackdrop.test.tsx
diff --git a/src/components/expenses/ExpenseBackdrop.test.tsx b/src/components/expenses/ExpenseBackdrop.test.tsx
new file mode 100644
index 00000000..a65fd806
--- /dev/null
+++ b/src/components/expenses/ExpenseBackdrop.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ExpenseEditor } from "./ExpenseEditor";
+import { ExpenseScopePanel } from "./ExpenseScopePanel";
+
+const state = vi.hoisted(() => ({ mobile: true, busy: false }));
+vi.mock("@/contexts/MobileViewContext", () => ({
+ useMobileView: () => ({ isMobileDevice: state.mobile }),
+}));
+vi.mock("./ExpenseProvider", () => ({
+ useExpenseContext: () => ({
+ roomId: "r", currentUserId: 1, members: [], memberStatus: "success",
+ canManage: true, schedules: [], schedulesReady: true, busy: state.busy,
+ currencies: { data: [{ currency: "KRW", fractionDigits: 0, maximumAmount: "999999999999999" }], isSuccess: true },
+ list: { data: [], isSuccess: true }, save: vi.fn(), readLatest: vi.fn(),
+ }),
+}));
+vi.mock("@/hooks/useRooms", () => ({
+ useSchedulePlanPlaces: () => ({ data: [], isSuccess: true, isPending: false, isError: false }),
+}));
+
+let root: Root;
+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 })));
+}
+
+for (const kind of ["editor", "list"] as const) {
+ describe(`${kind} backdrop dismissal`, () => {
+ async function mount() {
+ await act(async () => root.render(kind === "editor"
+ ?
+ : {}}
+ onEdit={() => {}} onDelete={async () => {}} onRetry={() => {}} onClose={close} />));
+ return host.querySelector("dialog")!;
+ }
+ 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");
+ 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);
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("keeps desktop backdrop presses open", async () => {
+ state.mobile = false;
+ const dialog = await mount();
+ await pointer(dialog, "pointerdown"); await pointer(dialog, "pointerup");
+ expect(close).not.toHaveBeenCalled();
+ });
+ it("blocks backdrop and Escape dismissal while busy", async () => {
+ 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);
+ 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(close).toHaveBeenCalledOnce();
+ });
+ });
+}
diff --git a/src/components/expenses/ExpenseEditor.tsx b/src/components/expenses/ExpenseEditor.tsx
index 5fd45d47..4fc3a7e5 100644
--- a/src/components/expenses/ExpenseEditor.tsx
+++ b/src/components/expenses/ExpenseEditor.tsx
@@ -337,10 +337,15 @@ export function ExpenseEditor({
if (!pending) onClose();
}}
onPointerDown={(event) => {
- backdropPress.current = event.target === event.currentTarget;
+ backdropPress.current = event.button === 0 && event.target === event.currentTarget;
}}
- onClick={(event) => {
- if (isMobileDevice && !pending && backdropPress.current && event.target === event.currentTarget) 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"
>
diff --git a/src/components/expenses/ExpenseScopePanel.tsx b/src/components/expenses/ExpenseScopePanel.tsx
index 46ce730f..ea26e561 100644
--- a/src/components/expenses/ExpenseScopePanel.tsx
+++ b/src/components/expenses/ExpenseScopePanel.tsx
@@ -107,10 +107,15 @@ export function ExpenseScopePanel({
if (!disabled && !confirming) onClose();
}}
onPointerDown={(event) => {
- backdropPress.current = event.target === event.currentTarget;
+ backdropPress.current = event.button === 0 && event.target === event.currentTarget;
}}
- onClick={(event) => {
- if (isMobileDevice && !disabled && !confirming && backdropPress.current && event.target === event.currentTarget) 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"
>
diff --git a/src/components/map/MapWithDetailPanel.tsx b/src/components/map/MapWithDetailPanel.tsx
index 8cebad7e..8c2ad120 100644
--- a/src/components/map/MapWithDetailPanel.tsx
+++ b/src/components/map/MapWithDetailPanel.tsx
@@ -37,13 +37,15 @@ export function MapWithDetailPanel({
const { setToolbarRef, panelTopPx } =
useMapDiscoverToolbarOffset(mapSectionEl);
+ const visibleStyle = mobileInline ? undefined : { minWidth: MAIN_MAP_MIN_WIDTH_PX };
+
return (
({
export function SectionWidthProvider({ children }: { children: ReactNode }) {
const [maxWidth, setMaxWidth] = useState("");
const [sideDetailWidthPx, setSideDetailWidthPx] = useState(0);
+ const value = useMemo(
+ () => ({ maxWidth, setMaxWidth, sideDetailWidthPx, setSideDetailWidthPx }),
+ [maxWidth, sideDetailWidthPx],
+ );
return (
-
+
{children}
);
From e85a402bdd83dd309dbbb1b399e8037d1405a053 Mon Sep 17 00:00:00 2001
From: minbros
Date: Tue, 13 Oct 2026 23:07:34 +0900
Subject: [PATCH 18/18] =?UTF-8?q?fix(invite):=20=EA=B3=B5=ED=86=B5=20?=
=?UTF-8?q?=EC=B4=88=EB=8C=80=20=EB=AA=A8=EB=8B=AC=EC=9D=98=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=98=A4=EB=A5=98=20=EB=B3=B5=EA=B5=AC=EC=99=80=20?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 홈 방 카드의 조회 재시도 연결과 접근성 오류 안내 복구
- 공통 모달 계약에 맞춰 선택한 방 링크와 비활성 동작·오류 복구 검증
---
.../_components/AddMemberPanel.tsx | 7 ++++---
src/app/home/_components/InviteRoomModal.tsx | 3 ++-
.../home/_components/RoomCardMenu.test.tsx | 20 +++++++++++++++++--
src/components/rooms/InviteRoomDialog.tsx | 12 +++++++++++
4 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
index 04993a86..3ae9471a 100644
--- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
+++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
@@ -40,9 +40,10 @@ function InviteFallback({ canIssue, inviteCode, isRoomDetailLoading, isRoomDetai
return ;
}
let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요.";
- if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요.";
- else if (isRoomDetailError || inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요.";
- return {message};
+ if (isRoomDetailError) message = "방 정보를 불러오지 못했어요.";
+ else if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요.";
+ else if (inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요.";
+ return {message};
}
export function AddMemberPanel({
diff --git a/src/app/home/_components/InviteRoomModal.tsx b/src/app/home/_components/InviteRoomModal.tsx
index f6b56fa7..9f1dbd2b 100644
--- a/src/app/home/_components/InviteRoomModal.tsx
+++ b/src/app/home/_components/InviteRoomModal.tsx
@@ -9,7 +9,7 @@ export function InviteRoomModal({ room, onClose }: {
room: RoomListItem;
onClose: () => void;
}) {
- const { data, isLoading, isError } = useRoomDetail(room.id);
+ const { data, isLoading, isError, refetch } = useRoomDetail(room.id);
return (
{ void refetch(); }}
onClose={onClose}
/>
);
diff --git a/src/app/home/_components/RoomCardMenu.test.tsx b/src/app/home/_components/RoomCardMenu.test.tsx
index ade0e36e..4e47173e 100644
--- a/src/app/home/_components/RoomCardMenu.test.tsx
+++ b/src/app/home/_components/RoomCardMenu.test.tsx
@@ -64,7 +64,7 @@ afterEach(async () => {
it("opens the selected room's invite link without regenerating it and restores focus on Escape", async () => {
await openInvite();
- expect(document.querySelector("dialog[open]")?.textContent).toContain("제주 여행");
+ expect(document.querySelector("dialog[open] h2")?.textContent).toBe("초대하기");
expect(state.detailId).toHaveBeenCalledWith("selected-room");
expect(state.regenerate).not.toHaveBeenCalled();
await click("복사");
@@ -92,11 +92,27 @@ it.each(["loading", "error"])("does not issue links while detail is %s", async (
state.error = mode === "error";
await openInvite();
expect(state.regenerate).not.toHaveBeenCalled();
- expect(button("복사")).toBeUndefined();
+ expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull();
+ expect(button("복사").disabled).toBe(true);
+ expect(button("공유").disabled).toBe(true);
+ await click("복사");
+ await click("공유");
+ expect(state.copy).not.toHaveBeenCalled();
+ expect(state.regenerate).not.toHaveBeenCalled();
if (mode === "error") {
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain("방 정보를 불러오지 못했어요");
await click("다시 시도");
expect(state.refetch).toHaveBeenCalledOnce();
+ expect(state.regenerate).not.toHaveBeenCalled();
}
+ state.loading = false;
+ state.error = false;
+ state.detail!.inviteCode = "recovered-code";
+ await render();
+ expect(button("다시 시도")).toBeUndefined();
+ await click("복사");
+ expect(state.copy).toHaveBeenCalledExactlyOnceWith(`${window.location.origin}/join/recovered-code`);
+ expect(state.regenerate).not.toHaveBeenCalled();
});
it("reports clipboard failure without claiming the link was copied", async () => {
diff --git a/src/components/rooms/InviteRoomDialog.tsx b/src/components/rooms/InviteRoomDialog.tsx
index ecae141d..3c91d4ec 100644
--- a/src/components/rooms/InviteRoomDialog.tsx
+++ b/src/components/rooms/InviteRoomDialog.tsx
@@ -11,6 +11,7 @@ export function InviteRoomDialog({
role,
isRoomDetailLoading,
isRoomDetailError,
+ onRetryRoomDetail,
onClose,
}: Readonly<{
roomId: string;
@@ -19,6 +20,7 @@ export function InviteRoomDialog({
role?: string;
isRoomDetailLoading: boolean;
isRoomDetailError: boolean;
+ onRetryRoomDetail?: () => void;
onClose: () => void;
}>) {
return (
@@ -35,6 +37,16 @@ export function InviteRoomDialog({
isRoomDetailError={isRoomDetailError}
showDescription={false}
/>
+ {isRoomDetailError && onRetryRoomDetail ? (
+
+ ) : null}
);