"use client"; import { useEffect, useId, useRef, useState, type ChangeEvent, type ComponentPropsWithRef, type FocusEvent, type FormEvent, type ReactNode, } from "react"; import { cx } from "../../utils"; import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label"; import { foxValidationMessage, foxValidators, type FoxValidationErrors, type FoxValidationMessages, type FoxValidator, } from "../../validation"; 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; requirement?: FoxFormLabelRequirement; /** 헬퍼 메시지. 색은 상태를 따라간다. */ message?: string; /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ messageIcon?: ReactNode; /** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ icon?: ReactNode; /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */ suffix?: ReactNode; /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ clearable?: boolean; clearLabel?: string; /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ invalid?: boolean; /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ state?: FoxInputState; validators?: FoxValidator[]; validationMessages?: FoxValidationMessages; validateOn?: "blur" | "change"; onValidationChange?: (errors: FoxValidationErrors | null) => void; /** 참이면 렌더하지 않는다(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"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ function errorKey(errors: FoxValidationErrors | null): string { return errors ? Object.keys(errors).sort().join("|") : ""; } export function FoxInput({ size = "md", onChange, onInput, label, requirement, message, messageIcon, icon, suffix, clearable = false, clearLabel = "입력 지우기", invalid = false, state, validators, validationMessages, validateOn = "blur", onValidationChange, 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); const [draft, setDraft] = useState(() => String(defaultValue ?? "")); const [touched, setTouched] = useState(false); const validationErrors = validators && validators.length > 0 ? foxValidators.compose(validators)( value !== undefined ? String(value) : draft ) : null; const errorsKey = errorKey(validationErrors); const latestRef = useRef({ errors: validationErrors, notify: onValidationChange }); useEffect(() => { latestRef.current = { errors: validationErrors, notify: onValidationChange }; }); useEffect(() => { latestRef.current.notify?.(latestRef.current.errors); }, [errorsKey]); if (hidden) { return null; } const fieldId = id ?? `${autoId}-field`; const messageId = `${autoId}-message`; const hasValue = value !== undefined ? String(value).length > 0 : typedIn; const showValidation = validateOn === "change" || touched; const validationText = showValidation ? foxValidationMessage(validationErrors, validationMessages) : undefined; const callerErrored = invalid || state === "error"; const shownMessage = callerErrored ? message : (validationText ?? message); const errored = callerErrored || validationText !== undefined; // 호출부 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); } setDraft(event.target.value); onChange?.(event.target.value, event); }; const handleBlur = (event: FocusEvent) => { setTouched(true); rest.onBlur?.(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); setDraft(""); onChange?.(""); }; return (
{label && ( {label} )}
{clearable && hasValue && (