From 7d614483983beed0e6e7a97e95c9b6ed4c8ce7e0 Mon Sep 17 00:00:00 2001 From: Huinno-ParkJinHyun Date: Sun, 1 Dec 2024 23:46:42 +0900 Subject: [PATCH] refactor(tooltip) : add asChild props --- packages/tooltip/src/Tooltip.stories.tsx | 65 +++++++++++++++- packages/tooltip/src/Tooltip.test.tsx | 74 ++++++++++++++++++- packages/tooltip/src/Tooltip.tsx | 44 ++++++----- .../src/hooks/useTooltip/useTooltip.tsx | 3 +- 4 files changed, 165 insertions(+), 21 deletions(-) diff --git a/packages/tooltip/src/Tooltip.stories.tsx b/packages/tooltip/src/Tooltip.stories.tsx index 30536a4..13e6367 100644 --- a/packages/tooltip/src/Tooltip.stories.tsx +++ b/packages/tooltip/src/Tooltip.stories.tsx @@ -22,6 +22,10 @@ export default { description: '툴팁에 적용할 클래스명', }, gap: { control: 'number', description: '툴팁과 트리거 사이의 간격' }, + asChild: { + control: { type: 'boolean' }, + description: '`true`일 경우, 자식 요소를 그대로 사용합니다.', + }, }, } as Meta; @@ -51,7 +55,7 @@ Default.args = { export const ClickTrigger = Template.bind({}); ClickTrigger.args = { tooltipContent: 'Click to toggle this tooltip', - placement: 'top', + placement: 'bottom', trigger: 'click', tooltipStyle: { backgroundColor: '#000', @@ -76,3 +80,62 @@ CustomStyles.args = { boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)', }, }; + +export const AsChildExample = () => ( +
+ +

Hover me (H1 element)

+
+ + + +
+); + +export const AllPlacements = () => ( +
+ + + + + + + + +

Left

+
+ +

Left

+
+ + + + + + + + + + + + + + +
+); diff --git a/packages/tooltip/src/Tooltip.test.tsx b/packages/tooltip/src/Tooltip.test.tsx index 9ae47a7..c7a7e39 100644 --- a/packages/tooltip/src/Tooltip.test.tsx +++ b/packages/tooltip/src/Tooltip.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect, useState } from 'react'; import { describe, expect, test } from 'vitest'; @@ -223,3 +223,75 @@ describe('Tooltip 비동기 데이터 테스트', () => { expect(screen.getByText('Fetched Content')).toBeInTheDocument(); }); }); + +describe('Tooltip asChild 속성 테스트', () => { + test('asChild가 true일 경우 자식 요소의 태그가 유지된다.', async () => { + render( + +

Hover me

+
, + ); + + const childElement = screen.getByText('Hover me'); + expect(childElement).toHaveProperty('tagName', 'H1'); + + await userEvent.hover(childElement); + const tooltip = await screen.findByText('This is a tooltip'); + expect(tooltip).toBeInTheDocument(); + }); + + test('asChild 속성을 주지 않았을 경우, 기본 값으로 동작한다.', async () => { + render( + +

Hover me

+
, + ); + + const childElement = screen.getByText('Hover me'); + expect(childElement).toHaveProperty('tagName', 'H1'); + + await userEvent.hover(childElement); + const tooltip = await screen.findByText('This is a tooltip'); + expect(tooltip).toBeInTheDocument(); + + await userEvent.unhover(childElement); + await waitFor(() => { + expect(screen.queryByText('This is a tooltip')).not.toBeInTheDocument(); + }); + }); + + test('asChild가 false일 경우 기본 div로 감싸진다.', async () => { + render( + + + , + ); + + const wrapperElement = screen.getByRole('tooltip'); + expect(wrapperElement.tagName).toBe('DIV'); + + const childElement = screen.getByText('Hover me'); + await userEvent.hover(childElement); + const tooltip = await screen.findByText('This is a tooltip'); + expect(tooltip).toBeInTheDocument(); + }); + + test('asChild가 true일 경우 이벤트 핸들러가 자식 요소에 적용된다.', async () => { + render( + + + , + ); + + const childElement = screen.getByText('Hover me'); + + await userEvent.hover(childElement); + const tooltip = await screen.findByText('This is a tooltip'); + expect(tooltip).toBeInTheDocument(); + + await userEvent.unhover(childElement); + await waitFor(() => { + expect(screen.queryByText('This is a tooltip')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/tooltip/src/Tooltip.tsx b/packages/tooltip/src/Tooltip.tsx index f10f1d9..c1ccf5c 100644 --- a/packages/tooltip/src/Tooltip.tsx +++ b/packages/tooltip/src/Tooltip.tsx @@ -1,3 +1,5 @@ +import { Slot } from '@radix-ui/react-slot'; + import clsx from 'clsx'; import { type CSSProperties, @@ -9,13 +11,14 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import styles from './Tooltip.module.css'; -import { useTooltip } from './hooks/useTooltip/useTooltip'; +import { useTooltip } from './hooks/useTooltip'; export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; export interface TooltipProps extends ComponentProps<'div'> { tooltipContent: ReactNode; placement?: TooltipPosition; + asChild?: boolean; trigger?: 'hover' | 'click'; tooltipStyle?: CSSProperties; tooltipClassName?: string; @@ -27,6 +30,7 @@ export const Tooltip = forwardRef(function Tooltip( tooltipContent, placement = 'top', trigger = 'hover', + asChild = true, children, tooltipStyle, tooltipClassName, @@ -49,22 +53,28 @@ export const Tooltip = forwardRef(function Tooltip( useImperativeHandle(ref, () => wrapperRef.current as HTMLElement); + const Component = asChild ? Slot : 'div'; + return ( -
toggleTooltip(true) : undefined} - onMouseLeave={ - trigger === 'hover' ? () => toggleTooltip(false) : undefined - } - onClick={ - trigger === 'click' ? () => toggleTooltip(!isVisible) : undefined - } - onKeyDown={handleKeyDown} - tabIndex={trigger === 'click' ? 0 : undefined} - > - {children} + <> + toggleTooltip(true) : undefined + } + onMouseLeave={ + trigger === 'hover' ? () => toggleTooltip(false) : undefined + } + onClick={ + trigger === 'click' ? () => toggleTooltip(!isVisible) : undefined + } + onKeyDown={handleKeyDown} + tabIndex={trigger === 'click' ? 0 : undefined} + className={clsx(styles.trigger, { [styles.asChild]: asChild })} + > + {children} + {isVisible && createPortal(
, document.body, )} -
+ ); }); diff --git a/packages/tooltip/src/hooks/useTooltip/useTooltip.tsx b/packages/tooltip/src/hooks/useTooltip/useTooltip.tsx index 7350c8e..41a2929 100644 --- a/packages/tooltip/src/hooks/useTooltip/useTooltip.tsx +++ b/packages/tooltip/src/hooks/useTooltip/useTooltip.tsx @@ -5,8 +5,7 @@ import { useRef, useState, } from 'react'; - -export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; +import type { TooltipPosition } from '../../Tooltip'; interface useTooltipProps { placement: TooltipPosition;