Skip to content

Commit

Permalink
Merge pull request #19 from Bandmators/feature/hovercard
Browse files Browse the repository at this point in the history
feat(common): hover card (#18)
  • Loading branch information
kyechan99 authored Mar 7, 2024
2 parents cb10f9f + 1df36f0 commit eadfcd2
Show file tree
Hide file tree
Showing 19 changed files with 397 additions and 27 deletions.
9 changes: 4 additions & 5 deletions src/components/common/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PortalProvider align={align} {...props}>
<PortalProvider align={align} space={space}>
{children}
</PortalProvider>
);
Expand Down
33 changes: 33 additions & 0 deletions src/components/common/HoverCard/HoverCard.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout | null>(null);

return (
<PortalProvider align={align} space={space} enableScroll={false}>
<HoverCardContext.Provider value={{ timer, openDelay, closeDelay }}>{children}</HoverCardContext.Provider>
</PortalProvider>
);
};
32 changes: 32 additions & 0 deletions src/components/common/HoverCard/HoverCardContent.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, ModalProps>(({ width, children, ...props }, ref) => {
const { onOpen, onClose } = useHoverWaiting();

return (
<PortalContent
width={width}
ref={ref}
disabledBG
onPointerEnter={composeEventHandlers(props.onPointerEnter, excludeTouchEventHandler(onOpen))}
onPointerLeave={composeEventHandlers(props.onPointerLeave, excludeTouchEventHandler(onClose))}
{...props}
>
{children}
</PortalContent>
);
});
HoverCardContent.displayName = 'HoverCardContent';
9 changes: 9 additions & 0 deletions src/components/common/HoverCard/HoverCardContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';

interface HoverCardContextType {
timer: React.MutableRefObject<NodeJS.Timeout | null>;
openDelay: number;
closeDelay: number;
}
const HoverCardContext = React.createContext<HoverCardContextType | null>(null);
export default HoverCardContext;
43 changes: 43 additions & 0 deletions src/components/common/HoverCard/HoverCardToggle.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
({ ...props }, ref) => {
const { setToggleElment } = useContext(PortalContext);
const compRef = React.useRef<HTMLDivElement | null>(null);
const { onOpen: onOpenWaiting, onClose } = useHoverWaiting();

const onOpen = () => {
if (compRef.current) {
const rect = compRef.current;
setToggleElment(rect);
}

onOpenWaiting();
};

return (
<div
ref={composeRefs(compRef, ref)}
onPointerEnter={composeEventHandlers(props.onPointerEnter, excludeTouchEventHandler(onOpen))}
onPointerLeave={composeEventHandlers(props.onPointerLeave, excludeTouchEventHandler(onClose))}
onFocus={composeEventHandlers(props.onFocus, onOpen)}
onBlur={composeEventHandlers(props.onBlur, onClose)}
// prevent focus event on touch devices
onTouchStart={composeEventHandlers(props.onTouchStart, event => event.preventDefault())}
{...props}
/>
);
},
);
HoverCardToggle.displayName = 'HoverCardToggle';
4 changes: 4 additions & 0 deletions src/components/common/HoverCard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
export * from './HoverCard';
export * from './HoverCardContent';
export * from './HoverCardToggle';
47 changes: 47 additions & 0 deletions src/components/common/HoverCard/useHoverWaiting.ts
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 4 additions & 5 deletions src/components/common/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelectType[]>([]);

return (
<PortalProvider align={align}>
<SelectContext.Provider value={{ multi, selectedValue, setSelectedValue }} {...props}>
{children}
</SelectContext.Provider>
<PortalProvider align={align} space={space}>
<SelectContext.Provider value={{ multi, selectedValue, setSelectedValue }}>{children}</SelectContext.Provider>
</PortalProvider>
);
};
1 change: 1 addition & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 4 additions & 4 deletions src/components/portal/PortalContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,24 @@ import { PortalContext } from './PortalContext';
interface ModalContentProps extends React.ComponentProps<'div'> {
ref: React.ForwardedRef<HTMLDivElement>;
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<HTMLDivElement>(null);
const { reorgPos } = usePortal({ modalRef });

const close = () => {
setShowModal(false);
};

const { reorgPos } = usePortal({ modalRef });

return (
<>
{showModal &&
createPortal(
<>
<ModalBG onClick={close} className="bmates-modal-bg" />
{!disabledBG && <ModalBG onClick={close} className="bmates-modal-bg" />}
<Modal
ref={composeRefs(modalRef, ref)}
width={width || toggleElement?.getBoundingClientRect().width}
Expand Down
7 changes: 6 additions & 1 deletion src/components/portal/PortalContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';

import { AlignType } from '@/types/align';

interface PortalContextType {
export interface PortalContextType {
/*
* Modal
*/
Expand All @@ -20,5 +20,10 @@ interface PortalContextType {
* Align
*/
align: AlignType;

/*
* Space Size
*/
space?: number;
}
export const PortalContext = React.createContext<PortalContextType | null>(null);
11 changes: 6 additions & 5 deletions src/components/portal/PortalProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>();

return (
Expand All @@ -21,6 +21,7 @@ export const PortalProvider = ({ align, children }: PortalProviderProps) => {
toggleElement,
setToggleElment,
align,
space,
}}
>
{children}
Expand Down
6 changes: 6 additions & 0 deletions src/components/portal/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { AlignType } from '@/types/align';

export interface PortalType {
align: AlignType;
space: number;
}
17 changes: 12 additions & 5 deletions src/components/portal/usePortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { PortalContext } from '@/components/portal/PortalContext';
import { PositionType } from '@/types/position';

export const usePortal = ({ modalRef }: { modalRef: React.RefObject<HTMLDivElement> }) => {
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<PositionType>({ x: 0, y: 0 });

React.useEffect(() => {
Expand All @@ -13,7 +20,7 @@ export const usePortal = ({ modalRef }: { modalRef: React.RefObject<HTMLDivEleme
const rect = modalRef.current;
const toggleRect = toggleElement.getBoundingClientRect();

const isOverflowing = rect.offsetHeight + toggleRect.bottom >= window.innerHeight;
const isOverflowing = rect.offsetHeight + toggleRect.bottom + space >= window.innerHeight;
const reorgPos = { x: 0, y: 0 };

switch (align) {
Expand All @@ -29,9 +36,9 @@ export const usePortal = ({ modalRef }: { modalRef: React.RefObject<HTMLDivEleme
}

if (isOverflowing) {
reorgPos.y = toggleRect.y - rect.offsetHeight;
reorgPos.y = toggleRect.y - rect.offsetHeight - space;
} else {
reorgPos.y = toggleRect.y + toggleRect.height;
reorgPos.y = toggleRect.y + toggleRect.height + space;
}

setReorgPos(reorgPos);
Expand All @@ -43,7 +50,7 @@ export const usePortal = ({ modalRef }: { modalRef: React.RefObject<HTMLDivEleme
return () => {
window.removeEventListener('resize', adjustmentPos);
};
}, [align, showModal, toggleElement, modalRef]);
}, [align, showModal, toggleElement, modalRef, space]);

return { showModal, align, toggleElement, setShowModal, setToggleElment, reorgPos };
};
6 changes: 4 additions & 2 deletions src/hooks/useModal.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as React from 'react';

const useModal = (): [boolean, React.Dispatch<React.SetStateAction<boolean>>] => {
const useModal = (enableScroll: boolean = true): [boolean, React.Dispatch<React.SetStateAction<boolean>>] => {
const [showModal, setShowModal] = React.useState<boolean>(false);

React.useEffect(() => {
if (!enableScroll) return;

if (showModal) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

Expand All @@ -13,7 +15,7 @@ const useModal = (): [boolean, React.Dispatch<React.SetStateAction<boolean>>] =>
document.body.style.overflow = 'auto';
document.body.style.marginRight = `${0}px`;
}
}, [showModal]);
}, [enableScroll, showModal]);

return [showModal, setShowModal];
};
Expand Down
Loading

0 comments on commit eadfcd2

Please sign in to comment.