From ed80ecf6ffa2cb0b3d9a97bec54a07a6db7d689f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:00:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B9=8C=EB=A6=B4=EA=B2=8C=20=EB=8C=80?= =?UTF-8?q?=EC=97=AC=20=EC=8B=A0=EC=B2=AD=20=ED=99=95=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../components/BililgeRentalConfirmModal.tsx | 206 ++++++++++++++++++ .../bililge/components/BililgeRentalSheet.tsx | 32 ++- 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/features/bililge/components/BililgeRentalConfirmModal.tsx diff --git a/src/features/bililge/components/BililgeRentalConfirmModal.tsx b/src/features/bililge/components/BililgeRentalConfirmModal.tsx new file mode 100644 index 0000000..57aeaa2 --- /dev/null +++ b/src/features/bililge/components/BililgeRentalConfirmModal.tsx @@ -0,0 +1,206 @@ +import { Button, Divider, Typography } from "@wanteddev/wds"; +import { useEffect, useId, useRef } from "react"; +import { createPortal } from "react-dom"; + +import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal"; + +interface BililgeRentalConfirmModalProps { + open: boolean; + itemName: string; + quantity: number; + timeLabel: string; + onCancel: () => void; + onConfirm: () => void; +} + +// Figma: 빌릴게 대여 확인 모달의 modal(nodeId 1133:49951) — 반납 신청 확인 모달 +// (BililgeReturnConfirmModal)과 같은 컨테이너 패턴(포털·딤드·rounded-3xl·접근성)을 그대로 쓰되, +// 이 모달은 타이틀이 가운데 정렬이 아니라 왼쪽 정렬이고, 설명 문구 대신 물품/수량/대여 시작 +// 시간을 보여주는 상세 박스가 있다. 취소 버튼도 "닫기"가 아니라 "수정"이다 — 모달만 닫고 +// 바텀시트로 돌아가 값을 바꿀 수 있게 하는 동작이라 onCancel이 시트를 닫지 않는다. +function BililgeRentalConfirmModal({ + open, + itemName, + quantity, + timeLabel, + onCancel, + onConfirm, +}: BililgeRentalConfirmModalProps) { + const portalEl = useScreenSheetPortal(); + const titleId = useId(); + const cancelButtonRef = useRef(null); + const confirmButtonRef = useRef(null); + const previousFocusRef = useRef(null); + + // 열릴 때: 이전 포커스를 기억해두고 기본 액션(신청하기)으로 포커스를 옮긴다. StrictMode에서 + // 이 이펙트가 두 번 실행되면 두 번째 실행 시점엔 activeElement가 이미 신청하기 버튼(직전에 + // 우리가 옮긴 포커스)이라, 그대로 덮어쓰면 원래 포커스를 영영 잃는다 — 이미 우리 버튼에 가 + // 있으면 갱신하지 않는다. + // 닫힐 때: 모달을 열었던 요소로 포커스를 되돌린다. + useEffect(() => { + if (open) { + const active = document.activeElement as HTMLElement | null; + if ( + active !== confirmButtonRef.current && + active !== cancelButtonRef.current + ) { + previousFocusRef.current = active; + } + confirmButtonRef.current?.focus(); + return; + } + previousFocusRef.current?.focus(); + }, [open]); + + // Esc로 닫기 + Tab이 두 버튼 밖으로 나가지 않도록 트랩(딤드 버튼은 tabIndex=-1이라 순환 대상 아님). + useEffect(() => { + if (!open) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCancel(); + return; + } + if (event.key !== "Tab") { + return; + } + const first = cancelButtonRef.current; + const last = confirmButtonRef.current; + if (!first || !last) { + return; + } + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onCancel]); + + if (!portalEl) { + return null; + } + + return createPortal( +
+ + +
+ + , + portalEl, + ); +} + +export default BililgeRentalConfirmModal; diff --git a/src/features/bililge/components/BililgeRentalSheet.tsx b/src/features/bililge/components/BililgeRentalSheet.tsx index 719b737..6f37c67 100644 --- a/src/features/bililge/components/BililgeRentalSheet.tsx +++ b/src/features/bililge/components/BililgeRentalSheet.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from "react"; import BottomSheet from "@/components/ui/BottomSheet"; import BililgeItemCard from "@/features/bililge/components/BililgeItemCard"; +import BililgeRentalConfirmModal from "@/features/bililge/components/BililgeRentalConfirmModal"; import type { BililgeItem } from "@/features/bililge/constants/bililgeItems"; interface BililgeRentalSheetProps { @@ -60,6 +61,15 @@ function getDefaultStartTime(): { return { hour: hour12, minute: now.getMinutes(), period }; } +// 확인 모달의 "대여 시작 시간" 행에 쓰는 문구 — 분은 휠 피커에 보이는 표기(0 패딩)와 맞춘다. +function formatTimeLabel(time: { + period: Period; + hour: number; + minute: number; +}): string { + return `${time.period} ${time.hour}시 ${String(time.minute).padStart(2, "0")}분`; +} + // Figma: 빌릴게 대여 바텀시트 (nodeId 1422:57155) 안의 Views / Bottom Sheets (1422:57176). // 대여할 물품(수량 스테퍼) + 대여 시작 시간(휠 피커) + 안내문 + 대여 신청하기 버튼으로 구성된다. // 대여 가능 시간 검증(대여가능 시간 아닐 때/점심시간 등 Figma의 다른 상태 프레임)은 백엔드 연동 @@ -67,14 +77,21 @@ function getDefaultStartTime(): { function BililgeRentalSheet({ item, open, onClose }: BililgeRentalSheetProps) { const [stepperValue, setStepperValue] = useState(1); const [time, setTime] = useState(getDefaultStartTime); + const [confirmOpen, setConfirmOpen] = useState(false); useEffect(() => { if (item) { setStepperValue(1); setTime(getDefaultStartTime()); + setConfirmOpen(false); } }, [item]); + const handleConfirm = () => { + setConfirmOpen(false); + onClose(); + }; + return ( {item && ( @@ -173,10 +190,23 @@ function BililgeRentalSheet({ item, open, onClose }: BililgeRentalSheetProps) { {/* Figma Main Action(1422:...;16215:35710)은 56px인데 ActionAreaButton의 size="large" Button은 padding(12px×2)+body1 line-height(24px)라 48px이 된다. WDS Button엔 large보다 큰 사이즈가 없어 sx로 높이만 보정한다. */} - + setConfirmOpen(true)} + sx={{ height: "56px" }} + > 대여 신청하기 + {item && ( + setConfirmOpen(false)} + onConfirm={handleConfirm} + open={confirmOpen} + quantity={stepperValue} + timeLabel={formatTimeLabel(time)} + /> + )} ); }