From 1df36f0879ba292c7c2d56c9f45a577f66ac1f64 Mon Sep 17 00:00:00 2001 From: Ye-Chan Kang Date: Thu, 7 Mar 2024 00:26:17 +0900 Subject: [PATCH] feat(common): hover card --- src/components/common/Dropdown/Dropdown.tsx | 9 +- src/components/common/HoverCard/HoverCard.tsx | 33 +++++ .../common/HoverCard/HoverCardContent.tsx | 32 +++++ .../common/HoverCard/HoverCardContext.tsx | 9 ++ .../common/HoverCard/HoverCardToggle.tsx | 43 +++++++ src/components/common/HoverCard/index.tsx | 4 + .../common/HoverCard/useHoverWaiting.ts | 47 ++++++++ src/components/common/Select/Select.tsx | 9 +- src/components/common/index.ts | 1 + src/components/portal/PortalContent.tsx | 8 +- src/components/portal/PortalContext.tsx | 7 +- src/components/portal/PortalProvider.tsx | 11 +- src/components/portal/type.ts | 6 + src/components/portal/usePortal.ts | 17 ++- src/hooks/useModal.ts | 6 +- src/libs/event.ts | 12 ++ src/stories/common/Dropdown.stories.tsx | 30 +++++ src/stories/common/HoverCard.stories.tsx | 114 ++++++++++++++++++ src/stories/common/Select.stories.tsx | 26 ++++ 19 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 src/components/common/HoverCard/HoverCard.tsx create mode 100644 src/components/common/HoverCard/HoverCardContent.tsx create mode 100644 src/components/common/HoverCard/HoverCardContext.tsx create mode 100644 src/components/common/HoverCard/HoverCardToggle.tsx create mode 100644 src/components/common/HoverCard/index.tsx create mode 100644 src/components/common/HoverCard/useHoverWaiting.ts create mode 100644 src/components/portal/type.ts create mode 100644 src/stories/common/HoverCard.stories.tsx diff --git a/src/components/common/Dropdown/Dropdown.tsx b/src/components/common/Dropdown/Dropdown.tsx index a50d706..b3e0e8b 100644 --- a/src/components/common/Dropdown/Dropdown.tsx +++ b/src/components/common/Dropdown/Dropdown.tsx @@ -1,19 +1,18 @@ -import * as React from 'react'; - import { PortalProvider } from '@/components/portal/PortalProvider'; import { AlignType } from '@/types/align'; -interface DropdownProps extends React.ComponentPropsWithoutRef<'div'> { +interface DropdownProps extends React.PropsWithChildren { align?: AlignType; + space?: number; } /** * Displays a list of menus. * @returns */ -export const Dropdown = ({ children, align = 'center', ...props }: DropdownProps) => { +export const Dropdown = ({ align = 'center', space = 0, children }: DropdownProps) => { return ( - + {children} ); diff --git a/src/components/common/HoverCard/HoverCard.tsx b/src/components/common/HoverCard/HoverCard.tsx new file mode 100644 index 0000000..2062830 --- /dev/null +++ b/src/components/common/HoverCard/HoverCard.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { PortalProvider } from '@/components/portal/PortalProvider'; +import { AlignType } from '@/types/align'; + +import HoverCardContext from './HoverCardContext'; + +interface HoverCardProps extends React.PropsWithChildren { + align?: AlignType; + space?: number; + openDelay?: number; + closeDelay?: number; +} + +/** + * Display content when hover toggle. + * @returns + */ +export const HoverCard = ({ + align = 'center', + space = 16, + openDelay = 500, + closeDelay = 200, + children, +}: HoverCardProps) => { + const timer = React.useRef(null); + + return ( + + {children} + + ); +}; diff --git a/src/components/common/HoverCard/HoverCardContent.tsx b/src/components/common/HoverCard/HoverCardContent.tsx new file mode 100644 index 0000000..c9c3e51 --- /dev/null +++ b/src/components/common/HoverCard/HoverCardContent.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import { PortalContent } from '@/components/portal/PortalContent'; +import { composeEventHandlers, excludeTouchEventHandler } from '@/libs/event'; + +import useHoverWaiting from './useHoverWaiting'; + +interface ModalProps extends React.ComponentPropsWithoutRef<'div'> { + width?: React.CSSProperties['width']; +} + +/** + * HoverCardContent + * @returns + */ +export const HoverCardContent = React.forwardRef(({ width, children, ...props }, ref) => { + const { onOpen, onClose } = useHoverWaiting(); + + return ( + + {children} + + ); +}); +HoverCardContent.displayName = 'HoverCardContent'; diff --git a/src/components/common/HoverCard/HoverCardContext.tsx b/src/components/common/HoverCard/HoverCardContext.tsx new file mode 100644 index 0000000..0926777 --- /dev/null +++ b/src/components/common/HoverCard/HoverCardContext.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; + +interface HoverCardContextType { + timer: React.MutableRefObject; + openDelay: number; + closeDelay: number; +} +const HoverCardContext = React.createContext(null); +export default HoverCardContext; diff --git a/src/components/common/HoverCard/HoverCardToggle.tsx b/src/components/common/HoverCard/HoverCardToggle.tsx new file mode 100644 index 0000000..97ec777 --- /dev/null +++ b/src/components/common/HoverCard/HoverCardToggle.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { PortalContext } from '@/components/portal/PortalContext'; +import useContext from '@/hooks/useContext'; +import { composeEventHandlers, excludeTouchEventHandler } from '@/libs/event'; +import { composeRefs } from '@/libs/ref'; + +import useHoverWaiting from './useHoverWaiting'; + +/** + * HoverCardToggle + * @returns + */ +export const HoverCardToggle = React.forwardRef>( + ({ ...props }, ref) => { + const { setToggleElment } = useContext(PortalContext); + const compRef = React.useRef(null); + const { onOpen: onOpenWaiting, onClose } = useHoverWaiting(); + + const onOpen = () => { + if (compRef.current) { + const rect = compRef.current; + setToggleElment(rect); + } + + onOpenWaiting(); + }; + + return ( +
event.preventDefault())} + {...props} + /> + ); + }, +); +HoverCardToggle.displayName = 'HoverCardToggle'; diff --git a/src/components/common/HoverCard/index.tsx b/src/components/common/HoverCard/index.tsx new file mode 100644 index 0000000..32777cb --- /dev/null +++ b/src/components/common/HoverCard/index.tsx @@ -0,0 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from './HoverCard'; +export * from './HoverCardContent'; +export * from './HoverCardToggle'; diff --git a/src/components/common/HoverCard/useHoverWaiting.ts b/src/components/common/HoverCard/useHoverWaiting.ts new file mode 100644 index 0000000..c8db2cc --- /dev/null +++ b/src/components/common/HoverCard/useHoverWaiting.ts @@ -0,0 +1,47 @@ +// import * as React from 'react'; +import { PortalContext } from '@/components/portal/PortalContext'; +import useContext from '@/hooks/useContext'; + +import HoverCardContext from './HoverCardContext'; + +const useHoverWaiting = () => { + const { timer, openDelay, closeDelay } = useContext(HoverCardContext); + const { showModal, setShowModal } = useContext(PortalContext); + + const onOpen = () => { + if (timer.current !== null) { + clearTimeout(timer.current); + timer.current = null; + } + + if (!showModal) { + timer.current = setTimeout(() => { + setShowModal(true); + + timer.current = null; + }, openDelay); + } + }; + + const onClose = () => { + if (showModal) { + timer.current = setTimeout(() => { + setShowModal(false); + + timer.current = null; + }, closeDelay); + } + }; + + // React.useEffect(() => { + // return () => { + // if (timer !== null) { + // clearTimeout(timer); + // timer = null; + // } + // }; + // }, []); + + return { onOpen, onClose }; +}; +export default useHoverWaiting; diff --git a/src/components/common/Select/Select.tsx b/src/components/common/Select/Select.tsx index 1685b98..b0aa5e9 100644 --- a/src/components/common/Select/Select.tsx +++ b/src/components/common/Select/Select.tsx @@ -9,20 +9,19 @@ import { SelectType } from './type'; interface SelectProps extends React.PropsWithChildren { multi?: boolean; align?: AlignType; + space?: number; } /** * Displays a list of options. * @returns */ -export const Select = ({ children, align = 'center', multi = false, ...props }: SelectProps) => { +export const Select = ({ align = 'center', space = 0, multi = false, children }: SelectProps) => { const [selectedValue, setSelectedValue] = React.useState([]); return ( - - - {children} - + + {children} ); }; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 8113fb2..97f596b 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -5,6 +5,7 @@ export * from './Card'; export * from './Dialog'; export * from './Dropdown'; export * from './Form'; +export * from './HoverCard'; export * from './Input'; export * from './Label'; export * from './Select'; diff --git a/src/components/portal/PortalContent.tsx b/src/components/portal/PortalContent.tsx index 55402e9..f510296 100644 --- a/src/components/portal/PortalContent.tsx +++ b/src/components/portal/PortalContent.tsx @@ -13,24 +13,24 @@ import { PortalContext } from './PortalContext'; interface ModalContentProps extends React.ComponentProps<'div'> { ref: React.ForwardedRef; width?: React.CSSProperties['width']; + disabledBG?: boolean; } -export const PortalContent = ({ children, ref, width, ...props }: ModalContentProps) => { +export const PortalContent = ({ children, ref, width, disabledBG = false, ...props }: ModalContentProps) => { const { showModal, setShowModal, toggleElement } = useContext(PortalContext)!; const modalRef = React.useRef(null); + const { reorgPos } = usePortal({ modalRef }); const close = () => { setShowModal(false); }; - const { reorgPos } = usePortal({ modalRef }); - return ( <> {showModal && createPortal( <> - + {!disabledBG && } (null); diff --git a/src/components/portal/PortalProvider.tsx b/src/components/portal/PortalProvider.tsx index 0452946..f4673d7 100644 --- a/src/components/portal/PortalProvider.tsx +++ b/src/components/portal/PortalProvider.tsx @@ -1,16 +1,16 @@ import React from 'react'; import useModal from '@/hooks/useModal'; -import { AlignType } from '@/types/align'; import { PortalContext } from './PortalContext'; +import { PortalType } from './type'; -interface PortalProviderProps extends React.PropsWithChildren { - align: AlignType; +export interface PortalProviderProps extends React.PropsWithChildren, PortalType { + enableScroll?: boolean; } -export const PortalProvider = ({ align, children }: PortalProviderProps) => { - const [showModal, setShowModal] = useModal(); +export const PortalProvider = ({ align, space, enableScroll, children }: PortalProviderProps) => { + const [showModal, setShowModal] = useModal(enableScroll); const [toggleElement, setToggleElment] = React.useState(); return ( @@ -21,6 +21,7 @@ export const PortalProvider = ({ align, children }: PortalProviderProps) => { toggleElement, setToggleElment, align, + space, }} > {children} diff --git a/src/components/portal/type.ts b/src/components/portal/type.ts new file mode 100644 index 0000000..158f047 --- /dev/null +++ b/src/components/portal/type.ts @@ -0,0 +1,6 @@ +import { AlignType } from '@/types/align'; + +export interface PortalType { + align: AlignType; + space: number; +} diff --git a/src/components/portal/usePortal.ts b/src/components/portal/usePortal.ts index 2852b7f..1192a6a 100644 --- a/src/components/portal/usePortal.ts +++ b/src/components/portal/usePortal.ts @@ -4,7 +4,14 @@ import { PortalContext } from '@/components/portal/PortalContext'; import { PositionType } from '@/types/position'; export const usePortal = ({ modalRef }: { modalRef: React.RefObject }) => { - const { showModal, align, toggleElement, setShowModal, setToggleElment } = React.useContext(PortalContext)!; + const { + showModal, + align, + space = 0, + toggleElement, + setShowModal, + setToggleElment, + } = React.useContext(PortalContext)!; const [reorgPos, setReorgPos] = React.useState({ x: 0, y: 0 }); React.useEffect(() => { @@ -13,7 +20,7 @@ export const usePortal = ({ modalRef }: { modalRef: React.RefObject= window.innerHeight; + const isOverflowing = rect.offsetHeight + toggleRect.bottom + space >= window.innerHeight; const reorgPos = { x: 0, y: 0 }; switch (align) { @@ -29,9 +36,9 @@ export const usePortal = ({ modalRef }: { modalRef: React.RefObject { window.removeEventListener('resize', adjustmentPos); }; - }, [align, showModal, toggleElement, modalRef]); + }, [align, showModal, toggleElement, modalRef, space]); return { showModal, align, toggleElement, setShowModal, setToggleElment, reorgPos }; }; diff --git a/src/hooks/useModal.ts b/src/hooks/useModal.ts index 3328043..d3b9cd8 100644 --- a/src/hooks/useModal.ts +++ b/src/hooks/useModal.ts @@ -1,9 +1,11 @@ import * as React from 'react'; -const useModal = (): [boolean, React.Dispatch>] => { +const useModal = (enableScroll: boolean = true): [boolean, React.Dispatch>] => { const [showModal, setShowModal] = React.useState(false); React.useEffect(() => { + if (!enableScroll) return; + if (showModal) { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; @@ -13,7 +15,7 @@ const useModal = (): [boolean, React.Dispatch>] => document.body.style.overflow = 'auto'; document.body.style.marginRight = `${0}px`; } - }, [showModal]); + }, [enableScroll, showModal]); return [showModal, setShowModal]; }; diff --git a/src/libs/event.ts b/src/libs/event.ts index cdbdc24..f8a21f5 100644 --- a/src/libs/event.ts +++ b/src/libs/event.ts @@ -10,3 +10,15 @@ export const composeEventHandlers = ( } }; }; + +/* + Exclude Touch Event (Mobile, Tablet env) +*/ +type ExcludeTouchFunction = (event: React.PointerEvent) => void; +export const excludeTouchEventHandler: (eventHandler: () => void) => ExcludeTouchFunction = eventHandler => { + return event => { + if (event.pointerType !== 'touch') { + eventHandler(); + } + }; +}; diff --git a/src/stories/common/Dropdown.stories.tsx b/src/stories/common/Dropdown.stories.tsx index 51c626a..61e260b 100644 --- a/src/stories/common/Dropdown.stories.tsx +++ b/src/stories/common/Dropdown.stories.tsx @@ -45,6 +45,36 @@ export const Default: Story = { ], }; +export const Space: Story = { + parameters: { + docs: { + description: { + story: 'space is `number` type. ``', + }, + }, + }, + args: { + align: 'start', + space: 16, + children: ( + <> + Dropdown + + Share Social + + + GitHub + ⌘+T + + + Facebook + Twitter + + + ), + }, +}; + export const AlignStart: Story = { parameters: { docs: { diff --git a/src/stories/common/HoverCard.stories.tsx b/src/stories/common/HoverCard.stories.tsx new file mode 100644 index 0000000..6798420 --- /dev/null +++ b/src/stories/common/HoverCard.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { Avatar, Button, HoverCard, HoverCardContent, HoverCardToggle } from '../../'; + +const meta = { + title: 'common/HoverCard', + component: HoverCard, + tags: ['autodocs'], + args: { + align: 'center', + }, + parameters: { + layout: 'centered', + componentSubtitle: 'Base HoverCard', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const SampleHoverCardContent = () => { + const [isFollowing, setIsFollowing] = React.useState(false); + return ( + +
+ +

Bandmates

+

@BMates

+

Lorem Ipsum is simply dummy text of the printing and typesetting industry.

+ +
+
+ ); +}; + +export const Default: Story = { + args: {}, + decorators: [ + () => { + return ( + + + + + + + ); + }, + ], +}; + +export const Align: Story = { + args: {}, + decorators: [ + () => { + return ( + + + @BMates-UI + + + + ); + }, + ], +}; + +export const Delay: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: {}, + decorators: [ + () => { + return ( + + + @BMates-UI + + + + ); + }, + ], +}; + +export const Space: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: {}, + decorators: [ + () => { + return ( + + + @BMates-UI + + + + ); + }, + ], +}; diff --git a/src/stories/common/Select.stories.tsx b/src/stories/common/Select.stories.tsx index 3ed2614..7ebe3a8 100644 --- a/src/stories/common/Select.stories.tsx +++ b/src/stories/common/Select.stories.tsx @@ -68,3 +68,29 @@ export const MultiSelect: Story = { ), }, }; + +export const Space: Story = { + parameters: { + docs: { + description: { + story: '`