/** * @title Radio * @description 여러 선택지 중 정확히 하나만 고르게 하는 라디오와 라디오 그룹이에요. */import * as React from 'react';import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';import { cn } from '@/lib/utils';// Atlassian @atlaskit/radio — radio / radio-group 스펙// 24px 원을 scale(.625) 로 줄여 15px 로 보이게 하고, ::after 점(9.6px → 6px)으로 선택을 표시한다.export type RadioValue = string;export interface RadioChangeEvent { /** 선택된 라디오의 value·name. (Atlaskit 의 e.currentTarget 과 같은 모양) */ currentTarget: { value: RadioValue; name?: string; checked: boolean }; target: { value: RadioValue; name?: string; checked: boolean };}export type OptionPropType = { isDisabled?: boolean; label?: React.ReactNode; name?: string; value?: RadioValue; testId?: string;};export type OptionsPropType = Array<OptionPropType>;type RadioGroupContextValue = { name?: string; isDisabled?: boolean; isRequired?: boolean; isInvalid?: boolean; value: RadioValue | null | undefined; testId?: string;};const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);const makeEvent = (value: RadioValue, name?: string): RadioChangeEvent => { const detail = { value, name, checked: true }; return { currentTarget: detail, target: detail };};export interface RadioProps extends Omit< React.ComponentProps<typeof RadioGroupPrimitive.Item>, 'value' | 'disabled' | 'required' | 'checked' | 'onChange' | 'aria-label' > { /** 보이는 라벨이 없을 때 쓰는 aria-label */ ariaLabel?: string; /** RadioGroup 의 isDisabled 가 있으면 그 값이 우선해요. */ isDisabled?: boolean; isRequired?: boolean; isInvalid?: boolean; /** 단독 Radio 의 선택 상태. RadioGroup 안에서는 그룹의 value 로 정해져요. */ isChecked?: boolean; label?: React.ReactNode; /** aria-labelledby 로 연결할 라벨 id */ labelId?: string; name?: string; value?: RadioValue; /** 이 라디오가 선택될 때 호출돼요. `e.currentTarget.value` 로 값을 읽어요. */ onChange?: (event: RadioChangeEvent) => void; testId?: string;}function RadioControl({ ariaLabel, isDisabled = false, isRequired, isInvalid = false, isChecked, label, labelId, value = '', testId, className, ...props}: Omit<RadioProps, 'onChange' | 'name'> & { isChecked: boolean }) { return ( <label data-slot="radio-label" data-disabled={isDisabled || undefined} data-testid={testId && `${testId}--radio-label`} className={cn( 'relative box-border flex items-start ds-font-body text-ds-text', isDisabled && 'cursor-not-allowed text-ds-text-disabled', className, )} > <RadioGroupPrimitive.Item data-slot="radio" value={value} disabled={isDisabled} required={isRequired} aria-label={ariaLabel} aria-labelledby={labelId} aria-invalid={isInvalid || undefined} data-invalid={isInvalid || undefined} data-testid={testId && `${testId}--radio-input`} className={cn( 'relative m-ds-0 flex size-6 shrink-0 scale-[.625] appearance-none items-center justify-center align-top', 'rounded-ds-full border-(length:--ds-border-width) border-solid border-ds-border-input bg-ds-background-input', 'transition-[border-color,background-color] duration-(--ds-motion-input-selection-duration) ease-(--ds-motion-input-selection-curve)', 'outline-none', // 선택 점 'after:absolute after:size-[9.6px] after:rounded-ds-full after:bg-ds-icon-inverse after:opacity-0 after:content-[""]', 'after:transition-[background-color,opacity] after:duration-(--ds-motion-input-selection-duration) after:ease-(--ds-motion-input-selection-curve)', // platform_design_system_selection_radial_fade ON: 점은 포인터를 받지 않고, 강제 색상 모드에선 전환 없음 'after:pointer-events-none forced-colors:transition-none forced-colors:after:transition-none', 'data-[state=checked]:after:opacity-100', !isDisabled && [ 'hover:bg-ds-background-input-hovered hover:border-ds-border-input', 'active:bg-ds-background-input-pressed', 'data-[state=checked]:bg-ds-background-selected-bold data-[state=checked]:border-ds-background-selected-bold', 'data-[state=checked]:hover:bg-ds-background-selected-bold-hovered data-[state=checked]:hover:border-ds-background-selected-bold-hovered', 'data-[state=checked]:active:bg-ds-background-selected-bold-pressed data-[state=checked]:active:border-ds-border-input', // 포커스 링: 2px border.focused, offset 3px (scale 전 기준) 'focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-ds-border-focused', ], isDisabled && 'cursor-not-allowed border-ds-border-disabled bg-ds-background-disabled after:bg-ds-icon-disabled', isInvalid && !isDisabled && 'border-ds-icon-danger hover:border-ds-icon-danger data-[state=checked]:border-ds-icon-danger data-[state=checked]:hover:border-ds-icon-danger', )} {...props} /> {label ? ( <span data-slot="radio-label-text" className="px-ds-050 py-ds-025"> {label} </span> ) : null} </label> );}/** 라디오 하나. RadioGroup 안에서 쓰거나, 표 안처럼 배치를 직접 정할 때 단독으로 써요. */function Radio({ isChecked = false, isDisabled, isInvalid, isRequired, name, value = '', onChange, testId, ...props }: RadioProps) { const group = React.useContext(RadioGroupContext); if (group) { const checked = group.value != null && group.value === value; return ( <RadioControl {...props} value={value} isChecked={checked} isDisabled={group.isDisabled ?? isDisabled} isRequired={group.isRequired ?? isRequired} // Atlaskit 과 같이 그룹의 isInvalid 는 선택된 라디오에만 표시돼요. isInvalid={group.isInvalid !== undefined ? checked && group.isInvalid : isInvalid} testId={testId ?? group.testId} /> ); } // 단독 Radio: Radix Item 은 그룹이 필요해서 보이지 않는 1항목 그룹으로 감싼다. return ( <RadioGroupPrimitive.Root data-slot="radio-standalone" asChild name={name} value={isChecked ? value : null} disabled={isDisabled} required={isRequired} onValueChange={(next) => onChange?.(makeEvent(next, name))} > <span className="contents"> <RadioControl {...props} value={value} isChecked={isChecked} isDisabled={isDisabled} isRequired={isRequired} isInvalid={isInvalid} testId={testId} /> </span> </RadioGroupPrimitive.Root> );}export interface RadioGroupProps extends Omit< React.ComponentProps<typeof RadioGroupPrimitive.Root>, 'value' | 'defaultValue' | 'onChange' | 'onValueChange' | 'disabled' | 'required' | 'asChild' | 'onInvalid' > { /** 제어 모드의 선택 값 */ value?: RadioValue | null; /** 비제어 모드의 초기 선택 값 */ defaultValue?: RadioValue | null; /** 그룹 안 모든 Radio 를 비활성으로 만들어요. 각 Radio 의 isDisabled 보다 우선해요. */ isDisabled?: boolean; isRequired?: boolean; /** 선택된 Radio 에 invalid 스타일을 줘요. */ isInvalid?: boolean; /** 각 항목을 Radio 로 그려요. children 으로 Radio 를 직접 넣어도 돼요. */ options?: OptionsPropType; /** 선택이 바뀔 때 호출돼요. `e.currentTarget.value` 로 새 값을 읽어요. */ onChange?: (event: RadioChangeEvent) => void; /** 각 Radio 의 name */ name?: string; /** aria-labelledby 로 연결할 그룹 라벨 id */ labelId?: string; testId?: string;}function RadioGroup({ value: valueProp, defaultValue, isDisabled, isRequired, isInvalid, options = [], onChange, name, labelId, testId, id, className, children, ...props}: RadioGroupProps) { const uid = React.useId(); const groupId = id ?? uid; const [selected, setSelected] = React.useState<RadioValue | null | undefined>( valueProp !== undefined ? valueProp : defaultValue, ); const value = valueProp !== undefined ? valueProp : selected; const handleValueChange = (next: string) => { setSelected(next); onChange?.(makeEvent(next, name)); }; return ( <RadioGroupContext.Provider value={{ name, isDisabled, isRequired, isInvalid, value, testId }}> <RadioGroupPrimitive.Root data-slot="radio-group" id={groupId} name={name} value={value ?? null} onValueChange={handleValueChange} disabled={isDisabled} required={isRequired} aria-labelledby={labelId} aria-describedby={isInvalid ? `${groupId}-error` : undefined} data-testid={testId} className={cn('flex flex-col', className)} {...props} > {options.map((option, index) => ( <Radio key={option.value ?? index} {...option} /> ))} {children} </RadioGroupPrimitive.Root> </RadioGroupContext.Provider> );}export { Radio, RadioGroup };