"use client"; import { useEffect, useId, useRef, useState, type ChangeEvent, type ClipboardEvent, type ComponentPropsWithRef, type FocusEvent, type FormEvent, type ReactNode, } from "react"; import { cx, onlyDigits } 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; /** 입력 오른쪽 아이콘. 아이콘 폰트를 넘긴다 — ``. */ icon?: ReactNode; /** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */ suffix?: ReactNode; /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ clearable?: boolean; clearLabel?: string; /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ invalid?: boolean; /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ state?: FoxInputState; /** * 숫자만 받는다. 타이핑·붙여넣기·끌어놓기·IME 어느 경로로 들어와도 숫자가 아닌 글자는 * 그 자리에서 버리고 커서를 지킨다. `value`·`defaultValue`에 하이픈이 섞여 와도 숫자만 * 보인다. `inputMode`를 따로 주지 않으면 `numeric`이 된다. */ digitsOnly?: boolean; 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, digitsOnly = false, validators, validationMessages, validateOn = "blur", onValidationChange, hidden = false, className, id, value, defaultValue, ref, ...rest }: FoxInputProps) { const autoId = useId(); const fieldValue = digitsOnly && value !== undefined ? onlyDigits(String(value)) : value; const fieldDefaultValue = digitsOnly && defaultValue !== undefined ? onlyDigits(String(defaultValue)) : defaultValue; // 지우기 버튼은 값이 있을 때만 뜬다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다. const [typedIn, setTypedIn] = useState(() => String(fieldDefaultValue ?? "").length > 0); const fieldRef = useRef(null); const [draft, setDraft] = useState(() => String(fieldDefaultValue ?? "")); 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; } }; // 숫자 전용이면 DOM 값을 그 자리에서 고쳐 쓴다. 커서 앞에서 지운 글자 수만큼 커서를 당겨 // 제자리를 지킨다 — 값을 통째로 바꾸면 브라우저가 커서를 끝으로 보내기 때문이다. const readFieldValue = (field: HTMLInputElement): string => { const raw = field.value; if (!digitsOnly) { return raw; } const clean = onlyDigits(raw); if (clean === raw) { return raw; } const caret = field.selectionStart; field.value = clean; if (caret !== null) { const position = onlyDigits(raw.slice(0, caret)).length; field.setSelectionRange(position, position); } return clean; }; const handleChange = (event: ChangeEvent) => { const next = readFieldValue(event.target); if (value === undefined) { setTypedIn(next.length > 0); } setDraft(next); onChange?.(next, event); }; const handleBlur = (event: FocusEvent) => { setTouched(true); rest.onBlur?.(event); }; // 숫자 전용 붙여넣기는 숫자만 추려 직접 넣는다. 브라우저는 maxLength를 붙여넣은 원문에 먼저 // 적용해 `123-45-67890`의 뒷자리를 잘라 버리므로 기본 동작에 맡길 수 없다. const handlePaste = (event: ClipboardEvent) => { rest.onPaste?.(event); if (!digitsOnly || event.defaultPrevented) { return; } event.preventDefault(); const digits = onlyDigits(event.clipboardData.getData("text")); if (digits === "") { return; } const field = event.currentTarget; if (document.execCommand("insertText", false, digits)) { return; } // execCommand가 없는 환경 — 값을 직접 넣고 input 이벤트를 흘린다. React가 노드에 심어 둔 // 값 추적기를 피하려면 프로토타입의 setter를 써야 onChange가 돈다. const start = field.selectionStart ?? field.value.length; const end = field.selectionEnd ?? start; let next = field.value.slice(0, start) + digits + field.value.slice(end); if (field.maxLength > 0) { next = next.slice(0, field.maxLength); } const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; setValue?.call(field, next); const caret = Math.min(start + digits.length, next.length); field.setSelectionRange(caret, caret); field.dispatchEvent(new Event("input", { bubbles: true })); }; const handleInput = (event: FormEvent) => { onInput?.(readFieldValue(event.target as HTMLInputElement), event); }; const handleClear = () => { const field = fieldRef.current; if (field) { // 비제어 입력은 DOM을 직접 비운다. 제어 입력이면 onChange를 받은 호출부가 비운다. field.value = ""; field.focus(); } setTypedIn(false); setDraft(""); onChange?.(""); }; return ( {label && ( {label} )} {clearable && hasValue && ( )} {suffix && {suffix}} {icon && ( {icon} )} {shownMessage && ( {messageIcon && ( {messageIcon} )} {shownMessage} )} ); }
{messageIcon && ( {messageIcon} )} {shownMessage}