From 57861b9ccc3b840238b53729cd7be659b2c79d7f Mon Sep 17 00:00:00 2001 From: cje Date: Sun, 11 Feb 2024 18:35:17 +0900 Subject: [PATCH 01/13] =?UTF-8?q?refactor:=20Dialog=20->=20Dropdown=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dialog/Dialog.stories.tsx | 69 ------------------- .../Dialog/components/DialogButton.tsx | 24 ------- .../Dialog/components/DialogTitle.tsx | 14 ---- src/components/Dialog/index.tsx | 63 ----------------- src/components/Dropdown/Dropdown.stories.tsx | 69 +++++++++++++++++++ .../Dropdown/components/DropdownButton.tsx | 24 +++++++ .../Dropdown/components/DropdownTitle.tsx | 14 ++++ src/components/Dropdown/index.tsx | 64 +++++++++++++++++ .../{Dialog => Dropdown}/style.css.ts | 6 +- .../Layout/components/ProfileDialog.tsx | 20 +++--- src/hooks/useDialogContext.ts | 15 ---- src/hooks/useDropdownContext.ts | 15 ++++ 12 files changed, 199 insertions(+), 198 deletions(-) delete mode 100644 src/components/Dialog/Dialog.stories.tsx delete mode 100644 src/components/Dialog/components/DialogButton.tsx delete mode 100644 src/components/Dialog/components/DialogTitle.tsx delete mode 100644 src/components/Dialog/index.tsx create mode 100644 src/components/Dropdown/Dropdown.stories.tsx create mode 100644 src/components/Dropdown/components/DropdownButton.tsx create mode 100644 src/components/Dropdown/components/DropdownTitle.tsx create mode 100644 src/components/Dropdown/index.tsx rename src/components/{Dialog => Dropdown}/style.css.ts (94%) delete mode 100644 src/hooks/useDialogContext.ts create mode 100644 src/hooks/useDropdownContext.ts diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx deleted file mode 100644 index 4a44b266..00000000 --- a/src/components/Dialog/Dialog.stories.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import Icon from '@components/Icon'; -import ProfileDialog from '@components/Layout/components/ProfileDialog'; -import { action } from '@storybook/addon-actions'; -import type { Meta, StoryObj } from '@storybook/react'; - -import Dialog from '.'; -import * as styles from './style.css'; - -const meta: Meta = { - title: 'Components/Dialog', - component: Dialog, - parameters: { - componentSubtitle: '다양한 액션을 제공하는 컴포넌트', - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Small: Story = { - render: () => ( - <> - - - 수정하기 - - - 삭제하기 - - - - ), -}; - -export const MediumFolder: Story = { - render: () => ( - <> - - - OOO님의 폴더기본 - - - 폴더 이름1 - - - 폴더 이름2 - - -
- -
- 새 폴더 만들기 -
-
- - ), -}; - -export const MediumProfile: Story = { - render: () => , - decorators: [ - (Story) => ( -
- -
- ), - ], -}; diff --git a/src/components/Dialog/components/DialogButton.tsx b/src/components/Dialog/components/DialogButton.tsx deleted file mode 100644 index c57bc153..00000000 --- a/src/components/Dialog/components/DialogButton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type PropsWithChildren } from 'react'; - -import Button from '@components/Button'; -import * as styles from '@components/Dialog/style.css'; -import { useDialogContext } from '@hooks/useDialogContext'; - -interface DialogButtonProps { - onClick: () => void; -} - -const DialogButton = ({ - children, - onClick, -}: PropsWithChildren) => { - const { type } = useDialogContext(); - - return ( - - ); -}; - -export default DialogButton; diff --git a/src/components/Dialog/components/DialogTitle.tsx b/src/components/Dialog/components/DialogTitle.tsx deleted file mode 100644 index e4f05213..00000000 --- a/src/components/Dialog/components/DialogTitle.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { type PropsWithChildren } from 'react'; - -import * as styles from '@components/Dialog/style.css'; - -const DialogTitle = ({ children }: PropsWithChildren) => { - return ( - <> -
{children}
-
- - ); -}; - -export default DialogTitle; diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx deleted file mode 100644 index 55fd12b9..00000000 --- a/src/components/Dialog/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createContext, type PropsWithChildren } from 'react'; -import { useEffect, useRef } from 'react'; -import clsx from 'clsx'; - -import DialogButton from '@components/Dialog/components/DialogButton'; -import DialogTitle from '@components/Dialog/components/DialogTitle'; -import * as styles from '@components/Dialog/style.css'; - -interface DialogContextProps { - type: 'small' | 'medium'; -} - -interface DialogProps { - className?: string; - closeDialog: () => void; -} - -type DialogRootProps = DialogContextProps & PropsWithChildren; - -export const DialogContext = createContext(null); - -const DialogRoot = ({ - children, - type, - className, - closeDialog, -}: DialogRootProps) => { - const dialogRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - const isClickOutside = - dialogRef.current && !dialogRef.current?.contains(e.target as Node); - isClickOutside && closeDialog(); - }; - - document.addEventListener('click', handleClickOutside); - - return () => document.removeEventListener('click', handleClickOutside); - }, [closeDialog]); - - return ( - -
- {children} -
-
- ); -}; - -const Dialog = Object.assign(DialogRoot, { - Title: DialogTitle, - Button: DialogButton, -}); - -export default Dialog; diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx new file mode 100644 index 00000000..995bb7a0 --- /dev/null +++ b/src/components/Dropdown/Dropdown.stories.tsx @@ -0,0 +1,69 @@ +import Icon from '@components/Icon'; +import ProfileDialog from '@components/Layout/components/ProfileDialog'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import Dropdown from '.'; +import * as styles from './style.css'; + +const meta: Meta = { + title: 'Components/Dropdown', + component: Dropdown, + parameters: { + componentSubtitle: '다양한 액션을 제공하는 컴포넌트', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Small: Story = { + render: () => ( + <> + + + 수정하기 + + + 삭제하기 + + + + ), +}; + +export const MediumFolder: Story = { + render: () => ( + <> + + + 바로님의 폴더기본 + + + 폴더 이름1 + + + 폴더 이름2 + + +
+ +
+ 새 폴더 만들기 +
+
+ + ), +}; + +export const MediumProfile: Story = { + render: () => , + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/src/components/Dropdown/components/DropdownButton.tsx b/src/components/Dropdown/components/DropdownButton.tsx new file mode 100644 index 00000000..368ade5a --- /dev/null +++ b/src/components/Dropdown/components/DropdownButton.tsx @@ -0,0 +1,24 @@ +import { type PropsWithChildren } from 'react'; + +import Button from '@components/Button'; +import * as styles from '@components/Dropdown/style.css'; +import { useDropdownContext } from '@hooks/useDropdownContext'; + +interface DropdownButtonProps { + onClick: () => void; +} + +const DropdownButton = ({ + children, + onClick, +}: PropsWithChildren) => { + const { type } = useDropdownContext(); + + return ( + + ); +}; + +export default DropdownButton; diff --git a/src/components/Dropdown/components/DropdownTitle.tsx b/src/components/Dropdown/components/DropdownTitle.tsx new file mode 100644 index 00000000..bc928f44 --- /dev/null +++ b/src/components/Dropdown/components/DropdownTitle.tsx @@ -0,0 +1,14 @@ +import { type PropsWithChildren } from 'react'; + +import * as styles from '@components/Dropdown/style.css'; + +const DropdownTitle = ({ children }: PropsWithChildren) => { + return ( + <> +
{children}
+
+ + ); +}; + +export default DropdownTitle; diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx new file mode 100644 index 00000000..5523fcff --- /dev/null +++ b/src/components/Dropdown/index.tsx @@ -0,0 +1,64 @@ +import { createContext, type PropsWithChildren } from 'react'; +import { useEffect, useRef } from 'react'; +import clsx from 'clsx'; + +import DropdownButton from '@components/Dropdown/components/DropdownButton'; +import DropdownTitle from '@components/Dropdown/components/DropdownTitle'; +import * as styles from '@components/Dropdown/style.css'; + +interface DropdownContextProps { + type: 'small' | 'medium'; +} + +interface DropdownProps { + className?: string; + onClose: () => void; +} + +type DropdownRootProps = DropdownContextProps & + PropsWithChildren; + +export const DropdownContext = createContext(null); + +const DropdownRoot = ({ + children, + type, + className, + onClose, +}: DropdownRootProps) => { + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const isClickOutside = + dropdownRef.current && !dropdownRef.current?.contains(e.target as Node); + isClickOutside && onClose(); + }; + + document.addEventListener('click', handleClickOutside); + + return () => document.removeEventListener('click', handleClickOutside); + }, [onClose]); + + return ( + +
+ {children} +
+
+ ); +}; + +const Dropdown = Object.assign(DropdownRoot, { + Title: DropdownTitle, + Button: DropdownButton, +}); + +export default Dropdown; diff --git a/src/components/Dialog/style.css.ts b/src/components/Dropdown/style.css.ts similarity index 94% rename from src/components/Dialog/style.css.ts rename to src/components/Dropdown/style.css.ts index b93e8f73..86a1f136 100644 --- a/src/components/Dialog/style.css.ts +++ b/src/components/Dropdown/style.css.ts @@ -4,7 +4,7 @@ import { recipe } from '@vanilla-extract/recipes'; import { sprinkles } from '@styles/sprinkles.css'; import { COLORS } from '@styles/tokens'; -export const dialogRoot = recipe({ +export const dropdownRoot = recipe({ base: { borderRadius: '12px', boxShadow: '0px 8px 15px 0px rgba(28, 28, 28, 0.08)', @@ -24,7 +24,7 @@ export const dialogRoot = recipe({ }, }); -export const dialogTitle = style({ +export const dropdownTitle = style({ padding: '10px 12px', textAlign: 'center', }); @@ -36,7 +36,7 @@ export const line = style({ margin: '4px 0', }); -export const dialogButton = recipe({ +export const dropdownButton = recipe({ base: [ sprinkles({ typography: '15/Body/Regular', diff --git a/src/components/Layout/components/ProfileDialog.tsx b/src/components/Layout/components/ProfileDialog.tsx index 1295dd30..bcd8436a 100644 --- a/src/components/Layout/components/ProfileDialog.tsx +++ b/src/components/Layout/components/ProfileDialog.tsx @@ -1,4 +1,4 @@ -import Dialog from '@components/Dialog'; +import Dropdown from '@components/Dropdown'; import Icon from '@components/Icon'; import * as styles from '../style.css'; @@ -9,32 +9,32 @@ interface ProfileDialogProps { const ProfileDialog = ({ closeDialog }: ProfileDialogProps) => { return ( - - +
바로가나다라마바님 -
- {}}> + + {}}>
계정 설정 -
- {}}> + + {}}>
로그아웃 -
-
+ + ); }; diff --git a/src/hooks/useDialogContext.ts b/src/hooks/useDialogContext.ts deleted file mode 100644 index 23636c72..00000000 --- a/src/hooks/useDialogContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useContext } from 'react'; - -import { DialogContext } from '../components/Dialog'; - -export const useDialogContext = () => { - const ctx = useContext(DialogContext); - - if (!ctx) { - throw new Error( - 'useDialogContext hook must be used within a Dialog component', - ); - } - - return ctx; -}; diff --git a/src/hooks/useDropdownContext.ts b/src/hooks/useDropdownContext.ts new file mode 100644 index 00000000..f10576f6 --- /dev/null +++ b/src/hooks/useDropdownContext.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import { DropdownContext } from '../components/Dropdown'; + +export const useDropdownContext = () => { + const ctx = useContext(DropdownContext); + + if (!ctx) { + throw new Error( + 'useDropdownContext hook must be used within a Dropdown component', + ); + } + + return ctx; +}; From fe6a580d2f958594621326838c6e2743f501f3b5 Mon Sep 17 00:00:00 2001 From: cje Date: Mon, 12 Feb 2024 05:25:11 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20useDisclosure=20hook=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useDisclosure.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/hooks/useDisclosure.ts diff --git a/src/hooks/useDisclosure.ts b/src/hooks/useDisclosure.ts new file mode 100644 index 00000000..b3c9fbad --- /dev/null +++ b/src/hooks/useDisclosure.ts @@ -0,0 +1,21 @@ +import { useCallback, useState } from 'react'; + +const useDisclosure = () => { + const [isOpen, setIsOpen] = useState(false); + + const onOpen = useCallback(() => { + setIsOpen(true); + }, []); + + const onClose = useCallback(() => { + setIsOpen(false); + }, []); + + const onToggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + return { isOpen, onOpen, onClose, onToggle }; +}; + +export default useDisclosure; From aecfdb95dde78d8fe02d25467eba7212668a27fc Mon Sep 17 00:00:00 2001 From: cje Date: Mon, 12 Feb 2024 05:25:50 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20usePosition=20hook=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/usePosition.ts | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/hooks/usePosition.ts diff --git a/src/hooks/usePosition.ts b/src/hooks/usePosition.ts new file mode 100644 index 00000000..c0a8b74c --- /dev/null +++ b/src/hooks/usePosition.ts @@ -0,0 +1,65 @@ +import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; + +const POSITION = { top: 0, left: 0 }; + +type YPlacement = 'top' | 'bottom'; +type XPlacement = 'left' | 'center' | 'right'; +type Placement = `${YPlacement}-${XPlacement}`; + +interface UsePositionProps { + defaultTriggerRef?: RefObject; + isOpen: boolean; + placement: Placement; +} + +const usePosition = < + T extends HTMLElement = HTMLDivElement, + U extends HTMLElement = HTMLDivElement, +>({ + defaultTriggerRef, + isOpen, + placement, +}: UsePositionProps) => { + const ref = useRef(null); + const triggerRef = defaultTriggerRef || ref; + const targetRef = useRef(null); + const [position, setPosition] = useState(POSITION); + + useLayoutEffect(() => { + if (!triggerRef.current || !targetRef.current || !isOpen) { + return; + } + + const { + x, + y, + width: triggerWidth, + height: triggerHeight, + } = triggerRef.current.getBoundingClientRect(); + const { width: targetWidth } = targetRef.current.getBoundingClientRect(); + + const { scrollX, scrollY } = window; + + const top = y + scrollY - triggerHeight; + const bottom = y + scrollY + triggerHeight; + + const left = x + scrollX; + const center = left + triggerWidth / 2 - targetWidth / 2; + const right = left + triggerWidth - targetWidth; + + const CALCULATED_POSITION: Record = { + 'top-left': { top, left }, + 'top-center': { top, left: center }, + 'top-right': { top, left: right }, + 'bottom-left': { top: bottom, left }, + 'bottom-center': { top: bottom, left: center }, + 'bottom-right': { top: bottom, left: right }, + }; + + setPosition(CALCULATED_POSITION[placement]); + }, [triggerRef, placement, isOpen]); + + return { triggerRef, targetRef, position }; +}; + +export default usePosition; From 01aecf30cae82358ef2dc579b7f9c3cab8da3b5f Mon Sep 17 00:00:00 2001 From: cje Date: Mon, 12 Feb 2024 05:26:16 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20useClickAway=20hook=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useClickAway.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/hooks/useClickAway.ts diff --git a/src/hooks/useClickAway.ts b/src/hooks/useClickAway.ts new file mode 100644 index 00000000..97fb6e35 --- /dev/null +++ b/src/hooks/useClickAway.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from 'react'; + +interface UseClickAway { + onClickAway: () => void; +} + +const useClickAway = ({ onClickAway }: UseClickAway) => { + const ref = useRef(null); + + useEffect(() => { + const handleClickAway = (e: MouseEvent) => { + const isClickAway = + ref.current && !ref.current?.contains(e.target as Node); + + isClickAway && onClickAway?.(); + }; + + document.addEventListener('click', handleClickAway); + + return () => { + document.removeEventListener('click', handleClickAway); + }; + }, [onClickAway]); + + return ref; +}; + +export default useClickAway; From 26db02f4b5ec577a16c1691338a4238313c0213c Mon Sep 17 00:00:00 2001 From: cje Date: Mon, 12 Feb 2024 05:28:35 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor(Tooltip):=20useDisclosure,=20use?= =?UTF-8?q?Position=20hook=EC=9D=84=20=EC=9D=B4=EC=9A=A9=ED=95=B4=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Tooltip/Tooltip.stories.tsx | 4 +- .../Tooltip/components/TooltipContent.tsx | 20 +++++-- .../Tooltip/components/TooltipTrigger.tsx | 7 +-- src/components/Tooltip/index.tsx | 60 +++++++------------ src/components/Tooltip/style.css.ts | 17 ++++-- src/utils/getPosition.ts | 27 --------- 6 files changed, 56 insertions(+), 79 deletions(-) delete mode 100644 src/utils/getPosition.ts diff --git a/src/components/Tooltip/Tooltip.stories.tsx b/src/components/Tooltip/Tooltip.stories.tsx index fe5cbd4e..f7b824aa 100644 --- a/src/components/Tooltip/Tooltip.stories.tsx +++ b/src/components/Tooltip/Tooltip.stories.tsx @@ -71,7 +71,7 @@ export const Minimal: Story = { args: { children: 'Minimal', hasArrow: false, - placement: 'bottom', + placement: 'bottom-center', }, render: (args) => ( @@ -94,7 +94,7 @@ export const Highlight: Story = { args: { children: 'Highlight', hasArrow: true, - placement: 'bottom', + placement: 'bottom-center', }, render: (args) => ( diff --git a/src/components/Tooltip/components/TooltipContent.tsx b/src/components/Tooltip/components/TooltipContent.tsx index 7ca5a2d9..2ae9eda3 100644 --- a/src/components/Tooltip/components/TooltipContent.tsx +++ b/src/components/Tooltip/components/TooltipContent.tsx @@ -9,21 +9,33 @@ import Portal from '../../Portal'; import * as styles from '../style.css'; const ARROW_STYLE = { - top: styles.bottomArrow, - bottom: styles.topArrow, + 'top-center': styles.bottomArrow, + 'bottom-center': styles.topArrow, +}; + +const MARGIN_STYLE = { + MINIMAL: styles.minimalTooltipMargin, + HIGHLIGHT: styles.highlightTooltipMargin, }; const TooltipContent = ({ children }: PropsWithChildren) => { - const { isVisible, hasArrow, placement, position } = useTooltipContext(); + const { targetRef, isOpen, hasArrow, placement, position } = + useTooltipContext(); + + const isTopHighlightTooltip = hasArrow && placement === 'top-center'; + const isTopMinimalTooltip = !hasArrow && placement === 'top-center'; return ( <> - {isVisible && ( + {isOpen && (
{ - const { tooltipRef, onOpenTooltip, onCloseTooltip } = useTooltipContext(); + const { onOpen, onClose } = useTooltipContext(); return (
{children}
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index af0dba18..4bbf7499 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -1,7 +1,8 @@ -import type { PropsWithChildren, Ref } from 'react'; -import { createContext, useEffect, useRef, useState } from 'react'; +import type { PropsWithChildren, RefObject } from 'react'; +import { createContext } from 'react'; -import { getPosition } from '@utils/getPosition'; +import useDisclosure from '@hooks/useDisclosure'; +import usePosition from '@hooks/usePosition'; import TooltipContent from './components/TooltipContent'; import TooltipTrigger from './components/TooltipTrigger'; @@ -10,15 +11,16 @@ const INIT_POSITION = { top: 0, left: 0 }; export interface TooltipShape { hasArrow?: boolean; - placement?: 'top' | 'bottom'; + placement?: 'top-center' | 'bottom-center'; } interface TooltipContextProps extends TooltipShape { - tooltipRef: Ref; - isVisible: boolean; + triggerRef: RefObject; + targetRef: RefObject; + isOpen: boolean; position: typeof INIT_POSITION; - onOpenTooltip: () => void; - onCloseTooltip: () => void; + onOpen: () => void; + onClose: () => void; } interface TooltipProps extends TooltipShape {} @@ -28,44 +30,28 @@ export const TooltipContext = createContext(null); const TooltipRoot = ({ children, hasArrow = false, - placement = 'bottom', + placement = 'bottom-center', }: PropsWithChildren) => { - const tooltipRef = useRef(null); - - const [isVisible, setIsVisible] = useState(false); - const [position, setPosition] = useState(INIT_POSITION); - - useEffect(() => { - if (!tooltipRef.current || !isVisible) { - return; - } - - const { top, left } = getPosition(tooltipRef.current, hasArrow, placement); - - setPosition({ top, left }); - }, [isVisible, hasArrow, placement]); - - const handleTooltipOpen = () => { - setIsVisible(true); - }; - - const handleTooltipClose = () => { - setIsVisible(false); - }; + const { isOpen, onOpen, onClose } = useDisclosure(); + const { triggerRef, targetRef, position } = usePosition({ + isOpen, + placement, + }); return ( - {children} +
{children}
); }; diff --git a/src/components/Tooltip/style.css.ts b/src/components/Tooltip/style.css.ts index d72d889a..749fb117 100644 --- a/src/components/Tooltip/style.css.ts +++ b/src/components/Tooltip/style.css.ts @@ -25,7 +25,6 @@ export const content = recipe({ backgroundColor: COLORS['Dim/70'], borderRadius: '8px', whiteSpace: 'nowrap', - transform: 'translateX(-50%)', zIndex: Z_INDEX['tooltip'], }, ], @@ -52,19 +51,27 @@ export const content = recipe({ }); export const topArrow = style({ - marginTop: '6px', + marginTop: '10px', '::before': { - top: '-12px', + top: '-11.5px', borderBottomColor: COLORS['Dim/70'], }, }); export const bottomArrow = style({ - marginBottom: '6px', + marginBottom: '8px', '::before': { - bottom: '-12px', + bottom: '-11.5px', borderTopColor: COLORS['Dim/70'], }, }); + +export const minimalTooltipMargin = style({ + marginTop: '-8px', +}); + +export const highlightTooltipMargin = style({ + marginTop: '-36px', +}); diff --git a/src/utils/getPosition.ts b/src/utils/getPosition.ts deleted file mode 100644 index b437fff6..00000000 --- a/src/utils/getPosition.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TooltipShape } from '../components/Tooltip'; - -const TOOLTIP_MARGIN = { - MINIMAL: 8, - HIGHLIGHT: 32, -}; - -export const getPosition = ( - triggerElement: HTMLDivElement, - hasArrow: TooltipShape['hasArrow'], - placement: TooltipShape['placement'], -) => { - const { x, y, width, height } = triggerElement.getBoundingClientRect(); - const { scrollX, scrollY } = window; - - const margin = hasArrow ? TOOLTIP_MARGIN.HIGHLIGHT : TOOLTIP_MARGIN.MINIMAL; - const left = x + scrollX + width / 2; - - switch (placement) { - case 'top': - return { top: y + scrollY - height - margin, left }; - case 'bottom': - return { top: y + scrollY + height, left }; - default: - throw new Error(`The placement value must be either 'top' or 'bottom'.`); - } -}; From 600de27392dd02afa4028ac910d8524de0fd3afe Mon Sep 17 00:00:00 2001 From: cje Date: Mon, 12 Feb 2024 19:39:30 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor(Dropdown):=20trigger=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=EC=99=80=20=ED=95=A8=EA=BB=98=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dropdown/components/DropdownButton.tsx | 24 ----- .../Dropdown/components/DropdownMenuItem.tsx | 27 ++++++ .../Dropdown/components/DropdownMenuList.tsx | 34 +++++++ .../Dropdown/components/DropdownTrigger.tsx | 38 ++++++++ src/components/Dropdown/index.tsx | 96 ++++++++++++------- src/components/Dropdown/style.css.ts | 80 +++++++++++++--- src/constants/portal.ts | 1 + src/hooks/usePosition.ts | 11 ++- 8 files changed, 236 insertions(+), 75 deletions(-) delete mode 100644 src/components/Dropdown/components/DropdownButton.tsx create mode 100644 src/components/Dropdown/components/DropdownMenuItem.tsx create mode 100644 src/components/Dropdown/components/DropdownMenuList.tsx create mode 100644 src/components/Dropdown/components/DropdownTrigger.tsx diff --git a/src/components/Dropdown/components/DropdownButton.tsx b/src/components/Dropdown/components/DropdownButton.tsx deleted file mode 100644 index 368ade5a..00000000 --- a/src/components/Dropdown/components/DropdownButton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type PropsWithChildren } from 'react'; - -import Button from '@components/Button'; -import * as styles from '@components/Dropdown/style.css'; -import { useDropdownContext } from '@hooks/useDropdownContext'; - -interface DropdownButtonProps { - onClick: () => void; -} - -const DropdownButton = ({ - children, - onClick, -}: PropsWithChildren) => { - const { type } = useDropdownContext(); - - return ( - - ); -}; - -export default DropdownButton; diff --git a/src/components/Dropdown/components/DropdownMenuItem.tsx b/src/components/Dropdown/components/DropdownMenuItem.tsx new file mode 100644 index 00000000..05f99f07 --- /dev/null +++ b/src/components/Dropdown/components/DropdownMenuItem.tsx @@ -0,0 +1,27 @@ +import { type ButtonHTMLAttributes, type PropsWithChildren } from 'react'; +import clsx from 'clsx'; + +import Button from '@components/Button'; +import * as styles from '@components/Dropdown/style.css'; +import { useDropdownContext } from '@hooks/useDropdownContext'; + +interface DropdownMenuItemProps + extends ButtonHTMLAttributes {} + +const DropdownMenuItem = ({ + children, + className, + ...props +}: PropsWithChildren) => { + const { size } = useDropdownContext(); + + return ( +
  • + +
  • + ); +}; + +export default DropdownMenuItem; diff --git a/src/components/Dropdown/components/DropdownMenuList.tsx b/src/components/Dropdown/components/DropdownMenuList.tsx new file mode 100644 index 00000000..2efef43c --- /dev/null +++ b/src/components/Dropdown/components/DropdownMenuList.tsx @@ -0,0 +1,34 @@ +import { type PropsWithChildren } from 'react'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; + +import Portal from '@components/Portal'; +import { PORTAL_ID } from '@constants/portal'; +import { useDropdownContext } from '@hooks/useDropdownContext'; + +import * as styles from '../style.css'; + +const DropdownMenuList = ({ children }: PropsWithChildren) => { + const { size, targetRef, position, fixed, isOpen } = useDropdownContext(); + + return ( + <> + {isOpen && ( + +
      + {children} +
    +
    + )} + + ); +}; + +export default DropdownMenuList; diff --git a/src/components/Dropdown/components/DropdownTrigger.tsx b/src/components/Dropdown/components/DropdownTrigger.tsx new file mode 100644 index 00000000..4e213f0e --- /dev/null +++ b/src/components/Dropdown/components/DropdownTrigger.tsx @@ -0,0 +1,38 @@ +import { + type ButtonHTMLAttributes, + type MouseEvent, + type PropsWithChildren, +} from 'react'; + +import Button from '@components/Button'; +import { useDropdownContext } from '@hooks/useDropdownContext'; + +import * as styles from '../style.css'; + +interface DropdownTriggerProps + extends ButtonHTMLAttributes {} + +const DropdownTrigger = ({ + children, + onClick, + ...props +}: PropsWithChildren) => { + const { onToggle } = useDropdownContext(); + + const handleDropdownTriggerClick = (e: MouseEvent) => { + onToggle(); + onClick?.(e); + }; + + return ( + + ); +}; + +export default DropdownTrigger; diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx index 5523fcff..ef88fa6c 100644 --- a/src/components/Dropdown/index.tsx +++ b/src/components/Dropdown/index.tsx @@ -1,53 +1,83 @@ -import { createContext, type PropsWithChildren } from 'react'; -import { useEffect, useRef } from 'react'; +import { + createContext, + type HTMLAttributes, + type PropsWithChildren, + type RefObject, +} from 'react'; import clsx from 'clsx'; -import DropdownButton from '@components/Dropdown/components/DropdownButton'; -import DropdownTitle from '@components/Dropdown/components/DropdownTitle'; -import * as styles from '@components/Dropdown/style.css'; +import useClickAway from '@hooks/useClickAway'; +import useDisclosure from '@hooks/useDisclosure'; +import usePosition from '@hooks/usePosition'; + +import DropdownMenuItem from './components/DropdownMenuItem'; +import DropdownMenuList from './components/DropdownMenuList'; +import DropdownTitle from './components/DropdownTitle'; +import DropdownTrigger from './components/DropdownTrigger'; +import * as styles from './style.css'; + +const INIT_POSITION = { top: 0, left: 0 }; interface DropdownContextProps { - type: 'small' | 'medium'; + /** dropdown menulist 크기 */ + size?: 'small' | 'medium'; + /** dropdown menulist 위치 */ + placement?: 'bottom-left' | 'bottom-center' | 'bottom-right'; + /** dropdown menulist {top, left} 위치 */ + position: typeof INIT_POSITION; + /** dropdown menulist 요소의 ref 객체 */ + targetRef: RefObject; + /** dropdown menulist css position fixed 적용 여부 */ + fixed?: boolean; + /** dropdown menulist 열림, 닫힘 상태 */ + isOpen: boolean; + /** dropdown trigger toggle 함수 */ + onToggle: () => void; } -interface DropdownProps { - className?: string; - onClose: () => void; +interface DropdownRootProps + extends HTMLAttributes, + Pick { + className?: HTMLAttributes['className']; } -type DropdownRootProps = DropdownContextProps & - PropsWithChildren; - export const DropdownContext = createContext(null); const DropdownRoot = ({ children, - type, - className, - onClose, -}: DropdownRootProps) => { - const dropdownRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - const isClickOutside = - dropdownRef.current && !dropdownRef.current?.contains(e.target as Node); - isClickOutside && onClose(); - }; - - document.addEventListener('click', handleClickOutside); - - return () => document.removeEventListener('click', handleClickOutside); - }, [onClose]); + size = 'small', + placement = 'bottom-left', + fixed = false, + ...props +}: PropsWithChildren) => { + const { isOpen, onClose, onToggle } = useDisclosure(); + const dropdownRef = useClickAway({ + onClickAway: onClose, + }); + const { targetRef, position } = usePosition( + { + defaultTriggerRef: dropdownRef, + isOpen, + placement, + fixed, + }, + ); return (
    {children} @@ -57,8 +87,10 @@ const DropdownRoot = ({ }; const Dropdown = Object.assign(DropdownRoot, { + Trigger: DropdownTrigger, Title: DropdownTitle, - Button: DropdownButton, + List: DropdownMenuList, + Item: DropdownMenuItem, }); export default Dropdown; diff --git a/src/components/Dropdown/style.css.ts b/src/components/Dropdown/style.css.ts index 86a1f136..4127e315 100644 --- a/src/components/Dropdown/style.css.ts +++ b/src/components/Dropdown/style.css.ts @@ -1,17 +1,36 @@ -import { style } from '@vanilla-extract/css'; +import { createVar, style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { sprinkles } from '@styles/sprinkles.css'; import { COLORS } from '@styles/tokens'; +import * as utils from '@styles/utils.css'; -export const dropdownRoot = recipe({ +export const wrapper = style({ + position: 'relative', + width: 'fit-content', +}); + +export const trigger = style({ + width: 'fit-content', +}); + +export const position = createVar(); +export const top = createVar(); +export const left = createVar(); + +export const menuList = recipe({ base: { + position, + top, + left, + marginTop: '4px', borderRadius: '12px', boxShadow: '0px 8px 15px 0px rgba(28, 28, 28, 0.08)', backgroundColor: COLORS['Grey/White'], + zIndex: 100, }, variants: { - type: { + size: { small: { width: '100px', padding: '8px', @@ -24,10 +43,12 @@ export const dropdownRoot = recipe({ }, }); -export const dropdownTitle = style({ - padding: '10px 12px', - textAlign: 'center', -}); +export const dropdownTitle = style([ + utils.flexCenter, + { + padding: '10px 12px', + }, +]); export const line = style({ height: '1px', @@ -36,23 +57,24 @@ export const line = style({ margin: '4px 0', }); -export const dropdownButton = recipe({ +export const menuItem = recipe({ base: [ sprinkles({ typography: '15/Body/Regular', }), + utils.flexAlignCenter, { color: COLORS['Grey/900'], borderRadius: '4px', - display: 'block', ':hover': { backgroundColor: COLORS['Grey/100'], }, }, ], variants: { - type: { + size: { small: { + justifyContent: 'center', padding: '8px', width: '84px', selectors: { @@ -75,6 +97,7 @@ export const dropdownButton = recipe({ }, }); +/** storybook */ export const badge = style([ sprinkles({ typography: '11/Caption/Medium', @@ -93,17 +116,44 @@ export const badge = style([ }, ]); -export const iconMediumText = style([ +export const newFolder = style([utils.flexAlignCenter]); + +export const newFolderText = style([ sprinkles({ typography: '15/Body/Medium', }), { color: COLORS['Grey/400'], - marginLeft: '28px', + marginLeft: '4px', }, ]); -export const icon = style({ - position: 'absolute', - marginTop: '2px', +export const dialogWrapper = style({ + padding: '10px', }); + +export const profile = style({ + padding: '10px', +}); + +export const profileName = style([ + sprinkles({ + typography: '16/Title/Medium', + }), + { + color: COLORS['Grey/900'], + verticalAlign: 'middle', + display: 'inline-block', + marginLeft: '8px', + }, +]); + +export const buttonText = style([ + sprinkles({ + typography: '15/Body/Regular', + }), + { + marginLeft: '6px', + color: COLORS['Grey/800'], + }, +]); diff --git a/src/constants/portal.ts b/src/constants/portal.ts index dbc1babb..cfccd0c4 100644 --- a/src/constants/portal.ts +++ b/src/constants/portal.ts @@ -2,4 +2,5 @@ export const PORTAL_ID = { MODAL: 'modal-root', TOAST: 'toast-root', TOOLTIP: 'tooltip-root', + DROPDOWN: 'dropdown-root', } as const; diff --git a/src/hooks/usePosition.ts b/src/hooks/usePosition.ts index c0a8b74c..67d96f85 100644 --- a/src/hooks/usePosition.ts +++ b/src/hooks/usePosition.ts @@ -1,4 +1,4 @@ -import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; +import { type RefObject, useEffect, useRef, useState } from 'react'; const POSITION = { top: 0, left: 0 }; @@ -10,6 +10,7 @@ interface UsePositionProps { defaultTriggerRef?: RefObject; isOpen: boolean; placement: Placement; + fixed?: boolean; } const usePosition = < @@ -19,13 +20,14 @@ const usePosition = < defaultTriggerRef, isOpen, placement, + fixed = false, }: UsePositionProps) => { const ref = useRef(null); const triggerRef = defaultTriggerRef || ref; const targetRef = useRef(null); const [position, setPosition] = useState(POSITION); - useLayoutEffect(() => { + useEffect(() => { if (!triggerRef.current || !targetRef.current || !isOpen) { return; } @@ -33,6 +35,7 @@ const usePosition = < const { x, y, + bottom: triggerBottom, width: triggerWidth, height: triggerHeight, } = triggerRef.current.getBoundingClientRect(); @@ -41,7 +44,7 @@ const usePosition = < const { scrollX, scrollY } = window; const top = y + scrollY - triggerHeight; - const bottom = y + scrollY + triggerHeight; + const bottom = !fixed ? y + scrollY + triggerHeight : triggerBottom; const left = x + scrollX; const center = left + triggerWidth / 2 - targetWidth / 2; @@ -57,7 +60,7 @@ const usePosition = < }; setPosition(CALCULATED_POSITION[placement]); - }, [triggerRef, placement, isOpen]); + }, [triggerRef, placement, isOpen, fixed]); return { triggerRef, targetRef, position }; }; From 3ea7a66fd6ea10fc3b5ac4e6d9501100f5979a35 Mon Sep 17 00:00:00 2001 From: cje Date: Mon, 12 Feb 2024 19:40:27 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat(Dropdown):=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=B6=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/preview-body.html | 1 + src/assets/icons/profileDialog.svg | 7 +- src/assets/icons/profileHeader.svg | 7 +- src/components/Dropdown/Dropdown.stories.tsx | 119 +++++++++++++------ 4 files changed, 92 insertions(+), 42 deletions(-) diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index f74e14cf..57e7030b 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -1,3 +1,4 @@