diff --git a/src/features/collections/components/CollectionOverlayShell.test.tsx b/src/features/collections/components/CollectionOverlayShell.test.tsx new file mode 100644 index 0000000..b6bac33 --- /dev/null +++ b/src/features/collections/components/CollectionOverlayShell.test.tsx @@ -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( + + + , + ), + ); + + const dialog = container.querySelector('[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( + + + + , + ), + ); + + const dialog = container.querySelector('[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(); + }); +}); diff --git a/src/features/collections/components/CollectionOverlayShell.tsx b/src/features/collections/components/CollectionOverlayShell.tsx index aa1507d..13f2aba 100644 --- a/src/features/collections/components/CollectionOverlayShell.tsx +++ b/src/features/collections/components/CollectionOverlayShell.tsx @@ -13,14 +13,13 @@ interface CollectionOverlayShellProps { * * ## 418에서 바뀐 것 * - * - **라우트 이동이 아니라 모달이다.** 컬렉션을 열면 원래 보던 화면(책장·Feed)이 뒤에 흐리게 남고 - * 그 위에 책이 펼쳐진다. **URL은 그대로 둔다**(사용자 확정 (a)안) — 라우트가 이 모달을 렌더하는 + * - **라우트 이동이 아니라 모달이다.** 컬렉션을 열면 원래 보던 화면 위에 책이 펼쳐진다. + * 별도 딤·블러 배경은 두지 않아 새 페이지처럼 화면 전체를 다시 칠하지 않는다. + * **URL은 그대로 둔다**(사용자 확정 (a)안) — 라우트가 이 모달을 렌더하는 * 형태라 뒤로가기·새로고침·공유·Feed CLICK 파라미터(`feedRequestId`/`feedPosition`) 흐름이 * 그대로 살아 있다. * - **ESC·포커스 트랩·포커스 복귀**를 385 설정 모달·ConfirmDialog와 같은 규칙으로 건다. 332 시절 * 이 셸은 그냥 블러 배경일 뿐이라 Tab이 뒤 화면으로 새어 나갔다. - * - 스크림을 남색에서 **따뜻한 잉크**로 바꿨다(415와 같은 값) — 뒤가 파랗게 물들면 크림·베이지 - * 종이가 차갑게 읽혀 다이어리 인상이 깨진다. * * ⚠️ **배경 클릭으로 닫지 않는다**(사용자 확정: "x 버튼을 누를 때만 닫히는 건 좋아"). 읽는 중에 * 배경을 잘못 눌러 책이 닫히는 사고를 막는 편을 택한 것이다. 닫는 길은 펼침면 우상단 ✕와 ESC다. @@ -30,32 +29,27 @@ interface CollectionOverlayShellProps { export function CollectionOverlayShell({ onClose, children }: CollectionOverlayShellProps) { const panelRef = useRef(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; @@ -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(); @@ -92,7 +92,7 @@ export function CollectionOverlayShell({ onClose, children }: CollectionOverlayS return ( // overflow-y-auto는 마지막 안전장치다 — 펼침면은 뷰포트 높이에 맞춰져 있어(CollectionSpreadPage) // PC 폭에서는 돌지 않는다. md 미만에서 두 면이 위아래로 쌓일 때만 쓰인다. -
+
{/* 332는 세로 중앙 정렬을 금지했다 — 장마다 높이가 달라 콘텐츠 전체가 위아래로 흔들리며 "번쩍임"으로 보였기 때문이다. 418에서 펼침면 높이가 `min(760px, 100dvh-88px)`로 **고정** 되면서 그 전제가 사라져(장을 넘겨도 높이가 변하지 않는다) 다시 중앙에 세운다 — 1080p에서 @@ -101,10 +101,11 @@ export function CollectionOverlayShell({ onClose, children }: CollectionOverlayS (중앙 정렬은 넘치는 콘텐츠의 윗부분을 스크롤로도 닿지 못하게 잘라낸다). */}
{children}
diff --git a/src/features/collections/components/MyShelfList.tsx b/src/features/collections/components/MyShelfList.tsx index e155437..124af20 100644 --- a/src/features/collections/components/MyShelfList.tsx +++ b/src/features/collections/components/MyShelfList.tsx @@ -18,6 +18,7 @@ import { ShelfCabinet, ShelfColumn, ShelfColumnSkeleton, + ShelfColumnStatus, ShelfLabel, ShelfTier, } from '@/shared/ui/Shelf'; @@ -80,7 +81,8 @@ export function MyShelfColumn({ return ( <> 내 컬렉션 - + + ); } @@ -89,11 +91,8 @@ export function MyShelfColumn({ return ( <> 내 컬렉션 - + + ); } @@ -126,10 +125,7 @@ export function MyShelfColumn({ return ( <> 내 컬렉션 - - {collections.length === 0 && ( -

아직 만든 컬렉션이 없어요.

- )} + {/* 287-8: flex-1 min-h-0으로 부모가 내어주는 세로 공간을 그대로 채운다(많으면 overflow-y-auto로 스크롤). 319: 여기 있던 min-h-[360px]/max-h-[590px]는 없앴다(위 주석). diff --git a/src/features/follows/components/FollowedShelfCard.tsx b/src/features/follows/components/FollowedShelfCard.tsx index ca79928..8e52eb0 100644 --- a/src/features/follows/components/FollowedShelfCard.tsx +++ b/src/features/follows/components/FollowedShelfCard.tsx @@ -12,6 +12,7 @@ import { ConfirmDialog } from '@/shared/ui/ConfirmDialog'; import { ShelfBookSpine, ShelfColumnSkeleton, + ShelfColumnStatus, ShelfIconButton, ShelfLabel, ShelfTier, @@ -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 ?? ''); @@ -287,19 +299,13 @@ export function FollowedShelfCard({
)} - {updateAliasMutation.isError && ( -

{updateAliasMutation.error.message}

- )} + {/* 416: 로딩·오류에서도 선반은 그대로 깔린다(ShelfColumnSkeleton 주석). 문구만 띄우면 목록이 도착할 때 선반이 통째로 나타나 "선반 수가 갑자기 바뀐다"로 보인다. */} {collectionsQuery.isPending ? ( - + ) : collectionsQuery.isError ? ( - + ) : ( - {collections.length === 0 && ( -

공개된 컬렉션이 없습니다

- )} - {/* 287-8/319: MyShelfColumn과 동일하게 flex-1 min-h-0이다 — 팔로우한 책장의 책 수와 무관하게, 부모(ShelfColumn)가 내어주는 세로 공간을 그대로 채운다(319에서 min-h-[360px]/ max-h-[590px] 고정 캡을 없앤 이유는 MyShelfList.tsx 상단 주석 참고). paddingTop/ diff --git a/src/features/home/paperAperture.css b/src/features/home/paperAperture.css index 58cfa7c..47570ba 100644 --- a/src/features/home/paperAperture.css +++ b/src/features/home/paperAperture.css @@ -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); } /* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ───────────────────────── @@ -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; diff --git a/src/features/records/components/PlaceRecordSheet.tsx b/src/features/records/components/PlaceRecordSheet.tsx index 4989161..4d30181 100644 --- a/src/features/records/components/PlaceRecordSheet.tsx +++ b/src/features/records/components/PlaceRecordSheet.tsx @@ -589,14 +589,16 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe 통째로 사라진다(S15P11A705-324). 닫기 진입점은 헤더 X 버튼 하나로 통일한다. */}
-
+ {/* 433: 장소 추천/기록 노트를 Record 상세와 같은 최대 높이 760px로 줄인다. 기존 720×900의 + 4:5 비율을 유지해 폭도 760×0.8=608px로 함께 축소한다. */} +
{/* page stack behind — mockup의 "노트 뒤에 쌓인 종이" 연출 */}
{/* notebook page */}
- + , ); statusMessageShown = true; diff --git a/src/shared/ui/Shelf.test.tsx b/src/shared/ui/Shelf.test.tsx new file mode 100644 index 0000000..dfdaaa3 --- /dev/null +++ b/src/shared/ui/Shelf.test.tsx @@ -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( + <> + + + , + ), + ); + + 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'); + }); +}); diff --git a/src/shared/ui/Shelf.tsx b/src/shared/ui/Shelf.tsx index 312ba8c..7165e2a 100644 --- a/src/shared/ui/Shelf.tsx +++ b/src/shared/ui/Shelf.tsx @@ -477,6 +477,32 @@ export function ShelfColumn({ children }: { children: ReactNode }) { ); } +/** + * Library 열의 상태 문구가 차지하는 고정 한 줄. + * + * 빈 목록일 때만 `

`를 끼우면 그 열의 flex-1 선반 영역만 한 줄만큼 짧아져, 데이터가 있는 + * 이웃 열과 ShelfTier 높이가 달라진다. 문구가 없어도 같은 높이를 예약해 모든 열의 선반 시작점과 + * 끝점을 고정한다. + */ +export function ShelfColumnStatus({ + message = null, + tone = 'muted', +}: { + message?: string | null; + tone?: 'muted' | 'error'; +}) { + return ( +

+ {message ?? '\u00a0'} +

+ ); +} + // 250: 한 행(row)에 들어갈 스파인들. chunkIntoShelfRows(getRowCapacity)로 이미 한 줄 분량으로 잘려 // 들어오므로 줄바꿈·스크롤은 이 레벨에서 다루지 않는다 — ShelfTier/바깥 컨테이너가 담당한다. // 287-4: 행 높이를 실제 책 높이(getSpineHeight, 112~168 가변)와 무관하게 SPINE_MAX_HEIGHT로