File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"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";
/**
* 네이티브 `<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;
/** 헬퍼 메시지. 색은 상태를 따라간다. */
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<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"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
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<HTMLInputElement>(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<HTMLInputElement>) => {
if (value === undefined) {
setTypedIn(event.target.value.length > 0);
}
onChange?.(event.target.value, 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);
onChange?.("");
};
return (
<div
className={cx("fox-input", SIZE_CLASS[size], className)}
data-state={state}
>
{label && (
<label className="fox-input__label" htmlFor={fieldId}>
{label}
</label>
)}
<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={message ? messageId : undefined}
onChange={handleChange}
onInput={handleInput}
/>
{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>
)}
{icon && (
<span className="fox-input__icon" aria-hidden="true">
{icon}
</span>
)}
</div>
{message && (
<p className="fox-input__message" id={messageId}>
{messageIcon && (
<span className="fox-input__message-icon" aria-hidden="true">
{messageIcon}
</span>
)}
{message}
</p>
)}
</div>
);
}