Skip to content

Commit 94334a5

Browse files
authored
feat(ui): Tooltip (#324)
1 parent 4b12d5d commit 94334a5

File tree

13 files changed

+781
-110
lines changed

13 files changed

+781
-110
lines changed

.changeset/quick-years-study.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sopt-makers/ui': minor
3+
'docs': minor
4+
---
5+
6+
feat: tooltip 컴포넌트 추가
Lines changed: 214 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,108 @@
11
import type { Meta, StoryObj } from '@storybook/react';
2-
import { Tooltip } from '@sopt-makers/ui';
3-
import { IconAlertCircle } from '@sopt-makers/icons';
2+
import { Button, Tag, Tooltip } from '@sopt-makers/ui';
3+
import { IconAlertCircle, IconChevronRight } from '@sopt-makers/icons';
4+
import type { ReactNode, CSSProperties } from 'react';
5+
import { colors } from '@sopt-makers/colors';
6+
import { fontsObject } from '@sopt-makers/fonts';
47

5-
const meta = {
8+
const Placement = [
9+
'top',
10+
'bottom',
11+
'left',
12+
'right',
13+
'topLeft',
14+
'topRight',
15+
'bottomLeft',
16+
'bottomRight',
17+
'leftTop',
18+
'leftBottom',
19+
'rightTop',
20+
'rightBottom',
21+
] as const;
22+
23+
interface TooltipStoryArgs {
24+
placement?: (typeof Placement)[number];
25+
isOpen?: boolean;
26+
size?: 'small' | 'large';
27+
title?: ReactNode;
28+
bodyText?: ReactNode;
29+
closeButton?: boolean;
30+
secondaryButton?: ReactNode;
31+
style?: CSSProperties;
32+
}
33+
34+
const meta: Meta<TooltipStoryArgs> = {
635
title: 'Components/Tooltip',
7-
component: Tooltip.Root,
836
tags: ['autodocs'],
937
parameters: {
1038
layout: 'centered',
1139
},
1240
argTypes: {
41+
size: {
42+
control: 'radio',
43+
options: ['small', 'large'],
44+
description: '툴팁 크기를 설정합니다.',
45+
table: {
46+
type: {
47+
summary: 'small | large',
48+
},
49+
},
50+
},
51+
placement: {
52+
control: 'select',
53+
options: Placement,
54+
description: '툴팁 위치를 설정합니다.',
55+
table: {
56+
type: {
57+
summary:
58+
'top | bottom | left | right | topLeft | topRight | bottomLeft | bottomRight | leftTop | leftBottom | rightTop | rightBottom',
59+
},
60+
},
61+
},
62+
title: {
63+
control: 'text',
64+
description: '툴팁 제목을 설정합니다.',
65+
table: {
66+
type: {
67+
summary: 'ReactNode',
68+
},
69+
},
70+
},
71+
bodyText: {
72+
control: 'text',
73+
description: '툴팁 내용을 설정합니다.',
74+
table: {
75+
type: {
76+
summary: 'ReactNode',
77+
},
78+
},
79+
},
80+
closeButton: {
81+
control: 'boolean',
82+
description: '툴팁 닫기 버튼을 설정합니다. <br />`large` 크기일 때만 표시됩니다.',
83+
table: {
84+
type: {
85+
summary: 'boolean',
86+
},
87+
},
88+
},
89+
secondaryButton: {
90+
control: false,
91+
description: '툴팁 보조 버튼을 설정합니다.',
92+
table: {
93+
type: {
94+
summary: 'ReactNode',
95+
},
96+
},
97+
},
1398
isOpen: {
1499
control: 'boolean',
100+
description: '툴팁 열림 여부를 설정합니다.',
101+
table: {
102+
type: {
103+
summary: 'boolean',
104+
},
105+
},
15106
},
16107
},
17108
decorators: [
@@ -28,35 +119,140 @@ const meta = {
28119
</div>
29120
),
30121
],
31-
} satisfies Meta<typeof Tooltip.Root>;
122+
};
32123

33124
export default meta;
34-
type Story = StoryObj<typeof meta>;
125+
type Story = StoryObj<TooltipStoryArgs>;
35126

36127
export const Default: Story = {
37128
render: (args) => (
38-
<Tooltip.Root {...args}>
129+
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
130+
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
131+
<Tooltip.Content
132+
title={args.title}
133+
bodyText={args.bodyText}
134+
size={args.size}
135+
closeButton={args.closeButton}
136+
secondaryButton={args.secondaryButton}
137+
>
138+
{!args.title && !args.bodyText && '툴팁 내용입니다.'}
139+
</Tooltip.Content>
140+
</Tooltip.Root>
141+
),
142+
args: {
143+
title: '',
144+
bodyText: '',
145+
size: 'small',
146+
closeButton: false,
147+
secondaryButton: undefined,
148+
},
149+
};
150+
151+
export const SmallWithTag: Story = {
152+
render: (args) => (
153+
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
39154
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
40-
<Tooltip.Content>툴팁 내용입니다.</Tooltip.Content>
155+
<Tooltip.Content bodyText={args.bodyText} style={{ width: '272px' }} />
41156
</Tooltip.Root>
42157
),
43-
args: {},
158+
args: {
159+
bodyText: (
160+
<div style={{ display: 'flex', gap: '8px' }}>
161+
<Tag style={{ backgroundColor: colors.orangeAlpha200, color: colors.secondary, whiteSpace: 'nowrap' }}>New</Tag>
162+
Small Tooltip은 본문을 두 줄 이상 작성하면 요런 모습이에요.
163+
</div>
164+
),
165+
size: 'small',
166+
},
44167
};
45168

46-
export const CustomContent: Story = {
169+
export const SmallWithPrefixIcon: Story = {
47170
render: (args) => (
48-
<Tooltip.Root {...args}>
171+
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
172+
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
173+
<Tooltip.Content
174+
prefixIcon={<IconAlertCircle style={{ width: '16px', height: '16px' }} />}
175+
bodyText={args.bodyText}
176+
style={{ width: 'fit-content' }}
177+
/>
178+
</Tooltip.Root>
179+
),
180+
args: {
181+
bodyText: 'min-width은 160px로 설정되어있어요.',
182+
size: 'small',
183+
},
184+
};
185+
186+
export const LargeWithPrefixIcon: Story = {
187+
render: (args) => (
188+
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
49189
<Tooltip.Trigger>
50190
호버해보세요
51191
<IconAlertCircle style={{ width: '15px', height: '15px' }} />
52192
</Tooltip.Trigger>
53-
<Tooltip.Content>
54-
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
55-
<IconAlertCircle style={{ width: '15px', height: '15px' }} />
56-
<span>툴팁 내용입니다.</span>
57-
</div>
58-
</Tooltip.Content>
193+
<Tooltip.Content
194+
size='large'
195+
prefixIcon={<IconAlertCircle style={{ width: '15px', height: '15px' }} />}
196+
title={args.title}
197+
bodyText={args.bodyText}
198+
closeButton={args.closeButton}
199+
secondaryButton={args.secondaryButton}
200+
style={{ width: '272px' }}
201+
/>
59202
</Tooltip.Root>
60203
),
61-
args: {},
204+
args: {
205+
title: '제목 텍스트입니다.',
206+
bodyText: '이곳에 본문 텍스트를 작성해주세요.',
207+
size: 'large',
208+
closeButton: true,
209+
secondaryButton: (
210+
<button
211+
style={{
212+
display: 'flex',
213+
width: 'fit-content',
214+
padding: '0',
215+
alignItems: 'center',
216+
border: 'none',
217+
backgroundColor: 'transparent',
218+
color: colors.gray30,
219+
...fontsObject.LABEL_4_12_SB,
220+
}}
221+
>
222+
Text Button
223+
<IconChevronRight style={{ width: '16px', height: '16px' }} />
224+
</button>
225+
),
226+
},
227+
};
228+
229+
export const LargeWithTitleAndBody: Story = {
230+
render: (args) => (
231+
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
232+
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
233+
<Tooltip.Content
234+
title={args.title}
235+
bodyText={args.bodyText}
236+
size={args.size}
237+
closeButton={args.closeButton}
238+
secondaryButton={args.secondaryButton}
239+
style={{ width: '272px' }}
240+
/>
241+
</Tooltip.Root>
242+
),
243+
args: {
244+
title: (
245+
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
246+
제목 텍스트입니다.<Tag style={{ backgroundColor: colors.orangeAlpha200, color: colors.secondary }}>New</Tag>
247+
</div>
248+
),
249+
bodyText: '이곳에 본문 텍스트를 작성해주세요.',
250+
size: 'large',
251+
closeButton: false,
252+
secondaryButton: (
253+
<Button theme='black' style={{ width: '100%' }}>
254+
Button
255+
</Button>
256+
),
257+
},
62258
};

packages/ui/Tooltip/Content.tsx

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,83 @@
1-
import type { PropsWithChildren } from 'react';
1+
import type { CSSProperties, PropsWithChildren, ReactNode } from 'react';
22
import { forwardRef } from 'react';
33
import BubblePointIcon from 'Tooltip/icons/bubblePoint';
44
import clsx from 'clsx';
55
import * as S from './style.css';
66
import { useTooltipContext } from './TooltipContext';
7-
import { useTooltipContentPosition } from 'Tooltip/useTooltip';
8-
9-
const TooltipContent = forwardRef<HTMLDivElement, PropsWithChildren>(({ children }) => {
10-
const { isOpen, tooltipId, contentRef } = useTooltipContext();
11-
const { position } = useTooltipContentPosition();
12-
13-
return (
14-
<div
15-
aria-hidden={!isOpen}
16-
className={clsx(
17-
S.contentWrapper[isOpen ? 'visible' : 'hidden'],
18-
S.contentWrapperPosition[position],
19-
S.commonContentWrapper,
20-
)}
21-
id={tooltipId}
22-
ref={contentRef}
23-
role='tooltip'
24-
>
25-
<BubblePointIcon className={clsx(S.bubblePointIcon, S.bubblePointIconPosition[position])} />
26-
<span className={S.content}>{children}</span>
27-
</div>
28-
);
29-
});
7+
import { useTooltipContentPlacement } from 'Tooltip/useTooltipContentPlacement';
8+
import CloseIcon from 'Tooltip/icons/close';
9+
10+
export interface TooltipContentProps extends PropsWithChildren {
11+
size?: 'small' | 'large';
12+
prefixIcon?: ReactNode;
13+
title?: ReactNode;
14+
bodyText?: ReactNode;
15+
closeButton?: boolean;
16+
secondaryButton?: ReactNode;
17+
style?: CSSProperties;
18+
}
19+
20+
const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
21+
({ size = 'small', prefixIcon, title, bodyText, closeButton, secondaryButton, style, children }) => {
22+
const { isOpen, tooltipId, contentRef, showTooltip, hideTooltip } = useTooltipContext();
23+
const { placement } = useTooltipContentPlacement();
24+
25+
const iconColor = { color: style?.backgroundColor };
26+
const isLargeWithCloseButton = size === 'large' && closeButton;
27+
28+
const prefixIconWithBodyOnly = prefixIcon && !title && bodyText;
29+
30+
return (
31+
<div
32+
aria-hidden={!isOpen}
33+
className={clsx(
34+
S.contentWrapper[isOpen ? 'visible' : 'hidden'],
35+
S.contentWrapperPosition[placement],
36+
S.commonContentWrapper,
37+
S.contentWrapperSize[size],
38+
)}
39+
id={tooltipId}
40+
ref={contentRef}
41+
role='tooltip'
42+
onMouseEnter={showTooltip}
43+
onMouseLeave={hideTooltip}
44+
style={style}
45+
>
46+
<BubblePointIcon className={clsx(S.bubblePointIcon, S.bubblePointIconPosition[placement])} style={iconColor} />
47+
48+
{isLargeWithCloseButton && (
49+
<button type='button' onClick={hideTooltip} className={S.closeButton}>
50+
<CloseIcon />
51+
</button>
52+
)}
53+
54+
<section className={S.content}>
55+
{prefixIconWithBodyOnly ? (
56+
<div className={S.contentBodyWithPrefixIcon}>
57+
<i className={S.prefixIcon}>{prefixIcon}</i>
58+
<span className={S.bodySection}>{bodyText}</span>
59+
</div>
60+
) : (
61+
<>
62+
{(prefixIcon || title) && (
63+
<div className={S.titleRow}>
64+
{prefixIcon && <i className={S.prefixIcon}>{prefixIcon}</i>}
65+
{title && <h1 className={S.titleSection}>{title}</h1>}
66+
</div>
67+
)}
68+
<div className={S.contentBody}>
69+
{bodyText && <span className={S.bodySection}>{bodyText}</span>}
70+
{children}
71+
</div>
72+
</>
73+
)}
74+
75+
{secondaryButton && <div className={S.secondaryButton}>{secondaryButton}</div>}
76+
</section>
77+
</div>
78+
);
79+
},
80+
);
3081

3182
TooltipContent.displayName = 'TooltipContent';
3283

0 commit comments

Comments
 (0)