Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
63fdbe5
feat: 툴팁의 기본 구조를 구현합니다.
WonJuneKim Nov 21, 2025
5b50dbc
feat: 툴팁의 Context 구독 방식을 이원화 합니다.
WonJuneKim Nov 21, 2025
ef5b324
test: 스토리북을 이용해 툴팁의 액션을 테스트합니다.
WonJuneKim Nov 22, 2025
7327576
refactor: Icon 컴포넌트에 ref를 forwardRef로 래핑합니다.
WonJuneKim Nov 22, 2025
a358768
refactor: Label 컴포넌트에 display 속성을 제거합니다.
WonJuneKim Nov 22, 2025
498929b
feat: 스토리북에 GlobalStyles를 주입합니다.
WonJuneKim Nov 22, 2025
2f92bb8
test: Tooltip 테스트 케이스를 구체화합니다.
WonJuneKim Nov 22, 2025
4ed7812
feat: 구현한 컴포넌트를 export합니다.
WonJuneKim Nov 22, 2025
e008dd1
feat: Tooltip의 Side를 결정짓는 props를 별도로 선언하지 않고 radix에서 제공하는 인터페이스를 사용합니다.
WonJuneKim Nov 25, 2025
34d11d1
feat: Tooltip이 자체 Context를 가지지 않고 radix의 설계를 따르도록 합니다.
WonJuneKim Nov 25, 2025
9882f21
feat: radix가 제공하는 avoidCollisions prop을 사용하여 툴팁 요소의 방향성을 결정합니다.
WonJuneKim Nov 25, 2025
bdf8011
test: 변경된 Tooltip 구조에 맞춰 스토리북의 테스트 케이스를 변경합니다.
WonJuneKim Nov 25, 2025
cb505c4
feat: 툴팁의 추가 구현 방향성에 대한 Todo를 작성합니다.
WonJuneKim Nov 25, 2025
714456d
Merge branch 'dev' into feat/263-tooltip-design-system
WonJuneKim Nov 25, 2025
5e1fd34
refactor: Icon의 Wrapper의 타입을 실제 컴포넌트의 타입을 직접 참조하도록 변경합니다.
WonJuneKim Nov 25, 2025
3a0449d
Merge branch 'feat/263-tooltip-design-system' of github.com:JECT-Stud…
WonJuneKim Nov 25, 2025
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
2 changes: 2 additions & 0 deletions packages/jds/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ThemeProvider } from '@emotion/react';
import { Global } from '@emotion/react';
import { globalStyles } from '../src/tokens/globalStyles';
import { theme } from '../src/tokens/theme';
import { GlobalStyles } from '../src/style/globalStyle';

const preview: Preview = {
parameters: {
Expand Down Expand Up @@ -41,6 +42,7 @@ const preview: Preview = {
return (
<ThemeProvider theme={theme}>
<Global styles={globalStyles} />
<GlobalStyles />
<div data-theme={context.globals.theme}>
<Story />
</div>
Expand Down
26 changes: 16 additions & 10 deletions packages/jds/src/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { forwardRef } from 'react';

import { StyledIconWrapper } from './Icon.styles';
import type { IconProps } from './Icon.types';
import { iconMap, sizeMap } from './IconMap';

export const Icon = ({ name, size = 'md', color = 'currentColor', ...props }: IconProps) => {
const IconComponent = iconMap[name];
export const Icon = forwardRef<HTMLSpanElement, IconProps>(
Copy link
Contributor

@whdgur5717 whdgur5717 Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTMLSpanElement대신, ElementRef<typeofStyledIconWrapper>(정확한 문법은아닙니다) 로 활용하는게 좋을것같습니다

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금처럼 Wrapper로 감싸는 경우 특히 해당 요소를 직접 참조하는 것이 더 적절하겠네요! 5e1fd34 에서 변경했습니다~

({ name, size = 'md', color = 'currentColor', ...props }, ref) => {
const IconComponent = iconMap[name];

if (!IconComponent) return null;

if (!IconComponent) return null;
const pixelSize = sizeMap[size];

const pixelSize = sizeMap[size];
return (
<StyledIconWrapper ref={ref}>
<IconComponent width={pixelSize} height={pixelSize} color={color} {...props} />
</StyledIconWrapper>
);
},
);

return (
<StyledIconWrapper>
<IconComponent width={pixelSize} height={pixelSize} color={color} {...props} />
</StyledIconWrapper>
);
};
Icon.displayName = 'Icon';
13 changes: 2 additions & 11 deletions packages/jds/src/components/Label/Label.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,8 @@ import isPropValid from '@emotion/is-prop-valid';
import type { Theme } from '@emotion/react';
import styled from '@emotion/styled';

const TEXT_ALIGN_MAPPING = {
center: 'center',
left: 'flex-start',
right: 'flex-end',
} as const;

export type LabelSize = 'lg' | 'md' | 'sm' | 'xs';
export type LabelTextAlign = keyof typeof TEXT_ALIGN_MAPPING;
export type LabelTextAlign = 'left' | 'center' | 'right';
export type LabelWeight = 'bold' | 'normal' | 'subtle';

interface LabelStyledProps {
Expand All @@ -32,12 +26,9 @@ export const LabelStyled = styled('label', {
shouldForwardProp: prop => isPropValid(prop) && !prop.startsWith('$'),
})<LabelStyledProps>(({ theme, $size, $textAlign, $weight, $color }) => {
const tokenKey = getLabelTokenKey($size, $weight);
const justifyContent = TEXT_ALIGN_MAPPING[$textAlign];

return {
display: 'flex',
justifyContent,
alignItems: 'center',
textAlign: $textAlign,
color: $color ?? theme.color.semantic.object.bold,
cursor: 'default',
...theme.textStyle[tokenKey],
Expand Down
222 changes: 222 additions & 0 deletions packages/jds/src/components/Tooltip/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FlexColumn, FlexRow, Label } from '@storybook-utils/layout';
import { Icon, IconButton, Input, Tooltip, BlockButton } from 'components';
import { Label as LabelComponent } from 'components';

const meta = {
title: 'Components/Tooltip',
component: Tooltip.Root,
decorators: [
Story => (
<Tooltip.Provider delayDuration={0} skipDelayDuration={0}>
<Story />
</Tooltip.Provider>
),
],
parameters: {
layout: 'centered',
},
argTypes: {
side: {
control: 'select',
options: ['top', 'right', 'bottom', 'left'],
description: '툴팁 표시 위치',
table: {
defaultValue: { summary: 'top' },
},
},
sideOffset: {
control: 'number',
description: '트리거 요소와의 간격 (px)',
table: {
defaultValue: { summary: '8' },
},
},
},
} satisfies Meta<typeof Tooltip.Root>;

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

export const Default: Story = {
render: args => (
<Tooltip.Root {...args}>
<Tooltip.Trigger>
<IconButton.Basic icon='information-line' />
</Tooltip.Trigger>
<Tooltip.Content>툴팁 테스트 레이블</Tooltip.Content>
</Tooltip.Root>
),
args: {
side: 'top',
sideOffset: 8,
children: undefined,
},
};

export const AllSides: Story = {
args: { children: undefined },
render: () => (
<FlexColumn gap='60px'>
<FlexRow gap='24px'>
<FlexColumn gap='12px'>
<Label>Top (기본값)</Label>
<Tooltip.Root side='top'>
<Tooltip.Trigger>
<IconButton.Basic icon='information-line' />
</Tooltip.Trigger>
<Tooltip.Content>툴팁 상단</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>

<FlexColumn gap='12px'>
<Label>Right</Label>
<Tooltip.Root side='right'>
<Tooltip.Trigger>
<IconButton.Basic icon='information-line' />
</Tooltip.Trigger>
<Tooltip.Content>툴팁 우측</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>

<FlexColumn gap='12px'>
<Label>Bottom</Label>
<Tooltip.Root side='bottom'>
<Tooltip.Trigger>
<IconButton.Basic icon='information-line' />
</Tooltip.Trigger>
<Tooltip.Content>툴팁 아래</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>

<FlexColumn gap='12px'>
<Label>Left</Label>
<Tooltip.Root side='left'>
<Tooltip.Trigger>
<IconButton.Basic icon='information-line' />
</Tooltip.Trigger>
<Tooltip.Content>툴팁 좌측</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>
</FlexRow>
</FlexColumn>
),
parameters: {
docs: {
description: {
story:
'툴팁은 네 가지 방향(top, right, bottom, left)으로 표시할 수 있습니다. 공간이 부족하면 자동으로 다른 방향으로 전환됩니다.',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피그마에서 정의된 내용 보니까 주변 여유 공간에 따라 방향 우선순위(상 -> 우 -> 하 -> 좌)가 있더라구요!
side 방향을 내부에서 자동으로 결정하도록 하는 auto placement 같은 옵션을 따로 두거나 해서 uncontrolled 방식이 추가되면 좋을 것 같습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9882f21 에서 우선적으로 기본적으로 제공하는 prop을 이용해서 설정하였으나 해당 요소는 (상 -> 우 -> 하 -> 좌) 의 요구사항을 만족하지는 못합니다.

cb505c4 에서 추가 개선 방향을 명시해두었습니다!

},
},
},
};

export const LongContent: Story = {
args: { children: undefined },
render: () => (
<Tooltip.Root>
<Tooltip.Trigger>
<BlockButton.Basic hierarchy='accent'>표시되는 요소가 길 경우</BlockButton.Basic>
</Tooltip.Trigger>
<Tooltip.Content>
아주 아주 아주 아주 긴 요소입니다. 모바일 환경에서도 정상적인 툴팁 내용이 표시되어야하기
때문에 이러한 처리를 하였습니다. 최대 길이는 320px이며 이 후 자동으로 줄바꿈 됩니다.
</Tooltip.Content>
</Tooltip.Root>
),
parameters: {
docs: {
description: {
story: '긴 텍스트는 자동으로 줄바꿈되며, 최대 너비는 320px입니다.',
},
},
},
};

export const WithCustomOffset: Story = {
args: { children: undefined },
render: () => (
<FlexRow gap='24px'>
<FlexColumn gap='12px'>
<Label>오프셋 기본값(8px)</Label>
<Tooltip.Root sideOffset={8}>
<Tooltip.Trigger>
<BlockButton.Basic hierarchy='accent'>기본 오프셋</BlockButton.Basic>
</Tooltip.Trigger>
<Tooltip.Content>트리거 요소로 부터 8px</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>

<FlexColumn gap='12px'>
<Label>오프셋 커스텀(24px)</Label>
<Tooltip.Root sideOffset={24}>
<Tooltip.Trigger>
<BlockButton.Basic hierarchy='accent'>커스텀(확장) 오프셋</BlockButton.Basic>
</Tooltip.Trigger>
<Tooltip.Content>트리거 요소로 부터 24px</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>
</FlexRow>
),
parameters: {
docs: {
description: {
story: 'sideOffset prop으로 트리거 요소와 툴팁 사이의 간격을 조절할 수 있습니다.',
},
},
},
};

export const CustomTrigger: Story = {
args: { children: undefined },
render: () => (
<FlexColumn gap='24px'>
<div>
<Tooltip.Root>
<Tooltip.Trigger>
<LabelComponent as='span'>텍스트 레이블입니다.</LabelComponent>
</Tooltip.Trigger>
<Tooltip.Content>레이블</Tooltip.Content>
</Tooltip.Root>
</div>

<FlexColumn gap='12px'>
<Label>Icon 컴포넌트</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<Icon name='information-line' size='lg' />
</Tooltip.Trigger>
<Tooltip.Content>아이콘 툴팁</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>

<FlexColumn gap='12px'>
<Label>Icon 버튼</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<IconButton.Basic icon='alert-line' />
</Tooltip.Trigger>
<Tooltip.Content>아이콘 버튼 툴팁</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>

<FlexColumn gap='12px'>
<Label>Input 예시</Label>
<Tooltip.Root side='top' sideOffset={12}>
<Tooltip.Trigger>
<Input.TextField value='레이블 명' onChange={e => e.preventDefault()} />
</Tooltip.Trigger>
<Tooltip.Content>안녕하세요? 툴팁입니다.</Tooltip.Content>
</Tooltip.Root>
</FlexColumn>
</FlexColumn>
),
parameters: {
docs: {
description: {
story:
'툴팁은 다양한 Interactive한 요소에 적용할 수 있습니다. 다만, forwardRef 로 래핑된 요소나 Content 자체에 Html Element를 가진 요소이어야 합니다. 버튼, 텍스트, 아이콘, 입력 필드 등 어떤 요소든 트리거로 사용할 수 있습니다.',
},
},
},
};
85 changes: 85 additions & 0 deletions packages/jds/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { createContext, useContext } from 'react';

import { StyledTooltipContent } from './tooltip.styles';
import type {
TooltipContentProps,
TooltipProps,
TooltipSide,
TooltipTriggerProps,
} from './tooltip.types';

interface TooltipContextValue {
side: TooltipSide;
sideOffset: number;
collisionPadding: number;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 속성들이 Root 컴포넌트에서 주입받아서- useContext를 통해 전달되어야 하는 이유가 있나요?
지금 코드를 봐도 크게 Root나 Trigger에서 사용되고 있지는 않는것 같은데,
그냥 간단하게, 원래 radix-ui TooltipContent의 활용법 그대로 활용해도 괜찮을것 같습니다

Copy link
Contributor

@rkdcodus rkdcodus Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tooltip.Content에서도 해당 props들을 직접 전달할 수 있지만 Root에서 관리하도록 API를 재구성한 것으로 보이네요!!
radix를 기반으로 하는 컴포넌트들의 API 패턴들을 전반적으로 Root에서 관리하는 패턴으로 갈 것인지 의견을 나눠보는게 좋을 것 같아요 다른 분들은 어떻게 생각하시나요?

다른 컴포넌트 포함 전반적으로 Root에서 관리하는 방식이면 일관성 면에서는 이런 방식도 좋다고 생각하는데 일부 핵심 속성을 Root에서 관리하면 구조적으로 간단해져서 좋다고 생각하는데 나머지 props는 또 Content에 직접 전달하고 있어서 이부분이 컴포 사용자 입장에서 조금 헷갈릴 수도 있을 것 같긴하네요!

Copy link
Contributor

@whdgur5717 whdgur5717 Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

합성 컴포넌트라는걸 사용하는 이유가, srp 관점에서 각 파트별로 책임을 담당하면서 복잡도를 줄이는 목적이 크다고 보는데
어디에 렌더링될지를 결정하는건 Content에서 담당하는 역할인데, 저렇게 Root에 넣게 되면 합성 컴포넌트를 쓸 이유가 크게 없는것 같습니다

  • 지금은 tooltip.Root에서 다른 속성들을 많이 사용하지 않게끔 구현하신 것 같은데 대부분의 Root 컴포넌트에서는 제어/비제어라던가 이벤트 콜백함수등을 정의하는 경우가 많아서 오히려 더 복잡할 것 같습니다

  • 오히려 저렇게 사용할거라면 합성컴포넌트 방식보다 차라리 Content에 해당하는 걸 props로 받는게 더 좋아보입니다

<Tooltip content={<Button>}/>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 구현되어 있는 Select 컴포넌트의 경우 Context로 받은 값을 Label, Checkbox, Radio 같은 여러 종류의 자식 컴포넌트에서 사용할 수 있도록 설계되어 있는 것 같은데, 이런 경우라면 Root에서 Context로 관리하는 것이 합리적이라고 생각합니다.
그런데 Tooltip의 경우 현재 Context로 받은 값을 Content 한 곳에서만 사용하고 있고, 향후에도 추가될 자식 컴포넌트가 없을 것 같다면, 꼭 Context를 사용해야 할까? 하는 생각이 들긴 합니다.
기존에 구현한 다른 컴포넌트들과의 일관성을 우선시한다면 현재 구조도 괜찮겠지만, Radix UI를 사용하기로 했다면 해당 props를 사용하는 곳에 직접 전달하는 것이 더 명확하지 않을까 생각합니다!

Copy link
Contributor Author

@WonJuneKim WonJuneKim Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동구님 말씀대로 Context로 받은 값을 내부 자식들에게 각각 뿌려주거나, 여러개의 자식 컴포넌트들이 와서 일괄적인 관리가 필요한 경우에는 Root에서 관리하는 것에 이점이 있지만 ToolTip의 경우 사실상 1대1 대응관계라 (물론 특정 트리거에 대해서 여러개의 Content가 올수도 있습니다...만 이러한 매우 예외적인건 제외) 오히려 Context가 컴포넌트의 복잡도를 올린 것 같네요.

또한 Radix 내부에서 이미 해당 구조를 사용하고 있기 때문에 Context 자체를 추가로 씌우는 구조는 변경하도록 하겠습니다~

Copy link
Contributor Author

@WonJuneKim WonJuneKim Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@whdgur5717 @rkdcodus @kimdonggu42
피그마 요구 사항 중

  • 상부에 여유 공간이 없을 경우 우측부, 우측부에도 여유 공간이 없을 경우 하부에 표시됩니다. 우선순위 순으로 상 → 우 → 하 → 좌 입니다.


해당 부분을 구현을 하기 위해서는 radix가 기본 제공하는 avoidCollisionsprop에는 지정된 방향성만 존재하기 때문에 Trigger의 ref를 Content가 사용해야하는 상황이 발생합니다. 따라서 위치 감지 로직을 구현하여 사용할 경우 다시 자체 Context가 필요할 것으로 생각되나, 일단 해당 부분은 Todo로 남겨놓겠습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

내부 Context는 34d11d1 에서 제거하였습니다!


const TooltipContext = createContext<TooltipContextValue | undefined>(undefined);

const useTooltipContext = () => {
const context = useContext(TooltipContext);
if (!context) {
throw new Error('Tooltip 하위 컴포넌트는 반드시 Tooltip 컴포넌트 내부에서 사용되어야 합니다.');
}
return context;
};

const TooltipRoot = ({
children,
side = 'top',
sideOffset = 8,
collisionPadding = 0,
delayDuration = 0,
...radixProps
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

radixProps라고 네이밍 지으니까 좋네요!!! 👍

}: TooltipProps) => {
const contextValue: TooltipContextValue = {
side,
sideOffset,
collisionPadding,
};

return (
<TooltipPrimitive.Root delayDuration={delayDuration} {...radixProps}>
<TooltipContext.Provider value={contextValue}>{children}</TooltipContext.Provider>
</TooltipPrimitive.Root>
);
};

TooltipRoot.displayName = 'Tooltip.Root';

const TooltipTrigger = ({ children, asChild = true, ...restProps }: TooltipTriggerProps) => {
return (
<TooltipPrimitive.Trigger asChild={asChild} {...restProps}>
{children}
</TooltipPrimitive.Trigger>
);
};

TooltipTrigger.displayName = 'Tooltip.Trigger';

const TooltipContent = ({ children, ...restProps }: TooltipContentProps) => {
const { side, sideOffset, collisionPadding } = useTooltipContext();

return (
<TooltipPrimitive.Portal>
<StyledTooltipContent
side={side}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
{...restProps}
>
{children}
</StyledTooltipContent>
</TooltipPrimitive.Portal>
);
};

TooltipContent.displayName = 'Tooltip.Content';

export const Tooltip = {
Provider: TooltipPrimitive.Provider,
Root: TooltipRoot,
Trigger: TooltipTrigger,
Content: TooltipContent,
};
2 changes: 2 additions & 0 deletions packages/jds/src/components/Tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Tooltip';
export type { TooltipProps, TooltipTriggerProps, TooltipContentProps } from './tooltip.types';
Loading