Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/features/collections/components/CollectionDetailView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('button')).find(
(button) => button.title === '목차',
);
await act(async () => {
tocTab?.click();
});
expect(container.textContent).toContain('이 컬렉션의 장소들');
});

it('표제는 컬렉션 제목과 "기록 N개 · 만든 날"이다', async () => {
Expand All @@ -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<HTMLButtonElement>('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('성수동 카페 온화');
Expand Down
8 changes: 5 additions & 3 deletions src/features/collections/components/CollectionDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,11 @@ export function CollectionDetailView({
)}
</div>

<div className="mt-5 flex flex-none flex-wrap items-center justify-between gap-2">
<p className="font-hand text-[21px] leading-none text-[#8a857e]">이 컬렉션의 장소들</p>
</div>
{isTocOpen && (
<div className="mt-5 flex flex-none flex-wrap items-center justify-between gap-2">
<p className="font-hand text-[21px] leading-none text-[#8a857e]">이 컬렉션의 장소들</p>
</div>
)}
</div>
);

Expand Down
26 changes: 8 additions & 18 deletions src/features/explore/components/ExploreSpreadPanels.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<>
Expand All @@ -59,13 +51,11 @@ export function ExploreLeftType({ pageNumber }: ExploreLeftTypeProps) {
<PaperNoteTally label="펼쳐본 책" value={openedCount === 0 ? '아직' : `${openedCount}권`} />
</div>

<div className="pl-note pe-note-folio">
<button type="button" className="pl-note pl-note-add" onClick={placeRecordSheet.open}>
<PaperNoteParts />
<span>지금 펼친 쪽</span>
{/* 측정 전(첫 응답 전)에는 자리만 잡아 둔다 — 숫자가 들어오면서 메모지 높이가 바뀌면
곁열이 한 번 출렁인다. 같은 자릿수의 대체 문자를 세워 두면 그 일이 없다. */}
<b>{pageNumber === null ? '—' : pageNumber}</b>
</div>
<b aria-hidden="true">+</b>
<span>장소 추가하기</span>
</button>
</>
);
}
Expand Down
45 changes: 45 additions & 0 deletions src/features/home/components/HomeSearchDock.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HomeSearchDockHandle>();
act(() => {
root.render(
<HomeSearchDock
ref={ref}
query=""
onQueryChange={vi.fn()}
onSubmit={vi.fn()}
isPending={false}
/>,
);
});

act(() => ref.current?.focus());

expect(document.activeElement).toBe(container.querySelector('#home-search'));
});
});
119 changes: 68 additions & 51 deletions src/features/home/components/HomeSearchDock.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,6 +17,10 @@ interface HomeSearchDockProps {
status?: string | null;
}

export interface HomeSearchDockHandle {
focus: () => void;
}

/** 순환 타이핑 속도(ms). 시안 값 그대로 — 치는 속도는 빠르고 지우는 속도는 더 빠르다. */
const TYPE_MS = 78;
const ERASE_MS = 34;
Expand All @@ -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 (
<div className="pl-dock">
<form className="pl-field" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="home-search">
저장한 장소 검색
</label>
<input
id="home-search"
type="text"
autoComplete="off"
spellCheck={false}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
/>
{/* 사용자가 한 글자라도 치면 사라진다. aria-hidden이라 스크린리더는 라벨만 읽는다. */}
{ghost !== null && (
<span className="pl-ghost" aria-hidden="true">
{ghost}
<i />
</span>
)}
<button type="submit" disabled={!query.trim() || isPending}>
{isPending ? '찾는 중' : '찾기'}
</button>
</form>

<p className="pl-status" aria-live="polite">
{status ?? ''}
</p>
</div>
);
}
export const HomeSearchDock = forwardRef<HomeSearchDockHandle, HomeSearchDockProps>(
function HomeSearchDock({ query, onQueryChange, onSubmit, isPending, status }, ref) {
const inputRef = useRef<HTMLInputElement>(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 (
<div className="pl-dock">
<form className="pl-field" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="home-search">
저장한 장소 검색
</label>
<input
ref={inputRef}
id="home-search"
type="text"
autoComplete="off"
spellCheck={false}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
/>
{/* 사용자가 한 글자라도 치면 사라진다. aria-hidden이라 스크린리더는 라벨만 읽는다. */}
{ghost !== null && (
<span className="pl-ghost" aria-hidden="true">
{ghost}
<i />
</span>
)}
<button type="submit" disabled={!query.trim() || isPending}>
{isPending ? '찾는 중' : '찾기'}
</button>
</form>

<p className="pl-status" aria-live="polite">
{status ?? ''}
</p>
</div>
);
},
);

/**
* idle일 때만 도는 순환 placeholder. 사람이 한 글자라도 치면 그 프레임에 멈추고,
Expand Down
48 changes: 48 additions & 0 deletions src/features/home/components/PaperApertureStage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PaperApertureStage
open={1}
top={<span>상판</span>}
left={<span>왼쪽</span>}
topmark={<span>워드마크</span>}
dock={<span>검색</span>}
>
<span>지도</span>
</PaperApertureStage>,
);
});

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);
});
});
2 changes: 0 additions & 2 deletions src/features/home/components/PaperApertureStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ export function PaperApertureStage({

<div className="pl-sheet pl-sheet-t">
<PaperSurface />
<span className="pl-deckle" aria-hidden="true" />
<div className="pl-type-t">{top}</div>
</div>

Expand All @@ -130,7 +129,6 @@ export function PaperApertureStage({

<div className="pl-sheet pl-sheet-b">
<PaperSurface />
<span className="pl-deckle" aria-hidden="true" />
{bottom ? <div className="pl-type-b">{bottom}</div> : null}
</div>

Expand Down
Loading
Loading