Skip to content

Commit

Permalink
[Feature/BAR-139] Tooltip 컴포넌트 구현 (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
dmswl98 authored Jan 8, 2024
1 parent 252eaf3 commit 2858061
Show file tree
Hide file tree
Showing 10 changed files with 393 additions and 0 deletions.
107 changes: 107 additions & 0 deletions src/components/Tooltip/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from '@storybook/react';

import Tooltip from './Tooltip';

const COMPONENT_DESCRIPTION = `
- \`<Tooltip />\`: 모든 컴포넌트에 대한 컨텍스트와 상태를 제공합니다.
- \`<Tooltip.Trigger />\`: \`<Tabs.Content />\` 컴포넌트를 활성화하는 컴포넌트입니다. 사용자가 이 컴포넌트 위로 마우스를 올릴 때 Tooltip이 나타납니다.
- \`<Tooltip.Content />\`: Tooltip에 표시되는 내용을 담당하는 컴포넌트입니다.
`;

const meta: Meta<typeof Tooltip> = {
title: 'Components/Tooltip',
component: Tooltip,
parameters: {
componentSubtitle:
'특정 사용자 인터페이스에 대한 보조 설명을 제공하는 컴포넌트',
docs: {
description: {
component: COMPONENT_DESCRIPTION,
},
},
},
decorators: [
(Story) => (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '200px',
height: '550px',
}}
>
<Story />
</div>
),
],
};

export default meta;

type Story = StoryObj<typeof Tooltip>;

export const Basic: Story = {
render: () => (
<>
<Tooltip>
<Tooltip.Trigger>
<div>Minimal</div>
</Tooltip.Trigger>
<Tooltip.Content>Minimal Tooltip 설명</Tooltip.Content>
</Tooltip>
<Tooltip hasArrow>
<Tooltip.Trigger>
<div>Highlight</div>
</Tooltip.Trigger>
<Tooltip.Content>Highlight Tooltip 설명</Tooltip.Content>
</Tooltip>
</>
),
};

export const Minimal: Story = {
parameters: {
docs: {
description: {
story: 'Hover시 Trigger 요소의 상위와 하위에 노출됩니다.',
},
},
},
args: {
children: 'Minimal',
hasArrow: false,
placement: 'bottom',
},
render: (args) => (
<Tooltip hasArrow={args.hasArrow} placement={args.placement}>
<Tooltip.Trigger>
<div>{args.children}</div>
</Tooltip.Trigger>
<Tooltip.Content>Minimal Tooltip 설명</Tooltip.Content>
</Tooltip>
),
};

export const Highlight: Story = {
parameters: {
docs: {
description: {
story: 'Hover시 Trigger 요소의 하위에 노출됩니다.',
},
},
},
args: {
children: 'Highlight',
hasArrow: true,
placement: 'bottom',
},
render: (args) => (
<Tooltip hasArrow={args.hasArrow} placement={args.placement}>
<Tooltip.Trigger>
<div>{args.children}</div>
</Tooltip.Trigger>
<Tooltip.Content>Highlight Tooltip 설명</Tooltip.Content>
</Tooltip>
),
};
78 changes: 78 additions & 0 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { PropsWithChildren, Ref } from 'react';
import { createContext, useEffect, useRef, useState } from 'react';

import { getPosition } from '@/src/utils/getPosition';

import TooltipContent from './TooltipContent';
import TooltipTrigger from './TooltipTrigger';

const INIT_POSITION = { top: 0, left: 0 };

export interface TooltipShape {
hasArrow?: boolean;
placement?: 'top' | 'bottom';
}

interface TooltipContextProps extends TooltipShape {
tooltipRef: Ref<HTMLDivElement>;
isVisible: boolean;
position: typeof INIT_POSITION;
onOpenTooltip: () => void;
onCloseTooltip: () => void;
}

interface TooltipProps extends TooltipShape {}

export const TooltipContext = createContext<TooltipContextProps | null>(null);

const TooltipRoot = ({
children,
hasArrow = false,
placement = 'bottom',
}: PropsWithChildren<TooltipProps>) => {
const tooltipRef = useRef<HTMLDivElement>(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);
};

return (
<TooltipContext.Provider
value={{
tooltipRef,
isVisible,
hasArrow,
placement,
position,
onOpenTooltip: handleTooltipOpen,
onCloseTooltip: handleTooltipClose,
}}
>
{children}
</TooltipContext.Provider>
);
};

const Tooltip = Object.assign(TooltipRoot, {
Trigger: TooltipTrigger,
Content: TooltipContent,
});

export default Tooltip;
40 changes: 40 additions & 0 deletions src/components/Tooltip/TooltipContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { PropsWithChildren } from 'react';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';

import { useTooltipContext } from '@/src/hooks/useTooltipContext';

import * as styles from './style.css';
import TooltipPortal from './TooltipPortal';

const ARROW_STYLE = {
top: styles.bottomArrow,
bottom: styles.topArrow,
};

const TooltipContent = ({ children }: PropsWithChildren) => {
const { isVisible, hasArrow, placement, position } = useTooltipContext();

return (
<>
{isVisible && (
<TooltipPortal>
<div
className={clsx(
styles.content({ hasArrow }),
hasArrow && ARROW_STYLE[placement],
)}
style={assignInlineVars({
[styles.top]: `${position.top}px`,
[styles.left]: `${position.left}px`,
})}
>
{children}
</div>
</TooltipPortal>
)}
</>
);
};

export default TooltipContent;
29 changes: 29 additions & 0 deletions src/components/Tooltip/TooltipPortal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ReactElement } from 'react';
import { useEffect } from 'react';
import ReactDOM from 'react-dom';

interface TooltipPortalProps {
children: ReactElement;
}

const TOOLTIP_PORTAL_ID = 'tooltip-root';
const TOOLTIP_ROOT_TAG = 'div';

const TooltipPortal = ({ children }: TooltipPortalProps) => {
const tooltipRoot = document.createElement(TOOLTIP_ROOT_TAG);
tooltipRoot.id = TOOLTIP_PORTAL_ID;

useEffect(() => {
if (tooltipRoot) {
document.body.appendChild(tooltipRoot);
}

return () => {
document.body.removeChild(tooltipRoot);
};
}, [tooltipRoot]);

return ReactDOM.createPortal(children, tooltipRoot);
};

export default TooltipPortal;
22 changes: 22 additions & 0 deletions src/components/Tooltip/TooltipTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type PropsWithChildren } from 'react';

import { useTooltipContext } from '@/src/hooks/useTooltipContext';

import * as styles from './style.css';

const TooltipTrigger = ({ children }: PropsWithChildren) => {
const { tooltipRef, onOpenTooltip, onCloseTooltip } = useTooltipContext();

return (
<div
ref={tooltipRef}
className={styles.trigger}
onMouseEnter={onOpenTooltip}
onMouseLeave={onCloseTooltip}
>
{children}
</div>
);
};

export default TooltipTrigger;
70 changes: 70 additions & 0 deletions src/components/Tooltip/style.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createVar, style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';

import { sprinkles } from '@/src/styles/sprinkles.css';
import { COLORS, Z_INDEX } from '@/src/styles/tokens';

export const trigger = style({
width: 'fit-content',
height: 'fit-content',
padding: '4px',
});

export const top = createVar();
export const left = createVar();

export const content = recipe({
base: [
sprinkles({ typography: '13/Body/Regular' }),
{
position: 'absolute',
top,
left,
width: 'fit-content',
color: COLORS['Grey/White'],
backgroundColor: COLORS['Dim/70'],
borderRadius: '8px',
whiteSpace: 'nowrap',
transform: 'translateX(-50%)',
zIndex: Z_INDEX['tooltip'],
},
],
variants: {
hasArrow: {
false: {
padding: '8px 12px',
},
true: {
padding: '16px',

'::before': {
content: '',
width: 0,
height: 0,
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
border: `6px solid ${COLORS['Grey/White']}`,
},
},
},
},
});

export const topArrow = style({
marginTop: '6px',

'::before': {
top: '-12px',
borderBottomColor: COLORS['Dim/70'],
},
});

export const bottomArrow = style({
marginBottom: '6px',

'::before': {
bottom: '-12px',
borderTopColor: COLORS['Dim/70'],
},
});
15 changes: 15 additions & 0 deletions src/hooks/useTooltipContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useContext } from 'react';

import { TooltipContext } from '../components/Tooltip/Tooltip';

export const useTooltipContext = () => {
const ctx = useContext(TooltipContext);

if (!ctx) {
throw new Error(
'useTooltipContext hook must be used within a Tooltip component',
);
}

return ctx;
};
5 changes: 5 additions & 0 deletions src/styles/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,8 @@ export const COLORS = {
LightRed: '#fd6666',
Purple: '#7c62ea',
};

export const Z_INDEX = {
modal: 100,
tooltip: 50,
};
Empty file removed src/utils/example.ts
Empty file.
Loading

0 comments on commit 2858061

Please sign in to comment.