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
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CollectionOverlayShell } from './CollectionOverlayShell';

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('CollectionOverlayShell', () => {
it('딤·블러 배경 없이 dialog 의미와 ESC 닫기를 유지한다', () => {
const onClose = vi.fn();
act(() =>
root.render(
<CollectionOverlayShell onClose={onClose}>
<button type="button">내용</button>
</CollectionOverlayShell>,
),
);

const dialog = container.querySelector<HTMLElement>('[role="dialog"]');
const backdrop = dialog?.parentElement;
expect(dialog?.getAttribute('aria-modal')).toBe('true');
expect(document.activeElement).toBe(dialog);
expect(backdrop?.className).not.toContain('backdrop-blur');
expect(backdrop?.className).not.toContain('bg-[#3a332c]');

act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })));
expect(onClose).toHaveBeenCalledOnce();
});

it('직접 URL 진입도 최초 포커스와 Tab containment를 유지한다', () => {
act(() =>
root.render(
<CollectionOverlayShell>
<button type="button">첫 버튼</button>
<button type="button">마지막 버튼</button>
</CollectionOverlayShell>,
),
);

const dialog = container.querySelector<HTMLElement>('[role="dialog"]');
expect(document.activeElement).toBe(dialog);

act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true })));
expect(document.activeElement?.textContent).toBe('마지막 버튼');

const outsideButton = document.createElement('button');
document.body.appendChild(outsideButton);
outsideButton.focus();
act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })));
expect(document.activeElement?.textContent).toBe('첫 버튼');
outsideButton.remove();
});
});
45 changes: 23 additions & 22 deletions src/features/collections/components/CollectionOverlayShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@ interface CollectionOverlayShellProps {
*
* ## 418에서 바뀐 것
*
* - **라우트 이동이 아니라 모달이다.** 컬렉션을 열면 원래 보던 화면(책장·Feed)이 뒤에 흐리게 남고
* 그 위에 책이 펼쳐진다. **URL은 그대로 둔다**(사용자 확정 (a)안) — 라우트가 이 모달을 렌더하는
* - **라우트 이동이 아니라 모달이다.** 컬렉션을 열면 원래 보던 화면 위에 책이 펼쳐진다.
* 별도 딤·블러 배경은 두지 않아 새 페이지처럼 화면 전체를 다시 칠하지 않는다.
* **URL은 그대로 둔다**(사용자 확정 (a)안) — 라우트가 이 모달을 렌더하는
* 형태라 뒤로가기·새로고침·공유·Feed CLICK 파라미터(`feedRequestId`/`feedPosition`) 흐름이
* 그대로 살아 있다.
* - **ESC·포커스 트랩·포커스 복귀**를 385 설정 모달·ConfirmDialog와 같은 규칙으로 건다. 332 시절
* 이 셸은 그냥 블러 배경일 뿐이라 Tab이 뒤 화면으로 새어 나갔다.
* - 스크림을 남색에서 **따뜻한 잉크**로 바꿨다(415와 같은 값) — 뒤가 파랗게 물들면 크림·베이지
* 종이가 차갑게 읽혀 다이어리 인상이 깨진다.
*
* ⚠️ **배경 클릭으로 닫지 않는다**(사용자 확정: "x 버튼을 누를 때만 닫히는 건 좋아"). 읽는 중에
* 배경을 잘못 눌러 책이 닫히는 사고를 막는 편을 택한 것이다. 닫는 길은 펼침면 우상단 ✕와 ESC다.
Expand All @@ -30,32 +29,27 @@ interface CollectionOverlayShellProps {
export function CollectionOverlayShell({ onClose, children }: CollectionOverlayShellProps) {
const panelRef = useRef<HTMLDivElement>(null);

// 포커스 복귀: 책을 열기 전에 포커스가 있던 자리(책장의 그 책, Feed의 그 카드)로 되돌린다.
// 닫을 길이 없는 진입(직접 URL)에서는 되돌릴 자리도 없어 걸지 않는다.
// 모달 최초 포커스 + 복귀: 셸 자체에 포커스를 주어 직접 URL에서도 aria-modal 의미가 실제
// 키보드 동작과 일치하게 한다. 내부 진입은 닫힐 때 책장의 그 책/Feed 카드로 되돌린다.
useEffect(() => {
if (!onClose) {
return;
}
const previouslyFocused = document.activeElement;
// ⚠️ 열자마자 첫 포커스 대상에 `.focus()`를 걸지 않는다. 크롬은 프로그램적 포커스에도
// `:focus-visible`을 적용해서, 실렌더에서 펼침면 전체(방향키 넘김을 받는 role="group")나
// 책장의 팔로우 버튼에 민트 아웃라인이 떠 있는 채로 화면이 열렸다. 아래 Tab 트랩이
// `!isInside`를 함께 다루므로 바깥에서 Tab을 눌러도 포커스는 모달 안으로 들어온다.
panelRef.current?.focus({ preventScroll: true });
return () => {
if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) {
if (
onClose &&
previouslyFocused instanceof HTMLElement &&
document.contains(previouslyFocused)
) {
previouslyFocused.focus();
}
};
}, [onClose]);

// ESC 닫기 + 포커스 트랩. 385 설정 모달과 같은 구현이다 — aria-modal은 보조기술에만 알릴 뿐
// 실제 Tab 순서를 막아주지 않아, 트랩이 없으면 딤 뒤 화면으로 포커스가 새어 나간다.
// ESC 닫기 + 포커스 트랩. 직접 URL은 돌아갈 지점이 없어 ESC만 비활성이고, Tab containment는
// 항상 유지한다 — aria-modal은 보조기술에만 알릴 뿐 실제 Tab 순서를 막아주지 않는다.
useEffect(() => {
if (!onClose) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === 'Escape' && onClose) {
event.preventDefault();
onClose();
return;
Expand All @@ -75,6 +69,12 @@ export function CollectionOverlayShell({ onClose, children }: CollectionOverlayS
const active = document.activeElement;
const isInside = panelRef.current.contains(active);

if (active === panelRef.current) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}

if (event.shiftKey && (active === first || !isInside)) {
event.preventDefault();
last.focus();
Expand All @@ -92,7 +92,7 @@ export function CollectionOverlayShell({ onClose, children }: CollectionOverlayS
return (
// overflow-y-auto는 마지막 안전장치다 — 펼침면은 뷰포트 높이에 맞춰져 있어(CollectionSpreadPage)
// PC 폭에서는 돌지 않는다. md 미만에서 두 면이 위아래로 쌓일 때만 쓰인다.
<div className="fixed inset-0 z-40 overflow-y-auto overscroll-contain bg-[#3a332c]/45 backdrop-blur-md">
<div className="fixed inset-0 z-40 overflow-y-auto overscroll-contain">
{/* 332는 세로 중앙 정렬을 금지했다 — 장마다 높이가 달라 콘텐츠 전체가 위아래로 흔들리며
"번쩍임"으로 보였기 때문이다. 418에서 펼침면 높이가 `min(760px, 100dvh-88px)`로 **고정**
되면서 그 전제가 사라져(장을 넘겨도 높이가 변하지 않는다) 다시 중앙에 세운다 — 1080p에서
Expand All @@ -101,10 +101,11 @@ export function CollectionOverlayShell({ onClose, children }: CollectionOverlayS
(중앙 정렬은 넘치는 콘텐츠의 윗부분을 스크롤로도 닿지 못하게 잘라낸다). */}
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label="컬렉션 펼침면"
className="flex min-h-full w-full items-start justify-center py-4 md:items-center"
className="flex min-h-full w-full items-start justify-center py-4 outline-none md:items-center"
>
{children}
</div>
Expand Down
16 changes: 6 additions & 10 deletions src/features/collections/components/MyShelfList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ShelfCabinet,
ShelfColumn,
ShelfColumnSkeleton,
ShelfColumnStatus,
ShelfLabel,
ShelfTier,
} from '@/shared/ui/Shelf';
Expand Down Expand Up @@ -80,7 +81,8 @@ export function MyShelfColumn({
return (
<>
<ShelfLabel>내 컬렉션</ShelfLabel>
<ShelfColumnSkeleton rowCount={visibleRowCount} message="불러오는 중…" />
<ShelfColumnStatus message="불러오는 중…" />
<ShelfColumnSkeleton rowCount={visibleRowCount} />
</>
);
}
Expand All @@ -89,11 +91,8 @@ export function MyShelfColumn({
return (
<>
<ShelfLabel>내 컬렉션</ShelfLabel>
<ShelfColumnSkeleton
rowCount={visibleRowCount}
message="컬렉션을 불러오지 못했어요."
tone="error"
/>
<ShelfColumnStatus message="컬렉션을 불러오지 못했어요." tone="error" />
<ShelfColumnSkeleton rowCount={visibleRowCount} />
</>
);
}
Expand Down Expand Up @@ -126,10 +125,7 @@ export function MyShelfColumn({
return (
<>
<ShelfLabel>내 컬렉션</ShelfLabel>

{collections.length === 0 && (
<p className="text-xs text-ink-gray">아직 만든 컬렉션이 없어요.</p>
)}
<ShelfColumnStatus message={collections.length === 0 ? '아직 만든 컬렉션이 없어요.' : null} />

{/* 287-8: flex-1 min-h-0으로 부모가 내어주는 세로 공간을 그대로 채운다(많으면
overflow-y-auto로 스크롤). 319: 여기 있던 min-h-[360px]/max-h-[590px]는 없앴다(위 주석).
Expand Down
28 changes: 15 additions & 13 deletions src/features/follows/components/FollowedShelfCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ConfirmDialog } from '@/shared/ui/ConfirmDialog';
import {
ShelfBookSpine,
ShelfColumnSkeleton,
ShelfColumnStatus,
ShelfIconButton,
ShelfLabel,
ShelfTier,
Expand Down Expand Up @@ -77,6 +78,17 @@ export function FollowedShelfCard({
const [isUnfollowConfirmOpen, setIsUnfollowConfirmOpen] = useState(false);
const [isEditingAlias, setIsEditingAlias] = useState(false);
const [aliasInput, setAliasInput] = useState(alias ?? '');
const collections = collectionsQuery.data?.pages.flatMap((page) => page.items) ?? [];
const statusMessage = updateAliasMutation.isError
? updateAliasMutation.error.message
: collectionsQuery.isPending
? '불러오는 중…'
: collectionsQuery.isError
? '책장을 불러오지 못했어요.'
: collections.length === 0
? '공개된 컬렉션이 없습니다'
: null;
const statusTone = updateAliasMutation.isError || collectionsQuery.isError ? 'error' : 'muted';

const handleStartEdit = () => {
setAliasInput(alias ?? '');
Expand Down Expand Up @@ -287,19 +299,13 @@ export function FollowedShelfCard({
</div>
)}

{updateAliasMutation.isError && (
<p className="text-xs text-red-600">{updateAliasMutation.error.message}</p>
)}
<ShelfColumnStatus message={statusMessage} tone={statusTone} />
{/* 416: 로딩·오류에서도 선반은 그대로 깔린다(ShelfColumnSkeleton 주석). 문구만 띄우면
목록이 도착할 때 선반이 통째로 나타나 "선반 수가 갑자기 바뀐다"로 보인다. */}
{collectionsQuery.isPending ? (
<ShelfColumnSkeleton rowCount={visibleRowCount} message="불러오는 중…" />
<ShelfColumnSkeleton rowCount={visibleRowCount} />
) : collectionsQuery.isError ? (
<ShelfColumnSkeleton
rowCount={visibleRowCount}
message="책장을 불러오지 못했어요."
tone="error"
/>
<ShelfColumnSkeleton rowCount={visibleRowCount} />
) : (
<FollowedShelfCollections
followId={followId}
Expand Down Expand Up @@ -439,10 +445,6 @@ function FollowedShelfCollections({

return (
<>
{collections.length === 0 && (
<p className="text-xs text-ink-gray">공개된 컬렉션이 없습니다</p>
)}

{/* 287-8/319: MyShelfColumn과 동일하게 flex-1 min-h-0이다 — 팔로우한 책장의 책 수와
무관하게, 부모(ShelfColumn)가 내어주는 세로 공간을 그대로 채운다(319에서 min-h-[360px]/
max-h-[590px] 고정 캡을 없앤 이유는 MyShelfList.tsx 상단 주석 참고). paddingTop/
Expand Down
14 changes: 10 additions & 4 deletions src/features/home/paperAperture.css
Original file line number Diff line number Diff line change
Expand Up @@ -1088,15 +1088,18 @@
}
/* ── 검색 결과 ───────────────────────────────────────────────────────
결과는 창이 완전히 열린 뒤(--open이 1일 때)에만 존재하므로 종이 판과 겹칠 일이 없다.
그래도 z-index는 판(6·7)보다 위, 도크(20)보다 아래에 둔다 — 지우다 만 상태에서
판이 아직 돌아오는 중일 때 결과가 종이 밑으로 들어가 보이지 않게 하려는 것이다. */
좌우 레일의 포스트잇·책은 지도 쪽으로 삐져나오므로 넓은 판형에서는 360px 이상의 안전 여백을
예약한다. 1080px 이하에서는 레일 자체가 사라져 원래의 유동 여백으로 되돌린다.
z-index는 판(6·7)보다 위, 도크(20)보다 아래에 둔다 — 지우다 만 상태에서 판이 아직 돌아오는 중일
때 결과가 종이 밑으로 들어가 보이지 않게 하려는 것이다. */
.pl-results {
position: absolute;
z-index: 15;
left: 0;
right: 0;
bottom: clamp(16px, 4%, 40px);
padding: 0 clamp(22px, 7%, 92px);
top: 50%;
transform: translateY(-50%);
padding: 0 clamp(360px, 25%, 440px);
}

/* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ─────────────────────────
Expand All @@ -1115,6 +1118,9 @@
--pl-sh-b: 26%;
--pl-sh-x: 0%;
}
.pl-results {
padding: 0 clamp(22px, 7%, 92px);
}
.pl-dock {
/* 곁열이 없으니 밀 대상도 없다 — 무대의 수학적 중앙이 곧 광학적 중앙이다. */
--pl-dock-nudge: 0px;
Expand Down
6 changes: 4 additions & 2 deletions src/features/records/components/PlaceRecordSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,14 +589,16 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe
통째로 사라진다(S15P11A705-324). 닫기 진입점은 헤더 X 버튼 하나로 통일한다.
*/}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#2c2a28]/45 p-4 backdrop-blur-sm">
<div className="relative w-full max-w-[720px]">
{/* 433: 장소 추천/기록 노트를 Record 상세와 같은 최대 높이 760px로 줄인다. 기존 720×900의
4:5 비율을 유지해 폭도 760×0.8=608px로 함께 축소한다. */}
<div className="relative w-full max-w-[608px]">
{/* page stack behind — mockup의 "노트 뒤에 쌓인 종이" 연출 */}
<div className="pointer-events-none absolute inset-0 translate-x-[14px] translate-y-[14px] rounded-[14px] bg-[#f6f4f1] shadow-[0_24px_48px_-20px_rgba(60,54,48,0.28)]" />
<div className="pointer-events-none absolute inset-0 translate-x-[7px] translate-y-[7px] rounded-[14px] bg-[#fbfaf8] shadow-[0_18px_36px_-18px_rgba(60,54,48,0.22)]" />

{/* notebook page */}
<section
className="relative z-10 flex h-[min(900px,calc(100dvh-64px))] flex-col rounded-[14px] bg-white px-6 pb-8 pt-9 shadow-[0_30px_60px_-24px_rgba(60,54,48,0.35)] sm:px-[52px] sm:pb-10 sm:pt-11"
className="relative z-10 flex h-[min(760px,calc(100dvh-72px))] flex-col rounded-[14px] bg-white px-6 pb-8 pt-9 shadow-[0_30px_60px_-24px_rgba(60,54,48,0.35)] sm:px-[44px] sm:pb-10 sm:pt-11"
style={{
backgroundImage: 'radial-gradient(rgba(120,110,100,0.025) 1px, transparent 1px)',
backgroundSize: '4px 4px',
Expand Down
5 changes: 3 additions & 2 deletions src/pages/LibraryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
ShelfColumnGrid,
ShelfColumnHeadSpacer,
ShelfColumnSkeleton,
ShelfColumnStatus,
} from '@/shared/ui/Shelf';

/**
Expand Down Expand Up @@ -245,11 +246,11 @@ function LibraryShelf({ area, layoutArea }: LibraryShelfProps) {
{/* 416/25번: 머리에 놓을 것이 없어도 자리는 비워 둔다 — 그래야 세 열의 첫 선반이 같은
높이에 온다(ShelfColumnHeadSpacer 주석). */}
<ShelfColumnHeadSpacer />
<ShelfColumnSkeleton
rowCount={visibleRowCount}
<ShelfColumnStatus
message={showsMessage ? followStatusMessage : null}
tone={followsQuery.isError ? 'error' : 'muted'}
/>
<ShelfColumnSkeleton rowCount={visibleRowCount} />
</ShelfColumn>,
);
statusMessageShown = true;
Expand Down
39 changes: 39 additions & 0 deletions src/shared/ui/Shelf.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ShelfColumnStatus } from './Shelf';

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('ShelfColumnStatus', () => {
it('문구 유무와 관계없이 같은 한 줄 높이를 예약한다', () => {
act(() =>
root.render(
<>
<ShelfColumnStatus message="별칭을 저장하지 못했습니다. 잠시 후 다시 시도해 주세요." />
<ShelfColumnStatus />
</>,
),
);

const statuses = container.querySelectorAll('p');
expect(statuses).toHaveLength(2);
expect(statuses[0].classList.contains('h-4')).toBe(true);
expect(statuses[0].classList.contains('truncate')).toBe(true);
expect(statuses[1].classList.contains('h-4')).toBe(true);
expect(statuses[1].getAttribute('aria-hidden')).toBe('true');
});
});
Loading
Loading