/** * @title Checkbox * @description 사용자가 하나 이상의 옵션을 선택할 수 있게 해주는 컴포넌트예요. 여러 항목 선택이나 약관 동의에 사용해요. */'use client';import * as React from 'react';import { Checkbox as CheckboxPrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: checkmark.yaml// 눌림(engaged)은 Checkmark 자체가 눌렸을 때(active)와 감싸는 Checkbox 라벨이 눌렸을 때(group-active/checkbox) 모두 적용해요.const checkmarkVariants = cva( [ 'peer relative box-border inline-flex flex-none cursor-pointer items-center justify-center rounded-r1', 'transition-[background-color,color,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.95] group-active/checkbox:enabled:scale-[0.95]', 'disabled:cursor-not-allowed', ], { variants: { variant: { square: [ 'border border-solid border-stroke-neutral-weak bg-bg-transparent', 'data-[state=checked]:border-0 data-[state=indeterminate]:border-0', 'enabled:active:bg-bg-transparent-pressed group-active/checkbox:enabled:bg-bg-transparent-pressed', 'disabled:border-stroke-neutral-muted disabled:bg-bg-disabled disabled:text-fg-disabled', ], ghost: [ 'bg-bg-transparent text-fg-placeholder', 'enabled:active:bg-bg-transparent-pressed group-active/checkbox:enabled:bg-bg-transparent-pressed', 'disabled:text-fg-disabled', ], }, tone: { brand: '', neutral: '', }, size: { medium: 'size-x5', large: 'size-x6', }, }, compoundVariants: [ { variant: 'square', tone: 'brand', className: [ 'enabled:data-[state=checked]:bg-bg-brand-solid enabled:data-[state=indeterminate]:bg-bg-brand-solid', 'enabled:data-[state=checked]:text-fg-on-brand-solid enabled:data-[state=indeterminate]:text-fg-on-brand-solid', 'enabled:data-[state=checked]:active:bg-bg-brand-solid-pressed enabled:data-[state=indeterminate]:active:bg-bg-brand-solid-pressed', 'group-active/checkbox:enabled:data-[state=checked]:bg-bg-brand-solid-pressed group-active/checkbox:enabled:data-[state=indeterminate]:bg-bg-brand-solid-pressed', ], }, { variant: 'square', tone: 'neutral', className: [ 'enabled:data-[state=checked]:bg-bg-neutral-solid enabled:data-[state=indeterminate]:bg-bg-neutral-solid', 'enabled:data-[state=checked]:text-fg-on-neutral-solid enabled:data-[state=indeterminate]:text-fg-on-neutral-solid', 'enabled:data-[state=checked]:active:bg-bg-neutral-solid-pressed enabled:data-[state=indeterminate]:active:bg-bg-neutral-solid-pressed', 'group-active/checkbox:enabled:data-[state=checked]:bg-bg-neutral-solid-pressed group-active/checkbox:enabled:data-[state=indeterminate]:bg-bg-neutral-solid-pressed', ], }, { variant: 'ghost', tone: 'brand', className: [ 'enabled:data-[state=checked]:text-fg-brand enabled:data-[state=indeterminate]:text-fg-brand', 'enabled:data-[state=checked]:active:bg-bg-brand-weak-pressed enabled:data-[state=indeterminate]:active:bg-bg-brand-weak-pressed', 'group-active/checkbox:enabled:data-[state=checked]:bg-bg-brand-weak-pressed group-active/checkbox:enabled:data-[state=indeterminate]:bg-bg-brand-weak-pressed', ], }, { variant: 'ghost', tone: 'neutral', className: [ 'enabled:data-[state=checked]:text-fg-neutral enabled:data-[state=indeterminate]:text-fg-neutral', 'enabled:data-[state=checked]:active:bg-palette-gray-200 enabled:data-[state=indeterminate]:active:bg-palette-gray-200', 'group-active/checkbox:enabled:data-[state=checked]:bg-palette-gray-200 group-active/checkbox:enabled:data-[state=indeterminate]:bg-palette-gray-200', ], }, // 아이콘 크기: square 12/14px, ghost 14/18px { variant: 'square', size: 'medium', className: '[&_svg]:size-[12px]' }, { variant: 'square', size: 'large', className: '[&_svg]:size-[14px]' }, { variant: 'ghost', size: 'medium', className: '[&_svg]:size-[14px]' }, { variant: 'ghost', size: 'large', className: '[&_svg]:size-[18px]' }, ], defaultVariants: { variant: 'square', tone: 'brand', size: 'medium', }, },);// SEED Rootage spec: checkbox.yamlconst checkboxVariants = cva( [ 'group/checkbox relative isolate inline-flex max-w-full cursor-pointer items-start gap-x2 align-top', 'has-[button:disabled]:cursor-not-allowed', ], { variants: { size: { medium: 'min-h-x8', large: 'min-h-x9', }, }, defaultVariants: { size: 'medium' }, },);const checkboxLabelVariants = cva( 'text-fg-neutral group-has-[button:disabled]/checkbox:text-fg-disabled', { variants: { size: { // 수직 위치 보정: (root 최소 높이 - 행간) / 2 medium: 'mt-[calc(var(--rb-dimension-x8)/2_-_var(--rb-line-height-t4)/2)] text-t4', large: 'mt-[calc(var(--rb-dimension-x9)/2_-_var(--rb-line-height-t5)/2)] text-t5', }, weight: { regular: 'font-regular', bold: 'font-bold', }, }, defaultVariants: { size: 'medium', weight: 'regular' }, },);// 수직 위치 보정: (root 최소 높이 - Checkmark 크기) / 2const checkmarkOffset = { medium: 'mt-[calc((var(--rb-dimension-x8)_-_var(--rb-dimension-x5))/2)]', large: 'mt-[calc((var(--rb-dimension-x9)_-_var(--rb-dimension-x6))/2)]',} as const;function CheckIcon() { return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden data-slot="checkmark-icon"> <path d="M4 12.5 9.5 18 20 6.5" stroke="currentColor" strokeWidth="3.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}function MinusIcon() { return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden data-slot="checkmark-icon"> <path d="M5 12h14" stroke="currentColor" strokeWidth="3.6" strokeLinecap="round" /> </svg> );}type CheckmarkVariantProps = VariantProps<typeof checkmarkVariants>;export interface CheckmarkProps extends React.ComponentProps<typeof CheckboxPrimitive.Root>, CheckmarkVariantProps {}/** 라벨 없이 쓰는 체크 표시예요. List 처럼 다른 요소와 조합할 때 사용해요. */function Checkmark({ className, variant, tone, size, ...props }: CheckmarkProps) { const isGhost = (variant ?? 'square') === 'ghost'; return ( <CheckboxPrimitive.Root data-slot="checkmark" data-variant={variant ?? 'square'} className={cn('group/checkmark', checkmarkVariants({ variant, tone, size }), className)} {...props} > {/* square 는 선택됐을 때만, ghost 는 항상 체크 아이콘을 보여줘요. */} <span className={cn( 'pointer-events-none absolute inset-0 m-auto items-center justify-center', isGhost ? 'flex group-data-[state=indeterminate]/checkmark:hidden' : 'hidden group-data-[state=checked]/checkmark:flex', )} > <CheckIcon /> </span> <span className="pointer-events-none absolute inset-0 m-auto hidden items-center justify-center group-data-[state=indeterminate]/checkmark:flex"> <MinusIcon /> </span> </CheckboxPrimitive.Root> );}type CheckboxGroupContextValue = Pick<CheckboxProps, 'variant' | 'tone' | 'size' | 'weight'>;const CheckboxGroupContext = React.createContext<CheckboxGroupContextValue>({});export interface CheckboxProps extends CheckmarkProps { /** 체크 표시 옆에 보여줄 라벨이에요. 라벨을 누르면 체크 상태가 바뀌어요. */ label?: React.ReactNode; /** 라벨 굵기예요. */ weight?: 'regular' | 'bold'; /** 바깥 label 요소에 줄 className 이에요. */ rootClassName?: string; rootRef?: React.Ref<HTMLLabelElement>;}function Checkbox({ label, weight, variant, tone, size, className, rootClassName, rootRef, ...props}: CheckboxProps) { const group = React.useContext(CheckboxGroupContext); const resolved = { variant: variant ?? group.variant ?? 'square', tone: tone ?? group.tone ?? 'brand', size: size ?? group.size ?? 'medium', weight: weight ?? group.weight ?? 'regular', }; if (label == null) { return ( <Checkmark variant={resolved.variant} tone={resolved.tone} size={resolved.size} className={className} {...props} /> ); } return ( <label ref={rootRef} data-slot="checkbox" className={cn(checkboxVariants({ size: resolved.size }), rootClassName)} > <Checkmark variant={resolved.variant} tone={resolved.tone} size={resolved.size} className={cn(checkmarkOffset[resolved.size], className)} {...props} /> <span data-slot="checkbox-label" className={checkboxLabelVariants({ size: resolved.size, weight: resolved.weight })} > {label} </span> </label> );}export interface CheckboxGroupProps extends React.ComponentProps<'div'>, CheckboxGroupContextValue {}/** 여러 Checkbox 를 세로로 묶어요. variant/tone/size/weight 를 지정하면 안쪽 Checkbox 의 기본값이 돼요. */function CheckboxGroup({ className, variant, tone, size, weight, ...props }: CheckboxGroupProps) { const value = React.useMemo(() => ({ variant, tone, size, weight }), [variant, tone, size, weight]); return ( <CheckboxGroupContext.Provider value={value}> <div role="group" data-slot="checkbox-group" className={cn('flex flex-col gap-x1', className)} {...props} /> </CheckboxGroupContext.Provider> );}export { Checkbox, CheckboxGroup, Checkmark, checkboxVariants, checkmarkVariants };