/** * @title Tag * @description 이슈나 문서에 붙는 카테고리·주제 같은 메타데이터를 라벨로 보여주고, 링크나 제거 버튼을 달 수 있어요. */import * as React from 'react';import { ChevronDown, X } from 'lucide-react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/tag — tag-new 스펙 (SimpleTag·RemovableTag 는 내부적으로 TagNew 를 그려요)export type NewTagColor = 'gray' | 'blue' | 'red' | 'yellow' | 'green' | 'teal' | 'purple' | 'lime' | 'orange' | 'magenta';/** 레거시 색 이름까지 포함한 전체 color */export type TagColor = | NewTagColor | 'standard' | 'grey' | 'limeLight' | 'orangeLight' | 'magentaLight' | 'greenLight' | 'blueLight' | 'redLight' | 'purpleLight' | 'greyLight' | 'tealLight' | 'yellowLight';// 레거시 색 → 새 색 (color-mapping). Light 변형도 같은 색으로 합쳐졌어요.const colorMapping: Record<TagColor, NewTagColor> = { standard: 'gray', gray: 'gray', grey: 'gray', blue: 'blue', green: 'green', red: 'red', yellow: 'yellow', purple: 'purple', lime: 'lime', magenta: 'magenta', orange: 'orange', teal: 'teal', greyLight: 'gray', blueLight: 'blue', greenLight: 'green', redLight: 'red', yellowLight: 'yellow', purpleLight: 'purple', limeLight: 'lime', magentaLight: 'magenta', orangeLight: 'orange', tealLight: 'teal',};type TagColorStyle = { /** 테두리(border.accent.*.subtle) + 아이콘(icon.accent.*) + 메트릭 배경(background.accent.*.subtler) 변수 */ root: string; /** 링크 hover/pressed 때 아이콘을 text.accent.* 로 바꿔 대비 3:1 확보 */ linkHover: string; /** 트리거 hover/pressed */ triggerHover: string; /** 색 스와치(background.accent.*.subtle) */ swatch: string;};const colorStyles: Record<NewTagColor, TagColorStyle> = { gray: { root: 'border-ds-border-accent-gray-subtle [--tag-icon:var(--ds-icon-accent-gray)] [--tag-metric-bg:var(--ds-background-accent-gray-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-gray)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-gray)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-gray)] active:[--tag-icon:var(--ds-text-accent-gray)]', swatch: 'bg-ds-background-accent-gray-subtle', }, blue: { root: 'border-ds-border-accent-blue-subtle [--tag-icon:var(--ds-icon-accent-blue)] [--tag-metric-bg:var(--ds-background-accent-blue-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-blue)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-blue)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-blue)] active:[--tag-icon:var(--ds-text-accent-blue)]', swatch: 'bg-ds-background-accent-blue-subtle', }, green: { root: 'border-ds-border-accent-green-subtle [--tag-icon:var(--ds-icon-accent-green)] [--tag-metric-bg:var(--ds-background-accent-green-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-green)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-green)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-green)] active:[--tag-icon:var(--ds-text-accent-green)]', swatch: 'bg-ds-background-accent-green-subtle', }, red: { root: 'border-ds-border-accent-red-subtle [--tag-icon:var(--ds-icon-accent-red)] [--tag-metric-bg:var(--ds-background-accent-red-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-red)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-red)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-red)] active:[--tag-icon:var(--ds-text-accent-red)]', swatch: 'bg-ds-background-accent-red-subtle', }, yellow: { root: 'border-ds-border-accent-yellow-subtle [--tag-icon:var(--ds-icon-accent-yellow)] [--tag-metric-bg:var(--ds-background-accent-yellow-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-yellow)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-yellow)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-yellow)] active:[--tag-icon:var(--ds-text-accent-yellow)]', swatch: 'bg-ds-background-accent-yellow-subtle', }, purple: { root: 'border-ds-border-accent-purple-subtle [--tag-icon:var(--ds-icon-accent-purple)] [--tag-metric-bg:var(--ds-background-accent-purple-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-purple)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-purple)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-purple)] active:[--tag-icon:var(--ds-text-accent-purple)]', swatch: 'bg-ds-background-accent-purple-subtle', }, lime: { root: 'border-ds-border-accent-lime-subtle [--tag-icon:var(--ds-icon-accent-lime)] [--tag-metric-bg:var(--ds-background-accent-lime-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-lime)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-lime)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-lime)] active:[--tag-icon:var(--ds-text-accent-lime)]', swatch: 'bg-ds-background-accent-lime-subtle', }, magenta: { root: 'border-ds-border-accent-magenta-subtle [--tag-icon:var(--ds-icon-accent-magenta)] [--tag-metric-bg:var(--ds-background-accent-magenta-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-magenta)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-magenta)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-magenta)] active:[--tag-icon:var(--ds-text-accent-magenta)]', swatch: 'bg-ds-background-accent-magenta-subtle', }, orange: { root: 'border-ds-border-accent-orange-subtle [--tag-icon:var(--ds-icon-accent-orange)] [--tag-metric-bg:var(--ds-background-accent-orange-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-orange)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-orange)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-orange)] active:[--tag-icon:var(--ds-text-accent-orange)]', swatch: 'bg-ds-background-accent-orange-subtle', }, teal: { root: 'border-ds-border-accent-teal-subtle [--tag-icon:var(--ds-icon-accent-teal)] [--tag-metric-bg:var(--ds-background-accent-teal-subtler)]', linkHover: 'has-[>a:hover]:[--tag-icon:var(--ds-text-accent-teal)] has-[>a:active]:[--tag-icon:var(--ds-text-accent-teal)]', triggerHover: 'hover:[--tag-icon:var(--ds-text-accent-teal)] active:[--tag-icon:var(--ds-text-accent-teal)]', swatch: 'bg-ds-background-accent-teal-subtle', },};// baseStyles: 높이 20px, 테두리 1px, radius small, 좌우 3px, 상하 2px, 바깥 여백 4px, 최대 180pxconst baseClass = cn( 'relative box-border inline-flex h-5 min-w-0 max-w-[11.25rem] cursor-default items-center gap-ds-050 align-middle', 'm-ds-050 rounded-ds-small border-(length:--ds-border-width) border-solid bg-ds-background-neutral-subtle px-[0.1875rem] py-ds-025', 'ds-font-body-small text-ds-text', 'outline-2 outline-offset-2 outline-transparent',);const textClass = 'min-w-0 grow overflow-hidden text-ellipsis whitespace-nowrap text-ds-text';const beforeClass = 'inline-flex shrink-0 items-center text-(--tag-icon) no-underline [&_svg]:size-3';const metricClass = 'box-border inline-flex h-min min-w-ds-300 shrink-0 items-center justify-center rounded-ds-xsmall bg-(--tag-metric-bg) px-ds-050 py-0 ds-font-body-small text-ds-text';export type TagSwatchBefore = boolean | string;function SwatchBefore({ color, swatchBefore, swatchBeforeLabel, swatchBeforeRole,}: { color: NewTagColor; swatchBefore?: TagSwatchBefore; swatchBeforeLabel?: string; swatchBeforeRole?: React.AriaRole;}) { if (swatchBefore === undefined || swatchBefore === false) return null; const a11y = swatchBeforeLabel && swatchBeforeRole ? { 'aria-label': swatchBeforeLabel, role: swatchBeforeRole } : {}; return ( <span data-slot="tag-swatch" {...a11y} // 문자열이면 디자인 토큰 CSS 값(예: var(--ds-background-accent-red-subtle))으로 칠해요 style={swatchBefore === true ? undefined : { backgroundColor: swatchBefore }} className={cn('size-3 shrink-0 rounded-ds-xsmall', swatchBefore === true && colorStyles[color].swatch)} /> );}const getTagText = (text: string | string[]) => (Array.isArray(text) ? text.join('') : text);interface TagOwnProps { /** 태그에 표시할 텍스트 (필수) */ text: string | string[]; /** 색. 테두리·아이콘·메트릭 배경에 쓰여요. 레거시 이름(standard, *Light)도 받아요. */ color?: TagColor; /** 텍스트 앞 요소 (아이콘 등) */ elemBefore?: React.ReactNode; /** 주소를 주면 태그가 링크가 돼요. */ href?: string; /** 기본 a 대신 쓸 링크 컴포넌트 (라우터 Link 등) */ linkComponent?: React.ElementType; /** 제거 버튼 표시 여부 */ isRemovable?: boolean; /** 제거 버튼의 aria-label 앞부분. 실제 라벨은 `${removeButtonLabel} ${text}` */ removeButtonLabel?: string; /** 제거 직전 호출. truthy 를 돌려줘야 제거돼요. */ onBeforeRemoveAction?: () => boolean; /** 제거된 뒤 호출 */ onAfterRemoveAction?: (text: string) => void; /** 최대 너비. 넘으면 말줄임 */ maxWidth?: string | number; /** false 면 기본 바깥 여백(4px)을 없애요. */ hasMargin?: boolean; /** 링크 태그 클릭 */ onClick?: (event: React.MouseEvent<HTMLElement>) => void; /** 앞쪽 12px 색 스와치. true 면 color 의 accent subtle, 문자열이면 그 색 */ swatchBefore?: TagSwatchBefore; swatchBeforeLabel?: string; swatchBeforeRole?: React.AriaRole; /** 텍스트 뒤 짧은 숫자 값 */ trailingMetric?: string | number; testId?: string;}export interface TagProps extends TagOwnProps, Omit<React.ComponentProps<'span'>, keyof TagOwnProps | 'children'> {}const defaultBeforeRemoveAction = () => true;/* ------------------------------------------------------------------------------------------------- * 라벨 모션 (platform-dst-motion-uplift-labels ON) * 태그는 inline-grid 래퍼 안에 그려지고, 추가·제거 때 grid 열 너비가 접히거나 펼쳐져서 * 옆 태그들이 자연스럽게 자리를 옮겨요. 들어올 때 0.8fr→1fr + 페이드 인(150ms), * 나갈 때 1fr→0fr + 페이드 아웃(100ms). * TagGroup 과는 전역 심볼 컨텍스트로 연결돼요 (서로 import 하지 않아도 동작). * -----------------------------------------------------------------------------------------------*/type TagGroupMotionValue = { /** 그룹이 이미 마운트됐는지 (이후 추가된 태그만 들어오는 모션을 재생) */ isGroupMounted: () => boolean; /** 그룹이 이 태그를 빼는 중이면 나가는 모션 뒤 onFinish 를 불러요. */ exit?: { onFinish: () => void };};const TAG_GROUP_MOTION_KEY = Symbol.for('@steel/tag-group-motion');/** TagGroup 과 공유하는 모션 컨텍스트 */function getTagGroupMotionContext(): React.Context<TagGroupMotionValue | null> { const store = globalThis as unknown as Record<symbol, React.Context<TagGroupMotionValue | null> | undefined>; return (store[TAG_GROUP_MOTION_KEY] ??= React.createContext<TagGroupMotionValue | null>(null));}const TAG_ENTER_MS = 150;const TAG_EXIT_MS = 100;const tagMotionClass = { entering: 'animate-[GridColumnIn80to100_var(--ds-motion-label-enter-duration)_var(--ds-motion-label-enter-curve)_backwards,FadeIn0to100_var(--ds-motion-label-enter-duration)_var(--ds-motion-label-enter-curve)_backwards]', exiting: 'animate-[GridColumnOut100to0_var(--ds-motion-label-exit-duration)_var(--ds-motion-label-exit-curve)_forwards,FadeOut100to0_var(--ds-motion-label-exit-duration)_var(--ds-motion-label-exit-curve)_forwards]',} as const;// motion.keyframe 정의 (원본 @atlaskit/tokens 의 GridColumnIn80to100 / GridColumnOut100to0 / FadeIn0to100 / FadeOut100to0)const TAG_KEYFRAMES = `@keyframes GridColumnIn80to100{0%{grid-template-columns:minmax(0,.8fr)}100%{grid-template-columns:minmax(0,1fr)}}@keyframes GridColumnOut100to0{0%{grid-template-columns:minmax(0,1fr)}100%{grid-template-columns:minmax(0,0fr)}}@keyframes FadeIn0to100{0%{opacity:0}100%{opacity:1}}@keyframes FadeOut100to0{0%{opacity:1}100%{opacity:0}}`;function TagKeyframes() { // React 19 가 같은 href 의 style 을 하나로 합쳐 head 에 넣어요 return ( <style href="steel-tag-motion-keyframes" precedence="default"> {TAG_KEYFRAMES} </style> );}/** * 기본 태그. 제거 버튼이 기본으로 붙어요 (Atlaskit 의 default export = RemovableTag). */function Tag({ text, color = 'standard', elemBefore, href, linkComponent, isRemovable = true, removeButtonLabel, onBeforeRemoveAction = defaultBeforeRemoveAction, onAfterRemoveAction, maxWidth, hasMargin = true, onClick, swatchBefore, swatchBeforeLabel, swatchBeforeRole, trailingMetric, testId, className, style, ...props}: TagProps) { const resolved = colorMapping[color ?? 'standard'] ?? 'gray'; const label = getTagText(text); const [status, setStatus] = React.useState<'showing' | 'exiting' | 'removed'>('showing'); const isLink = Boolean(href); const LinkComponent = linkComponent ?? 'a'; const hasMetric = trailingMetric != null && trailingMetric !== ''; const groupMotion = React.useContext(getTagGroupMotionContext()); const motionRef = React.useRef<HTMLSpanElement>(null); // 그룹이 이미 그려진 뒤에 추가된 태그만 들어오는 모션을 재생해요 const [shouldAppear] = React.useState(() => groupMotion?.isGroupMounted() ?? false); const [isEnterDone, setIsEnterDone] = React.useState(!shouldAppear); const [hasEllipsis, setHasEllipsis] = React.useState<boolean | null>(null); const isGroupExiting = Boolean(groupMotion?.exit); const isExiting = status === 'exiting' || isGroupExiting; const isEntering = !isExiting && !isEnterDone; const motionPhase = isExiting ? 'exiting' : isEntering ? 'entering' : null; // 모션 시작 시점에 최종 상태에서 말줄임이 필요한지 재요 (truncation fix: 필요할 때만 말줄임표) React.useLayoutEffect(() => { const el = motionRef.current; if (!motionPhase || !el) return; const inline = el.style.animation; el.style.animation = 'none'; const textEl = el.querySelector<HTMLElement>('[data-tag-text]'); setHasEllipsis(Boolean(textEl && textEl.scrollWidth > textEl.clientWidth)); el.style.animation = inline; }, [motionPhase]); // 모션이 끝나면 정리 (애니메이션이 꺼져 있어도 같은 시간에 끝나요) const latest = React.useRef({ onAfterRemoveAction, label, groupMotion }); latest.current = { onAfterRemoveAction, label, groupMotion }; React.useEffect(() => { if (motionPhase === 'entering') { const timer = window.setTimeout(() => setIsEnterDone(true), TAG_ENTER_MS); return () => window.clearTimeout(timer); } if (motionPhase === 'exiting') { const timer = window.setTimeout(() => { const current = latest.current; if (status === 'exiting') { setStatus('removed'); current.onAfterRemoveAction?.(current.label); } current.groupMotion?.exit?.onFinish(); }, TAG_EXIT_MS); return () => window.clearTimeout(timer); } return undefined; }, [motionPhase, status]); const handleRemove = () => { if (onBeforeRemoveAction()) setStatus('exiting'); }; if (status === 'removed') return null; const content = ( <> <SwatchBefore color={resolved} swatchBefore={swatchBefore} swatchBeforeLabel={swatchBeforeLabel} swatchBeforeRole={swatchBeforeRole} /> {elemBefore && <span className={beforeClass}>{elemBefore}</span>} <span data-tag-text="" className={cn(textClass, hasEllipsis === false && motionPhase && 'text-clip')}> {label} </span> {hasMetric && ( <span data-testid={testId && `${testId}--metric`} className={metricClass}> {trailingMetric} </span> )} </> ); const tag = ( <span data-slot="tag" data-color={resolved} data-testid={testId} style={{ ...style, ...(maxWidth !== undefined ? { maxWidth } : null) }} className={cn( baseClass, // 모션 래퍼가 바깥 여백(4px)을 padding 으로 대신해요 'm-0', colorStyles[resolved].root, isLink && [ 'cursor-pointer', // 링크 위에 있을 때만 hover (제거 버튼 위에서는 그대로) 'has-[>a:hover]:bg-ds-background-neutral-subtle-hovered has-[>a:active]:bg-ds-background-neutral-subtle-pressed', colorStyles[resolved].linkHover, // 링크에 키보드 포커스가 오면 태그 전체에 포커스 링 'has-[>a:focus-visible]:outline-ds-border-focused', ], !isRemovable && hasMetric && 'pe-px', 'transition-[background-color,border-color] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', motionPhase && 'overflow-hidden', isExiting && 'pointer-events-none', className, )} {...props} > {isLink ? ( <LinkComponent href={href} data-testid={testId ? `${testId}--link` : undefined} onClick={onClick} className={cn( 'inline-flex min-w-0 items-center gap-ds-050 overflow-hidden text-inherit no-underline shadow-none outline-none visited:text-inherit', '[&:hover>[data-tag-text]]:underline', )} > {content} </LinkComponent> ) : ( content )} {isRemovable && ( <span className="pointer-events-auto relative flex min-w-ds-150 shrink-0 items-center"> {!isExiting && ( <button type="button" data-slot="tag-remove-button" data-testid={testId ? `close-button-${testId}` : undefined} aria-label={`${removeButtonLabel || '제거'} ${label}`} onClick={handleRemove} onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); }} className={cn( 'relative -mx-ds-025 flex size-4 shrink-0 cursor-pointer appearance-none items-center justify-center self-center rounded-ds-xsmall border-none p-0', 'bg-ds-background-neutral-subtle text-ds-text hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-pressed', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > <X aria-hidden className="size-3" /> </button> )} </span> )} </span> ); return ( <span ref={motionRef} data-slot="tag-motion" className={cn( 'inline-grid grid-cols-[minmax(0,1fr)] overflow-hidden align-middle motion-reduce:animate-none', motionPhase && tagMotionClass[motionPhase], )} > <TagKeyframes /> {hasMargin ? <span className="flex min-w-0 p-ds-050">{tag}</span> : tag} </span> );}export interface SimpleTagProps extends Omit<TagProps, 'isRemovable' | 'removeButtonLabel' | 'onBeforeRemoveAction' | 'onAfterRemoveAction'> {}/** * 제거할 수 없는 태그. (Atlaskit 에서는 deprecated — `<Tag isRemovable={false} />` 와 같아요.) */function SimpleTag(props: SimpleTagProps) { return <Tag {...props} isRemovable={false} />;}/** 제거 가능한 태그 (Tag 와 같아요). */const RemovableTag = Tag;// TagGroup 이 모션 경계를 둘지 판단하는 표시 (원본 markAsTagMotionCapable)const TAG_MOTION_CAPABLE = Symbol.for('@steel/tag/motion-capable');Object.assign(Tag, { [TAG_MOTION_CAPABLE]: true });Object.assign(SimpleTag, { [TAG_MOTION_CAPABLE]: true });export interface TagDropdownTriggerProps extends Omit< TagOwnProps, 'isRemovable' | 'onBeforeRemoveAction' | 'onAfterRemoveAction' | 'removeButtonLabel' | 'href' | 'linkComponent' | 'onClick' >, Omit<React.ComponentProps<'button'>, keyof TagOwnProps | 'children'> { /** 드롭다운이 열려 있거나 선택된 상태 */ isSelected?: boolean; /** 로딩 중에는 스피너를 보여주고 클릭을 막아요. */ isLoading?: boolean; /** false 면 끝의 아래 화살표를 숨겨요. */ hasChevron?: boolean; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;}/** * 필터 메뉴처럼 팝업을 여는 태그 모양 버튼. */function TagDropdownTrigger({ text, color = 'gray', elemBefore, maxWidth, hasMargin = true, onClick, isSelected = false, isLoading = false, hasChevron = true, swatchBefore, swatchBeforeLabel, swatchBeforeRole, trailingMetric, testId, className, style, ...props}: TagDropdownTriggerProps) { const resolved = colorMapping[color ?? 'gray'] ?? 'gray'; const hasMetric = trailingMetric != null && trailingMetric !== ''; return ( <button type="button" data-slot="tag-dropdown-trigger" data-color={resolved} data-selected={isSelected || undefined} data-testid={testId} aria-busy={isLoading || undefined} aria-disabled={isLoading || undefined} onClick={isLoading ? undefined : onClick} style={{ ...style, ...(maxWidth !== undefined ? { maxWidth } : null) }} className={cn( baseClass, 'cursor-pointer focus-visible:outline-ds-border-focused', 'transition-[background-color,border-color] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', !hasMargin && 'm-0', colorStyles[resolved].root, 'hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-pressed', colorStyles[resolved].triggerHover, isSelected && 'border-ds-border-selected bg-ds-background-selected text-ds-text-selected hover:bg-ds-background-selected-hovered active:bg-ds-background-selected-pressed', isLoading && 'cursor-progress', !hasChevron && hasMetric && 'pe-px', className, )} {...props} > <span className={cn( 'inline-flex min-w-0 shrink grow items-center gap-ds-050 overflow-hidden', // 로딩 전환: 내용은 150ms 로 사라지고 스피너는 150ms 페이드 인 (motion-uplift-labels) 'transition-opacity duration-150 ease-[cubic-bezier(0.4,1,0.6,1)] motion-reduce:transition-none', isLoading && 'opacity-0', )} > <SwatchBefore color={resolved} swatchBefore={swatchBefore} swatchBeforeLabel={swatchBeforeLabel} swatchBeforeRole={swatchBeforeRole} /> {elemBefore && <span className={cn(beforeClass, isSelected && 'text-ds-icon-selected')}>{elemBefore}</span>} <span data-tag-text="" className={cn(textClass, isSelected && 'text-ds-text-selected')}> {getTagText(text)} </span> {hasMetric && ( <span data-testid={testId && `${testId}--metric`} className={metricClass}> {trailingMetric} </span> )} {hasChevron && <ChevronDown aria-hidden className="size-3 shrink-0" />} </span> {isLoading && ( <span className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden animate-in fade-in-0 duration-150 ease-[cubic-bezier(0.4,1,0.6,1)] motion-reduce:animate-none"> <svg viewBox="0 0 16 16" className="size-3 animate-spin" fill="none" aria-hidden> <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" /> <path d="M14.5 8A6.5 6.5 0 0 0 8 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> </span> )} </button> );}export { Tag, SimpleTag, RemovableTag, TagDropdownTrigger, colorMapping };