diff --git a/src/components/BottomSheet/BottomSheet.css.ts b/src/components/BottomSheet/BottomSheet.css.ts index 3e21394b..34b0a558 100644 --- a/src/components/BottomSheet/BottomSheet.css.ts +++ b/src/components/BottomSheet/BottomSheet.css.ts @@ -2,13 +2,14 @@ import { themeTokens } from '@/styles/theme.css'; import { style, globalStyle } from '@vanilla-extract/css'; import { fontVariant } from '@/styles/variant.css'; import { recipe } from '@vanilla-extract/recipes'; +import { sizeProp } from '@/utils/sizeProp'; const { color, zIndices } = themeTokens; export const titleWrapper = style({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - marginBottom: '30px', + padding: '20px 20px 30px 20px', }); globalStyle(`${titleWrapper} > div`, { ...fontVariant.title3, @@ -46,13 +47,14 @@ export const wrap = recipe({ position: 'fixed', display: 'flex', bottom: '0', + left: '0', width: '100%', background: color.white, zIndex: zIndices.modal, - padding: '20px', borderRadius: '14px 14px 0px 0px', flexDirection: 'column', - justifyContent: 'flex-end', + justifyContent: 'flex-start', + minHeight: sizeProp('632px'), }, variants: { open: { diff --git a/src/components/BottomSheet/BottomSheet.tsx b/src/components/BottomSheet/BottomSheet.tsx index cfa6f1cd..9c56eecf 100644 --- a/src/components/BottomSheet/BottomSheet.tsx +++ b/src/components/BottomSheet/BottomSheet.tsx @@ -1,32 +1,15 @@ 'use client'; -import { HTMLAttributes } from 'react'; -import cx from 'classnames'; -import { titleWrapper, background, wrap } from './BottomSheet.css'; -import IconButton from '../IconButton/IconButton'; -import { useLockScroll } from '@/hooks'; + +import { ComponentProps } from 'react'; +import BottomSheetView from './BottomSheetView'; import { useModalControl } from './hooks'; -type BottomSheetProps = { - onClose: () => void; - title?: string; -} & HTMLAttributes; +type BottomSheetProps = Omit, 'isOpen'>; -const BottomSheet = ({ className, onClose, title, children, ...rest }: BottomSheetProps) => { - useLockScroll(); +const BottomSheet = ({ title, onClose, ...rest }: BottomSheetProps) => { const { ref, isOpen, closeModalWithTransition } = useModalControl({ onClose }); - return ( - <> -
-
-
-
{title}
- -
- {children} -
- - ); + return ; }; export default BottomSheet; diff --git a/src/components/BottomSheet/BottomSheetView.tsx b/src/components/BottomSheet/BottomSheetView.tsx new file mode 100644 index 00000000..8578dcdc --- /dev/null +++ b/src/components/BottomSheet/BottomSheetView.tsx @@ -0,0 +1,39 @@ +import { HTMLAttributes, forwardRef } from 'react'; +import cx from 'classnames'; +import { IconButton } from '@/components'; +import { useLockScroll } from '@/hooks'; +import { titleWrapper, background, wrap } from './BottomSheet.css'; + +interface Props extends HTMLAttributes { + /** BottomSheet 제목으로 표시할 텍스트 */ + title?: string; + + /** bottomsheet 열림/닫힘 상태 */ + isOpen: boolean; + + /** bottomsheet 닫을때 호출되는 함수 */ + onClose: () => void; +} + +const BottomSheetView = forwardRef( + ({ title, isOpen, children, onClose, className, ...rest }, ref) => { + useLockScroll(); + + return ( + <> +
+
+
+
{title}
+ +
+ {children} +
+ + ); + }, +); + +BottomSheetView.displayName = 'BottomSheetView'; + +export default BottomSheetView; diff --git a/src/components/BottomSheet/hooks/useModalControl/useModalControl.ts b/src/components/BottomSheet/hooks/useModalControl/useModalControl.ts index bf1c2593..d4ebdd8c 100644 --- a/src/components/BottomSheet/hooks/useModalControl/useModalControl.ts +++ b/src/components/BottomSheet/hooks/useModalControl/useModalControl.ts @@ -1,10 +1,10 @@ import { useState, useEffect, useRef } from 'react'; -import { useClickOutside } from '@/hooks'; interface Props { onClose: () => void; } const useModalControl = ({ onClose }: Props) => { + const ref = useRef(null); const [isOpen, setIsOpen] = useState(false); const closeStatus = useRef(false); const closeModalWithTransition = () => { @@ -14,11 +14,7 @@ const useModalControl = ({ onClose }: Props) => { const openModalWithTransition = () => { setIsOpen(true); }; - const ref = useClickOutside({ - onClickOutside: () => { - closeModalWithTransition(); - }, - }); + const handleTransitionEnd = (event: TransitionEvent) => { if (event.propertyName === 'transform' && closeStatus.current) { onClose(); diff --git a/src/components/Dropdown/Dropdown.css.ts b/src/components/Dropdown/Dropdown.css.ts index 126fef17..7fbaa686 100644 --- a/src/components/Dropdown/Dropdown.css.ts +++ b/src/components/Dropdown/Dropdown.css.ts @@ -2,6 +2,7 @@ import { style } from '@vanilla-extract/css'; import { themeTokens } from '@/styles/theme.css'; import { fontVariant } from '@/styles/variant.css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; +import { sizeProp } from '@/utils/sizeProp'; const { color, space, borderRadius, zIndices } = themeTokens; @@ -10,17 +11,15 @@ export const wrapper = style({ }); export const buttonBase = style({ - height: '3.5rem', + width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', - padding: `0 ${space.lg}`, - width: '100%', backgroundColor: color.grey50, - borderRadius: borderRadius.md, cursor: 'pointer', border: `1px solid ${color.grey100}`, color: color.sub, + padding: `0 ${space.lg}`, }); export const buttonVariant = recipe({ @@ -30,8 +29,19 @@ export const buttonVariant = recipe({ borderColor: color.red500, }, }, + size: { + medium: { + height: '3.5rem', + borderRadius: borderRadius.md, + }, + small: { + height: '2.5rem', + borderRadius: borderRadius.xs, + }, + }, }, defaultVariants: { + size: 'medium', isError: false, }, }); @@ -80,13 +90,7 @@ export const menu = recipe({ overflow: 'auto', selectors: { '&::-webkit-scrollbar': { - width: '12px', - }, - '&::-webkit-scrollbar-thumb': { - borderRadius: borderRadius.pill, - border: `4px solid rgba(0,0,0,0)`, - backgroundClip: 'padding-box', - backgroundColor: color.grey300, + display: 'none', }, }, }, @@ -133,7 +137,6 @@ export const item = recipe({ base: { ...fontVariant.label2, margin: '0', - padding: `${space.md} ${space.lg}`, listStyleType: 'none', cursor: 'pointer', display: 'flex', @@ -159,9 +162,18 @@ export const item = recipe({ }, }, }, + size: { + medium: { + padding: `${space.md} ${space.lg}`, + }, + small: { + padding: `${space.md} ${space.md}`, + }, + }, }, defaultVariants: { selected: false, + size: 'small', }, }); @@ -169,5 +181,23 @@ export const checkedIconColor = style({ color: color.grey500, }); +export const modal = style({ + width: 'calc(100% - 40px) !important', + padding: `${space.sm} !important`, + maxHeight: sizeProp('540px'), + overflow: 'scroll', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +}); + +export const modalContent = style({ + margin: '0 !important', +}); + export type MenuVariant = RecipeVariants; +export type ButtonVariant = RecipeVariants; +export type Size = NonNullable['size']; export type Placement = NonNullable['placement']; diff --git a/src/components/Dropdown/DropdownBottomSheet.tsx b/src/components/Dropdown/DropdownBottomSheet.tsx new file mode 100644 index 00000000..99d31b6b --- /dev/null +++ b/src/components/Dropdown/DropdownBottomSheet.tsx @@ -0,0 +1,56 @@ +'use client'; + +import cx from 'classnames'; +import BottomSheetView from '@/components/BottomSheet/BottomSheetView'; +import { HTMLAttributes, useMemo, forwardRef, useImperativeHandle, ComponentProps } from 'react'; +import { useDropdownContext } from './contexts/DropdownContext'; +import { type DropdownMenuContextValue, DropdownMenuContextProvider } from './contexts/DropdownMenuContext'; +import useDropdownBottomSheet from './hooks/useDropdownBottomSheet/useDropdownBottomSheet'; + +interface Props + extends HTMLAttributes, + Partial, + Omit, 'isOpen' | 'onClose'> {} + +export interface DropdownMenuHandle { + /** menu를 닫는다 */ + close: () => void; +} + +const DropdownBottomSheet = forwardRef( + ({ children, className, selectable = false, selectedItemKey = null, onSelectChange = () => null, ...rest }, ref) => { + const { isOpen: isDropdownOpen, closeDropdown } = useDropdownContext(); + + const [bottomSheetRef, isBottmSheetOpen, , closeBottomSheet] = useDropdownBottomSheet({ + isDropdownOpen, + onClose: closeDropdown, + }); + + const menuContextValue = useMemo( + () => ({ selectable, selectedItemKey, onSelectChange, closeDropdown: closeBottomSheet }), + [selectable, selectedItemKey, onSelectChange, closeBottomSheet], + ); + + useImperativeHandle(ref, () => ({ + close: closeBottomSheet, + })); + + return ( + + + {children} + + + ); + }, +); + +DropdownBottomSheet.displayName = 'DropdownBottomSheet'; + +export default DropdownBottomSheet; diff --git a/src/components/Dropdown/DropdownButton.tsx b/src/components/Dropdown/DropdownButton.tsx index 1a1ca5a2..d56a8fd8 100644 --- a/src/components/Dropdown/DropdownButton.tsx +++ b/src/components/Dropdown/DropdownButton.tsx @@ -2,22 +2,38 @@ import cx from 'classnames'; import { ButtonHTMLAttributes } from 'react'; -import { buttonBase, buttonVariant, buttonText } from './Dropdown.css'; +import { buttonBase, buttonVariant, buttonText, type Size } from './Dropdown.css'; import CaretDownIcon from './Icons/CaretDownIcon'; import DropdownToggle from './DropdownToggle'; -/** - * @property {string} placeholder? - 선택된 요소가 없을때 기본적으로 버튼에 표시할 텍스트 - */ - interface Props extends ButtonHTMLAttributes { + /** + * 선택된 요소가 없을때 기본적으로 버튼에 표시할 텍스트 @default 선택 + */ placeholder?: string; + + /** + * 버튼의 크기를 설정한다. @default medium + */ + size?: Size; + + /** + * 에러 상태의 유무 @default false + */ isError?: boolean; } -const DropdownButton = ({ children, placeholder = '선택', onClick, isError, ...rest }: Props) => { +const DropdownButton = ({ + children, + placeholder = '선택', + onClick, + isError = false, + size, + className, + ...rest +}: Props) => { return ( - + {children ?? placeholder} diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx index c2aec4aa..9ecba844 100644 --- a/src/components/Dropdown/DropdownItem.tsx +++ b/src/components/Dropdown/DropdownItem.tsx @@ -1,23 +1,26 @@ 'use client'; import { LiHTMLAttributes, MouseEvent } from 'react'; -import { item, checkedIconColor } from './Dropdown.css'; -import { useDropdownContext } from './contexts/DropdownContext'; +import { item, checkedIconColor, type Size } from './Dropdown.css'; import clsx from 'classnames'; import { useDropdownMenuContext } from './contexts/DropdownMenuContext'; import CheckedIcon from './Icons/CheckedIcon'; -/** - * @property {string} itemKey - Item 별로 가지는 고유값 (선택 state 값에 사용됨) - */ interface Props extends LiHTMLAttributes { + /** + * Item 별로 가지는 고유값 (선택 state 값에 사용됨) + */ itemKey: string; + + /** + * Item의 크기를 설정한다. @default medium + */ + size?: Size; } -const DropdownItem = ({ children, className, onClick, itemKey, ...rest }: Props) => { - const { closeDropdown } = useDropdownContext(); - const { selectable, selectedItemKey, onSelectChange } = useDropdownMenuContext(); +const DropdownItem = ({ children, className, onClick, itemKey, size, ...rest }: Props) => { + const { selectable, selectedItemKey, onSelectChange, closeDropdown } = useDropdownMenuContext(); const handleClickItem = (e: MouseEvent) => { if (selectable) onSelectChange(selectedItemKey === itemKey ? null : itemKey); @@ -29,7 +32,7 @@ const DropdownItem = ({ children, className, onClick, itemKey, ...rest }: Props) return (
  • , Partial { - placement?: Placement; + /** + * Menu를 표시할 기준 위치를 선정하는 값 + */ + placement?: Placement; } export interface DropdownMenuHandle { - /** menu를 닫는다 */ - close: () => void; + /** menu를 닫는다 */ + close: () => void; } const DropdownMenu = forwardRef( - ( - { - children, - className, - selectable = false, - selectedItemKey = null, - onSelectChange = () => null, - placement = 'bottom', - ...rest - }, - ref, - ) => { - const { isOpen, closeDropdown, yplacement: systemYPlacement, menuRef, menuId } = useDropdownContext(); + ( + { + children, + className, + selectable = false, + selectedItemKey = null, + onSelectChange = () => null, + placement = 'bottom', + ...rest + }, + ref, + ) => { + const { isOpen, closeDropdown, yplacement: systemYPlacement, menuRef, menuId } = useDropdownContext(); - const menuContextValue = useMemo( - () => ({ selectable, selectedItemKey, onSelectChange }), - [selectable, selectedItemKey, onSelectChange], - ); + const menuContextValue = useMemo( + () => ({ selectable, selectedItemKey, onSelectChange, closeDropdown }), + [selectable, selectedItemKey, onSelectChange, closeDropdown], + ); - const computedPlacement = useMemo( - () => getComputedPlacement(placement, systemYPlacement), - [placement, systemYPlacement], - ); + const computedPlacement = useMemo( + () => getComputedPlacement(placement, systemYPlacement), + [placement, systemYPlacement], + ); - useImperativeHandle(ref, () => ({ - close: closeDropdown, - })); + useImperativeHandle(ref, () => ({ + close: closeDropdown, + })); - return ( - - - - ); - }, + return ( + + + + ); + }, ); DropdownMenu.displayName = 'DropdownMenu'; diff --git a/src/components/Dropdown/DropdownModal.tsx b/src/components/Dropdown/DropdownModal.tsx new file mode 100644 index 00000000..b9fff4e0 --- /dev/null +++ b/src/components/Dropdown/DropdownModal.tsx @@ -0,0 +1,49 @@ +'use client'; + +import cx from 'classnames'; +import { HTMLAttributes, useMemo, forwardRef, useImperativeHandle } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal } from '@/components'; +import { useDropdownContext } from './contexts/DropdownContext'; +import { type DropdownMenuContextValue, DropdownMenuContextProvider } from './contexts/DropdownMenuContext'; +import { modal, modalContent } from './Dropdown.css'; + +interface Props extends HTMLAttributes, Partial {} + +export interface DropdownMenuHandle { + /** menu를 닫는다 */ + close: () => void; +} + +const DropdownModal = forwardRef( + ({ children, className, selectable = false, selectedItemKey = null, onSelectChange = () => null, ...rest }, ref) => { + const { isOpen, closeDropdown } = useDropdownContext(); + + const menuContextValue = useMemo( + () => ({ selectable, selectedItemKey, onSelectChange, closeDropdown }), + [selectable, selectedItemKey, onSelectChange, closeDropdown], + ); + + useImperativeHandle(ref, () => ({ + close: closeDropdown, + })); + + return ( + + {isOpen && + createPortal( + + + {children} + + , + document.getElementById('overlay-container') as HTMLElement, + )} + + ); + }, +); + +DropdownModal.displayName = 'DropdownModal'; + +export default DropdownModal; diff --git a/src/components/Dropdown/README.md b/src/components/Dropdown/README.md index 6816732e..31840c8e 100644 --- a/src/components/Dropdown/README.md +++ b/src/components/Dropdown/README.md @@ -296,6 +296,54 @@ const [selection, setSelection] = useState(null); - 각 요소들은 커스터마이징 하여 스타일을 조절할 수 있습니다.(className이나 인라인 스타일 이용가능) +### Dropdown.Modal + +```tsx +`Dropdown` 버튼을 통해 모달(모바일에서 화면 가운데 위치한 모달)을 트리거할때 사용합니다. + +const [selection, setSelection] = useState(null); + + + + + + 서울 + + + 경기도 + + + 강원도 + + + +``` + + +### Dropdown.BottomSheet + +`Dropdown` 버튼을 통해 BottomSheet를 트리거할때 사용합니다. + +```tsx +const [selection, setSelection] = useState(null); + + + + + + 서울 + + + 경기도 + + + 강원도 + + + +``` + + ## 참고 사항 - [ ] 향후 sprite icon을 만들면 아이콘 접근 방법을 변경해야할듯 합니다. diff --git a/src/components/Dropdown/contexts/DropdownMenuContext.tsx b/src/components/Dropdown/contexts/DropdownMenuContext.tsx index ff84cb0c..851adcea 100644 --- a/src/components/Dropdown/contexts/DropdownMenuContext.tsx +++ b/src/components/Dropdown/contexts/DropdownMenuContext.tsx @@ -2,29 +2,39 @@ import { useContext, createContext, ReactNode } from 'react'; -/** - * @property {boolean} selectable - true = 선택 가능모드, false(default) = 선택 불가능모드 - * @property {string | null} selectedItemKey - 선택된 itemkey 값 (selectionMode가 selectable일때만 입력) - * @property {(itemKey: string | null) => void} onSelectChange - 선택 state 업데이트 함수 (selectionMode가 selectable 일때만 입력) - */ - interface DropdownMenuContextValue { - selectable: boolean; - selectedItemKey: string | null; - onSelectChange: (itemKey: string | null) => void; + /** + * true = 선택 가능모드, false(default) = 선택 불가능모드 + */ + selectable: boolean; + + /** + * 선택된 itemkey 값 (selectionMode가 selectable일때만 입력) + */ + selectedItemKey: string | null; + + /** + * 선택 state 업데이트 함수 (selectionMode가 selectable 일때만 입력) + */ + onSelectChange: (itemKey: string | null) => void; + + /** + * dropdown menu를 닫는 함수 + */ + closeDropdown: () => void; } const DropdownMenuContext = createContext(null); const DropdownMenuContextProvider = ({ children, value }: { children: ReactNode; value: DropdownMenuContextValue }) => { - return {children}; + return {children}; }; const useDropdownMenuContext = () => { - const ctx = useContext(DropdownMenuContext); - if (!ctx) - throw new Error('Cannot find DropdownMenuContext. It should be wrapped within DropdownMenuContextProvider.'); - return ctx; + const ctx = useContext(DropdownMenuContext); + if (!ctx) + throw new Error('Cannot find DropdownMenuContext. It should be wrapped within DropdownMenuContextProvider.'); + return ctx; }; export { DropdownMenuContextProvider, useDropdownMenuContext, type DropdownMenuContextValue }; diff --git a/src/components/Dropdown/hooks/useDropdownBottomSheet/useDropdownBottomSheet.test.ts b/src/components/Dropdown/hooks/useDropdownBottomSheet/useDropdownBottomSheet.test.ts new file mode 100644 index 00000000..5e9c107e --- /dev/null +++ b/src/components/Dropdown/hooks/useDropdownBottomSheet/useDropdownBottomSheet.test.ts @@ -0,0 +1,21 @@ +import { renderHook } from '@/tests/test-utils'; +import useDropdownBottomSheet from './useDropdownBottomSheet'; + +describe('useDropdownBottomSheet 테스트', () => { + test('isDropdownOpen 값이 true가 되면 bottomSheetState는 true가 되고 그 반대인 경우에는 false가 된다', () => { + let openState = false; + const { result: bottomSheetState, rerender } = renderHook(() => + useDropdownBottomSheet({ + isDropdownOpen: openState, + onClose: () => null, + }), + ); + openState = true; + rerender(); + expect(bottomSheetState.current[1]).toBe(true); + + openState = false; + rerender(); + expect(bottomSheetState.current[1]).toBe(false); + }); +}); diff --git a/src/components/Dropdown/hooks/useDropdownBottomSheet/useDropdownBottomSheet.tsx b/src/components/Dropdown/hooks/useDropdownBottomSheet/useDropdownBottomSheet.tsx new file mode 100644 index 00000000..1673d8d4 --- /dev/null +++ b/src/components/Dropdown/hooks/useDropdownBottomSheet/useDropdownBottomSheet.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useClickOutside, useBooleanState } from '@/hooks'; + +interface Props { + isDropdownOpen: boolean; + onClose: () => void; +} + +const useDropdownBottomSheet = ({ isDropdownOpen, onClose }: Props) => { + const firstRender = useRef(true); + const [isOpen, setOpen, setClose] = useBooleanState(false); + const closeStatus = useRef(false); + + const openBottomSheet = useCallback(() => { + closeStatus.current = false; + setOpen(); + }, [setOpen]); + + const closeBottomSheet = useCallback(() => { + closeStatus.current = true; + setClose(); + }, [setClose]); + + const ref = useClickOutside({ + onClickOutside: () => { + closeBottomSheet(); + }, + }); + + const handleTransitionEnd = useCallback( + (event: TransitionEvent) => { + if (event.propertyName === 'transform' && closeStatus.current) { + onClose(); + } + }, + [onClose], + ); + + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + + if (isDropdownOpen) { + openBottomSheet(); + } else { + closeBottomSheet(); + } + }, [isDropdownOpen, openBottomSheet, closeBottomSheet]); + + useEffect(() => { + const element = ref.current; + if (!element) return; + element.addEventListener('transitionend', handleTransitionEnd); + return () => { + element.removeEventListener('transitionend', handleTransitionEnd); + }; + }, [ref, handleTransitionEnd]); + + return [ref, isOpen, openBottomSheet, closeBottomSheet] as const; +}; + +export default useDropdownBottomSheet; diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts index 07b82fa3..86764cb5 100644 --- a/src/components/Dropdown/index.ts +++ b/src/components/Dropdown/index.ts @@ -5,12 +5,16 @@ import DropdownButton from './DropdownButton'; import DropdownToggle from './DropdownToggle'; import DropdownItem from './DropdownItem'; import DropdownMenu from './DropdownMenu'; +import DropdownModal from './DropdownModal'; +import DropdownBottomSheet from './DropdownBottomSheet'; const DropdownRoot = Object.assign(Dropdown, { Button: DropdownButton, Toggle: DropdownToggle, Item: DropdownItem, Menu: DropdownMenu, + Modal: DropdownModal, + BottomSheet: DropdownBottomSheet, }); export default DropdownRoot; diff --git a/src/components/Modal/Modal.css.ts b/src/components/Modal/Modal.css.ts index ec523306..fa8c771d 100644 --- a/src/components/Modal/Modal.css.ts +++ b/src/components/Modal/Modal.css.ts @@ -7,7 +7,7 @@ const { color, borderRadius, zIndices } = themeTokens; export const modalWrapper = recipe({ base: { - position: 'absolute', + position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 813c6f36..907190fe 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -8,9 +8,10 @@ type ModalProps = { size?: Size; overflow?: boolean; onClose: () => void; + withBackground?: boolean; } & HTMLAttributes; -const Modal = ({ size, onClose, overflow, className, children, ...rest }: ModalProps) => { +const Modal = ({ size, onClose, overflow, className, children, withBackground = true, ...rest }: ModalProps) => { useLockScroll(); const ref = useClickOutside({ onClickOutside: () => { @@ -19,7 +20,7 @@ const Modal = ({ size, onClose, overflow, className, children, ...rest }: ModalP }); return ( <> -
    + {withBackground &&
    }
    {children}
    diff --git a/src/hooks/useClickOutside/useClickOutside.test.tsx b/src/hooks/useClickOutside/useClickOutside.test.tsx index 187ca2cb..a792f685 100644 --- a/src/hooks/useClickOutside/useClickOutside.test.tsx +++ b/src/hooks/useClickOutside/useClickOutside.test.tsx @@ -4,32 +4,41 @@ import useClickOutside from './useClickOutside'; import userEvent from '@testing-library/user-event'; const TestComponent = ({ handleClickOutside }: { handleClickOutside: () => void }) => { - const ref = useClickOutside({ - onClickOutside: handleClickOutside, - }); + const ref = useClickOutside({ + onClickOutside: handleClickOutside, + }); - return ( -
    -
    target
    -
    nontarget
    -
    - ); + return ( +
    +
    overlay
    +
    target
    +
    nontarget
    +
    + ); }; describe('useClickOutside 테스트', () => { - test('ref로 지정한 영역 바깥 부분을 누를때 이벤트가 감지된다', async () => { - const handleClickOutside = vi.fn(); - render(); - const nonTarget = screen.getByText('nontarget'); - await userEvent.click(nonTarget); - expect(handleClickOutside).toHaveBeenCalled(); - }); + test('ref로 지정한 영역 바깥 부분을 누를때 이벤트가 감지된다', async () => { + const handleClickOutside = vi.fn(); + render(); + const nonTarget = screen.getByText('nontarget'); + await userEvent.click(nonTarget); + expect(handleClickOutside).toHaveBeenCalled(); + }); - test('ref로 지정한 영역을 누르면 이벤트가 감지되지 않는다', async () => { - const handleClickOutside = vi.fn(); - render(); - const target = screen.getByText('target'); - await userEvent.click(target); - expect(handleClickOutside).not.toHaveBeenCalled(); - }); + test('ref로 지정한 영역을 누르면 이벤트가 감지되지 않는다', async () => { + const handleClickOutside = vi.fn(); + render(); + const target = screen.getByText('target'); + await userEvent.click(target); + expect(handleClickOutside).not.toHaveBeenCalled(); + }); + + test('ref로 지정한 대상이 overlay-container에 없는 상황에서 클릭 대상이 overlay-container에 있으면 이벤트는 감지되지 않는다', async () => { + const handleClickOutside = vi.fn(); + render(); + const target = screen.getByText('overlay'); + await userEvent.click(target); + expect(handleClickOutside).not.toHaveBeenCalled(); + }); }); diff --git a/src/hooks/useClickOutside/useClickOutside.ts b/src/hooks/useClickOutside/useClickOutside.ts index 32ef218b..5604fc7b 100644 --- a/src/hooks/useClickOutside/useClickOutside.ts +++ b/src/hooks/useClickOutside/useClickOutside.ts @@ -1,18 +1,17 @@ import { useEffect, useRef } from 'react'; type ClickOutsideEvents = Pick< - WindowEventMap, - 'mousedown' | 'mouseup' | 'touchstart' | 'touchend' | 'pointerdown' | 'pointerup' + WindowEventMap, + 'mousedown' | 'mouseup' | 'touchstart' | 'touchend' | 'pointerdown' | 'pointerup' >; -/** - * @property {() => void} onClickOutside - 외부 클릭 이벤트 발생 시 실행할 함수 - * @property {keyof ClickOutsideEvents} event - 외부 클릭 이벤트 종류 - */ +const OVERLAY_CONTAINER = '#overlay-container'; interface Props { - onClickOutside: () => void; - event?: keyof ClickOutsideEvents; + /** 외부 클릭 이벤트 발생 시 실행할 함수 */ + onClickOutside: () => void; + /** 외부 클릭 이벤트 종류 */ + event?: keyof ClickOutsideEvents; } /** @@ -21,26 +20,30 @@ interface Props { * @param {keyof ClickOutsideEvents} props.event - 외부 클릭 이벤트 종류 * @returns {React.RefObject} 외부 클릭 이벤트를 감지할 element ref */ - const useClickOutside = ({ - onClickOutside, - event = 'mousedown', + onClickOutside, + event = 'mousedown', }: Props): React.RefObject => { - const ref = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: ClickOutsideEvents[typeof event]) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClickOutside(); - } - }; - window.addEventListener(event, handleClickOutside); - return () => { - window.removeEventListener(event, handleClickOutside); - }; - }, [onClickOutside, event]); - - return ref; + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: ClickOutsideEvents[typeof event]) => { + // ref가 overlay-container내에 없는데, overlay-container 안에서 발생한 이벤트 인 경우 + if (!ref.current?.closest(OVERLAY_CONTAINER) && (e.target as HTMLElement).closest(OVERLAY_CONTAINER)) { + return; + } + + if (ref.current && !ref.current.contains(e.target as Node)) { + onClickOutside(); + } + }; + window.addEventListener(event, handleClickOutside); + return () => { + window.removeEventListener(event, handleClickOutside); + }; + }, [onClickOutside, event]); + + return ref; }; export default useClickOutside;