{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radio",
  "type": "registry:ui",
  "title": "Radio",
  "description": "서로 배타적인 항목 중에서 하나만 고르게 하는 선택 컴포넌트입니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-context",
    "@atom/lib-field-context",
    "@atom/lib-use-controllable-state"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/radio.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  type HTMLAttributes,\n  type InputHTMLAttributes,\n  type ReactNode,\n  type Ref,\n  isValidElement,\n  useId,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { createStrictContext } from \"../lib/context\";\nimport { useFieldControl } from \"../lib/field-context\";\nimport { useControllableState } from \"../lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------- */\n/*  Radio (components/radio.md, styles/atom/elements/radio.css)               */\n/*    <RadioGroup aria-label=\"처리 방식\" defaultValue=\"now\">                   */\n/*      <Radio value=\"now\" description=\"국내 모든 카드사\">즉시 처리</Radio>      */\n/*      <Radio value=\"later\">예약 처리</Radio>                                 */\n/*    </RadioGroup>                                                           */\n/*    <RadioGroup aria-label=\"배송\" value={v} onValueChange={setV}>            */\n/*      <RadioCard value=\"pickup\" description=\"약속 장소에서 만나 거래해요\">     */\n/*        직접 가져가기                                                        */\n/*      </RadioCard>                                                          */\n/*    </RadioGroup>                                                           */\n/* -------------------------------------------------------------------------- */\n\nexport type RadioSize = \"small\" | \"medium\" | \"large\";\nexport type RadioGroupLayout = \"vertical\" | \"horizontal\";\n\nconst B = \"atom-radio\";\nconst G = \"atom-radio-group\";\nconst C = \"atom-radio-card\";\n\nconst [RadioContext, useRadioContext] = createStrictContext<true>(\"Radio\");\n\ntype GroupContext = {\n  name: string;\n  value: string | undefined;\n  select: (value: string) => void;\n  disabled: boolean;\n  invalid: boolean;\n  required: boolean;\n};\nconst [RadioGroupContext, , useRadioGroupContext] = createStrictContext<GroupContext>(\"RadioGroup\");\n\nexport type RadioProps = Omit<InputHTMLAttributes<HTMLInputElement>, \"size\" | \"value\" | \"type\" | \"defaultChecked\"> & {\n  ref?: Ref<HTMLInputElement>;\n  /** 그룹 안에서 이 항목을 식별하는 값입니다. */\n  value: string;\n  /** 선택 상태입니다. 보통 그룹이 관리합니다. */\n  checked?: boolean;\n  /** Control 크기와 폰트 크기를 결정합니다. */\n  size?: RadioSize;\n  /** 테두리를 오류 색으로 바꿉니다. */\n  invalid?: boolean;\n  /** 조작할 수 없게 만듭니다. */\n  disabled?: boolean;\n  /** 라벨 아래에 놓이는 보충 설명입니다. */\n  description?: ReactNode;\n  /** 라벨이 여러 줄일 때 Control 을 첫 줄에 맞춥니다 (`--align_top`). description 이 있으면 자동으로 켜집니다. */\n  alignTop?: boolean;\n};\n\n/** 그룹 · Field · 단독 사용을 하나로 정리한 항목 상태 */\nfunction useRadioItem({ value, checked, disabled, invalid, required, name, id }: {\n  value: string;\n  checked: boolean | undefined;\n  disabled: boolean;\n  invalid: boolean;\n  required: boolean | undefined;\n  name: string | undefined;\n  id: string | undefined;\n}) {\n  const group = useRadioGroupContext();\n  const fieldContext = useFieldControl();\n  // 그룹 안의 항목은 Field 의 controlId 를 쓰지 않습니다 (id 가 겹침). Field 배선은 그룹이 받습니다.\n  const field = group ? null : fieldContext;\n  const isChecked = group ? group.value === value : !!checked;\n  return {\n    group,\n    checked: isChecked,\n    state: isChecked ? \"checked\" : \"unchecked\",\n    disabled: disabled || !!group?.disabled || !!field?.disabled,\n    invalid: invalid || !!group?.invalid || !!field?.invalid,\n    required: required ?? (group?.required || field?.required || undefined),\n    name: name ?? group?.name,\n    id: id ?? field?.controlId,\n    describedBy: field?.describedBy,\n  } as const;\n}\n\nfunction layoutText(children: ReactNode, description: ReactNode) {\n  const label: ReactNode[] = [];\n  const descriptions: ReactNode[] = [];\n  Children.forEach(children, (child) => {\n    if (child === null || child === undefined || child === false) return;\n    if (isValidElement(child) && child.type === RadioDescription) descriptions.push(child);\n    else label.push(child);\n  });\n  const labelNode =\n    label.length === 1 && isValidElement(label[0]) && label[0].type === RadioLabel ? label[0] : label.length ? <span>{label}</span> : null;\n  const hasDescription = descriptions.length > 0 || (description !== undefined && description !== null);\n  if (!hasDescription) return { text: labelNode, hasDescription };\n  return {\n    hasDescription,\n    text: (\n      <span className={`${B}__text`}>\n        {labelNode}\n        {descriptions}\n        {description !== undefined && description !== null && <span className={`${B}__description`}>{description}</span>}\n      </span>\n    ),\n  };\n}\n\ntype RadioBodyProps = RadioProps & { element: \"label\" | \"span\"; forceAlignTop?: boolean };\n\n/** `atom-radio` 한 줄. 단독이면 `<label>`, Card 안이면 `<span>` (label 중첩 금지) */\nfunction RadioBody({\n  element,\n  forceAlignTop,\n  ref,\n  value,\n  checked,\n  size = \"medium\",\n  invalid = false,\n  disabled = false,\n  description,\n  alignTop,\n  name,\n  id,\n  required,\n  className,\n  children,\n  onChange,\n  readOnly,\n  \"aria-describedby\": ariaDescribedBy,\n  ...rest\n}: RadioBodyProps) {\n  const item = useRadioItem({ value, checked, disabled, invalid, required, name, id });\n  const { text, hasDescription } = layoutText(children, description);\n  const Element = element;\n  return (\n    <RadioContext value={true}>\n      <Element\n        className={cn(\n          B,\n          mod(B, \"size\", size, \"medium\"),\n          (forceAlignTop || (alignTop ?? hasDescription)) && mod(B, \"align\", \"top\"),\n          className,\n        )}\n        data-state={item.state}\n        data-invalid={item.invalid ? \"\" : undefined}\n        data-disabled={item.disabled ? \"\" : undefined}\n      >\n        <input\n          {...rest}\n          ref={ref}\n          className={`${B}__input`}\n          type=\"radio\"\n          id={item.id}\n          name={item.name}\n          value={value}\n          required={item.required}\n          checked={item.checked}\n          disabled={item.disabled}\n          readOnly={readOnly ?? (!item.group && !onChange ? true : undefined)}\n          aria-invalid={item.invalid || undefined}\n          aria-describedby={cn(ariaDescribedBy, item.describedBy) || undefined}\n          onChange={(e) => {\n            onChange?.(e);\n            if (e.currentTarget.checked) item.group?.select(value);\n          }}\n        />\n        <span className={`${B}__control`} />\n        {text}\n      </Element>\n    </RadioContext>\n  );\n}\n\n/** 배타적인 선택지 하나. `<RadioGroup>` 안에서 씁니다 (Radio 는 혼자 쓰이지 않습니다). className 은 바깥 `<label>` 에 붙습니다. */\nfunction RadioRoot({ size = \"medium\", invalid = false, disabled = false, ...props }: RadioProps) {\n  return <RadioBody {...props} size={size} invalid={invalid} disabled={disabled} element=\"label\" />;\n}\n\n/** 라벨 텍스트 (`<span>`). Description 과 함께 쓸 때만 필요합니다. */\nfunction RadioLabel({ className, ...rest }: HTMLAttributes<HTMLSpanElement>) {\n  useRadioContext(\"Radio.Label\");\n  return <span {...rest} className={className} />;\n}\n\n/** 라벨 아래 보충 설명 (`atom-radio__description`). */\nfunction RadioDescription({ className, ...rest }: HTMLAttributes<HTMLSpanElement>) {\n  useRadioContext(\"Radio.Description\");\n  return <span {...rest} className={cn(`${B}__description`, className)} />;\n}\n\nexport const Radio = Object.assign(RadioRoot, { Label: RadioLabel, Description: RadioDescription });\n\nexport type RadioCardProps = RadioProps & {\n  /** 바깥 카드(`<label class=\"atom-radio-card\">`)에 줄 className 입니다. */\n  cardClassName?: string;\n};\n\n/**\n * Card 변형 (`atom-radio-card`). 선택지마다 설명이 붙어 길어질 때 경계를 명확히 하고 터치 영역을 넓힙니다.\n * 카드 전체가 `<label>` 이고, 안쪽 Radio 는 `<span class=\"atom-radio atom-radio--align_top\">` 입니다.\n */\nexport function RadioCard({ cardClassName, size = \"medium\", invalid = false, disabled = false, ...props }: RadioCardProps) {\n  const item = useRadioItem({\n    value: props.value,\n    checked: props.checked,\n    disabled,\n    invalid,\n    required: props.required,\n    name: props.name,\n    id: props.id,\n  });\n  return (\n    <label\n      className={cn(C, cardClassName)}\n      data-state={item.state}\n      data-disabled={item.disabled ? \"\" : undefined}\n    >\n      <RadioBody {...props} size={size} invalid={invalid} disabled={disabled} element=\"span\" forceAlignTop />\n    </label>\n  );\n}\n\nexport type RadioGroupProps = Omit<HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 세로(기본) 또는 가로 배치입니다. 가로는 선택지가 2~3개이고 라벨이 아주 짧을 때만 씁니다. */\n  layout?: RadioGroupLayout;\n  /** 선택된 항목의 value 입니다. */\n  value?: string;\n  /** uncontrolled 로 쓸 때의 처음 선택 값입니다. 가장 흔한 선택지를 기본으로 켜둡니다. */\n  defaultValue?: string;\n  /** 선택 값이 바뀔 때 호출됩니다. */\n  onValueChange?: (value: string) => void;\n  /** 안의 `<input type=\"radio\">` 들이 공유할 name 입니다. 생략하면 자동으로 만듭니다. */\n  name?: string;\n  /** 그룹 전체를 조작할 수 없게 만듭니다. */\n  disabled?: boolean;\n  /** 그룹 전체를 오류 상태로 표시합니다. */\n  invalid?: boolean;\n  /** 선택이 필수입니다. */\n  required?: boolean;\n};\n\n/**\n * 배타 선택 묶음 (`atom-radio-group`, `role=\"radiogroup\"`). `aria-label` / `aria-labelledby` 로 \"무엇을 고르는 목록인지\" 를 붙입니다.\n * 같은 name 의 실제 radio 이므로 그룹 전체가 하나의 탭 정거장이고, 방향키로 이동하며 선택이 바뀝니다.\n */\nexport function RadioGroup({\n  ref,\n  layout = \"vertical\",\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  name,\n  disabled = false,\n  invalid = false,\n  required = false,\n  className,\n  children,\n  ...rest\n}: RadioGroupProps) {\n  const field = useFieldControl();\n  const autoName = useId();\n  const [value, setValue] = useControllableState<string | undefined>({\n    value: valueProp,\n    defaultValue,\n    onChange: (next) => next !== undefined && onValueChange?.(next),\n  });\n  const isInvalid = invalid || !!field?.invalid;\n  const isRequired = required || !!field?.required;\n  return (\n    <RadioGroupContext\n      value={{\n        name: name ?? autoName,\n        value,\n        select: setValue,\n        disabled: disabled || !!field?.disabled,\n        invalid: isInvalid,\n        required: isRequired,\n      }}\n    >\n      <div\n        role=\"radiogroup\"\n        aria-labelledby={rest[\"aria-label\"] ? undefined : field?.labelId}\n        aria-describedby={field?.describedBy}\n        aria-invalid={isInvalid || undefined}\n        aria-required={isRequired || undefined}\n        {...rest}\n        ref={ref}\n        id={rest.id ?? field?.controlId}\n        className={cn(G, mod(G, \"layout\", layout, \"vertical\"), className)}\n      >\n        {children}\n      </div>\n    </RadioGroupContext>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/radio.tsx"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/radio.css",
      "content": "/* ============================================================================\n   Element — Radio\n   ----------------------------------------------------------------------------\n   atom-radio\n     --size_{small|medium|large}             medium is the default\n     --align_top\n     [data-state=\"unchecked|checked\"]  [data-invalid]  [data-disabled]\n     __control  __label  __description\n\n   atom-radio-group  (+ --layout_horizontal)\n   atom-radio-card                            the bordered, tappable variant\n   ========================================================================== */\n\n.atom-radio {\n  display: inline-flex;\n  align-items: center;\n  gap: 10px;\n  font-size: 15px;\n  line-height: 1.45;\n  color: var(--atom-fg-neutral);\n  cursor: pointer;\n}\n\n.atom-radio--align_top {\n  align-items: flex-start;\n}\n\n.atom-radio__input {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  opacity: 0;\n  pointer-events: none;\n}\n\n.atom-radio__control {\n  position: relative;\n  width: 22px;\n  height: 22px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1.5px var(--atom-border-default);\n  transition: box-shadow var(--atom-duration-fast) ease;\n}\n\n.atom-radio--align_top .atom-radio__control {\n  margin-top: 1px;\n}\n\n.atom-radio__control::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  margin: auto;\n  width: 8px;\n  height: 8px;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-layer);\n  opacity: 0;\n  transition: opacity var(--atom-duration-fast) ease;\n}\n\n.atom-radio__text {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  min-width: 0;\n}\n\n.atom-radio__description {\n  font-size: 13px;\n  color: var(--atom-fg-neutral-muted);\n  word-break: keep-all;\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-radio--size_small {\n  gap: 8px;\n  font-size: 14px;\n}\n\n.atom-radio--size_small .atom-radio__control {\n  width: 18px;\n  height: 18px;\n}\n\n.atom-radio--size_small .atom-radio__control::after {\n  width: 6px;\n  height: 6px;\n}\n\n.atom-radio--size_large {\n  gap: 12px;\n  font-size: 16px;\n}\n\n.atom-radio--size_large .atom-radio__control {\n  width: 26px;\n  height: 26px;\n}\n\n.atom-radio--size_large .atom-radio__control::after {\n  width: 10px;\n  height: 10px;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-radio[data-state=\"checked\"] .atom-radio__control,\n.atom-radio__input:checked + .atom-radio__control {\n  box-shadow: inset 0 0 0 7px var(--atom-bg-neutral-solid);\n}\n\n.atom-radio--size_small[data-state=\"checked\"] .atom-radio__control {\n  box-shadow: inset 0 0 0 6px var(--atom-bg-neutral-solid);\n}\n\n.atom-radio--size_large[data-state=\"checked\"] .atom-radio__control {\n  box-shadow: inset 0 0 0 8px var(--atom-bg-neutral-solid);\n}\n\n.atom-radio[data-state=\"checked\"] .atom-radio__control::after,\n.atom-radio__input:checked + .atom-radio__control::after {\n  opacity: 1;\n}\n\n.atom-radio:hover:not([data-disabled])[data-state=\"unchecked\"]\n  .atom-radio__control {\n  box-shadow: inset 0 0 0 1.5px var(--atom-fg-neutral-subtle);\n}\n\n.atom-radio__input:focus-visible + .atom-radio__control {\n  outline: 2px solid var(--atom-primary-600);\n  outline-offset: 2px;\n}\n\n.atom-radio[data-invalid][data-state=\"unchecked\"] .atom-radio__control {\n  box-shadow: inset 0 0 0 1.5px var(--atom-fg-critical);\n}\n\n.atom-radio[data-disabled] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n/* --- group --------------------------------------------------------------- */\n.atom-radio-group {\n  display: flex;\n  flex-direction: column;\n  gap: var(--atom-x4);\n}\n\n.atom-radio-group--layout_horizontal {\n  flex-direction: row;\n  flex-wrap: wrap;\n  gap: var(--atom-x5);\n}\n\n/* --- card variant -------------------------------------------------------- */\n.atom-radio-card {\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x3);\n  width: 100%;\n  padding: var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1px var(--atom-border-subtle);\n  cursor: pointer;\n  transition: box-shadow var(--atom-duration-fast) ease;\n}\n\n.atom-radio-card[data-state=\"checked\"] {\n  box-shadow: inset 0 0 0 2px var(--atom-border-strong);\n}\n\n.atom-radio-card[data-disabled] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n.atom-radio-card .atom-radio {\n  flex: 1;\n  min-width: 0;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/radio.css"
    }
  ],
  "categories": [
    "action-input"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "element",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "RadioCard",
      "RadioGroup",
      "Radio",
      "Radio.Label",
      "Radio.Description"
    ],
    "docs": "poc/design-system/components/radio.md"
  }
}
