diff --git a/src/features/collections/components/CollectionDetailView.test.tsx b/src/features/collections/components/CollectionDetailView.test.tsx index 2cb909d..102908c 100644 --- a/src/features/collections/components/CollectionDetailView.test.tsx +++ b/src/features/collections/components/CollectionDetailView.test.tsx @@ -237,10 +237,24 @@ describe('CollectionDetailView — 펼침면 소유자 배치(418)', () => { // 펼치면 목차가 먼저다. expect(container.textContent).toContain('목차'); + expect(container.textContent).toContain('이 컬렉션의 장소들'); expect(container.textContent).not.toContain('1 / 2'); await goToRecordPage('성수동 카페 온화'); expect(container.textContent).toContain('1 / 2'); + expect(container.textContent).not.toContain('이 컬렉션의 장소들'); + + await goToRecordPage('망원 한강공원'); + expect(container.textContent).toContain('2 / 2'); + expect(container.textContent).not.toContain('이 컬렉션의 장소들'); + + const tocTab = Array.from(container.querySelectorAll('button')).find( + (button) => button.title === '목차', + ); + await act(async () => { + tocTab?.click(); + }); + expect(container.textContent).toContain('이 컬렉션의 장소들'); }); it('표제는 컬렉션 제목과 "기록 N개 · 만든 날"이다', async () => { @@ -252,6 +266,26 @@ describe('CollectionDetailView — 펼침면 소유자 배치(418)', () => { }); describe('CollectionDetailView — 펼침면 타인 배치(418)', () => { + it('목차에서만 컬렉션 장소 안내 문구를 보여 준다', async () => { + await renderView({ ownedByMe: false, contextCount: 0 }); + + expect(container.textContent).toContain('이 컬렉션의 장소들'); + + await goToRecordPage('성수동 카페 온화'); + expect(container.textContent).not.toContain('이 컬렉션의 장소들'); + + await goToRecordPage('망원 한강공원'); + expect(container.textContent).not.toContain('이 컬렉션의 장소들'); + + const tocTab = Array.from(container.querySelectorAll('button')).find( + (button) => button.title === '목차', + ); + await act(async () => { + tocTab?.click(); + }); + expect(container.textContent).toContain('이 컬렉션의 장소들'); + }); + it('Context 원문을 한 글자도 렌더하지 않는다(공개 범위)', async () => { await renderView({ ownedByMe: false, contextCount: 3 }); await goToRecordPage('성수동 카페 온화'); diff --git a/src/features/collections/components/CollectionDetailView.tsx b/src/features/collections/components/CollectionDetailView.tsx index 57493ab..c6e831f 100644 --- a/src/features/collections/components/CollectionDetailView.tsx +++ b/src/features/collections/components/CollectionDetailView.tsx @@ -314,9 +314,11 @@ export function CollectionDetailView({ )} -
-

이 컬렉션의 장소들

-
+ {isTocOpen && ( +
+

이 컬렉션의 장소들

+
+ )} ); diff --git a/src/features/explore/components/ExploreSpreadPanels.tsx b/src/features/explore/components/ExploreSpreadPanels.tsx index 37c601d..c353ce8 100644 --- a/src/features/explore/components/ExploreSpreadPanels.tsx +++ b/src/features/explore/components/ExploreSpreadPanels.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router'; +import { usePlaceRecordSheet } from '@/contexts/usePlaceRecordSheet'; import { PaperCoverFace } from '@/features/home/components/HomeSheetPanels'; import { useRecentlyOpenedCount } from '@/features/feed/hooks/useRecentlyOpenedCount'; import { PaperCornerNav } from '@/shared/ui/PaperCornerNav'; @@ -25,25 +26,16 @@ export function ExploreHeadType() { ); } -interface ExploreLeftTypeProps { - /** 지금 펼친 쪽(1부터). 아직 첫 응답 전이면 null이다. */ - pageNumber: number | null; -} - /** - * 좌측 메모지 두 장 — 위는 이 책장이 무엇인지, 아래는 지금 펼친 쪽. - * - * ⚠️ 쪽 번호 외에 다른 수치를 적지 않는다. Feed 응답에는 전체 쪽 수도 전체 컬렉션 수도 없고 - * (opaque cursor 기반, 08_API_명세 10.1) 소유자 정보는 공개 화면 노출 금지다 - * (docs/privacy-rules.md). 지어내면 메모가 거짓말을 한다 — 홈 우측 표지에서 저자명 줄을 - * 옮기지 않은 것과 같은 판단이다. + * 좌측 메모지 두 장 — 위는 이 책장이 무엇인지, 아래는 장소 추가 진입점. * * 「펼쳐본 책」한 줄만 예외로 수를 적는데, 그것은 서버가 준 값이 아니라 **이 브라우저에 남은 내 * 기록**이다(382의 recentlyOpenedCollections). 세는 범위와 상한은 useRecentlyOpenedCount 주석에 * 있고, 문구를 "지금까지 본 책 전부"로 쓰지 않은 이유도 그것이다. */ -export function ExploreLeftType({ pageNumber }: ExploreLeftTypeProps) { +export function ExploreLeftType() { const openedCount = useRecentlyOpenedCount(); + const placeRecordSheet = usePlaceRecordSheet(); return ( <> @@ -59,13 +51,11 @@ export function ExploreLeftType({ pageNumber }: ExploreLeftTypeProps) { -
+
+ + 장소 추가하기 + ); } diff --git a/src/features/home/components/HomeSearchDock.test.tsx b/src/features/home/components/HomeSearchDock.test.tsx new file mode 100644 index 0000000..f314f1f --- /dev/null +++ b/src/features/home/components/HomeSearchDock.test.tsx @@ -0,0 +1,45 @@ +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HomeSearchDock, type HomeSearchDockHandle } from './HomeSearchDock'; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true }) as MediaQueryList), + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe('HomeSearchDock', () => { + it('명시적인 focus API로 검색 입력창에 포커스를 돌려준다', () => { + const ref = createRef(); + act(() => { + root.render( + , + ); + }); + + act(() => ref.current?.focus()); + + expect(document.activeElement).toBe(container.querySelector('#home-search')); + }); +}); diff --git a/src/features/home/components/HomeSearchDock.tsx b/src/features/home/components/HomeSearchDock.tsx index 52f9ee7..50cc7cb 100644 --- a/src/features/home/components/HomeSearchDock.tsx +++ b/src/features/home/components/HomeSearchDock.tsx @@ -1,4 +1,11 @@ -import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type FormEvent, +} from 'react'; import { SEARCH_PLACEHOLDERS } from '../lib/paperAperture'; interface HomeSearchDockProps { @@ -10,6 +17,10 @@ interface HomeSearchDockProps { status?: string | null; } +export interface HomeSearchDockHandle { + focus: () => void; +} + /** 순환 타이핑 속도(ms). 시안 값 그대로 — 치는 속도는 빠르고 지우는 속도는 더 빠르다. */ const TYPE_MS = 78; const ERASE_MS = 34; @@ -25,56 +36,62 @@ const NEXT_MS = 260; * * 근거: 디자인 시안 home-paper-aperture.html. */ -export function HomeSearchDock({ - query, - onQueryChange, - onSubmit, - isPending, - status, -}: HomeSearchDockProps) { - const ghost = useCyclingPlaceholder(query.length === 0); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - const trimmed = query.trim(); - if (!trimmed) { - return; - } - onSubmit(trimmed); - }; - - return ( -
-
- - onQueryChange(event.target.value)} - /> - {/* 사용자가 한 글자라도 치면 사라진다. aria-hidden이라 스크린리더는 라벨만 읽는다. */} - {ghost !== null && ( - - )} - - - -

- {status ?? ''} -

-
- ); -} +export const HomeSearchDock = forwardRef( + function HomeSearchDock({ query, onQueryChange, onSubmit, isPending, status }, ref) { + const inputRef = useRef(null); + const ghost = useCyclingPlaceholder(query.length === 0); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + }), + [], + ); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) { + return; + } + onSubmit(trimmed); + }; + + return ( +
+
+ + onQueryChange(event.target.value)} + /> + {/* 사용자가 한 글자라도 치면 사라진다. aria-hidden이라 스크린리더는 라벨만 읽는다. */} + {ghost !== null && ( + + )} + + + +

+ {status ?? ''} +

+
+ ); + }, +); /** * idle일 때만 도는 순환 placeholder. 사람이 한 글자라도 치면 그 프레임에 멈추고, diff --git a/src/features/home/components/PaperApertureStage.test.tsx b/src/features/home/components/PaperApertureStage.test.tsx new file mode 100644 index 0000000..4d02505 --- /dev/null +++ b/src/features/home/components/PaperApertureStage.test.tsx @@ -0,0 +1,48 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PaperApertureStage } from './PaperApertureStage'; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false }) as MediaQueryList), + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe('PaperApertureStage', () => { + it('상하 경계의 deckle만 제거하고 종이 표면 질감은 유지한다', () => { + act(() => { + root.render( + 상판} + left={왼쪽} + topmark={워드마크} + dock={검색} + > + 지도 + , + ); + }); + + expect(container.querySelector('.pl-deckle')).toBeNull(); + expect(container.querySelectorAll('.pl-sheet')).toHaveLength(2); + expect(container.querySelectorAll('.pl-grain')).toHaveLength(2); + expect(container.querySelectorAll('.pl-emboss')).toHaveLength(2); + expect(container.querySelectorAll('.pl-spot')).toHaveLength(2); + }); +}); diff --git a/src/features/home/components/PaperApertureStage.tsx b/src/features/home/components/PaperApertureStage.tsx index 4b81b82..fb32ca0 100644 --- a/src/features/home/components/PaperApertureStage.tsx +++ b/src/features/home/components/PaperApertureStage.tsx @@ -103,7 +103,6 @@ export function PaperApertureStage({
-
@@ -130,7 +129,6 @@ export function PaperApertureStage({
-
diff --git a/src/features/home/components/SearchResultGallery.tsx b/src/features/home/components/SearchResultGallery.tsx index f28e070..9646a52 100644 --- a/src/features/home/components/SearchResultGallery.tsx +++ b/src/features/home/components/SearchResultGallery.tsx @@ -84,18 +84,10 @@ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalle interface SearchEmptyModalProps { isOpen: boolean; - /** - * 방금 검색한 문장. S15P11A705-426 리터치로 표제 인용 문구를 시안 그대로("아직 이 지도에 - * 남긴 기억이 없어요")로 바꾸면서 JSX에서는 더 이상 쓰지 않는다. 다만 `HomePage.tsx`(L3 - * 영역)가 아직 이 prop을 넘기고 있어(읽기만 허용된 파일이라 배선을 바꾸지 않았다) 시그니처는 - * 유지한다 — L3가 호출부를 정리하면 그때 지운다. - */ - query: string; /** ESC·배경 클릭으로 닫을 때. */ onClose: () => void; - /** 하단 CTA. 호출부가 검색어를 지우고 입력으로 포커스를 되돌리는 동작까지 책임진다 — 이 - * 컴포넌트는 검색 상태를 모른다. */ - onRetry: () => void; + /** 하단 CTA. 호출부가 검색 상태를 정리하고 장소 추가 시트를 여는 동작을 책임진다. */ + onAddPlace: () => void; } /** @@ -124,11 +116,8 @@ interface SearchEmptyModalProps { * 잃을 입력이 없는 안내문이라 정책이 다르게 적용된다(같은 파일 안의 두 모달이 서로 다른 배경 * 클릭 정책을 갖는 게 아니라, 이 결정은 ConfirmDialog의 정책과는 별개로 이 모달 하나에만 해당). * - * ⚠️ 이 컴포넌트는 아직 `HomePage.tsx`에서 쓰이지 않는다. `hasNoResults`일 때 무엇을 렌더할지는 - * HomePage가 정하는 배선이고, L3 영역이라 이 레인에서 손대지 않았다 — 보고에 필요한 배선 변경을 - * 적었다. */ -export function SearchEmptyModal({ isOpen, onClose, onRetry }: SearchEmptyModalProps) { +export function SearchEmptyModal({ isOpen, onClose, onAddPlace }: SearchEmptyModalProps) { const panelRef = useRef(null); const retryButtonRef = useRef(null); const titleId = useId(); @@ -223,9 +212,7 @@ export function SearchEmptyModal({ isOpen, onClose, onRetry }: SearchEmptyModalP

- 아직 이 지도에 남긴 -
- 기억이 없어요 + 아직 남긴 기억이 없어요

@@ -236,10 +223,10 @@ export function SearchEmptyModal({ isOpen, onClose, onRetry }: SearchEmptyModalP diff --git a/src/features/library/components/LibrarySpreadPanels.tsx b/src/features/library/components/LibrarySpreadPanels.tsx index 8ec523a..75d25cd 100644 --- a/src/features/library/components/LibrarySpreadPanels.tsx +++ b/src/features/library/components/LibrarySpreadPanels.tsx @@ -3,6 +3,7 @@ import { PaperCoverFace } from '@/features/home/components/HomeSheetPanels'; import { useMyCollectionsQuery } from '@/features/collections/hooks/useMyCollectionsQuery'; import { useRecentlyOpenedCount } from '@/features/feed/hooks/useRecentlyOpenedCount'; import { useFollowsQuery } from '@/features/follows/hooks/useFollowsQuery'; +import { usePlaceRecordSheet } from '@/contexts/usePlaceRecordSheet'; import { PaperCornerNav } from '@/shared/ui/PaperCornerNav'; import { PaperNoteParts, PaperNoteTally } from '@/shared/ui/PaperNoteParts'; @@ -35,13 +36,8 @@ export function LibraryHeadType() { ); } -interface LibraryLeftTypeProps { - /** 지금 펼친 쪽(1부터). 캐비닛의 좌우 페이지 이동과 같은 값이다. */ - pageNumber: number | null; -} - /** - * 좌측 메모지 두 장 — 위는 **이 책장의 장부**, 아래는 지금 펼친 쪽. + * 좌측 메모지 두 장 — 위는 **이 책장의 장부**, 아래는 장소 추가 진입점. * * 29번(사용자 지시): 탐색의 메모지를 그대로 쓰지 않는다. 두 화면이 같은 종이 문법(제목 · 한 줄 * 설명 · 점선 리더 장부)을 쓰되 **적는 사실은 달라야** 그 자리에 있을 이유가 생긴다. 탐색은 @@ -59,8 +55,9 @@ interface LibraryLeftTypeProps { * 「펼쳐본 책」만 탐색과 **같은 기록·같은 문구**다(382의 recentlyOpenedCollections). 사용자가 * 책을 어디서 펼쳤든 "내가 펼쳐본 책"은 하나의 사실이라 두 화면이 같은 수를 말하는 것이 맞다. */ -export function LibraryLeftType({ pageNumber }: LibraryLeftTypeProps) { +export function LibraryLeftType() { const openedCount = useRecentlyOpenedCount(); + const sheet = usePlaceRecordSheet(); const myCollectionsQuery = useMyCollectionsQuery(); const collectionPages = myCollectionsQuery.data?.pages ?? []; @@ -99,13 +96,11 @@ export function LibraryLeftType({ pageNumber }: LibraryLeftTypeProps) { -

+
+ + 장소 추가하기 + ); } diff --git a/src/pages/FeedPage.test.tsx b/src/pages/FeedPage.test.tsx new file mode 100644 index 0000000..7f86df4 --- /dev/null +++ b/src/pages/FeedPage.test.tsx @@ -0,0 +1,53 @@ +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FeedPage } from './FeedPage'; + +vi.mock('@/features/paper/components/PaperSpreadStage', () => ({ + PaperSpreadStage: ({ left }: { left: ReactNode }) =>
{left}
, +})); + +vi.mock('@/features/feed/components/FeedList', () => ({ + FeedList: () => null, +})); + +vi.mock('@/features/records/components/PlaceRecordSheet', async () => { + const { usePlaceRecordSheet } = await import('@/contexts/usePlaceRecordSheet'); + return { + PlaceRecordSheet: () => { + const sheet = usePlaceRecordSheet(); + return sheet.isOpen ?
장소 기록
: null; + }, + }; +}); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('FeedPage 장소 추가', () => { + it('두 번째 포스트잇을 누르면 실제 장소 기록 시트를 여는 context가 연결된다', () => { + act(() => root.render()); + + const addPlaceButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.replace('+', '').trim() === '장소 추가하기', + ); + expect(addPlaceButton).toBeDefined(); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + + act(() => addPlaceButton?.click()); + + expect(container.querySelector('[role="dialog"]')?.textContent).toBe('장소 기록'); + }); +}); diff --git a/src/pages/FeedPage.tsx b/src/pages/FeedPage.tsx index afb6e73..250fda5 100644 --- a/src/pages/FeedPage.tsx +++ b/src/pages/FeedPage.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from 'react'; +import { PlaceRecordSheetProvider } from '@/contexts/PlaceRecordSheetProvider'; +import { PlaceRecordSheet } from '@/features/records/components/PlaceRecordSheet'; import { FeedList } from '@/features/feed/components/FeedList'; import { PaperSpreadStage } from '@/features/paper/components/PaperSpreadStage'; import { @@ -25,29 +26,22 @@ import { * 책장이 쓸 상자는 지면 안에서 실측한다(PaperSpreadStage → FeedList의 area). */ export function FeedPage() { - // 쪽 번호는 FeedList가 소유한 페이지네이션 상태의 **읽기 전용 사본**이다(FeedList 주석). - // 콜백을 useCallback으로 고정해 두면 FeedList의 보고 effect가 쪽이 바뀔 때만 돈다. - const [pageNumber, setPageNumber] = useState(null); - const handlePageNumberChange = useCallback((value: number) => setPageNumber(value), []); - return ( - // 홈과 같은 이유로 PAGE_MIN_HEIGHT_CLASS를 쓰지 않는다 — 이 지면은 여백 없이 화면을 - // 가장자리까지 채운다. 414에서 셸
의 여백이 전부 사라져 content box 높이가 정확히 - // 100dvh라 h-full이면 된다(HomePage와 같은 근거). -
- } - left={} - right={} - corner={} - shelf={(area) => ( - - )} - /> -
+ + {/* 홈과 같은 이유로 PAGE_MIN_HEIGHT_CLASS를 쓰지 않는다 — 이 지면은 여백 없이 화면을 + 가장자리까지 채운다. 414에서 셸
의 여백이 전부 사라져 content box 높이가 정확히 + 100dvh라 h-full이면 된다(HomePage와 같은 근거). */} +
+ } + left={} + right={} + corner={} + shelf={(area) => } + /> +
+ + + ); } diff --git a/src/pages/HomePage.test.tsx b/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..8bf7e99 --- /dev/null +++ b/src/pages/HomePage.test.tsx @@ -0,0 +1,145 @@ +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HomePage } from './HomePage'; + +const { openPlaceSheetMock, resetSearchMock } = vi.hoisted(() => ({ + openPlaceSheetMock: vi.fn(), + resetSearchMock: vi.fn(), +})); + +vi.mock('@/features/search/hooks/useSearchRecordsMutation', async () => { + const React = await import('react'); + return { + useSearchRecordsMutation: () => { + const [isSuccess, setIsSuccess] = React.useState(true); + return { + data: { bounds: null, items: [] }, + isSuccess, + isPending: false, + isError: false, + mutate: vi.fn(), + reset: () => { + resetSearchMock(); + setIsSuccess(false); + }, + }; + }, + }; +}); + +vi.mock('@/features/map/hooks/useRecordMapMarkersQuery', () => ({ + useRecordMapMarkersQuery: () => ({ data: { items: [] } }), +})); +vi.mock('@/contexts/PlaceRecordSheetProvider', () => ({ + PlaceRecordSheetProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock('@/contexts/usePlaceRecordSheet', () => ({ + usePlaceRecordSheet: () => ({ open: openPlaceSheetMock }), +})); +vi.mock('@/features/records/components/PlaceRecordSheet', () => ({ + PlaceRecordSheet: () => , +})); +vi.mock('@/features/records/components/RecordDetailOverlay', () => ({ + RecordDetailOverlay: () => null, +})); +vi.mock('@/features/home/components/HomeMapSection', () => ({ HomeMapSection: () => null })); +vi.mock('@/features/home/components/PaperApertureStage', () => ({ + PaperApertureStage: ({ dock, children }: { dock: ReactNode; children: ReactNode }) => ( + <> + {dock} + {children} + + ), +})); +vi.mock('@/features/home/components/HomeSheetPanels', () => ({ + HomeLeftType: () => null, + HomeRightType: () => null, + HomeTopType: () => null, + HomeTopmark: () => null, +})); +vi.mock('@/features/home/lib/paperAperture', () => ({ + computeOpen: () => 1, + MAP_TOP_OBSTRUCTION_PX: 0, + SEARCH_PLACEHOLDERS: ['비 오는 카페'], +})); +vi.mock('@/shared/ui/PaperCornerNav', () => ({ PaperCornerNav: () => null })); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true }) as MediaQueryList), + ); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + resetSearchMock.mockClear(); + openPlaceSheetMock.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe('HomePage 빈 검색 결과', () => { + it('장소 추가하기를 누르면 검색 상태를 비우고 장소 추가 시트를 연다', async () => { + await act(async () => { + root.render(); + }); + + const input = container.querySelector('#home-search'); + expect(input).not.toBeNull(); + await act(async () => { + if (input) { + input.value = '비 오는 카페'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + } + }); + expect(input?.value).toBe('비 오는 카페'); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(dialog?.textContent).toContain('아직 남긴 기억이 없어요'); + + const addPlaceButton = Array.from(dialog?.querySelectorAll('button') ?? []).find((button) => + button.textContent?.includes('장소 추가하기'), + ); + await act(async () => { + addPlaceButton?.click(); + }); + + expect(resetSearchMock).toHaveBeenCalledOnce(); + expect(input?.value).toBe(''); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(openPlaceSheetMock).toHaveBeenCalledOnce(); + expect(document.activeElement?.id).toBe('place-search-input'); + }); + + it('ESC로 닫으면 장소 시트는 열지 않고 검색 입력창으로 포커스를 돌린다', async () => { + await act(async () => { + root.render(); + }); + + const input = container.querySelector('#home-search'); + expect(document.body.querySelector('[role="dialog"]')).not.toBeNull(); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + + expect(resetSearchMock).toHaveBeenCalledOnce(); + expect(openPlaceSheetMock).not.toHaveBeenCalled(); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 69a5596..3300207 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,5 +1,6 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { PlaceRecordSheetProvider } from '@/contexts/PlaceRecordSheetProvider'; +import { usePlaceRecordSheet } from '@/contexts/usePlaceRecordSheet'; import { PlaceRecordSheet } from '@/features/records/components/PlaceRecordSheet'; import { RecordDetailOverlay } from '@/features/records/components/RecordDetailOverlay'; import { useSearchRecordsMutation } from '@/features/search/hooks/useSearchRecordsMutation'; @@ -10,7 +11,10 @@ import { SearchResultGallery, } from '@/features/home/components/SearchResultGallery'; import { PaperApertureStage } from '@/features/home/components/PaperApertureStage'; -import { HomeSearchDock } from '@/features/home/components/HomeSearchDock'; +import { + HomeSearchDock, + type HomeSearchDockHandle, +} from '@/features/home/components/HomeSearchDock'; import { HomeLeftType, HomeRightType, @@ -20,6 +24,25 @@ import { import { computeOpen, MAP_TOP_OBSTRUCTION_PX } from '@/features/home/lib/paperAperture'; import { PaperCornerNav } from '@/shared/ui/PaperCornerNav'; +interface HomeSearchEmptyModalProps { + isOpen: boolean; + onClose: () => void; + onReset: () => void; +} + +function HomeSearchEmptyModal({ isOpen, onClose, onReset }: HomeSearchEmptyModalProps) { + const sheet = usePlaceRecordSheet(); + const handleAddPlace = useCallback(() => { + onReset(); + sheet.open(); + requestAnimationFrame(() => + document.querySelector('#place-search-input')?.focus(), + ); + }, [onReset, sheet]); + + return ; +} + /** * 홈 화면 — "종이에 오려낸 창". * @@ -38,6 +61,7 @@ import { PaperCornerNav } from '@/shared/ui/PaperCornerNav'; */ export function HomePage() { const searchMutation = useSearchRecordsMutation(); + const searchDockRef = useRef(null); const [query, setQuery] = useState(''); const [openRecordId, setOpenRecordId] = useState(null); // 방금 저장한 Record. 마커 목록이 갱신되는 대로 지도가 그 좌표로 이동하고 값을 비운다. @@ -67,6 +91,18 @@ export function HomePage() { const open = computeOpen(query); + const handleEmptySearchReset = useCallback(() => { + setQuery(''); + resetSearch(); + }, [resetSearch]); + + const handleEmptySearchClose = useCallback(() => { + handleEmptySearchReset(); + // SearchEmptyModal의 unmount cleanup이 열리기 전 포커스를 먼저 복구한다. 그 커밋 뒤 다음 + // 프레임에서 검색창을 다시 잡아야 ESC·딤으로 닫은 뒤 새 검색을 바로 시작할 수 있다. + requestAnimationFrame(() => searchDockRef.current?.focus()); + }, [handleEmptySearchReset]); + const hasResults = searchMutation.isSuccess && searchMutation.data.items.length > 0; const hasNoResults = searchMutation.isSuccess && searchMutation.data.items.length === 0; @@ -107,6 +143,7 @@ export function HomePage() { topmark={} dock={ searchMutation.mutate(value)} @@ -147,11 +184,10 @@ export function HomePage() { )} - diff --git a/src/pages/LibraryPage.test.tsx b/src/pages/LibraryPage.test.tsx new file mode 100644 index 0000000..accfca7 --- /dev/null +++ b/src/pages/LibraryPage.test.tsx @@ -0,0 +1,61 @@ +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LibraryPage } from './LibraryPage'; + +vi.mock('@/features/paper/components/PaperSpreadStage', () => ({ + PaperSpreadStage: ({ left }: { left: ReactNode }) => <>{left}, +})); +vi.mock('@/features/collections/hooks/useMyCollectionsQuery', () => ({ + useMyCollectionsQuery: () => ({ data: { pages: [] }, isPending: false }), +})); +vi.mock('@/features/follows/hooks/useFollowsQuery', () => ({ + useFollowsQuery: () => ({ data: { pages: [] }, isPending: false }), +})); +vi.mock('@/features/feed/hooks/useRecentlyOpenedCount', () => ({ + useRecentlyOpenedCount: () => 0, +})); +vi.mock('@/features/records/components/PlaceRecordSheet', async () => { + const { usePlaceRecordSheet } = await import('@/contexts/usePlaceRecordSheet'); + return { + PlaceRecordSheet: () => { + const sheet = usePlaceRecordSheet(); + return sheet.isOpen ?
: null; + }, + }; +}); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('LibraryPage 장소 추가 포스트잇', () => { + it('장소 추가하기를 누르면 같은 Provider에 연결된 장소 추가 시트를 연다', async () => { + await act(async () => { + root.render(); + }); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + const addPlaceButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('장소 추가하기'), + ); + expect(addPlaceButton).toBeDefined(); + + await act(async () => { + addPlaceButton?.click(); + }); + + expect(container.querySelector('[role="dialog"][aria-label="장소 추가"]')).not.toBeNull(); + }); +}); diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 3522846..3c737df 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { PlaceRecordSheetProvider } from '@/contexts/PlaceRecordSheetProvider'; +import { PlaceRecordSheet } from '@/features/records/components/PlaceRecordSheet'; import { MyShelfColumn } from '@/features/collections/components/MyShelfList'; import { FollowedShelfCard } from '@/features/follows/components/FollowedShelfCard'; import { useFollowsQuery } from '@/features/follows/hooks/useFollowsQuery'; @@ -65,30 +67,22 @@ import { * 이 컴포넌트(무대)와 아래 LibraryShelf(캐비닛)로 나뉜 이유가 그것이다. */ export function LibraryPage() { - // 쪽 번호는 캐비닛이 소유한 페이지네이션 상태의 **읽기 전용 사본**이다(탐색과 같은 규약). - // 콜백을 useCallback으로 고정해 두면 보고 effect가 쪽이 바뀔 때만 돈다. - const [pageNumber, setPageNumber] = useState(null); - const handlePageNumberChange = useCallback((value: number) => setPageNumber(value), []); - return ( - // 홈·탐색과 같은 이유로 PAGE_MIN_HEIGHT_CLASS·PAGE_CONTAINER_CLASS를 쓰지 않는다 — 이 지면은 - // 여백 없이 화면을 가장자리까지 채운다. 414에서 셸
의 여백이 전부 사라져 content box - // 높이가 정확히 100dvh라 h-full이면 된다. -
- } - left={} - right={} - corner={} - shelf={(area) => ( - - )} - /> -
+ + {/* 홈·탐색과 같은 이유로 PAGE_MIN_HEIGHT_CLASS·PAGE_CONTAINER_CLASS를 쓰지 않는다 — 이 지면은 + 여백 없이 화면을 가장자리까지 채운다. 414에서 셸
의 여백이 전부 사라져 content box + 높이가 정확히 100dvh라 h-full이면 된다. */} +
+ } + left={} + right={} + corner={} + shelf={(area) => } + /> +
+ + ); } @@ -101,11 +95,9 @@ interface LibraryShelfProps { area?: ShelfArea | null; /** **구성**(몇 열 · 몇 행)을 정할 때만 쓰는 상자. 크기는 area, 구성은 이 값 — ShelfAreaFeed 주석. */ layoutArea?: ShelfArea | null; - /** 지금 펼친 쪽(1부터)을 알린다. 곁열의 쪽 번호 메모지가 쓴다. */ - onPageNumberChange?: (pageNumber: number) => void; } -function LibraryShelf({ area, layoutArea, onPageNumberChange }: LibraryShelfProps) { +function LibraryShelf({ area, layoutArea }: LibraryShelfProps) { // ⚠️ 훅은 분기와 무관하게 항상 부른다(호출 순서 고정). 상자를 받으면 값을 쓰지 않을 뿐이다. const viewportTier = useShelfWidthTier(); const { height: viewportHeight } = useViewportSize(); @@ -155,12 +147,6 @@ function LibraryShelf({ area, layoutArea, onPageNumberChange }: LibraryShelfProp setVirtualPageIndex(0); } - // 411: 쪽 번호를 곁열 메모지로 흘려보낸다. 페이지네이션 상태는 계속 이 컴포넌트가 소유하고 - // 읽기 전용 값만 나간다(탐색의 FeedList와 같은 규약). - useEffect(() => { - onPageNumberChange?.(virtualPageIndex + 1); - }, [onPageNumberChange, virtualPageIndex]); - const followsQuery = useFollowsQuery(); const pages = followsQuery.data?.pages ?? []; const allFollows = pages.flatMap((page) => page.items);