Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quick-years-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sopt-makers/ui': minor
'docs': minor
---

feat: tooltip 컴포넌트 추가
232 changes: 214 additions & 18 deletions apps/docs/src/stories/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,108 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Tooltip } from '@sopt-makers/ui';
import { IconAlertCircle } from '@sopt-makers/icons';
import { Button, Tag, Tooltip } from '@sopt-makers/ui';
import { IconAlertCircle, IconChevronRight } from '@sopt-makers/icons';
import type { ReactNode, CSSProperties } from 'react';
import { colors } from '@sopt-makers/colors';
import { fontsObject } from '@sopt-makers/fonts';

const meta = {
const Placement = [
'top',
'bottom',
'left',
'right',
'topLeft',
'topRight',
'bottomLeft',
'bottomRight',
'leftTop',
'leftBottom',
'rightTop',
'rightBottom',
] as const;

interface TooltipStoryArgs {
placement?: (typeof Placement)[number];
isOpen?: boolean;
size?: 'small' | 'large';
title?: ReactNode;
bodyText?: ReactNode;
closeButton?: boolean;
secondaryButton?: ReactNode;
style?: CSSProperties;
}

const meta: Meta<TooltipStoryArgs> = {
title: 'Components/Tooltip',
component: Tooltip.Root,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
size: {
control: 'radio',
options: ['small', 'large'],
description: '툴팁 크기를 설정합니다.',
table: {
type: {
summary: 'small | large',
},
},
},
placement: {
control: 'select',
options: Placement,
description: '툴팁 위치를 설정합니다.',
table: {
type: {
summary:
'top | bottom | left | right | topLeft | topRight | bottomLeft | bottomRight | leftTop | leftBottom | rightTop | rightBottom',
},
},
},
title: {
control: 'text',
description: '툴팁 제목을 설정합니다.',
table: {
type: {
summary: 'ReactNode',
},
},
},
bodyText: {
control: 'text',
description: '툴팁 내용을 설정합니다.',
table: {
type: {
summary: 'ReactNode',
},
},
},
closeButton: {
control: 'boolean',
description: '툴팁 닫기 버튼을 설정합니다. <br />`large` 크기일 때만 표시됩니다.',
table: {
type: {
summary: 'boolean',
},
},
},
secondaryButton: {
control: false,
description: '툴팁 보조 버튼을 설정합니다.',
table: {
type: {
summary: 'ReactNode',
},
},
},
isOpen: {
control: 'boolean',
description: '툴팁 열림 여부를 설정합니다.',
table: {
type: {
summary: 'boolean',
},
},
},
},
decorators: [
Expand All @@ -28,35 +119,140 @@ const meta = {
</div>
),
],
} satisfies Meta<typeof Tooltip.Root>;
};

export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<TooltipStoryArgs>;

export const Default: Story = {
render: (args) => (
<Tooltip.Root {...args}>
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
<Tooltip.Content
title={args.title}
bodyText={args.bodyText}
size={args.size}
closeButton={args.closeButton}
secondaryButton={args.secondaryButton}
>
{!args.title && !args.bodyText && '툴팁 내용입니다.'}
</Tooltip.Content>
</Tooltip.Root>
),
args: {
title: '',
bodyText: '',
size: 'small',
closeButton: false,
secondaryButton: undefined,
},
};

export const SmallWithTag: Story = {
render: (args) => (
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
<Tooltip.Content>툴팁 내용입니다.</Tooltip.Content>
<Tooltip.Content bodyText={args.bodyText} style={{ width: '272px' }} />
</Tooltip.Root>
),
args: {},
args: {
bodyText: (
<div style={{ display: 'flex', gap: '8px' }}>
<Tag style={{ backgroundColor: colors.orangeAlpha200, color: colors.secondary, whiteSpace: 'nowrap' }}>New</Tag>
Small Tooltip은 본문을 두 줄 이상 작성하면 요런 모습이에요.
</div>
),
size: 'small',
},
};

export const CustomContent: Story = {
export const SmallWithPrefixIcon: Story = {
render: (args) => (
<Tooltip.Root {...args}>
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
<Tooltip.Content
prefixIcon={<IconAlertCircle style={{ width: '16px', height: '16px' }} />}
bodyText={args.bodyText}
style={{ width: 'fit-content' }}
/>
</Tooltip.Root>
),
args: {
bodyText: 'min-width은 160px로 설정되어있어요.',
size: 'small',
},
};

export const LargeWithPrefixIcon: Story = {
render: (args) => (
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
<Tooltip.Trigger>
호버해보세요
<IconAlertCircle style={{ width: '15px', height: '15px' }} />
</Tooltip.Trigger>
<Tooltip.Content>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<IconAlertCircle style={{ width: '15px', height: '15px' }} />
<span>툴팁 내용입니다.</span>
</div>
</Tooltip.Content>
<Tooltip.Content
size='large'
prefixIcon={<IconAlertCircle style={{ width: '15px', height: '15px' }} />}
title={args.title}
bodyText={args.bodyText}
closeButton={args.closeButton}
secondaryButton={args.secondaryButton}
style={{ width: '272px' }}
/>
</Tooltip.Root>
),
args: {},
args: {
title: '제목 텍스트입니다.',
bodyText: '이곳에 본문 텍스트를 작성해주세요.',
size: 'large',
closeButton: true,
secondaryButton: (
<button
style={{
display: 'flex',
width: 'fit-content',
padding: '0',
alignItems: 'center',
border: 'none',
backgroundColor: 'transparent',
color: colors.gray30,
...fontsObject.LABEL_4_12_SB,
}}
>
Text Button
<IconChevronRight style={{ width: '16px', height: '16px' }} />
</button>
),
},
};

export const LargeWithTitleAndBody: Story = {
render: (args) => (
<Tooltip.Root placement={args.placement} isOpen={args.isOpen}>
<Tooltip.Trigger>호버해보세요</Tooltip.Trigger>
<Tooltip.Content
title={args.title}
bodyText={args.bodyText}
size={args.size}
closeButton={args.closeButton}
secondaryButton={args.secondaryButton}
style={{ width: '272px' }}
/>
</Tooltip.Root>
),
args: {
title: (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
제목 텍스트입니다.<Tag style={{ backgroundColor: colors.orangeAlpha200, color: colors.secondary }}>New</Tag>
</div>
),
bodyText: '이곳에 본문 텍스트를 작성해주세요.',
size: 'large',
closeButton: false,
secondaryButton: (
<Button theme='black' style={{ width: '100%' }}>
Button
</Button>
),
},
};
99 changes: 75 additions & 24 deletions packages/ui/Tooltip/Content.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,83 @@
import type { PropsWithChildren } from 'react';
import type { CSSProperties, PropsWithChildren, ReactNode } from 'react';
import { forwardRef } from 'react';
import BubblePointIcon from 'Tooltip/icons/bubblePoint';
import clsx from 'clsx';
import * as S from './style.css';
import { useTooltipContext } from './TooltipContext';
import { useTooltipContentPosition } from 'Tooltip/useTooltip';

const TooltipContent = forwardRef<HTMLDivElement, PropsWithChildren>(({ children }) => {
const { isOpen, tooltipId, contentRef } = useTooltipContext();
const { position } = useTooltipContentPosition();

return (
<div
aria-hidden={!isOpen}
className={clsx(
S.contentWrapper[isOpen ? 'visible' : 'hidden'],
S.contentWrapperPosition[position],
S.commonContentWrapper,
)}
id={tooltipId}
ref={contentRef}
role='tooltip'
>
<BubblePointIcon className={clsx(S.bubblePointIcon, S.bubblePointIconPosition[position])} />
<span className={S.content}>{children}</span>
</div>
);
});
import { useTooltipContentPlacement } from 'Tooltip/useTooltipContentPlacement';
import CloseIcon from 'Tooltip/icons/close';

export interface TooltipContentProps extends PropsWithChildren {
size?: 'small' | 'large';
prefixIcon?: ReactNode;
title?: ReactNode;
bodyText?: ReactNode;
closeButton?: boolean;
secondaryButton?: ReactNode;
style?: CSSProperties;
}

const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
({ size = 'small', prefixIcon, title, bodyText, closeButton, secondaryButton, style, children }) => {
const { isOpen, tooltipId, contentRef, showTooltip, hideTooltip } = useTooltipContext();
const { placement } = useTooltipContentPlacement();

const iconColor = { color: style?.backgroundColor };
const isLargeWithCloseButton = size === 'large' && closeButton;

const prefixIconWithBodyOnly = prefixIcon && !title && bodyText;

return (
<div
aria-hidden={!isOpen}
className={clsx(
S.contentWrapper[isOpen ? 'visible' : 'hidden'],
S.contentWrapperPosition[placement],
S.commonContentWrapper,
S.contentWrapperSize[size],
)}
id={tooltipId}
ref={contentRef}
role='tooltip'
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
style={style}
>
<BubblePointIcon className={clsx(S.bubblePointIcon, S.bubblePointIconPosition[placement])} style={iconColor} />

{isLargeWithCloseButton && (
<button type='button' onClick={hideTooltip} className={S.closeButton}>
<CloseIcon />
</button>
)}

<section className={S.content}>
{prefixIconWithBodyOnly ? (
<div className={S.contentBodyWithPrefixIcon}>
<i className={S.prefixIcon}>{prefixIcon}</i>
<span className={S.bodySection}>{bodyText}</span>
</div>
) : (
<>
{(prefixIcon || title) && (
<div className={S.titleRow}>
{prefixIcon && <i className={S.prefixIcon}>{prefixIcon}</i>}
{title && <h1 className={S.titleSection}>{title}</h1>}
</div>
)}
<div className={S.contentBody}>
{bodyText && <span className={S.bodySection}>{bodyText}</span>}
{children}
</div>
</>
)}

{secondaryButton && <div className={S.secondaryButton}>{secondaryButton}</div>}
</section>
</div>
);
},
);

TooltipContent.displayName = 'TooltipContent';

Expand Down
Loading