File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-14
08-18
08-18
File name
Commit message
Commit date
"use client";
import { type ChangeEvent, type ComponentPropsWithRef, type ReactNode } from "react";
import { cx } from "../../utils";
import { FoxRadioIcon } from "../fox-radio-icon";
export type FoxRadioSize = "lg" | "md";
/**
* 네이티브 `<input type="radio">`가 받는 것을 그대로 넘겨받는다 — name·value·checked·
* defaultChecked·disabled·required·form 등. 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다.
*/
type NativeRadioProps = Omit<
ComponentPropsWithRef<"input">,
"type" | "size" | "className" | "children" | "hidden" | "onChange"
>;
export interface FoxRadioProps extends NativeRadioProps {
size?: FoxRadioSize;
label: ReactNode;
/** 라벨 아래 보조 설명. 넘기지 않으면 영역을 렌더링하지 않는다. */
description?: ReactNode;
/**
* 이 칸이 선택될 때 부른다. 값이 첫 인자다 — 네이티브 `onChange`를 `Omit`으로 걷어내고
* 그 자리를 대신한다(`FoxInput`과 같은 방식).
*/
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
/**
* 글자를 눈에서만 감춘다 — 표의 선택 열처럼 네모(동그라미)만 필요한 자리에 쓴다.
* DOM에는 남으므로 스크린리더는 그대로 읽는다. `description`도 함께 감춰진다.
*/
hideLabel?: boolean;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
const SIZE_CLASS: Record<FoxRadioSize, string> = {
lg: "fox-radio--lg",
md: "fox-radio--md",
};
/**
* @fox 라디오 버튼 한 칸. 상태를 갖지 않는다 — 선택 여부는 네이티브
* `<input type="radio">`가 소유한다.
*
* `name`이 같은 라디오끼리는 브라우저가 그룹으로 묶어 화살표 이동·단일 선택·폼 전송을
* 처리한다. 그래서 별도 group 컴포넌트를 두지 않는다.
*
* 루트가 `<label>`이라 라벨이나 설명을 눌러도 선택된다. 동그란 표시는 CSS가 input의
* `:checked`·`:disabled`를 보고 그리므로, 비제어로 써도 모양이 어긋나지 않는다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxRadio({
size = "lg",
label,
description,
onChange,
hideLabel = false,
hidden = false,
className,
...rest
}: FoxRadioProps) {
if (hidden) {
return null;
}
// 라디오는 켜질 때만 change가 뛴다(꺼질 때는 안 뛴다). 그래도 명시적으로 걸러 두면
// 브라우저가 달라져도 "선택될 때"라는 계약이 흔들리지 않는다.
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
onChange?.(event.target.value, event);
}
};
return (
<label className={cx("fox-radio", SIZE_CLASS[size], hideLabel && "fox-radio--hide-label", className)}>
{/* 형제 선택자(`~`)가 뒤따르는 표시와 본문에 닿아야 하므로 맨 앞에 둔다. */}
<input {...rest} type="radio" className="fox-radio__input" onChange={handleChange} />
<FoxRadioIcon size={size} />
<span className="fox-radio__body">
<span className="fox-radio__label">{label}</span>
{description ? (
<span className="fox-radio__description">{description}</span>
) : null}
</span>
</label>
);
}