diff --git a/.changeset/wet-pants-train.md b/.changeset/wet-pants-train.md new file mode 100644 index 00000000..76211d3c --- /dev/null +++ b/.changeset/wet-pants-train.md @@ -0,0 +1,5 @@ +--- +"@sipe-team/tooltip": patch +--- + +improve accessibility and refactor styles for Tooltip diff --git a/packages/tooltip/package.json b/packages/tooltip/package.json index ed4ee714..c5397f69 100644 --- a/packages/tooltip/package.json +++ b/packages/tooltip/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@radix-ui/react-slot": "catalog:", + "@vanilla-extract/recipes": "catalog:", "clsx": "catalog:" }, "devDependencies": { diff --git a/packages/tooltip/src/Tooltip.css.ts b/packages/tooltip/src/Tooltip.css.ts index 6e7869cf..f487b001 100644 --- a/packages/tooltip/src/Tooltip.css.ts +++ b/packages/tooltip/src/Tooltip.css.ts @@ -1,120 +1,124 @@ -import { style, styleVariants } from '@vanilla-extract/css'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; -export const tooltip = style({ - position: 'fixed', - backgroundColor: 'var(--tooltip-bg-color, #000000)', - color: '#ffffff', - padding: '8px 12px', - borderRadius: '8px', - fontSize: '12px', - lineHeight: 1.5, - whiteSpace: 'normal', - wordWrap: 'break-word', - maxWidth: '250px', - boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)', - zIndex: 1000, - opacity: 0, - transform: 'scale(0.95)', - transition: 'opacity 0.3s ease, transform 0.3s ease', - pointerEvents: 'none', - selectors: { - '&.visible': { - opacity: 1, - transform: 'scale(1)', - pointerEvents: 'auto', - }, - '&::after': { - content: '""', - position: 'absolute', - width: 0, - height: 0, - borderStyle: 'solid', - }, - }, -}); - -export const placement = styleVariants({ - 'top-left': { +export const tooltip = recipe({ + base: { + position: 'fixed', + backgroundColor: 'var(--tooltip-bg-color, #000000)', + color: '#ffffff', + padding: '8px 12px', + borderRadius: '8px', + fontSize: '12px', + lineHeight: 1.5, + whiteSpace: 'normal', + wordWrap: 'break-word', + maxWidth: '250px', + boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)', + zIndex: 1000, + opacity: 0, + transform: 'scale(0.95)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: 'none', selectors: { - '&::after': { - bottom: '-6px', - left: '8px', - borderWidth: '6px 6px 0 6px', - borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + '&.visible': { + opacity: 1, + transform: 'scale(1)', + pointerEvents: 'auto', }, - }, - }, - 'top-right': { - selectors: { '&::after': { - bottom: '-6px', - right: '8px', - borderWidth: '6px 6px 0 6px', - borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + content: '""', + position: 'absolute', + width: 0, + height: 0, + borderStyle: 'solid', }, }, }, - 'bottom-left': { - selectors: { - '&::after': { - top: '-6px', - left: '8px', - borderWidth: '0 6px 6px 6px', - borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + variants: { + placement: { + 'top-left': { + selectors: { + '&::after': { + bottom: '-6px', + left: '8px', + borderWidth: '6px 6px 0 6px', + borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + }, + }, }, - }, - }, - 'bottom-right': { - selectors: { - '&::after': { - top: '-6px', - right: '8px', - borderWidth: '0 6px 6px 6px', - borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + 'top-right': { + selectors: { + '&::after': { + bottom: '-6px', + right: '8px', + borderWidth: '6px 6px 0 6px', + borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + }, + }, }, - }, - }, - top: { - selectors: { - '&::after': { - bottom: '-6px', - left: '50%', - transform: 'translateX(-50%)', - borderWidth: '6px 6px 0 6px', - borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + 'bottom-left': { + selectors: { + '&::after': { + top: '-6px', + left: '8px', + borderWidth: '0 6px 6px 6px', + borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + }, + }, }, - }, - }, - bottom: { - selectors: { - '&::after': { - top: '-6px', - left: '50%', - transform: 'translateX(-50%)', - borderWidth: '0 6px 6px 6px', - borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + 'bottom-right': { + selectors: { + '&::after': { + top: '-6px', + right: '8px', + borderWidth: '0 6px 6px 6px', + borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + }, + }, }, - }, - }, - left: { - selectors: { - '&::after': { - right: '-6px', - top: '50%', - transform: 'translateY(-50%)', - borderWidth: '6px 0 6px 6px', - borderColor: 'transparent transparent transparent var(--tooltip-bg-color, #000000)', + top: { + selectors: { + '&::after': { + bottom: '-6px', + left: '50%', + transform: 'translateX(-50%)', + borderWidth: '6px 6px 0 6px', + borderColor: 'var(--tooltip-bg-color, #000000) transparent transparent transparent', + }, + }, }, - }, - }, - right: { - selectors: { - '&::after': { - left: '-6px', - top: '50%', - transform: 'translateY(-50%)', - borderWidth: '6px 6px 6px 0', - borderColor: 'transparent var(--tooltip-bg-color, #000000) transparent transparent', + bottom: { + selectors: { + '&::after': { + top: '-6px', + left: '50%', + transform: 'translateX(-50%)', + borderWidth: '0 6px 6px 6px', + borderColor: 'transparent transparent var(--tooltip-bg-color, #000000) transparent', + }, + }, + }, + left: { + selectors: { + '&::after': { + right: '-6px', + top: '50%', + transform: 'translateY(-50%)', + borderWidth: '6px 0 6px 6px', + borderColor: 'transparent transparent transparent var(--tooltip-bg-color, #000000)', + }, + }, + }, + right: { + selectors: { + '&::after': { + left: '-6px', + top: '50%', + transform: 'translateY(-50%)', + borderWidth: '6px 6px 6px 0', + borderColor: 'transparent var(--tooltip-bg-color, #000000) transparent transparent', + }, + }, }, }, }, diff --git a/packages/tooltip/src/Tooltip.test.tsx b/packages/tooltip/src/Tooltip.test.tsx index 10d32d38..a1c8684c 100644 --- a/packages/tooltip/src/Tooltip.test.tsx +++ b/packages/tooltip/src/Tooltip.test.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, test } from 'vitest'; +import { calculateTooltipPosition } from './hooks/useTooltip/useTooltip'; import { Tooltip, type TooltipPosition } from './Tooltip'; -describe('Tooltip 기본 동작 테스트', () => { - test('Tooltip은 초기 상태에서 보이지 않아야 한다.', () => { +describe('Tooltip basic behavior', () => { + test('Tooltip should not be visible in the initial state.', () => { render( @@ -18,7 +19,7 @@ describe('Tooltip 기본 동작 테스트', () => { expect(tooltip).not.toBeInTheDocument(); }); - test('tooltipContent를 주입하지 않으면 Tooltip이 렌더링되지 않는다.', () => { + test('Tooltip is not rendered when tooltipContent is not provided.', () => { render( @@ -30,7 +31,7 @@ describe('Tooltip 기본 동작 테스트', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); - test('hover 트리거일 경우 마우스를 올리면 Tooltip이 나타나고 이탈 시 사라진다.', async () => { + test('Tooltip appears on mouse enter and disappears on mouse leave when trigger is hover.', async () => { render( @@ -46,7 +47,7 @@ describe('Tooltip 기본 동작 테스트', () => { expect(screen.queryByText('This is a tooltip')).not.toBeInTheDocument(); }); - test('click 트리거일 경우 클릭하면 Tooltip이 표시되고 다시 클릭하면 사라진다.', async () => { + test('Tooltip appears on click and disappears on second click when trigger is click.', async () => { render( @@ -62,7 +63,7 @@ describe('Tooltip 기본 동작 테스트', () => { expect(screen.queryByText('This is a tooltip')).not.toBeInTheDocument(); }); - test('Tooltip 외부 클릭 시 닫힌다.', async () => { + test('Tooltip closes when clicking outside.', async () => { render(
@@ -83,8 +84,8 @@ describe('Tooltip 기본 동작 테스트', () => { }); }); -describe('Tooltip 위치 테스트', () => { - test('Tooltip이 기본적으로 top 위치에 렌더링된다.', async () => { +describe('Tooltip placement', () => { + test('Tooltip renders at the top position by default.', async () => { render( @@ -108,7 +109,7 @@ describe('Tooltip 위치 테스트', () => { ['bottom-right'], ['left'], ['right'], - ])('Tooltip이 %s 위치에 올바르게 렌더링된다.', async (placement) => { + ])('Tooltip renders correctly at %s position.', async (placement) => { render( @@ -124,8 +125,8 @@ describe('Tooltip 위치 테스트', () => { }); }); -describe('Tooltip 접근성 테스트', () => { - test('role="tooltip" 속성이 포함되어 접근성을 보장한다.', async () => { +describe('Tooltip accessibility', () => { + test('role="tooltip" attribute is included to ensure accessibility.', async () => { render( @@ -139,7 +140,7 @@ describe('Tooltip 접근성 테스트', () => { expect(tooltip).toBeInTheDocument(); }); - test('Esc 키를 누르면 Tooltip이 닫힌다.', async () => { + test('Tooltip closes when pressing the Escape key.', async () => { render( @@ -154,10 +155,55 @@ describe('Tooltip 접근성 테스트', () => { await userEvent.keyboard('{Escape}'); expect(screen.queryByText('This is a tooltip')).not.toBeInTheDocument(); }); + + test('Tooltip opens when pressing the Space key with click trigger.', async () => { + render( + + + , + ); + + const trigger = screen.getByText('Click me'); + trigger.focus(); + + await userEvent.keyboard(' '); + expect(screen.getByText('This is a tooltip')).toBeInTheDocument(); + }); + + test('trigger element has aria-describedby linked to the tooltip id.', async () => { + render( + + + , + ); + + const triggerEl = screen.getByText('Hover me'); + await userEvent.hover(triggerEl); + + const tooltip = screen.getByRole('tooltip'); + const wrapper = triggerEl.closest('[aria-describedby]') ?? triggerEl.parentElement; + expect(wrapper?.getAttribute('aria-describedby')).toBe(tooltip.id); + expect(tooltip.id).toBeTruthy(); + }); + + test('aria-expanded reflects the open/closed state when trigger is click.', async () => { + render( + + + , + ); + + const triggerEl = screen.getByText('Click me'); + + expect(triggerEl).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(triggerEl); + expect(triggerEl).toHaveAttribute('aria-expanded', 'true'); + }); }); -describe('Tooltip 스타일 테스트', () => { - test('props로 주입한 backgroundColor와 padding이 CSS 변수에 반영된다.', async () => { +describe('Tooltip style', () => { + test('backgroundColor and padding injected via props are reflected in CSS variables.', async () => { render( @@ -172,26 +218,47 @@ describe('Tooltip 스타일 테스트', () => { expect(tooltip).toHaveStyle('padding: 20px'); }); - test('Tooltip이 뷰포트 밖으로 벗어나지 않는다.', async () => { - render( - - - , - ); + test('Tooltip does not overflow upward when the trigger is near the top of the screen.', () => { + const { top } = calculateTooltipPosition({ + wrapperRect: { top: 5, bottom: 35, left: 100, right: 200, width: 100, height: 30 } as DOMRect, + tooltipRect: { top: 0, left: 0, bottom: 0, right: 0, width: 100, height: 50 } as DOMRect, + placement: 'top', + gap: 8, + }); - const trigger = screen.getByText('Hover me'); - await userEvent.hover(trigger); + expect(top).toBeGreaterThanOrEqual(8); + }); - const tooltip = screen.getByText('This is a tooltip'); - const rect = tooltip.getBoundingClientRect(); + test('Tooltip does not overflow downward when the trigger is near the bottom of the screen.', () => { + const { top } = calculateTooltipPosition({ + wrapperRect: { + top: window.innerHeight - 10, + bottom: window.innerHeight, + left: 100, + right: 200, + width: 100, + height: 10, + } as DOMRect, + tooltipRect: { top: 0, left: 0, bottom: 0, right: 0, width: 100, height: 50 } as DOMRect, + placement: 'bottom', + gap: 8, + }); + + expect(top).toBeLessThanOrEqual(window.innerHeight - 50 - 8); + }); + + test('Tooltip does not overflow to the left when the trigger is near the left edge of the screen.', () => { + const { left } = calculateTooltipPosition({ + wrapperRect: { top: 100, bottom: 130, left: 5, right: 55, width: 50, height: 30 } as DOMRect, + tooltipRect: { top: 0, left: 0, bottom: 0, right: 0, width: 150, height: 40 } as DOMRect, + placement: 'left', + gap: 8, + }); - expect(rect.top).toBeGreaterThanOrEqual(0); - expect(rect.bottom).toBeLessThanOrEqual(window.innerHeight); + expect(left).toBeGreaterThanOrEqual(8); }); - test('긴 텍스트가 툴팁 내에서 줄 바꿈 처리된다.', async () => { + test('Long text wraps within the tooltip container.', async () => { render( { }); }); -test('Tooltip이 비동기 데이터로 업데이트된다.', async () => { - const fetchMockData = async () => { - return new Promise((resolve) => setTimeout(() => resolve('Fetched Content'), 500)); - }; +test('Tooltip updates with async data.', async () => { + let resolveContent!: (value: string) => void; const AsyncTooltip = () => { const [content, setContent] = useState('Loading...'); useEffect(() => { - fetchMockData().then((data) => setContent(data)); + new Promise((resolve) => { + resolveContent = resolve; + }).then((data) => setContent(data)); }, []); return ( @@ -231,33 +298,19 @@ test('Tooltip이 비동기 데이터로 업데이트된다.', async () => { render(); const trigger = screen.getByText('Hover me'); - await userEvent.hover(trigger); - const loadingTooltip = await screen.findByText('Loading...'); - expect(loadingTooltip).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); - const updatedTooltip = await screen.findByText('Fetched Content'); - expect(updatedTooltip).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(); + await act(async () => { + resolveContent('Fetched Content'); }); - test('asChild 속성을 주지 않았을 경우, 기본 값으로 동작한다.', async () => { + expect(screen.getByText('Fetched Content')).toBeInTheDocument(); +}); + +describe('Tooltip asChild prop', () => { + test('asChild defaults to true and preserves the child element tag.', async () => { render(

Hover me

@@ -270,45 +323,20 @@ describe('Tooltip asChild 속성 테스트', () => { 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 () => { + test('When asChild is false, the child is wrapped in a default 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'); + expect(childElement.parentElement?.tagName).toBe('DIV'); 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 2f6e46ba..c10fa5db 100644 --- a/packages/tooltip/src/Tooltip.tsx +++ b/packages/tooltip/src/Tooltip.tsx @@ -4,6 +4,7 @@ import { type ForwardedRef, forwardRef, type ReactNode, + useId, useImperativeHandle, } from 'react'; import { createPortal } from 'react-dom'; @@ -15,17 +16,23 @@ import clsx from 'clsx'; import { useTooltip } from './hooks/useTooltip'; import * as styles from './Tooltip.css'; -export type TooltipPosition = - | 'top-left' - | 'top' - | 'top-right' - | 'bottom-left' - | 'bottom' - | 'bottom-right' - | 'left' - | 'right'; +export const TooltipPosition = { + 'top-left': 'top-left', + top: 'top', + 'top-right': 'top-right', + 'bottom-left': 'bottom-left', + bottom: 'bottom', + 'bottom-right': 'bottom-right', + left: 'left', + right: 'right', +} as const; +export type TooltipPosition = (typeof TooltipPosition)[keyof typeof TooltipPosition]; -export type TooltipTrigger = 'hover' | 'click'; +export const TooltipTrigger = { + hover: 'hover', + click: 'click', +} as const; +export type TooltipTrigger = (typeof TooltipTrigger)[keyof typeof TooltipTrigger]; export interface TooltipProps extends ComponentProps<'div'> { tooltipContent: ReactNode; @@ -40,8 +47,8 @@ export interface TooltipProps extends ComponentProps<'div'> { export const Tooltip = forwardRef(function Tooltip( { tooltipContent, - placement: placementProp = 'top', - trigger = 'hover', + placement: placementProp = TooltipPosition.top, + trigger = TooltipTrigger.hover, asChild = true, children, tooltipStyle, @@ -50,6 +57,7 @@ export const Tooltip = forwardRef(function Tooltip( }: TooltipProps, ref: ForwardedRef, ) { + const tooltipId = useId(); const { isVisible, toggleTooltip, tooltipStyles, wrapperRef, tooltipRef, handleKeyDown } = useTooltip({ placement: placementProp, gap, @@ -68,12 +76,13 @@ export const Tooltip = forwardRef(function Tooltip( <> toggleTooltip(true) : undefined} - onMouseLeave={trigger === 'hover' ? () => toggleTooltip(false) : undefined} - onClick={trigger === 'click' ? () => toggleTooltip(!isVisible) : undefined} + aria-describedby={isVisible ? tooltipId : undefined} + aria-expanded={trigger === TooltipTrigger.click ? isVisible : undefined} + onMouseEnter={trigger === TooltipTrigger.hover ? () => toggleTooltip(true) : undefined} + onMouseLeave={trigger === TooltipTrigger.hover ? () => toggleTooltip(false) : undefined} + onClick={trigger === TooltipTrigger.click ? () => toggleTooltip(!isVisible) : undefined} onKeyDown={handleKeyDown} - tabIndex={trigger === 'click' ? 0 : undefined} + tabIndex={trigger === TooltipTrigger.click ? 0 : undefined} className={styles.button} > {children} @@ -81,8 +90,10 @@ export const Tooltip = forwardRef(function Tooltip( {isVisible && createPortal(