/** * @title Link * @description 다른 위치로 이동하는 인라인 텍스트 링크예요. 새 창 링크에는 아이콘과 스크린 리더 안내가 자동으로 붙어요. */import * as React from 'react';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';export type LinkAppearance = 'default' | 'subtle' | 'inverse';/** * @atlaskit/link 해독 값 * - base: radius.xsmall, font-family body (크기·굵기는 주변 텍스트를 상속) * - default: color.link + 밑줄, hover/pressed 에서 밑줄이 사라지고 pressed 는 color.link.pressed * - subtle: color.text.subtle + 밑줄 없음, hover 에서 밑줄, pressed 는 color.text * - inverse: color.text.inverse + 밑줄, hover 에서 밑줄이 사라짐 (방문 색 없음) * - visited: color.link.visited / pressed 는 color.link.visited.pressed (inverse 제외) * - transition: 50ms (hover), 100ms (pressed) cubic-bezier(0.4,1,0.6,1) */const linkVariants = cva( [ 'relative box-border rounded-ds-xsmall font-ds-body underline decoration-solid', 'transition-[background-color,border-color,color,text-decoration-color] duration-50 ease-[cubic-bezier(0.4,1,0.6,1)] active:duration-100', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', ], { variants: { appearance: { default: 'text-ds-link decoration-current hover:text-ds-link hover:decoration-transparent active:text-ds-link-pressed active:decoration-transparent', subtle: 'text-ds-text-subtle decoration-transparent hover:text-ds-text-subtle hover:decoration-current active:text-ds-text active:decoration-current', inverse: 'text-ds-text-inverse decoration-current visited:text-ds-text-inverse hover:text-ds-text-inverse hover:decoration-transparent active:text-ds-text-inverse active:decoration-transparent', }, visited: { true: 'visited:text-ds-link-visited visited:hover:text-ds-link-visited visited:focus:text-ds-link-visited visited:active:text-ds-link-visited-pressed', false: '', }, }, defaultVariants: { appearance: 'default', visited: true }, },);export interface LinkProps extends React.ComponentProps<'a'> { /** 기본 `default`. `subtle` 은 평소엔 밑줄 없이 옅은 색, `inverse` 는 진한 배경 위에서 써요. */ appearance?: LinkAppearance; /** `target="_blank"` 일 때 스크린 리더에 읽히는 안내. 기본 "(새 창에서 열림)" */ newWindowLabel?: string; /** 자동화 테스트용 `data-testid` (새 창 아이콘은 `${testId}__icon`) */ testId?: string;}/** 새 창 아이콘 — @atlaskit/link 의 12×12 path 그대로, 0.9em 크기로 글자에 맞춘다 */function NewWindowIcon() { return ( <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden className="ms-[0.3em] inline h-[0.9em] w-[0.9em]"> <path fillRule="evenodd" clipRule="evenodd" d="M0 1.82609C0 0.817938 0.817938 0 1.82609 0H3.91304V1.04348H1.82609C1.39424 1.04348 1.04348 1.39424 1.04348 1.82609V10.1739C1.04348 10.6058 1.39424 10.9565 1.82609 10.9565H10.1739C10.6058 10.9565 10.9565 10.6058 10.9565 10.1739V8.08696H12V10.1739C12 11.1821 11.1821 12 10.1739 12H1.82609C0.817938 12 0 11.1821 0 10.1739V1.82609ZM7.04348 0H12V4.95652H10.9565V1.78133L6.36893 6.36893L5.63107 5.63107L10.2187 1.04348H7.04348V0Z" fill="currentColor" /> </svg> );}function Link({ appearance = 'default', newWindowLabel = '(새 창에서 열림)', testId, target, className, children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props}: LinkProps) { const hiddenId = React.useId(); const isNewWindow = target === '_blank'; const showHiddenLabel = isNewWindow && ((children != null && !ariaLabel && !ariaLabelledBy) || Boolean(ariaLabelledBy)); return ( <a data-slot="link" data-appearance={appearance} data-testid={testId} target={target} aria-label={ariaLabel && isNewWindow && !ariaLabelledBy ? `${ariaLabel} , ${newWindowLabel}` : ariaLabel} aria-labelledby={ariaLabelledBy && isNewWindow ? `${ariaLabelledBy} ${hiddenId}` : ariaLabelledBy} // inverse 는 대비 문제로 방문 색을 쓰지 않는다 className={cn(linkVariants({ appearance, visited: appearance !== 'inverse' }), className)} {...props} > {children} {isNewWindow && ( // 줄바꿈 시 아이콘만 다음 줄로 떨어지지 않도록 앞에 폭 없는 문자(U+FEFF)를 붙인다 <span data-testid={testId && `${testId}__icon`} className="relative top-[0.11em] inline align-baseline whitespace-nowrap"> {''} <NewWindowIcon /> </span> )} {showHiddenLabel && ( <span id={hiddenId} className="sr-only"> {`, ${newWindowLabel}`} </span> )} </a> );}export { Link, linkVariants };