'use client';/** * @title Chip * @description 사용자가 선택하거나 입력하는 값을 표시하는 컴포넌트예요. 버튼, 토글, 단일 선택(라디오) 세 가지 방식으로 사용할 수 있어요. */import * as React from 'react';import { RadioGroup as RadioGroupPrimitive, Slot } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: chip.yaml// 선택 상태는 aria-checked="true" 로 표현해요. (Chip.Toggle 은 role="checkbox", Chip.RadioItem 은 role="radio")const chipVariants = cva( [ 'relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap text-start leading-none antialiased rounded-full', 'transition-[background-color,color,box-shadow,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'enabled:active:scale-[0.97]', 'disabled:cursor-not-allowed disabled:opacity-50', '[&_svg]:pointer-events-none [&_svg]:shrink-0', ], { variants: { variant: { solid: [ 'bg-bg-neutral-weak-alpha text-fg-neutral enabled:active:bg-bg-neutral-weak-alpha-pressed', 'aria-checked:bg-bg-neutral-solid aria-checked:text-fg-on-neutral-solid aria-checked:enabled:active:bg-bg-neutral-solid-pressed', ], outlineStrong: [ 'bg-bg-transparent text-fg-neutral inset-ring inset-ring-stroke-neutral-muted enabled:active:bg-bg-transparent-pressed', 'aria-checked:bg-bg-neutral-solid aria-checked:text-fg-on-neutral-solid aria-checked:enabled:active:bg-bg-neutral-solid-pressed', ], outlineWeak: [ 'bg-bg-transparent text-fg-neutral inset-ring inset-ring-stroke-neutral-muted enabled:active:bg-bg-transparent-pressed', 'aria-checked:bg-bg-neutral-weak aria-checked:inset-ring-stroke-neutral-contrast aria-checked:enabled:active:bg-bg-neutral-weak-pressed', ], }, size: { small: 'h-x8 px-x1_5 [&>svg]:size-x3_5', medium: 'h-x9 px-x2 [&>svg]:size-x4', large: 'h-x10 px-x2_5 [&>svg]:size-x4', }, layout: { withText: '', iconOnly: '', }, }, compoundVariants: [ { size: 'small', layout: 'withText', className: 'min-w-[44px]' }, { size: 'medium', layout: 'withText', className: 'min-w-x12' }, { size: 'large', layout: 'withText', className: 'min-w-x13' }, { size: 'small', layout: 'iconOnly', className: 'min-w-x8' }, { size: 'medium', layout: 'iconOnly', className: 'min-w-x9' }, { size: 'large', layout: 'iconOnly', className: 'min-w-x10' }, ], defaultVariants: { variant: 'solid', size: 'medium', layout: 'withText', }, },);type ChipVariantProps = VariantProps<typeof chipVariants>;type ChipSize = NonNullable<ChipVariantProps['size']>;const ChipContext = React.createContext<{ size: ChipSize }>({ size: 'medium' });// 슬롯별 크기 (size=small | medium | large)const prefixIconSize: Record<ChipSize, string> = { small: '[&_svg]:size-x3_5', medium: '[&_svg]:size-x4', large: '[&_svg]:size-x4' };const suffixIconSize: Record<ChipSize, string> = { small: '[&_svg]:size-x3_5', medium: '[&_svg]:size-x3_5', large: '[&_svg]:size-x4' };const prefixAvatarSize: Record<ChipSize, string> = { small: 'size-x5', medium: 'size-x6', large: 'size-x7' };/* ------------------------------------------------------------------------------------------------- * Chip.Button * -----------------------------------------------------------------------------------------------*/export interface ChipButtonProps extends React.ComponentProps<'button'>, ChipVariantProps { asChild?: boolean;}function ChipButton({ className, variant, size, layout, asChild = false, type, ...props }: ChipButtonProps) { const Comp = asChild ? Slot.Root : 'button'; return ( <ChipContext.Provider value={{ size: size ?? 'medium' }}> <Comp data-slot="chip" type={asChild ? type : (type ?? 'button')} className={cn(chipVariants({ variant, size, layout }), className)} {...props} /> </ChipContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * Chip.Toggle * -----------------------------------------------------------------------------------------------*/export interface ChipToggleProps extends Omit<React.ComponentProps<'button'>, 'value' | 'defaultValue'>, ChipVariantProps { /** 선택 상태 (제어 컴포넌트) */ checked?: boolean; /** 처음 선택 상태 (비제어 컴포넌트) */ defaultChecked?: boolean; /** 선택 상태가 바뀔 때 호출돼요. */ onCheckedChange?: (checked: boolean) => void;}function ChipToggle({ className, variant, size, layout, checked: checkedProp, defaultChecked = false, onCheckedChange, onClick, ...props}: ChipToggleProps) { const [uncontrolled, setUncontrolled] = React.useState(defaultChecked); const isControlled = checkedProp !== undefined; const checked = isControlled ? checkedProp : uncontrolled; return ( <ChipContext.Provider value={{ size: size ?? 'medium' }}> <button type="button" role="checkbox" aria-checked={checked} data-slot="chip" data-state={checked ? 'checked' : 'unchecked'} className={cn(chipVariants({ variant, size, layout }), className)} onClick={(event) => { onClick?.(event); if (event.defaultPrevented) return; if (!isControlled) setUncontrolled(!checked); onCheckedChange?.(!checked); }} {...props} /> </ChipContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * Chip.RadioRoot / Chip.RadioItem * -----------------------------------------------------------------------------------------------*/export type ChipRadioRootProps = React.ComponentProps<typeof RadioGroupPrimitive.Root>;function ChipRadioRoot({ className, ...props }: ChipRadioRootProps) { return ( <RadioGroupPrimitive.Root data-slot="chip-radio-root" className={cn('flex flex-wrap items-center gap-x2', className)} {...props} /> );}export interface ChipRadioItemProps extends React.ComponentProps<typeof RadioGroupPrimitive.Item>, ChipVariantProps {}function ChipRadioItem({ className, variant, size, layout, ...props }: ChipRadioItemProps) { return ( <ChipContext.Provider value={{ size: size ?? 'medium' }}> <RadioGroupPrimitive.Item data-slot="chip" className={cn(chipVariants({ variant, size, layout }), className)} {...props} /> </ChipContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * Slots * -----------------------------------------------------------------------------------------------*/function ChipLabel({ className, ...props }: React.ComponentProps<'span'>) { return ( <span data-slot="chip-label" className={cn('inline-flex items-center justify-center px-x1_5 text-t4 font-medium', className)} {...props} /> );}function ChipPrefixIcon({ className, ...props }: React.ComponentProps<'span'>) { const { size } = React.useContext(ChipContext); return ( <span data-slot="chip-prefix-icon" aria-hidden className={cn('inline-flex shrink-0 items-center pl-x1_5', prefixIconSize[size], className)} {...props} /> );}function ChipSuffixIcon({ className, ...props }: React.ComponentProps<'span'>) { const { size } = React.useContext(ChipContext); return ( <span data-slot="chip-suffix-icon" className={cn('inline-flex shrink-0 items-center pr-x1_5', suffixIconSize[size], className)} {...props} /> );}/** 프로필 이미지(img)나 이니셜을 동그랗게 잘라 보여주는 Prefix 슬롯이에요. */function ChipPrefixAvatar({ className, ...props }: React.ComponentProps<'span'>) { const { size } = React.useContext(ChipContext); return ( <span data-slot="chip-prefix-avatar" className={cn( 'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-palette-gray-200 text-t1 font-bold [&>img]:size-full [&>img]:object-cover', prefixAvatarSize[size], className, )} {...props} /> );}/** SEED 와 같은 `Chip.Button` 형태로도 쓸 수 있어요. */const Chip = { Button: ChipButton, Toggle: ChipToggle, RadioRoot: ChipRadioRoot, RadioItem: ChipRadioItem, Label: ChipLabel, PrefixIcon: ChipPrefixIcon, PrefixAvatar: ChipPrefixAvatar, SuffixIcon: ChipSuffixIcon,};export { Chip, ChipButton, ChipToggle, ChipRadioRoot, ChipRadioItem, ChipLabel, ChipPrefixIcon, ChipPrefixAvatar, ChipSuffixIcon, chipVariants,};