Skip to content

Commit

Permalink
[#575] Drawer 컴포넌트 개선 (#576)
Browse files Browse the repository at this point in the history
* 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 컨테이너 추가
  • Loading branch information
gxxrxn committed Jun 17, 2024
1 parent 55a08ee commit cff01fd
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 69 deletions.
36 changes: 0 additions & 36 deletions src/hooks/useBodyScrollLock.ts

This file was deleted.

66 changes: 66 additions & 0 deletions src/hooks/useRemoveVerticalScroll.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
.app-layout {
/* TODO: Chakra UI 걷어내면 제거 */
max-width: 43rem;
margin: 0 auto !important;
margin: 0 auto;
}

/* DatePicker Calendar 스타일링 */
Expand Down
32 changes: 32 additions & 0 deletions src/utils/eventListener.ts
Original file line number Diff line number Diff line change
@@ -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;
92 changes: 62 additions & 30 deletions src/v1/base/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,44 +27,66 @@ const Drawer = ({
onClose,
children,
}: PropsWithChildren<DrawerProps>) => {
useBodyScrollLock({ enabled: isOpen });
useRemoveVerticalScroll({ enabled: isOpen });

return (
<DrawerContext.Provider value={{ isOpen, onClose }}>
<Portal id="drawer-root">
<div
className={`fixed inset-0 z-10 flex w-screen transform justify-center overflow-hidden ease-in-out ${
isOpen
? 'translate-x-0 scale-x-100 opacity-100 transition-opacity duration-500'
: 'translate-x-full scale-x-0 opacity-0 transition-all delay-100 duration-500'
}`}
>
<Transition.Root show={isOpen} as={Fragment}>
<Dialog className="relative z-10" onClose={onClose}>
{/** overlay */}
<div className="absolute h-full w-full" onClick={onClose} />
{/** drawer section */}
<section
className={`duration-400 ease-out-in relative flex h-full w-full max-w-[43rem] transform flex-col gap-[2rem] overflow-hidden bg-white p-[2rem] shadow-bookcard transition-all ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
<Transition.Child
as={Fragment}
enter="ease-in-out duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{isOpen && children}
</section>
</div>
</Portal>
<div className=" fixed inset-0 bg-black-900/50 transition-opacity" />
</Transition.Child>

<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div
className={`pointer-events-none fixed inset-y-0 right-0 flex w-full max-w-full justify-center`}
>
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-600"
enterFrom="translate-x-full opacity-0"
enterTo="translate-x-0 opacity-1"
leave="transform transition ease-in-out duration-500 sm:duration-600"
leaveFrom="translate-x-0 opacity-1"
leaveTo="translate-x-full opacity-0"
>
<Dialog.Panel className="pointer-events-auto relative w-screen max-w-[43rem]">
<div
className={`flex h-full flex-col overflow-y-scroll bg-white pt-6 shadow-xl`}
>
{children}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</div>
</Dialog>
</Transition.Root>
</DrawerContext.Provider>
);
};

const DrawerHeader = ({ children }: { children?: ReactNode }) => {
return (
<div className="flex items-center justify-between py-[0.5rem]">
<div className="flex items-center justify-between px-6 py-[0.5rem] sm:px-8">
{children}
</div>
);
};

const DrawerContent = ({ children }: { children?: ReactNode }) => {
return <div className="w-full text-md">{children}</div>;
return <div className="w-full px-6 pt-6 text-md sm:px-8">{children}</div>;
};

const Title = ({ text }: { text?: string }) => {
Expand All @@ -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]';
}
};

Expand All @@ -85,10 +114,13 @@ const CloseButton = ({ position = 'top-left' }: { position?: Position }) => {
const positionClasses = getPositionClasses(position);

return (
<IconClose
className={`absolute h-[2rem] w-[2rem] cursor-pointer fill-black-900 ${positionClasses}`}
<Button
onClick={onClose}
/>
fill={false}
className={`absolute border-none !p-0 ${positionClasses}`}
>
<IconClose className={`h-[2rem] w-[2rem] fill-black-900 `} />
</Button>
);
};

Expand Down
21 changes: 19 additions & 2 deletions src/v1/comment/CommentDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +21,21 @@ const CommentDrawer = forwardRef<HTMLTextAreaElement, CommentDrawerProps>(
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 (
<Drawer isOpen={isOpen} onClose={onClose}>
<Drawer.Header>
Expand All @@ -36,7 +53,7 @@ const CommentDrawer = forwardRef<HTMLTextAreaElement, CommentDrawerProps>(
</Drawer.Header>
<Drawer.Content>
<textarea
className="h-full w-full resize-none border-none text-md focus:outline-none"
className="w-full resize-none border-none text-md focus:outline-none"
rows={15}
defaultValue={defaultComment}
placeholder={placeholder}
Expand Down

0 comments on commit cff01fd

Please sign in to comment.