diff --git a/src/features/home/components/SearchResultGallery.test.tsx b/src/features/home/components/SearchResultGallery.test.tsx new file mode 100644 index 0000000..5d01421 --- /dev/null +++ b/src/features/home/components/SearchResultGallery.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 type { SearchResultItem } from '@/features/search/api/searchRecords'; +import { SearchResultGallery } from './SearchResultGallery'; + +const ITEMS: SearchResultItem[] = Array.from({ length: 4 }, (_, index) => ({ + recordId: index + 1, + similarity: 1 - index * 0.1, + place: { + placeId: index + 10, + name: `장소 ${index + 1}`, + address: `주소 ${index + 1}`, + lat: 37.5, + lng: 127, + }, + matchedContext: { + contextId: index + 20, + body: `맥락 ${index + 1}`, + createdAt: '2026-08-12T00:00:00+09:00', + }, + keywords: [], + keywordStatus: 'COMPLETED', + createdAt: '2026-08-12T00:00:00+09:00', +})); + +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('SearchResultGallery', () => { + it('가로 탐색 가능한 결과 영역과 응답 순서의 카드를 렌더한다', () => { + const onSelectRecord = vi.fn(); + + act(() => { + root.render(); + }); + + const gallery = container.querySelector('.pl-results__gallery'); + const cards = Array.from(container.querySelectorAll('.pl-results__card')); + + expect(gallery?.getAttribute('role')).toBe('region'); + expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 4곳'); + expect(gallery?.tabIndex).toBe(0); + expect(cards.map((card) => card.textContent)).toEqual([ + expect.stringContaining('장소 1/4'), + expect.stringContaining('장소 2/4'), + expect.stringContaining('장소 3/4'), + expect.stringContaining('장소 4/4'), + ]); + + act(() => cards[2].click()); + expect(onSelectRecord).toHaveBeenCalledWith(3); + }); +}); diff --git a/src/features/home/components/SearchResultGallery.tsx b/src/features/home/components/SearchResultGallery.tsx index 9646a52..86fa9e1 100644 --- a/src/features/home/components/SearchResultGallery.tsx +++ b/src/features/home/components/SearchResultGallery.tsx @@ -26,21 +26,27 @@ interface SearchResultGalleryProps { * `POST /search/records`는 "내 기록의 맥락"만 검색하므로(api-contract.md "Place · 지도 · 검색" * 표) matchedContext.body는 항상 본인 Context 원문이라 공개 범위 규칙(타인 Context 원문 비공개) * 위반이 아니다. attachment='flat'을 쓴 이유는 lifted보다 회전·그림자가 절제돼 있어 좁은 - * 카드(w-72) 안에서 다른 항목(이름·위치·키워드)과 부딪히지 않기 때문 — 디자인 시안이 도착하면 - * 이 선택은 다시 확인한다. + * 카드 안에서 다른 항목(이름·위치·키워드)과 부딪히지 않기 때문 — 디자인 시안이 도착하면 + * 이 선택은 다시 확인한다. S15P11A705-437부터 카드 폭은 고정값이 아니라 갤러리 가용 폭의 + * 3등분이다. 바깥 결과 영역이 좁아져도 세 번째 카드만 잘리는 대신 세 카드가 함께 줄어든다. * editable을 넘기지 않는다(기본 false) — 검색 결과 카드는 Context를 고치는 자리가 아니다. */ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalleryProps) { const total = items.length; return ( - + {items.map((item, index) => ( onSelectRecord(item.recordId)} - className="flex w-72 flex-none flex-col gap-3 rounded-2xl border border-line-card bg-white p-5 text-left shadow-sm transition-transform hover:-translate-y-1" + className="pl-results__card flex min-w-0 flex-col gap-3 rounded-2xl border border-line-card bg-white p-5 text-left shadow-sm transition-transform hover:-translate-y-1" > 장소 {index + 1}/{total} diff --git a/src/features/home/paperAperture.css b/src/features/home/paperAperture.css index 47570ba..d1e79e8 100644 --- a/src/features/home/paperAperture.css +++ b/src/features/home/paperAperture.css @@ -1088,18 +1088,67 @@ } /* ── 검색 결과 ─────────────────────────────────────────────────────── 결과는 창이 완전히 열린 뒤(--open이 1일 때)에만 존재하므로 종이 판과 겹칠 일이 없다. - 좌우 레일의 포스트잇·책은 지도 쪽으로 삐져나오므로 넓은 판형에서는 360px 이상의 안전 여백을 - 예약한다. 1080px 이하에서는 레일 자체가 사라져 원래의 유동 여백으로 되돌린다. + 437: 예전처럼 좌우에 각각 360px 이상을 예약하면 1280px PC에서 실제 결과 폭이 약 560px밖에 + 남지 않아 288px 카드 세 장 중 마지막 장이 잘렸다. 레일 소품이 실제로 침범하는 160px만 + 양쪽에 남기고 중앙 갤러리를 최대 960px로 제한한다. 내부 카드는 이 폭을 정확히 3등분한다. z-index는 판(6·7)보다 위, 도크(20)보다 아래에 둔다 — 지우다 만 상태에서 판이 아직 돌아오는 중일 때 결과가 종이 밑으로 들어가 보이지 않게 하려는 것이다. */ .pl-results { position: absolute; z-index: 15; - left: 0; - right: 0; + left: 50%; top: 50%; - transform: translateY(-50%); - padding: 0 clamp(360px, 25%, 440px); + width: min(960px, calc(100% - 320px)); + transform: translate(-50%, -50%); +} + +/* 437: 세 카드가 한 화면의 한 "쪽"이다. 네 번째부터는 같은 폭으로 오른쪽에 이어지고, 각 쪽의 + 첫 카드(1·4·7…)에만 snap을 걸어 휠·트랙패드 탐색이 세 장 단위로 안정된다. 스크롤 영역 자체에 + 포커스를 줄 수 있어 키보드 화살표/페이지 이동도 브라우저 기본 동작으로 사용할 수 있다. */ +.pl-results__gallery { + display: grid; + grid-auto-flow: column; + grid-auto-columns: calc((100% - 2rem) / 3); + gap: 1rem; + overflow-x: auto; + overflow-y: hidden; + padding: 4px 2px 12px; + scroll-padding-inline: 2px; + scroll-snap-type: x mandatory; + scrollbar-color: theme('colors.log-mint') rgba(250, 247, 246, 0.82); + outline: none; +} + +.pl-results__gallery:focus-visible { + border-radius: 18px; + outline: 2px solid theme('colors.log-mint'); + outline-offset: 4px; +} + +.pl-results__card:nth-child(3n + 1) { + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.pl-results__gallery::-webkit-scrollbar { + height: 12px; +} + +.pl-results__gallery::-webkit-scrollbar-track { + background-color: rgba(250, 247, 246, 0.82); + border: 1px solid theme('colors.line-card'); + border-radius: 9999px; +} + +.pl-results__gallery::-webkit-scrollbar-thumb { + border: 3px solid transparent; + background-clip: content-box; + background-color: theme('colors.log-mint'); + border-radius: 9999px; +} + +.pl-results__gallery::-webkit-scrollbar-thumb:hover { + background-color: theme('colors.pin-navy'); } /* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ───────────────────────── @@ -1119,7 +1168,7 @@ --pl-sh-x: 0%; } .pl-results { - padding: 0 clamp(22px, 7%, 92px); + width: min(960px, calc(100% - 44px)); } .pl-dock { /* 곁열이 없으니 밀 대상도 없다 — 무대의 수학적 중앙이 곧 광학적 중앙이다. */ @@ -1152,6 +1201,13 @@ padding: 10px 14px; font-size: 12px; } + .pl-results__gallery { + grid-auto-columns: min(86%, 288px); + scroll-snap-type: x mandatory; + } + .pl-results__card:nth-child(n) { + scroll-snap-align: start; + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/features/records/components/PlaceRecordSheet.test.tsx b/src/features/records/components/PlaceRecordSheet.test.tsx index 542e5f8..1682a82 100644 --- a/src/features/records/components/PlaceRecordSheet.test.tsx +++ b/src/features/records/components/PlaceRecordSheet.test.tsx @@ -147,7 +147,9 @@ describe('PlaceRecordSheet 760px 노트 밀도', () => { (button) => button.textContent?.trim() === '이 이미지로 분석 시작', ); - expect(dropzone?.className).toContain('min-h-[120px]'); + expect(dropzone?.className).toContain('flex-1'); + expect(dropzone?.closest('div')?.className).toContain('min-h-[156px]'); + expect(dropzone?.closest('div')?.className).toContain('my-3'); expect(analyzeButton?.className).toContain('h-12'); expect(analyzeButton?.className).not.toContain('h-[60px]'); }); diff --git a/src/features/records/components/PlaceRecordSheet.tsx b/src/features/records/components/PlaceRecordSheet.tsx index f7a857e..643101c 100644 --- a/src/features/records/components/PlaceRecordSheet.tsx +++ b/src/features/records/components/PlaceRecordSheet.tsx @@ -683,7 +683,7 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe * 분석된 이미지는 저장되지 않으며, 장소 후보 확인에만 사용됩니다. - + { @@ -693,10 +693,9 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe onDragLeave={() => setIsDraggingImage(false)} onDrop={handleDropImage} className={ - 'block cursor-pointer overflow-hidden rounded-xl border-4 shadow-[0_8px_20px_-10px_rgba(60,54,48,0.4)] transition-colors ' + + 'flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-xl border-4 shadow-[0_8px_20px_-10px_rgba(60,54,48,0.4)] transition-colors ' + (isDraggingImage ? 'border-[#e9f4ee]' : 'border-white') } - style={{ transform: 'rotate(-0.8deg)' }} > {imagePreviewUrl && imageFile ? ( - - - + + + + + {imageFile.name} @@ -724,17 +725,19 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe ) : ( - - + + + + + + + + 이미지를 드래그하거나 클릭하여 업로드 + + + JPG, PNG · 최대 1장(10MB 이하) + - - 이미지를 드래그하거나 클릭하여 업로드 - - - JPG, PNG · 최대 1장(10MB 이하) - )}
장소 {index + 1}/{total} diff --git a/src/features/home/paperAperture.css b/src/features/home/paperAperture.css index 47570ba..d1e79e8 100644 --- a/src/features/home/paperAperture.css +++ b/src/features/home/paperAperture.css @@ -1088,18 +1088,67 @@ } /* ── 검색 결과 ─────────────────────────────────────────────────────── 결과는 창이 완전히 열린 뒤(--open이 1일 때)에만 존재하므로 종이 판과 겹칠 일이 없다. - 좌우 레일의 포스트잇·책은 지도 쪽으로 삐져나오므로 넓은 판형에서는 360px 이상의 안전 여백을 - 예약한다. 1080px 이하에서는 레일 자체가 사라져 원래의 유동 여백으로 되돌린다. + 437: 예전처럼 좌우에 각각 360px 이상을 예약하면 1280px PC에서 실제 결과 폭이 약 560px밖에 + 남지 않아 288px 카드 세 장 중 마지막 장이 잘렸다. 레일 소품이 실제로 침범하는 160px만 + 양쪽에 남기고 중앙 갤러리를 최대 960px로 제한한다. 내부 카드는 이 폭을 정확히 3등분한다. z-index는 판(6·7)보다 위, 도크(20)보다 아래에 둔다 — 지우다 만 상태에서 판이 아직 돌아오는 중일 때 결과가 종이 밑으로 들어가 보이지 않게 하려는 것이다. */ .pl-results { position: absolute; z-index: 15; - left: 0; - right: 0; + left: 50%; top: 50%; - transform: translateY(-50%); - padding: 0 clamp(360px, 25%, 440px); + width: min(960px, calc(100% - 320px)); + transform: translate(-50%, -50%); +} + +/* 437: 세 카드가 한 화면의 한 "쪽"이다. 네 번째부터는 같은 폭으로 오른쪽에 이어지고, 각 쪽의 + 첫 카드(1·4·7…)에만 snap을 걸어 휠·트랙패드 탐색이 세 장 단위로 안정된다. 스크롤 영역 자체에 + 포커스를 줄 수 있어 키보드 화살표/페이지 이동도 브라우저 기본 동작으로 사용할 수 있다. */ +.pl-results__gallery { + display: grid; + grid-auto-flow: column; + grid-auto-columns: calc((100% - 2rem) / 3); + gap: 1rem; + overflow-x: auto; + overflow-y: hidden; + padding: 4px 2px 12px; + scroll-padding-inline: 2px; + scroll-snap-type: x mandatory; + scrollbar-color: theme('colors.log-mint') rgba(250, 247, 246, 0.82); + outline: none; +} + +.pl-results__gallery:focus-visible { + border-radius: 18px; + outline: 2px solid theme('colors.log-mint'); + outline-offset: 4px; +} + +.pl-results__card:nth-child(3n + 1) { + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.pl-results__gallery::-webkit-scrollbar { + height: 12px; +} + +.pl-results__gallery::-webkit-scrollbar-track { + background-color: rgba(250, 247, 246, 0.82); + border: 1px solid theme('colors.line-card'); + border-radius: 9999px; +} + +.pl-results__gallery::-webkit-scrollbar-thumb { + border: 3px solid transparent; + background-clip: content-box; + background-color: theme('colors.log-mint'); + border-radius: 9999px; +} + +.pl-results__gallery::-webkit-scrollbar-thumb:hover { + background-color: theme('colors.pin-navy'); } /* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ───────────────────────── @@ -1119,7 +1168,7 @@ --pl-sh-x: 0%; } .pl-results { - padding: 0 clamp(22px, 7%, 92px); + width: min(960px, calc(100% - 44px)); } .pl-dock { /* 곁열이 없으니 밀 대상도 없다 — 무대의 수학적 중앙이 곧 광학적 중앙이다. */ @@ -1152,6 +1201,13 @@ padding: 10px 14px; font-size: 12px; } + .pl-results__gallery { + grid-auto-columns: min(86%, 288px); + scroll-snap-type: x mandatory; + } + .pl-results__card:nth-child(n) { + scroll-snap-align: start; + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/features/records/components/PlaceRecordSheet.test.tsx b/src/features/records/components/PlaceRecordSheet.test.tsx index 542e5f8..1682a82 100644 --- a/src/features/records/components/PlaceRecordSheet.test.tsx +++ b/src/features/records/components/PlaceRecordSheet.test.tsx @@ -147,7 +147,9 @@ describe('PlaceRecordSheet 760px 노트 밀도', () => { (button) => button.textContent?.trim() === '이 이미지로 분석 시작', ); - expect(dropzone?.className).toContain('min-h-[120px]'); + expect(dropzone?.className).toContain('flex-1'); + expect(dropzone?.closest('div')?.className).toContain('min-h-[156px]'); + expect(dropzone?.closest('div')?.className).toContain('my-3'); expect(analyzeButton?.className).toContain('h-12'); expect(analyzeButton?.className).not.toContain('h-[60px]'); }); diff --git a/src/features/records/components/PlaceRecordSheet.tsx b/src/features/records/components/PlaceRecordSheet.tsx index f7a857e..643101c 100644 --- a/src/features/records/components/PlaceRecordSheet.tsx +++ b/src/features/records/components/PlaceRecordSheet.tsx @@ -683,7 +683,7 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe * 분석된 이미지는 저장되지 않으며, 장소 후보 확인에만 사용됩니다.