diff --git a/src/features/home/components/SearchResultGallery.test.tsx b/src/features/home/components/SearchResultGallery.test.tsx index c17d557..07119b2 100644 --- a/src/features/home/components/SearchResultGallery.test.tsx +++ b/src/features/home/components/SearchResultGallery.test.tsx @@ -61,11 +61,16 @@ describe('SearchResultGallery', () => { const gallery = container.querySelector('.pl-results__gallery'); const cards = Array.from(container.querySelectorAll('.pl-results__card')); + const contextBody = container.querySelector( + '.pl-results__context .context-sticky-note > p:first-of-type', + ); expect(gallery?.getAttribute('role')).toBe('region'); expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 7곳'); expect(gallery?.tabIndex).toBe(0); expect(container.querySelectorAll('.pl-results__page')).toHaveLength(2); + expect(contextBody?.style.fontSize).toBe('18px'); + expect(contextBody?.style.lineHeight).toBe('22px'); expect(cards.map((card) => card.textContent)).toEqual( Array.from({ length: 7 }, (_, index) => expect.stringContaining(`장소 ${index + 1}/7`)), ); @@ -103,4 +108,48 @@ describe('SearchResultGallery', () => { expect(nextButton?.disabled).toBe(true); expect(container.querySelector('.pl-results__page-status')?.textContent).toBe('2/2'); }); + + it('결과 영역을 좌우로 드래그하면 가장 가까운 6개 단위 페이지로 이동한다', () => { + const onSelectRecord = vi.fn(); + act(() => { + root.render(); + }); + + const gallery = container.querySelector('.pl-results__gallery')!; + const pages = Array.from(container.querySelectorAll('.pl-results__page')); + const firstCard = container.querySelector('.pl-results__card')!; + const scrollTo = vi.fn(); + + Object.defineProperties(gallery, { + scrollLeft: { configurable: true, writable: true, value: 0 }, + scrollTo: { configurable: true, value: scrollTo }, + setPointerCapture: { configurable: true, value: vi.fn() }, + hasPointerCapture: { configurable: true, value: () => true }, + releasePointerCapture: { configurable: true, value: vi.fn() }, + }); + Object.defineProperty(pages[1], 'offsetLeft', { configurable: true, value: 960 }); + + act(() => { + gallery.dispatchEvent( + Object.assign(new MouseEvent('pointerdown', { bubbles: true, clientX: 800, button: 0 }), { + pointerId: 1, + }), + ); + gallery.dispatchEvent( + Object.assign(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }), { + pointerId: 1, + }), + ); + gallery.dispatchEvent( + Object.assign(new MouseEvent('pointerup', { bubbles: true, clientX: 200 }), { + pointerId: 1, + }), + ); + firstCard.click(); + }); + + expect(gallery.scrollLeft).toBe(600); + expect(scrollTo).toHaveBeenCalledWith({ left: 960, behavior: 'smooth' }); + expect(onSelectRecord).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/home/components/SearchResultGallery.tsx b/src/features/home/components/SearchResultGallery.tsx index 6a07549..c567c39 100644 --- a/src/features/home/components/SearchResultGallery.tsx +++ b/src/features/home/components/SearchResultGallery.tsx @@ -12,9 +12,10 @@ interface SearchResultGalleryProps { } const SEARCH_RESULT_PAGE_SIZE = 6; +const DRAG_THRESHOLD_PX = 6; const SEARCH_NOTE_METRICS = { - bodyFontPx: 15, - bodyLineHeightPx: 18, + bodyFontPx: 18, + bodyLineHeightPx: 22, metaFontPx: 9, padXPx: 14, padTopPx: 18, @@ -52,7 +53,12 @@ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalle ); const galleryId = useId(); const galleryRef = useRef(null); + const dragStartXRef = useRef(0); + const dragStartScrollLeftRef = useRef(0); + const didDragRef = useRef(false); + const isDraggingRef = useRef(false); const [pageIndex, setPageIndex] = useState(0); + const [isDragging, setIsDragging] = useState(false); const scrollToPage = (nextPageIndex: number) => { const gallery = galleryRef.current; @@ -89,16 +95,80 @@ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalle setPageIndex(nearestPageIndex); }; + const handlePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + const gallery = galleryRef.current; + if (gallery === null) { + return; + } + dragStartXRef.current = event.clientX; + dragStartScrollLeftRef.current = gallery.scrollLeft; + didDragRef.current = false; + isDraggingRef.current = true; + setIsDragging(true); + gallery.setPointerCapture(event.pointerId); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + const gallery = galleryRef.current; + if (!isDraggingRef.current || gallery === null) { + return; + } + const deltaX = event.clientX - dragStartXRef.current; + if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) { + didDragRef.current = true; + } + if (didDragRef.current) { + gallery.scrollLeft = dragStartScrollLeftRef.current - deltaX; + } + }; + + const finishDrag = (event: React.PointerEvent) => { + const gallery = galleryRef.current; + if (!isDraggingRef.current || gallery === null) { + return; + } + isDraggingRef.current = false; + setIsDragging(false); + if (gallery.hasPointerCapture(event.pointerId)) { + gallery.releasePointerCapture(event.pointerId); + } + if (didDragRef.current) { + const pages = Array.from(gallery.querySelectorAll('.pl-results__page')); + const nearestPageIndex = pages.reduce( + (nearest, page, index) => + Math.abs(gallery.scrollLeft - page.offsetLeft) < + Math.abs(gallery.scrollLeft - pages[nearest].offsetLeft) + ? index + : nearest, + 0, + ); + scrollToPage(nearestPageIndex); + } + }; + return (
{ + if (didDragRef.current) { + event.preventDefault(); + event.stopPropagation(); + } + }} > {pages.map((pageItems, pageNumber) => (