/** * @title Checkbox * @description 여러 선택지 중 하나 이상을 고르거나 설정을 명시적으로 확인받을 때 쓰는 체크박스예요. */import * as React from 'react';import { Checkbox as CheckboxPrimitive } from 'radix-ui';import { cn } from '@/lib/utils';// Atlassian @atlaskit/checkbox — checkbox / internal/label / internal/checkbox-icon 스펙// 상태 색은 label 의 :hover / :active 에 따라 아이콘(svg)의 color(배경)·stroke(테두리)·fill(체크)로 바뀐다.// 플래그 ON(platform_design_system_selection_radial_fade): 150ms --ds-input-selection 으로 색·체크 글리프 교차 페이드.export interface CheckboxChangeEvent { /** 바뀐 뒤의 체크 상태와 name·value 를 담아요. (Atlaskit 의 e.currentTarget 과 같은 모양) */ currentTarget: { checked: boolean; name?: string; value?: string }; target: { checked: boolean; name?: string; value?: string };}export interface CheckboxProps extends Omit< React.ComponentProps<typeof CheckboxPrimitive.Root>, 'checked' | 'defaultChecked' | 'onCheckedChange' | 'onChange' | 'disabled' | 'required' | 'value' > { /** 체크박스 오른쪽에 보이는 라벨. 라벨도 클릭 영역에 포함돼요. */ label?: React.ReactNode; /** 제어 모드의 체크 상태 */ isChecked?: boolean; /** 비제어 모드의 초기 체크 상태 */ defaultChecked?: boolean; /** 일부만 선택된 상태. 스타일만 바꾸고 isChecked 는 바꾸지 않아요. */ isIndeterminate?: boolean; /** 유효하지 않은 상태. 테두리가 danger 색이 돼요. */ isInvalid?: boolean; isDisabled?: boolean; /** 필수 항목. 라벨 뒤에 * 가 붙어요. */ isRequired?: boolean; name?: string; value?: string | number; /** 체크 상태가 바뀔 때 호출돼요. `e.currentTarget.checked` 로 새 값을 읽어요. */ onChange?: (event: CheckboxChangeEvent) => void; testId?: string;}const glyphClass = 'transition-[fill,opacity] duration-(--ds-motion-input-selection-duration) ease-(--ds-motion-input-selection-curve) forced-colors:transition-none';function CheckboxIcon({ isChecked, isIndeterminate, isInvalid, isDisabled,}: { isChecked: boolean; isIndeterminate: boolean; isInvalid: boolean; isDisabled: boolean;}) { const selected = isChecked || isIndeterminate; return ( <svg width={24} height={24} viewBox="0 0 24 24" role="presentation" aria-hidden data-slot="checkbox-icon" className={cn( 'pointer-events-none col-start-1 row-start-1 overflow-hidden rounded-ds-small', 'transition-[color,fill,stroke] duration-(--ds-motion-input-selection-duration) ease-(--ds-motion-input-selection-curve)', // 포커스 링: 2px border.focused, offset -2px (space.negative.025) 'outline-offset-[-2px] group-has-[:focus-visible]/checkbox:outline-2 group-has-[:focus-visible]/checkbox:outline-solid group-has-[:focus-visible]/checkbox:outline-ds-border-focused', // 기본 (미선택) !selected && [ 'fill-transparent stroke-ds-border-input text-ds-background-input', 'group-hover/checkbox:text-ds-background-input-hovered', 'group-active/checkbox:stroke-ds-border group-active/checkbox:text-ds-background-input-pressed', ], // 선택 / 일부 선택 selected && [ 'fill-ds-icon-inverse stroke-ds-background-selected-bold text-ds-background-selected-bold', 'group-hover/checkbox:stroke-ds-background-selected-bold-hovered group-hover/checkbox:text-ds-background-selected-bold-hovered', 'group-active/checkbox:stroke-ds-border group-active/checkbox:text-ds-background-input-pressed group-active/checkbox:fill-ds-icon-inverse', ], // 유효하지 않음: hover/active 에도 빨간 테두리 유지 isInvalid && 'stroke-ds-border-danger group-hover/checkbox:stroke-ds-border-danger group-active/checkbox:stroke-ds-border-danger', // 비활성 isDisabled && [ 'stroke-ds-background-disabled text-ds-background-disabled', 'group-hover/checkbox:stroke-ds-background-disabled group-hover/checkbox:text-ds-background-disabled', 'group-active/checkbox:stroke-ds-background-disabled group-active/checkbox:text-ds-background-disabled', ], isDisabled && selected && 'fill-ds-icon-disabled group-hover/checkbox:fill-ds-icon-disabled group-active/checkbox:fill-ds-icon-disabled', )} > <g fillRule="evenodd"> <rect fill="currentColor" x="5.5" y="5.5" width="13" height="13" rx="1.5" strokeWidth={1} className="transition-[stroke] duration-(--ds-motion-input-selection-duration) ease-(--ds-motion-input-selection-curve)" /> {/* platform_design_system_selection_radial_fade ON: 두 글리프를 모두 그리고 opacity 0↔1 로 교차 페이드 */} <path fillRule="evenodd" clipRule="evenodd" d="M16.3262 9.48011L15.1738 8.51984L10.75 13.8284L8.82616 11.5198L7.67383 12.4801L10.1738 15.4801C10.3163 15.6511 10.5274 15.75 10.75 15.75C10.9726 15.75 11.1837 15.6511 11.3262 15.4801L16.3262 9.48011Z" fill="inherit" data-slot="checkbox-check-glyph" className={cn(glyphClass, isChecked && !isIndeterminate ? 'opacity-100' : 'opacity-0')} /> <path fillRule="evenodd" clipRule="evenodd" d="M7.75 12.75H16.25V11.25H7.75V12.75Z" fill="inherit" data-slot="checkbox-indeterminate-glyph" className={cn(glyphClass, isIndeterminate ? 'opacity-100' : 'opacity-0')} /> </g> </svg> );}function Checkbox({ label, isChecked: isCheckedProp, defaultChecked = false, isIndeterminate = false, isInvalid = false, isDisabled = false, isRequired = false, name, value, onChange, testId, id, className, ...props}: CheckboxProps) { const [checkedState, setCheckedState] = React.useState(isCheckedProp ?? defaultChecked); const isChecked = isCheckedProp === undefined ? checkedState : isCheckedProp; const stringValue = value === undefined ? undefined : String(value); const handleCheckedChange = (next: boolean | 'indeterminate') => { const checked = next === true; if (isCheckedProp === undefined) setCheckedState(checked); const detail = { checked, name, value: stringValue }; onChange?.({ currentTarget: detail, target: detail }); }; return ( <label data-slot="checkbox-label" data-disabled={isDisabled || undefined} data-testid={testId && `${testId}--checkbox-label`} id={id ? `${id}-label` : undefined} className={cn( 'group/checkbox grid auto-cols-fr auto-rows-min ds-font-body text-ds-text', label && 'grid-cols-[min-content_auto] gap-x-ds-050 gap-y-ds-0', isDisabled ? 'cursor-not-allowed text-ds-text-disabled' : 'cursor-default', className, )} > <CheckboxPrimitive.Root data-slot="checkbox" id={id} name={name} value={stringValue} checked={isIndeterminate ? 'indeterminate' : isChecked} onCheckedChange={handleCheckedChange} disabled={isDisabled} required={isRequired} aria-invalid={isInvalid || undefined} data-invalid={isInvalid || undefined} data-testid={testId && `${testId}--hidden-checkbox`} className="col-start-1 row-start-1 m-ds-0 size-6 cursor-[inherit] appearance-none border-none bg-transparent p-ds-0 outline-none" {...props} /> <CheckboxIcon isChecked={isChecked} isIndeterminate={isIndeterminate} isInvalid={isInvalid} isDisabled={isDisabled} /> {label && ( <span data-slot="checkbox-label-text" className="col-start-2 row-start-1 self-center"> {label} {isRequired && ( <span aria-hidden className="ps-ds-025 text-ds-text-danger"> * </span> )} </span> )} </label> );}export { Checkbox };