From f352d6037b82ad471f4605237c134584692ef664 Mon Sep 17 00:00:00 2001 From: mokjakA Date: Sun, 9 Aug 2026 09:33:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(S15P11A705-426):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=97=86=EC=9D=8C=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A2=85=EC=9D=B4=20=EB=A9=94=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=EB=A7=8C=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 결과 없음 문구가 지면 위에 그대로 얹혀 로고와 겹쳤다. PaperNoteParts(마스킹 테이프)·PinStanding(핀 아이콘)을 재사용해 모달 형태의 SearchEmptyModal을 SearchResultGallery.tsx에 추가한다. 포커스 트랩·ESC 닫기·배경 클릭 닫기(비파괴적 안내라 허용)를 갖췄다. HomePage 배선은 L3 영역이라 이번 커밋에 포함하지 않는다. Co-Authored-By: Claude Sonnet 5 --- .../home/components/SearchResultGallery.tsx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/src/features/home/components/SearchResultGallery.tsx b/src/features/home/components/SearchResultGallery.tsx index 5755e9c..02ea804 100644 --- a/src/features/home/components/SearchResultGallery.tsx +++ b/src/features/home/components/SearchResultGallery.tsx @@ -1,5 +1,10 @@ +import { useEffect, useId, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { SearchResultItem } from '@/features/search/api/searchRecords'; import { ContextStickyNote } from '@/shared/ui/ContextStickyNote'; +import { PaperNoteParts } from '@/shared/ui/PaperNoteParts'; +import { PinStanding } from '@/shared/ui/PinSymbols'; +import { FOCUSABLE_SELECTOR } from '@/shared/lib/focusableSelector'; interface SearchResultGalleryProps { items: SearchResultItem[]; @@ -76,3 +81,165 @@ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalle ); } + +interface SearchEmptyModalProps { + isOpen: boolean; + /** 방금 검색한 문장. 표제에 그대로 인용해 "무엇을 찾다가 못 찾았는지"를 보여준다. */ + query: string; + /** ESC·배경 클릭으로 닫을 때. */ + onClose: () => void; + /** 하단 CTA. 호출부가 검색어를 지우고 입력으로 포커스를 되돌리는 동작까지 책임진다 — 이 + * 컴포넌트는 검색 상태를 모른다. */ + onRetry: () => void; +} + +/** + * S15P11A705-426: 검색 결과가 0건일 때의 안내를 상태 텍스트(`.pl-status`)가 아니라 모달로 띄운다. + * + * 종전에는 "그 문장으로는 아직 찾지 못했습니다" 문구가 도크 바로 아래(`.pl-status`)에 얹혔는데, + * 도크는 창이 열리면 위로 올라가 상단 워드마크(`HomeTopmark` — `.pl-topmark`)와 같은 자리를 + * 지나간다. 그 순간 두 글자가 겹쳐 보이는 것이 이 티켓이 없애려는 문제다. 문구를 지면 위 텍스트가 + * 아니라 화면 중앙의 별도 레이어(모달)로 옮기면 도크·워드마크가 어디에 있든 겹칠 수가 없다. + * + * 새로 그리지 않고 재사용한 것: + * - `PaperNoteParts`(마스킹 테이프 + 접힌 모서리) — 종이 메모지 장식은 여기 것을 그대로 쓴다. + * 테이프 기울기만 `[&_.pl-note-tape]:-rotate-3`로 살짝 준다(기본값은 회전이 없다 — 그 값은 + * `.pl-note-list`/`.pl-note-add`처럼 특정 부모 클래스에 걸려 있는데, 이 모달은 그 계열이 아니다). + * - `PinStanding`(마커 핀 심볼) — 시안의 "민트색 지도 핀 아이콘" 자리에 currentColor로 색만 + * 입혀 쓴다. 새 SVG를 그리지 않는다. + * - 포커스 트랩·ESC 닫기·포커스 복귀는 `ConfirmDialog`(shared/ui)와 같은 패턴이다. 그쪽의 + * `FOCUSABLE_SELECTOR`(shared/lib)를 그대로 가져다 쓴다. + * + * 새로 만든 것 — **스프링 바인더 구멍**은 재사용할 부품이 없어(grep으로 확인, 아무 데도 없다) + * 여기서 처음 만든다. 진짜 종이를 오려낸 것처럼 배경까지 뚫는 mask 기법(`.pl-diary-card`가 쓰는 + * 방식)이 더 정확하지만, 이 모달은 배경이 고정된 딤(#042142/45%) 한 색이라 눌린 자국(inset + * box-shadow)만으로도 같은 인상을 준다 — 마스크만큼 손이 가지 않는 쪽을 택했다. + * + * ⚠️ 배경 클릭으로 닫는다. `ConfirmDialog`는 파괴적 확인이라 배경 클릭을 막지만, 이 모달은 + * 잃을 입력이 없는 안내문이라 정책이 다르게 적용된다(같은 파일 안의 두 모달이 서로 다른 배경 + * 클릭 정책을 갖는 게 아니라, 이 결정은 ConfirmDialog의 정책과는 별개로 이 모달 하나에만 해당). + * + * ⚠️ 이 컴포넌트는 아직 `HomePage.tsx`에서 쓰이지 않는다. `hasNoResults`일 때 무엇을 렌더할지는 + * HomePage가 정하는 배선이고, L3 영역이라 이 레인에서 손대지 않았다 — 보고에 필요한 배선 변경을 + * 적었다. + */ +export function SearchEmptyModal({ isOpen, query, onClose, onRetry }: SearchEmptyModalProps) { + const panelRef = useRef(null); + const retryButtonRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + + // 열릴 때 CTA로 포커스를 옮기고, 닫히면 열기 전 자리로 복귀한다(ConfirmDialog와 같은 규칙). + useEffect(() => { + if (!isOpen) { + return; + } + const previouslyFocused = document.activeElement as HTMLElement | null; + retryButtonRef.current?.focus(); + return () => { + if (previouslyFocused !== null && previouslyFocused.isConnected) { + previouslyFocused.focus(); + } + }; + }, [isOpen]); + + // ESC 닫기 + Tab 포커스 트랩. + useEffect(() => { + if (!isOpen) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab' || panelRef.current === null) { + return; + } + const focusables = Array.from( + panelRef.current.querySelectorAll(FOCUSABLE_SELECTOR), + ); + if (focusables.length === 0) { + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + const isInside = panelRef.current.contains(active); + + if (event.shiftKey && (active === first || !isInside)) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && (active === last || !isInside)) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen || typeof document === 'undefined') { + return null; + } + + return createPortal( +
+
event.stopPropagation()} + className="relative w-full max-w-xs rounded-md bg-[#faf7f6] px-8 pb-8 pt-10 text-center shadow-[0_24px_50px_rgba(4,33,66,.3)] [&_.pl-note-tape]:-rotate-3" + > + + + {/* 스프링 바인더 구멍 — 왼쪽 가장자리 세로 한 줄. */} + + + + +

+ “{query}”로는 +
+ 아직 남긴 기억이 없어요 +

+ +

+ 다른 낱말로 다시 찾아보거나 +
+ 새로운 장소를 기록해 보세요 +

+ + +
+
, + document.body, + ); +}