/** * @title Tag Group * @description 아이콘과 텍스트 태그를 한 줄로 나열해 여러 속성·상태·메타데이터를 한눈에 보여주는 정보 요약 컴포넌트예요. */import * as React from 'react';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: tag-group.yaml, tag-group-item.yaml / qvism recipe: tag-group.tsconst tagGroupVariants = cva('', { variants: { size: { t2: '', t3: '', t4: '', }, truncate: { // 한 줄 유지 + 말줄임 true: 'inline-flex max-w-full items-center', // 기본: 줄바꿈 허용 (font-size 0 으로 인라인 사이 공백 제거) false: 'inline-block text-[0px]', }, }, compoundVariants: [ { size: 't2', truncate: false, className: 'leading-t2' }, { size: 't3', truncate: false, className: 'leading-t3' }, { size: 't4', truncate: false, className: 'leading-t4' }, ], defaultVariants: { size: 't2', truncate: false, },});const tagGroupSeparatorVariants = cva('select-none whitespace-pre font-regular text-palette-gray-600', { variants: { size: { t2: 'text-t2', t3: 'text-t3', t4: 'text-t4', }, truncate: { true: '', false: 'align-middle', }, }, defaultVariants: { size: 't2', truncate: false, },});const tagGroupItemVariants = cva('min-w-0 align-middle', { variants: { tone: { neutralSubtle: 'text-fg-neutral-subtle', neutral: 'text-fg-neutral', brand: 'text-fg-brand', }, truncate: { true: 'inline-flex items-baseline', false: 'inline', }, }, defaultVariants: { tone: 'neutralSubtle', truncate: false, },});const tagGroupItemLabelVariants = cva( 'inline min-w-0 align-middle break-normal not-first:ml-x0_5 not-last:mr-x0_5', { variants: { size: { t2: 'text-t2', t3: 'text-t3', t4: 'text-t4', }, weight: { regular: 'font-regular', bold: 'font-bold', }, truncate: { true: 'overflow-hidden text-ellipsis whitespace-nowrap', false: 'overflow-visible text-clip whitespace-normal', }, }, defaultVariants: { size: 't2', weight: 'regular', truncate: false, }, },);// 아이콘 크기: t2 = x3, t3 = 13px, t4 = x3_5// 아이콘 세로 위치는 라벨의 대문자 높이(cap) 중앙에 맞춰요 (SEED 정렬 공식).const tagGroupIconVariants = cva( 'relative inline-flex shrink-0 items-center justify-center [&_svg]:size-full', { variants: { size: { t2: 'size-x3 text-t2', t3: 'size-[13px] text-t3', t4: 'size-x3_5 text-t4', }, truncate: { true: '', false: 'align-middle', }, }, compoundVariants: [ { size: 't2', truncate: false, className: 'top-[calc(1em-var(--rb-line-height-t2)/2-0.5cap)]' }, { size: 't3', truncate: false, className: 'top-[calc(1em-var(--rb-line-height-t3)/2-0.5cap)]' }, { size: 't4', truncate: false, className: 'top-[calc(1em-var(--rb-line-height-t4)/2-0.5cap)]' }, { size: 't2', truncate: true, className: 'top-[calc(var(--rb-dimension-x3)/2-0.5cap)]' }, { size: 't3', truncate: true, className: 'top-[calc(13px/2-0.5cap)]' }, { size: 't4', truncate: true, className: 'top-[calc(var(--rb-dimension-x3_5)/2-0.5cap)]' }, ], defaultVariants: { size: 't2', truncate: false, }, },);type TagGroupSize = 't2' | 't3' | 't4';type TagGroupTone = 'neutralSubtle' | 'neutral' | 'brand';type TagGroupWeight = 'regular' | 'bold';interface TagGroupContextValue { size?: TagGroupSize; tone?: TagGroupTone; weight?: TagGroupWeight; truncate: boolean;}const TagGroupContext = React.createContext<TagGroupContextValue>({ truncate: false });export interface TagGroupRootProps extends React.ComponentProps<'span'> { /** 모든 Item 에 같은 사이즈를 적용해요. */ size?: TagGroupSize; /** Item 기본 색상. Item 마다 덮어쓸 수 있어요. */ tone?: TagGroupTone; /** Item 기본 두께. Item 마다 덮어쓸 수 있어요. */ weight?: TagGroupWeight; /** 한 줄로 유지하고 넘치면 말줄임해요. */ truncate?: boolean; /** Item 사이 구분 기호. 스크린 리더는 읽지 않아요. */ separator?: React.ReactNode;}function TagGroupRoot({ className, size = 't2', tone, weight, truncate = false, separator = ' · ', children, ...props}: TagGroupRootProps) { const contextValue = React.useMemo(() => ({ size, tone, weight, truncate }), [size, tone, weight, truncate]); return ( <TagGroupContext.Provider value={contextValue}> <span data-slot="tag-group" data-truncate={truncate || undefined} className={cn(tagGroupVariants({ size, truncate }), className)} {...props} > {React.Children.toArray(children) .filter(Boolean) .map((child, index) => ( <React.Fragment key={index}> {index > 0 && ( <span data-slot="tag-group-separator" aria-hidden className={tagGroupSeparatorVariants({ size, truncate })} > {separator} </span> )} {child} </React.Fragment> ))} </span> </TagGroupContext.Provider> );}export interface TagGroupItemProps extends Omit<React.ComponentProps<'span'>, 'children'> { /** 라벨 앞 아이콘. Suffix 와 동시에 쓰지 않는 것을 권장해요. */ prefixIcon?: React.ReactNode; label: React.ReactNode; /** 라벨 뒤 아이콘. */ suffixIcon?: React.ReactNode; size?: TagGroupSize; tone?: TagGroupTone; weight?: TagGroupWeight; /** truncate 모드에서 줄어드는 우선순위. 0 이면 말줄임되지 않아요. */ flexShrink?: number;}function TagGroupItem({ className, prefixIcon, label, suffixIcon, size, tone, weight, flexShrink, style, ...props}: TagGroupItemProps) { const ctx = React.useContext(TagGroupContext); const resolvedSize = size ?? ctx.size ?? 't2'; const resolvedTone = tone ?? ctx.tone ?? 'neutralSubtle'; const resolvedWeight = weight ?? ctx.weight ?? 'regular'; const { truncate } = ctx; const iconClassName = tagGroupIconVariants({ size: resolvedSize, truncate }); return ( <span data-slot="tag-group-item" className={cn(tagGroupItemVariants({ tone: resolvedTone, truncate }), className)} style={{ flexShrink: flexShrink ?? 1, ...style }} {...props} > {prefixIcon != null && ( <span data-slot="tag-group-item-prefix-icon" aria-hidden className={iconClassName}> {prefixIcon} </span> )} <span data-slot="tag-group-item-label" className={tagGroupItemLabelVariants({ size: resolvedSize, weight: resolvedWeight, truncate })} > {label} </span> {suffixIcon != null && ( <span data-slot="tag-group-item-suffix-icon" aria-hidden className={iconClassName}> {suffixIcon} </span> )} </span> );}export { TagGroupRoot, TagGroupItem, tagGroupVariants, tagGroupItemVariants };