"use client"; import { useId, useRef, useState, type ChangeEvent, type ComponentPropsWithRef, type FormEvent, type ReactNode, } from "react"; import { cx } from "../../utils"; export type FoxInputSize = "lg" | "md" | "sm"; export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; /** * 네이티브 ``이 받는 것을 그대로 넘겨받는다 — type·name·autoComplete·maxLength· * min·max·step·pattern·inputMode·required·readOnly·disabled 등. 이름이 겹치거나 의미가 * 다른 것만 걷어내고 아래에서 다시 정의한다. */ type NativeInputProps = Omit< ComponentPropsWithRef<"input">, "size" | "onChange" | "onInput" | "className" | "children" | "hidden" >; export interface FoxInputProps extends NativeInputProps { size?: FoxInputSize; /** 값이 첫 인자다. 지우기 버튼은 이벤트 없이 빈 문자열로 호출한다. */ onChange?: (value: string, event?: ChangeEvent) => void; onInput?: (value: string, event: FormEvent) => void; label?: string; /** 헬퍼 메시지. 색은 상태를 따라간다. */ message?: string; /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ messageIcon?: ReactNode; /** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ icon?: ReactNode; /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ clearable?: boolean; clearLabel?: string; /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ invalid?: boolean; /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ state?: FoxInputState; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 배치 조정용. */ className?: string; } const SIZE_CLASS: Record = { lg: "fox-input--lg", md: "fox-input--md", sm: "fox-input--sm", }; /** * @fox 입력 필드. 네이티브 ``을 감싸므로 date·password·email 등 모든 타입을 받는다. * * date 타입은 브라우저가 그리는 달력 아이콘과 피커를 그대로 쓴다 — 시안의 달력 아이콘으로 * 바꾸지 않는다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxInput({ size = "md", onChange, onInput, label, message, messageIcon, icon, clearable = false, clearLabel = "입력 지우기", invalid = false, state, hidden = false, className, id, value, defaultValue, ref, ...rest }: FoxInputProps) { const autoId = useId(); // 지우기 버튼은 값이 있을 때만 뜬다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다. const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); const fieldRef = useRef(null); if (hidden) { return null; } const fieldId = id ?? `${autoId}-field`; const messageId = `${autoId}-message`; const hasValue = value !== undefined ? String(value).length > 0 : typedIn; const errored = invalid || state === "error"; // 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다. const attachField = (node: HTMLInputElement | null) => { fieldRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { (ref as { current: HTMLInputElement | null }).current = node; } }; const handleChange = (event: ChangeEvent) => { if (value === undefined) { setTypedIn(event.target.value.length > 0); } onChange?.(event.target.value, event); }; const handleInput = (event: FormEvent) => { onInput?.((event.target as HTMLInputElement).value, event); }; const handleClear = () => { const field = fieldRef.current; if (field) { // 비제어 입력은 DOM을 직접 비운다. 제어 입력이면 onChange를 받은 호출부가 비운다. field.value = ""; field.focus(); } setTypedIn(false); onChange?.(""); }; return (
{label && ( )}
{clearable && hasValue && ( )} {icon && ( )}
{message && (

{messageIcon && ( )} {message}

)}
); }