-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 디자인 시스템 - Tooltip(툴팁) 컴포넌트 구현 #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
63fdbe5
5b50dbc
ef5b324
7327576
a358768
498929b
2f92bb8
4ed7812
e008dd1
34d11d1
9882f21
bdf8011
cb505c4
714456d
5e1fd34
3a0449d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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>( | ||
| ({ 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'; | ||
| 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)으로 표시할 수 있습니다. 공간이 부족하면 자동으로 다른 방향으로 전환됩니다.', | ||
|
||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| 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를 가진 요소이어야 합니다. 버튼, 텍스트, 아이콘, 입력 필드 등 어떤 요소든 트리거로 사용할 수 있습니다.', | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| 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; | ||
| } | ||
|
||
|
|
||
| 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 | ||
|
||
| }: 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, | ||
| }; | ||
| 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'; |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HTMLSpanElement대신,
ElementRef<typeofStyledIconWrapper>(정확한 문법은아닙니다) 로 활용하는게 좋을것같습니다There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
지금처럼 Wrapper로 감싸는 경우 특히 해당 요소를 직접 참조하는 것이 더 적절하겠네요! 5e1fd34 에서 변경했습니다~