From cff01fd5473cb5ee71a65ea045b034d9795bfc7b Mon Sep 17 00:00:00 2001 From: kyuran kim <57716832+gxxrxn@users.noreply.github.com> Date: Thu, 16 May 2024 15:06:40 +0900 Subject: [PATCH] =?UTF-8?q?[#575]=20Drawer=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EC=84=A0=20(#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Drawer 컴포넌트 headless-ui로 리팩터링 * test: body scroll 막기 위한 textarea blink 애니메이션 적용 * feat: touchevent preventDefault 호출 * fix: textarea 내부에 스크롤 없는 경우에도 toucmove 브라우저 동작 막음 * fix: touchmove 이벤트 조건문 수정 * fix: textarea scrollbar 유무 판단 로직 수정 * fix: useBodyScrollLock -> useRemoveScroll 수정 - eventListner option 타입 정의 (FixedEventListenerOptions) - nonPassive option 정의 * fix: textarea 내부에서 바깥 영역 스크롤 되는 현상 해결 * refactor: 불필요한 log, textarea 배경색 제거, event handler memoization * chore: touchstart handler 선언부 상위로 이동 * feat: comment drawer 열릴 때 text 끝에 focus되도록 수정 - Drawer.CloseButton이 foucs를 받을 수 있도록 button 컨테이너 추가 --- src/hooks/useBodyScrollLock.ts | 36 ----------- src/hooks/useRemoveVerticalScroll.ts | 66 ++++++++++++++++++++ src/styles/global.css | 2 +- src/utils/eventListener.ts | 32 ++++++++++ src/v1/base/Drawer.tsx | 92 +++++++++++++++++++--------- src/v1/comment/CommentDrawer.tsx | 21 ++++++- 6 files changed, 180 insertions(+), 69 deletions(-) delete mode 100644 src/hooks/useBodyScrollLock.ts create mode 100644 src/hooks/useRemoveVerticalScroll.ts create mode 100644 src/utils/eventListener.ts diff --git a/src/hooks/useBodyScrollLock.ts b/src/hooks/useBodyScrollLock.ts deleted file mode 100644 index b251031b2..000000000 --- a/src/hooks/useBodyScrollLock.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; - -type Options = { - enabled?: boolean; -}; - -export function useBodyScrollLock(options?: Options) { - const enabled = options?.enabled; - const scrollRef = useRef(0); - - const lockScroll = useCallback(() => { - scrollRef.current = window.scrollY; - document.body.style.overflow = 'hidden'; - document.body.style.marginTop = `-${scrollRef.current}px`; - }, []); - - const openScroll = useCallback(() => { - document.body.style.removeProperty('overflow'); - document.body.style.removeProperty('margin-top'); - window.scrollTo(0, scrollRef.current); - }, []); - - useEffect(() => { - if (enabled === undefined) { - return; - } - - if (enabled) { - lockScroll(); - } else { - openScroll(); - } - }, [enabled, lockScroll, openScroll]); - - return { lockScroll, openScroll }; -} diff --git a/src/hooks/useRemoveVerticalScroll.ts b/src/hooks/useRemoveVerticalScroll.ts new file mode 100644 index 000000000..6f0a6f128 --- /dev/null +++ b/src/hooks/useRemoveVerticalScroll.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { nonPassive } from '@/utils/eventListener'; + +type Options = { + enabled?: boolean; +}; + +const getTouchXY = (event: TouchEvent | WheelEvent) => + 'changedTouches' in event + ? [event.changedTouches[0].clientX, event.changedTouches[0].clientY] + : [0, 0]; + +const useRemoveVerticalScroll = (options?: Options) => { + const enabled = options?.enabled; + + const touchStartRef = useRef([0, 0]); + + const scrollTouchStart = useCallback((event: TouchEvent) => { + touchStartRef.current = getTouchXY(event); + }, []); + + const shouldLock = useCallback((event: TouchEvent | WheelEvent) => { + if (!event.target) return; + + const node = event.target as HTMLElement; + const { clientHeight, scrollHeight, scrollTop } = node; + + const touch = getTouchXY(event); + const touchStart = touchStartRef.current; + const deltaY = 'deltaY' in event ? event.deltaY : touchStart[1] - touch[1]; + + const isDeltaYPositive = deltaY > 0; // scroll down + + const isScrollToTopEnd = !isDeltaYPositive && scrollTop === 0; + const isScrollToBottomEnd = + isDeltaYPositive && scrollTop + clientHeight === scrollHeight; + + if ( + node.tagName !== 'TEXTAREA' || + isScrollToTopEnd || + isScrollToBottomEnd + ) { + if (event.cancelable) { + event.preventDefault(); + } + } + }, []); + + useEffect(() => { + if (!enabled) { + return; + } + + document.addEventListener('wheel', shouldLock, nonPassive); + document.addEventListener('touchmove', shouldLock, nonPassive); + document.addEventListener('touchstart', scrollTouchStart, nonPassive); + + return () => { + document.removeEventListener('wheel', shouldLock, nonPassive); + document.removeEventListener('touchmove', shouldLock, nonPassive); + document.removeEventListener('touchstart', scrollTouchStart, nonPassive); + }; + }, [enabled, shouldLock, scrollTouchStart]); +}; + +export default useRemoveVerticalScroll; diff --git a/src/styles/global.css b/src/styles/global.css index a98c4987e..86b73f2f6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -29,7 +29,7 @@ .app-layout { /* TODO: Chakra UI 걷어내면 제거 */ max-width: 43rem; - margin: 0 auto !important; + margin: 0 auto; } /* DatePicker Calendar 스타일링 */ diff --git a/src/utils/eventListener.ts b/src/utils/eventListener.ts new file mode 100644 index 000000000..18731f98e --- /dev/null +++ b/src/utils/eventListener.ts @@ -0,0 +1,32 @@ +/** + * 참고 + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#safely_detecting_option_support + * */ + +type FixedEventListenerOptions = + | (AddEventListenerOptions & EventListenerOptions) + | boolean; + +let passiveSupported = false; + +const emptyListener = () => void {}; + +try { + const options: FixedEventListenerOptions = { + get passive() { + // This function will be called when the browser + // attempts to access the passive property. + passiveSupported = true; + return false; + }, + }; + + window.addEventListener('test', emptyListener, options); + window.removeEventListener('test', emptyListener, options); +} catch { + passiveSupported = false; +} + +export const nonPassive: FixedEventListenerOptions = passiveSupported + ? { passive: false } + : false; diff --git a/src/v1/base/Drawer.tsx b/src/v1/base/Drawer.tsx index 8daf4ff89..d5fb2d9e7 100644 --- a/src/v1/base/Drawer.tsx +++ b/src/v1/base/Drawer.tsx @@ -1,13 +1,20 @@ -import { createContext, PropsWithChildren, ReactNode, useContext } from 'react'; +import { + createContext, + Fragment, + PropsWithChildren, + ReactNode, + useContext, +} from 'react'; +import { Dialog, Transition } from '@headlessui/react'; import { IconClose } from '@public/icons'; -import { useBodyScrollLock } from '@/hooks/useBodyScrollLock'; +import useRemoveVerticalScroll from '@/hooks/useRemoveVerticalScroll'; -import Portal from '@/v1/base/Portal'; +import Button from './Button'; interface DrawerProps { isOpen: boolean; - onClose?: () => void; + onClose: () => void; } type DrawerContextValue = DrawerProps; @@ -20,44 +27,66 @@ const Drawer = ({ onClose, children, }: PropsWithChildren) => { - useBodyScrollLock({ enabled: isOpen }); + useRemoveVerticalScroll({ enabled: isOpen }); return ( - -
+ + {/** overlay */} -
- {/** drawer section */} -
- {isOpen && children} -
-
- +
+ + +
+
+
+ + +
+ {children} +
+
+
+
+
+
+
+
); }; const DrawerHeader = ({ children }: { children?: ReactNode }) => { return ( -
+
{children}
); }; const DrawerContent = ({ children }: { children?: ReactNode }) => { - return
{children}
; + return
{children}
; }; const Title = ({ text }: { text?: string }) => { @@ -73,10 +102,10 @@ type Position = 'top-left' | 'top-right'; const getPositionClasses = (postion: Position) => { switch (postion) { case 'top-right': - return 'top-[2.7rem] right-[2rem]'; + return 'top-[2.4rem] right-[1.8rem]'; case 'top-left': default: - return 'top-[2.7rem] left-[2rem]'; + return 'top-[2.4rem] left-[1.8rem]'; } }; @@ -85,10 +114,13 @@ const CloseButton = ({ position = 'top-left' }: { position?: Position }) => { const positionClasses = getPositionClasses(position); return ( - + fill={false} + className={`absolute border-none !p-0 ${positionClasses}`} + > + + ); }; diff --git a/src/v1/comment/CommentDrawer.tsx b/src/v1/comment/CommentDrawer.tsx index c47edea7b..4ea697968 100644 --- a/src/v1/comment/CommentDrawer.tsx +++ b/src/v1/comment/CommentDrawer.tsx @@ -1,4 +1,6 @@ -import { forwardRef } from 'react'; +'use client'; + +import { forwardRef, useEffect } from 'react'; import Button from '@/v1/base/Button'; import Drawer from '@/v1/base/Drawer'; @@ -19,6 +21,21 @@ const CommentDrawer = forwardRef( onClose(); }; + useEffect(() => { + if (!isOpen) return; + + // Drawer가 열릴 때 textarea의 끝에 focus + setTimeout(() => { + const textarea = document.querySelector('textarea'); + + if (textarea) { + textarea.focus(); + textarea.select(); + window.getSelection()?.collapseToEnd(); + } + }, 100); + }, [isOpen]); + return ( @@ -36,7 +53,7 @@ const CommentDrawer = forwardRef(