diff --git a/src/shared/styles/base/colors.css b/src/shared/styles/base/colors.css index 5e2e1c1..df8c0eb 100644 --- a/src/shared/styles/base/colors.css +++ b/src/shared/styles/base/colors.css @@ -39,4 +39,8 @@ --color-black: #000000; --color-white: #ffffff; + + /* Shadow Tokens */ + --shadow-tooltip-arrow: 0 0 16px rgba(118, 186, 255, 0.18); + --shadow-tooltip: 0 14px 44px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(118, 186, 255, 0.08); } diff --git a/src/shared/styles/base/z-index.css b/src/shared/styles/base/z-index.css index a28f518..27edf16 100644 --- a/src/shared/styles/base/z-index.css +++ b/src/shared/styles/base/z-index.css @@ -7,4 +7,5 @@ --z-index-form-floating: 10; --z-index-surface-overlay: 40; --z-index-surface-content: 50; + --z-index-tooltip: 60; } diff --git a/src/shared/ui/tooltip/Tooltip.stories.tsx b/src/shared/ui/tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000..6a1fc54 --- /dev/null +++ b/src/shared/ui/tooltip/Tooltip.stories.tsx @@ -0,0 +1,153 @@ +import type { ComponentProps } from 'react'; + +import { Button } from '@/shared/ui/button'; + +import { Tooltip } from './index'; + +import type { Meta, StoryObj } from '@storybook/nextjs'; + +type TooltipStoryProps = ComponentProps; + +const PLACEMENT_EXAMPLES = [ + { + description: '위에 표시됩니다.', + gridClassName: 'col-start-2 row-start-1', + label: 'Top', + placement: 'top', + }, + { + description: '왼쪽에 표시됩니다.', + gridClassName: 'col-start-1 row-start-2', + label: 'Left', + placement: 'left', + }, + { + description: '오른쪽에 표시됩니다.', + gridClassName: 'col-start-3 row-start-2', + label: 'Right', + placement: 'right', + }, + { + description: '아래에 표시됩니다.', + gridClassName: 'col-start-2 row-start-3', + label: 'Bottom', + placement: 'bottom', + }, +] satisfies Array<{ + description: string; + gridClassName: string; + label: string; + placement: NonNullable; +}>; + +function TooltipExample(props: TooltipStoryProps) { + return ( +
+ + + + + + AI 첨삭 결과는 자기소개서 문항별로 생성됩니다. + + + +
+ ); +} + +const meta = { + title: 'Shared/Tooltip', + component: Tooltip.Root, + tags: ['autodocs'], + args: { + children: null, + defaultOpen: false, + offset: 10, + placement: 'top', + }, + argTypes: { + children: { + control: false, + table: { + disable: true, + }, + }, + offset: { + control: 'number', + }, + placement: { + control: 'inline-radio', + options: ['top', 'right', 'bottom', 'left'], + }, + defaultOpen: { + control: 'boolean', + }, + onOpenChange: { + action: 'openChange', + }, + open: { + control: false, + }, + }, + parameters: { + backgrounds: { + default: 'black', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => , +}; + +export const Placements: Story = { + render: () => ( +
+
+
+ Hover / Focus +
+ {PLACEMENT_EXAMPLES.map(({ description, gridClassName, label, placement }) => ( + + + + + + {description} + + + + ))} +
+
+ ), +}; + +export const TextTrigger: Story = { + render: () => ( +

+ 문장 안에서도{' '} + + + + + + 직무와 연결되는 핵심 표현을 추출해 보여줍니다. + + + {' '} + 설명을 자연스럽게 연결할 수 있습니다. +

+ ), +}; diff --git a/src/shared/ui/tooltip/Tooltip.tsx b/src/shared/ui/tooltip/Tooltip.tsx new file mode 100644 index 0000000..af7b273 --- /dev/null +++ b/src/shared/ui/tooltip/Tooltip.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '@/shared/hooks'; + +import { TooltipArrow } from './TooltipArrow'; +import { TooltipContent } from './TooltipContent'; +import { TooltipContext } from './TooltipContext'; +import { TooltipTrigger } from './TooltipTrigger'; + +import type { + TooltipOpenChangeReason, + TooltipPlacement, + TooltipContextValue, + TooltipPosition, + TooltipProps, +} from './Tooltip.types'; + +const TOOLTIP_EXIT_DURATION = 150; + +const getTooltipPosition = ( + trigger: HTMLElement, + placement: TooltipPlacement, + offset: number +): TooltipPosition => { + const rect = trigger.getBoundingClientRect(); + + if (placement === 'top') { + return { + left: rect.left + rect.width / 2, + top: rect.top - offset, + }; + } + + if (placement === 'right') { + return { + left: rect.right + offset, + top: rect.top + rect.height / 2, + }; + } + + if (placement === 'left') { + return { + left: rect.left - offset, + top: rect.top + rect.height / 2, + }; + } + + return { + left: rect.left + rect.width / 2, + top: rect.bottom + offset, + }; +}; + +/** + * ## Tooltip + * + * @description + * 아이콘, 버튼, 링크처럼 짧은 보조 설명이 필요한 요소에 안내 문구를 표시하는 공통 + * Compound Tooltip입니다. + * 사용자가 특정 UI에 마우스를 올리거나 키보드 포커스를 이동했을 때 보조 정보를 잠깐 보여주는 + * 용도로 사용합니다. + * + * ### 주요 내용 + * + * `Tooltip.Trigger`는 hover와 focus로 열림 상태를 요청하고, `Tooltip.Content`는 + * trigger 위치를 기준으로 fixed 좌표에 렌더링됩니다. + * `Tooltip.Content`는 기본적으로 portal을 사용해 `document.body`에 렌더링되므로 부모 요소의 + * `overflow: hidden` 영향을 줄일 수 있습니다. + * + * `placement`는 `top`, `right`, `bottom`, `left`를 지원합니다. + * + * `open`을 전달하면 controlled 방식으로 동작하고, 생략하면 `defaultOpen`을 초기값으로 + * 내부 상태를 관리합니다. + * hover와 focus 상태는 서로 분리해서 관리하므로, trigger가 focus된 상태에서 pointer가 벗어나도 + * blur 전까지 안내 문구가 유지됩니다. + * + * ### 접근성 + * + * 열려 있는 동안 Trigger에는 `aria-describedby`가 연결되고 Content는 `role="tooltip"`을 + * 사용합니다. + * 키보드 사용자는 Trigger focus로 설명을 확인할 수 있습니다. + * 아이콘만 있는 Trigger를 사용할 때는 trigger 자체에 `aria-label`을 제공해야 합니다. + * + * + * ### 조합 + * + * `Tooltip`은 `Tooltip.Root`와 같은 루트 컴포넌트이며, compound API 사용을 위해 + * `Tooltip.Trigger`, `Tooltip.Content`, `Tooltip.Arrow`를 함께 노출합니다. + * + * @param children - `Tooltip.Trigger`, `Tooltip.Content`, `Tooltip.Arrow`를 조합한 하위 요소입니다. + * @param placement - Tooltip 표시 위치입니다. + * `top`, `right`, `bottom`, `left`를 지원합니다. + * @param offset - Trigger와 Tooltip 사이 간격입니다. + * 단위는 px입니다. + * @param open - 외부에서 열림 상태를 제어할 때 사용하는 controlled 값입니다. + * @param defaultOpen - uncontrolled 방식에서 최초 렌더링 시 열어둘지 여부입니다. + * @param onOpenChange - 열림 상태가 변경될 때 호출됩니다. + * 변경 원인은 meta.reason으로 전달됩니다. + * + * @example + * ```tsx + * + * + * + * + * + * AI 첨삭 결과는 자기소개서 문항별로 생성됩니다. + * + * + * + * ``` + * + * @example controlled 사용 + * ```tsx + * + * + * + * + * 입력 기준을 확인할 수 있습니다. + * + * ``` + */ +function TooltipRoot({ + children, + defaultOpen = false, + offset = 10, + onOpenChange, + open, + placement = 'top', +}: TooltipProps) { + const generatedId = useId(); + const isFocusedRef = useRef(false); + const isPointerInsideRef = useRef(false); + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useControllableState({ + defaultValue: defaultOpen, + value: open, + }); + const [isPositioned, setIsPositioned] = useState(false); + const [previousOpen, setPreviousOpen] = useState(isOpen); + const [shouldRenderContent, setShouldRenderContent] = useState(defaultOpen || open === true); + const [position, setPosition] = useState({ left: 0, top: 0 }); + + if (previousOpen !== isOpen) { + setPreviousOpen(isOpen); + + if (isOpen) { + setShouldRenderContent(true); + setIsPositioned(false); + } + } + + const updatePosition = useCallback(() => { + if (!triggerRef.current) { + return; + } + + setPosition(getTooltipPosition(triggerRef.current, placement, offset)); + setIsPositioned(true); + }, [offset, placement]); + + const requestOpenChange = useCallback( + (nextOpen: boolean, reason: TooltipOpenChangeReason) => { + if (Object.is(isOpen, nextOpen)) { + return; + } + + setIsOpen(nextOpen); + onOpenChange?.(nextOpen, { reason }); + }, + [isOpen, onOpenChange, setIsOpen] + ); + + const requestCloseIfInactive = useCallback( + (reason: TooltipOpenChangeReason) => { + if (!isFocusedRef.current && !isPointerInsideRef.current) { + requestOpenChange(false, reason); + } + }, + [requestOpenChange] + ); + + useLayoutEffect(() => { + if (isOpen && shouldRenderContent) { + updatePosition(); + } + }, [isOpen, shouldRenderContent, updatePosition]); + + useEffect(() => { + if (!isOpen) { + const timeoutId = window.setTimeout(() => { + setShouldRenderContent(false); + }, TOOLTIP_EXIT_DURATION); + + return () => window.clearTimeout(timeoutId); + } + + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [isOpen, updatePosition]); + + const contextValue = useMemo( + () => ({ + actions: { + hideForBlur: () => { + isFocusedRef.current = false; + requestCloseIfInactive('blur'); + }, + hideForPointer: () => { + isPointerInsideRef.current = false; + requestCloseIfInactive('hover'); + }, + setTriggerElement: (element) => { + triggerRef.current = element; + }, + showForFocus: () => { + isFocusedRef.current = true; + requestOpenChange(true, 'focus'); + }, + showForPointer: () => { + isPointerInsideRef.current = true; + requestOpenChange(true, 'hover'); + }, + }, + meta: { + contentId: `tooltip-content-${generatedId}`, + }, + state: { + isPositioned, + isOpen, + placement, + position, + shouldRenderContent: isOpen || shouldRenderContent, + }, + }), + [ + generatedId, + isOpen, + isPositioned, + placement, + position, + requestCloseIfInactive, + requestOpenChange, + shouldRenderContent, + ] + ); + + return {children}; +} + +const Tooltip = Object.assign(TooltipRoot, { + Arrow: TooltipArrow, + Content: TooltipContent, + Root: TooltipRoot, + Trigger: TooltipTrigger, +}); + +export { Tooltip }; diff --git a/src/shared/ui/tooltip/Tooltip.types.ts b/src/shared/ui/tooltip/Tooltip.types.ts new file mode 100644 index 0000000..f9e6e30 --- /dev/null +++ b/src/shared/ui/tooltip/Tooltip.types.ts @@ -0,0 +1,174 @@ +import type { ComponentPropsWithRef, PointerEventHandler, ReactElement, ReactNode } from 'react'; + +type TooltipPlacement = 'bottom' | 'left' | 'right' | 'top'; + +type TooltipOpenChangeReason = 'blur' | 'focus' | 'hover'; + +interface TooltipOpenChangeMeta { + /** + * 열림 상태가 변경된 이유입니다. + * + * `hover`는 pointer enter/leave, `focus`는 focus 진입, `blur`는 focus 이탈에 의해 + * 상태가 바뀌었음을 의미합니다. + */ + reason: TooltipOpenChangeReason; +} + +interface TooltipProps { + /** + * Tooltip compound 하위 컴포넌트입니다. + * + * 일반적으로 `Tooltip.Trigger`와 `Tooltip.Content`를 포함하고, 필요할 때 + * `Tooltip.Arrow`를 Content 내부에 조합합니다. + */ + children: ReactNode; + /** + * uncontrolled 방식에서 최초로 열어둘지 여부입니다. + * + * Storybook 시각 확인이나 특정 온보딩 상태처럼 최초 진입 시 설명을 보여줘야 하는 경우에만 + * 사용합니다. + * 일반 사용에서는 기본값 false를 권장합니다. + */ + defaultOpen?: boolean; + /** + * Tooltip과 Trigger 사이 간격입니다. + * + * px 단위 숫자를 전달합니다. + * 값이 클수록 Trigger와 Content 사이 거리가 멀어집니다. + */ + offset?: number; + /** + * 열림 상태가 변경될 때 호출됩니다. + * + * controlled 상태 동기화, 분석 이벤트, 테스트 확인 등에 사용할 수 있습니다. + * 변경 원인은 두 번째 인자의 `reason`으로 전달됩니다. + */ + onOpenChange?: (open: boolean, meta: TooltipOpenChangeMeta) => void; + /** + * controlled 방식으로 열림 상태를 제어할 때 사용합니다. + * + * 값을 전달하면 Root 내부 상태 대신 외부 상태를 기준으로 열림 여부가 결정됩니다. + */ + open?: boolean; + /** + * Tooltip 표시 위치입니다. + * + * `top`, `right`, `bottom`, `left`를 지원합니다. + * 위치는 Trigger 기준으로 계산됩니다. + */ + placement?: TooltipPlacement; +} + +type TooltipTriggerButtonProps = Omit, 'children' | 'type'>; + +interface TooltipTriggerAsChildProps { + /** + * 자식 컴포넌트에 Trigger 동작과 ARIA 속성을 위임할지 여부입니다. + * + * 기존 Button, Link, icon button을 Tooltip Trigger로 사용할 때 true로 설정합니다. + */ + asChild: true; + /** + * Trigger로 사용할 단일 React Element입니다. + * + * 단일 Element만 허용되며 Fragment는 사용할 수 없습니다. + */ + children: ReactElement<{ + 'aria-describedby'?: string; + className?: string; + onBlur?: ComponentPropsWithRef<'button'>['onBlur']; + onFocus?: ComponentPropsWithRef<'button'>['onFocus']; + onPointerEnter?: PointerEventHandler; + onPointerLeave?: PointerEventHandler; + }>; +} + +type TooltipTriggerProps = + | (TooltipTriggerButtonProps & { + /** + * 자식 컴포넌트에 Trigger 동작과 ARIA 속성을 위임할지 여부입니다. + * + * false이거나 생략하면 `Tooltip.Trigger`가 직접 button을 렌더링합니다. + */ + asChild?: false; + /** + * Trigger 버튼에 표시할 내용입니다. + * + * 아이콘만 전달하는 경우 접근성 이름을 위해 `aria-label`도 함께 전달합니다. + */ + children: ReactNode; + }) + | (TooltipTriggerButtonProps & TooltipTriggerAsChildProps); + +type TooltipPortalContainer = DocumentFragment | Element; + +interface TooltipContentProps extends Omit, 'children' | 'role'> { + /** + * Tooltip에 표시할 안내 문구 또는 조합 UI입니다. + * + * 짧은 읽기 전용 설명을 전달합니다. + * focus 가능한 요소나 복잡한 액션 UI는 넣지 않습니다. + */ + children: ReactNode; + /** + * portal을 렌더링할 대상 요소입니다. + * + * 생략하면 `document.body`를 사용합니다. `null`을 전달하면 portal 렌더링을 하지 않습니다. + */ + container?: TooltipPortalContainer | null; + /** + * false이면 portal을 사용하지 않고 현재 React 트리 위치에 렌더링합니다. + * + * 기본값은 true입니다. + * 대부분의 제품 UI에서는 부모 overflow 영향을 줄이기 위해 기본값을 유지합니다. + */ + usePortal?: boolean; +} + +type TooltipArrowProps = Omit, 'children'>; + +interface TooltipPosition { + left: number; + top: number; +} + +interface TooltipState { + isPositioned: boolean; + isOpen: boolean; + placement: TooltipPlacement; + position: TooltipPosition; + shouldRenderContent: boolean; +} + +interface TooltipActions { + hideForBlur: () => void; + hideForPointer: () => void; + setTriggerElement: (element: HTMLElement | null) => void; + showForFocus: () => void; + showForPointer: () => void; +} + +interface TooltipMeta { + contentId: string; +} + +interface TooltipContextValue { + actions: TooltipActions; + meta: TooltipMeta; + state: TooltipState; +} + +export type { + TooltipActions, + TooltipArrowProps, + TooltipContentProps, + TooltipContextValue, + TooltipMeta, + TooltipOpenChangeMeta, + TooltipOpenChangeReason, + TooltipPlacement, + TooltipPosition, + TooltipProps, + TooltipState, + TooltipTriggerProps, +}; diff --git a/src/shared/ui/tooltip/TooltipArrow.tsx b/src/shared/ui/tooltip/TooltipArrow.tsx new file mode 100644 index 0000000..3631a59 --- /dev/null +++ b/src/shared/ui/tooltip/TooltipArrow.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { cva } from 'class-variance-authority'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useTooltipContext } from './TooltipContext'; + +import type { TooltipArrowProps } from './Tooltip.types'; + +const tooltipArrowVariants = cva( + 'absolute size-2 rotate-45 border-gray-700 bg-gray-900 shadow-(--shadow-tooltip-arrow)', + { + variants: { + placement: { + bottom: '-top-1 left-1/2 -translate-x-1/2 border-l border-t', + left: '-right-1 top-1/2 -translate-y-1/2 border-r border-t', + right: '-left-1 top-1/2 -translate-y-1/2 border-b border-l', + top: '-bottom-1 left-1/2 -translate-x-1/2 border-b border-r', + }, + }, + } +); + +/** + * ## Tooltip.Arrow + * + * @description + * Tooltip.Content의 방향을 시각적으로 연결하는 작은 화살표입니다. + * + * ### 주요 내용 + * + * Arrow는 Root의 `placement` 값을 읽어 Content의 상하좌우 가장자리에 배치됩니다. + * 별도 상태를 가지지 않으며, Content 안에 선택적으로 조합해서 사용합니다. + * + * ### 접근성 + * + * Arrow는 장식 요소이므로 `aria-hidden="true"`가 자동으로 적용됩니다. + * 안내 문구 자체는 `Tooltip.Content`의 children으로 전달합니다. + * + * @example + * ```tsx + * + * 상세 설명입니다. + * + * + * ``` + */ +export function TooltipArrow({ className, ...props }: TooltipArrowProps) { + const { state } = useTooltipContext(); + + return ( +