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-18
08-14
08-18
08-18
08-18
08-18
File name
Commit message
Commit date
"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";
/**
* 네이티브 `<input>`이 받는 것을 그대로 넘겨받는다 — 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<HTMLInputElement>) => void;
onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void;
label?: string;
/** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxSelect`와 같은 규약). */
requirement?: FoxFormLabelRequirement;
/** 헬퍼 메시지. 색은 상태를 따라간다. */
message?: string;
/** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */
messageIcon?: ReactNode;
/** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */
icon?: ReactNode;
/** 입력 오른쪽에 붙는 글자. 아이콘 슬롯과 달리 크기가 글자에 맞춰진다. */
suffix?: ReactNode;
/** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */
clearable?: boolean;
clearLabel?: string;
/** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */
invalid?: boolean;
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
state?: FoxInputState;
/**
* 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다.
* `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다.
*
* 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다.
*/
validators?: FoxValidator[];
/** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */
validationMessages?: FoxValidationMessages;
/**
* 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라
* 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와
* 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다).
*/
validateOn?: "blur" | "change";
/** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */
onValidationChange?: (errors: FoxValidationErrors | null) => void;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
const SIZE_CLASS: Record<FoxInputSize, string> = {
lg: "fox-input--lg",
md: "fox-input--md",
sm: "fox-input--sm",
};
/**
* @fox 입력 필드. 네이티브 `<input>`을 감싸므로 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<HTMLInputElement>(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;
// 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다.
// 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다).
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;
// `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에
// 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다).
// 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이
// 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다.
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<HTMLInputElement>) => {
if (value === undefined) {
setTypedIn(event.target.value.length > 0);
}
setDraft(event.target.value);
onChange?.(event.target.value, event);
};
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
setTouched(true);
rest.onBlur?.(event);
};
const handleInput = (event: FormEvent<HTMLInputElement>) => {
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 (
<div
className={cx("fox-input", SIZE_CLASS[size], className)}
data-state={state}
>
{label && (
<FoxFormLabel htmlFor={fieldId} requirement={requirement}>
{label}
</FoxFormLabel>
)}
<div className="fox-input__box">
<input
{...rest}
ref={attachField}
id={fieldId}
className="fox-input__field"
value={value}
defaultValue={defaultValue}
aria-invalid={errored || undefined}
aria-describedby={shownMessage ? messageId : undefined}
onChange={handleChange}
onInput={handleInput}
onBlur={handleBlur}
/>
{clearable && hasValue && (
<button
type="button"
className="fox-input__clear"
aria-label={clearLabel}
onClick={handleClear}
>
{/* 시안의 XCircle. @fox에 아이콘 세트가 없어 같은 모양을 직접 그린다. */}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.25" />
<path d="M6 6l4 4M10 6l-4 4" />
</svg>
</button>
)}
{suffix && <span className="fox-input__suffix">{suffix}</span>}
{icon && (
<span className="fox-input__icon" aria-hidden="true">
{icon}
</span>
)}
</div>
{shownMessage && (
<p className="fox-input__message" id={messageId}>
{messageIcon && (
<span className="fox-input__message-icon" aria-hidden="true">
{messageIcon}
</span>
)}
{shownMessage}
</p>
)}
</div>
);
}