diff --git a/solution/site/src/lib/ui/Modal.tsx b/solution/site/src/lib/ui/Modal.tsx index 2c49db2..b0205de 100644 --- a/solution/site/src/lib/ui/Modal.tsx +++ b/solution/site/src/lib/ui/Modal.tsx @@ -1,6 +1,7 @@ import {useEffect, useRef} from 'react'; import type {ReactNode} from 'react'; import {X} from 'lucide-react'; +import {useScrollLock} from './use-scroll-lock'; /** * 바텀시트(모바일) · 가운데 카드(데스크톱) 모달 한 장. @@ -29,30 +30,15 @@ export function Modal({ const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + useScrollLock(open); + useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onCloseRef.current(); }; - const scrollY = window.scrollY; - const body = document.body; - const prevPosition = body.style.position; - const prevTop = body.style.top; - const prevWidth = body.style.width; - const prevOverflow = body.style.overflow; - body.style.position = 'fixed'; - body.style.top = `-${scrollY}px`; - body.style.width = '100%'; - body.style.overflow = 'hidden'; window.addEventListener('keydown', onKey); - return () => { - body.style.position = prevPosition; - body.style.top = prevTop; - body.style.width = prevWidth; - body.style.overflow = prevOverflow; - window.scrollTo(0, scrollY); - window.removeEventListener('keydown', onKey); - }; + return () => window.removeEventListener('keydown', onKey); }, [open]); if (!open) return null; diff --git a/solution/site/src/lib/ui/index.ts b/solution/site/src/lib/ui/index.ts index c3d2cb1..e1b5753 100644 --- a/solution/site/src/lib/ui/index.ts +++ b/solution/site/src/lib/ui/index.ts @@ -3,3 +3,4 @@ export {Carousel, CarouselSlide} from './Carousel'; export {Card, Chip, Field, ActionLink} from './primitives'; export {ShowMore} from './ShowMore'; export {Modal} from './Modal'; +export {useScrollLock} from './use-scroll-lock'; diff --git a/solution/site/src/lib/ui/use-scroll-lock.ts b/solution/site/src/lib/ui/use-scroll-lock.ts new file mode 100644 index 0000000..f589fca --- /dev/null +++ b/solution/site/src/lib/ui/use-scroll-lock.ts @@ -0,0 +1,39 @@ +/** + * 모달·오버레이가 열려 있는 동안 배경 스크롤을 잠근다 — **한 벌만 둔다.** + * + * ★ 왜 훅으로 뺐나 (2026-09-21, 사장님: "이거 목업 하나만 해결하면 끝이야? 훅으로 만들어놓던가") + * 같은 잠금·복귀 코드가 Modal · GallerySection · EventSection 세 곳에 따로 박혀 있었다. + * 모달 하나만 고치고 나니 나머지 둘은 그대로 남아 같은 스크롤 버그가 다시 났다 — + * 복사한 코드는 복사한 곳마다 따로 고쳐야 한다. + * ★ 복귀는 즉시 이동이어야 한다. `index.css` 의 전역 `html { scroll-behavior: smooth }` 때문에 + * `window.scrollTo` 가 애니메이션으로 움직이며 "위로 튀었다 내려오는" 것처럼 보인다 — + * 이 한 번만 `scroll-behavior: auto` 로 강제한다. + */ +import {useEffect} from 'react'; + +export function useScrollLock(active: boolean) { + useEffect(() => { + if (!active) return; + const scrollY = window.scrollY; + const body = document.body; + const prevPosition = body.style.position; + const prevTop = body.style.top; + const prevWidth = body.style.width; + const prevOverflow = body.style.overflow; + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + return () => { + body.style.position = prevPosition; + body.style.top = prevTop; + body.style.width = prevWidth; + body.style.overflow = prevOverflow; + const root = document.documentElement; + const prevScrollBehavior = root.style.scrollBehavior; + root.style.scrollBehavior = 'auto'; + window.scrollTo(0, scrollY); + root.style.scrollBehavior = prevScrollBehavior; + }; + }, [active]); +} diff --git a/solution/site/src/sections/GallerySection.tsx b/solution/site/src/sections/GallerySection.tsx index a27e484..e5a3ebc 100644 --- a/solution/site/src/sections/GallerySection.tsx +++ b/solution/site/src/sections/GallerySection.tsx @@ -2,7 +2,7 @@ import {useCallback, useEffect, useState} from 'react'; import {ChevronLeft, ChevronRight, X} from 'lucide-react'; import {useSite} from '@site/lib/site-context'; import {galleryImages} from '@site/lib/derive'; -import {Carousel, CarouselSlide, Section} from '@site/lib/ui'; +import {Carousel, CarouselSlide, Section, useScrollLock} from '@site/lib/ui'; /** * 사진 갤러리. @@ -29,6 +29,8 @@ export function GallerySection() { // 크게 보기 중에는 Esc 로 닫고 좌우 키로 넘긴다. 뒤 배경은 스크롤을 잠근다 — // 잠그지 않으면 모달 위에서 휠을 굴렸을 때 뒤 페이지가 밀린다. + useScrollLock(openIndex !== null); + useEffect(() => { if (openIndex === null) return; const onKey = (event: KeyboardEvent) => { @@ -36,25 +38,8 @@ export function GallerySection() { if (event.key === 'ArrowLeft') step(-1); if (event.key === 'ArrowRight') step(1); }; - const scrollY = window.scrollY; - const body = document.body; - const prevPosition = body.style.position; - const prevTop = body.style.top; - const prevWidth = body.style.width; - const prevOverflow = body.style.overflow; - body.style.position = 'fixed'; - body.style.top = `-${scrollY}px`; - body.style.width = '100%'; - body.style.overflow = 'hidden'; window.addEventListener('keydown', onKey); - return () => { - body.style.position = prevPosition; - body.style.top = prevTop; - body.style.width = prevWidth; - body.style.overflow = prevOverflow; - window.scrollTo(0, scrollY); - window.removeEventListener('keydown', onKey); - }; + return () => window.removeEventListener('keydown', onKey); }, [openIndex, close, step]); if (images.length === 0) return null; diff --git a/solution/site/src/sections/items/EventSection.tsx b/solution/site/src/sections/items/EventSection.tsx index e9be1c0..0206d40 100644 --- a/solution/site/src/sections/items/EventSection.tsx +++ b/solution/site/src/sections/items/EventSection.tsx @@ -18,7 +18,7 @@ * 카드가 무엇을 흉내 내는지 한눈에 보여야 목록이 아니라 물건으로 읽힌다. */ import {useCallback, useEffect, useState} from 'react'; -import {ShowMore} from '@site/lib/ui'; +import {ShowMore, useScrollLock} from '@site/lib/ui'; import {X} from 'lucide-react'; import type {EventItem} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; @@ -90,30 +90,15 @@ export function EventSection() { const close = useCallback(() => setOpen(null), []); // 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다. + useScrollLock(open !== null); + useEffect(() => { if (open === null) return; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close(); }; - const scrollY = window.scrollY; - const body = document.body; - const prevPosition = body.style.position; - const prevTop = body.style.top; - const prevWidth = body.style.width; - const prevOverflow = body.style.overflow; - body.style.position = 'fixed'; - body.style.top = `-${scrollY}px`; - body.style.width = '100%'; - body.style.overflow = 'hidden'; window.addEventListener('keydown', onKey); - return () => { - body.style.position = prevPosition; - body.style.top = prevTop; - body.style.width = prevWidth; - body.style.overflow = prevOverflow; - window.scrollTo(0, scrollY); - window.removeEventListener('keydown', onKey); - }; + return () => window.removeEventListener('keydown', onKey); }, [open, close]); if (parsed.items.length === 0) return null;