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,
useState,
type ChangeEvent,
type ComponentPropsWithRef,
type FormEvent,
type ReactNode,
} from "react";
import { cx } from "../../utils";
export type FoxTextAreaState = "default" | "focused" | "completed" | "error" | "disabled" | "view";
/**
* 네이티브 `<textarea>`가 받는 것을 그대로 넘겨받는다 — rows·cols·maxLength·name·
* required·readOnly·disabled·autoComplete 등. 이름이 겹치거나 의미가 다른 것만 걷어낸다.
*/
type NativeTextAreaProps = Omit<
ComponentPropsWithRef<"textarea">,
"onChange" | "onInput" | "className" | "children" | "hidden"
>;
export interface FoxTextAreaProps extends NativeTextAreaProps {
/** 값이 첫 인자다. */
onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
onInput?: (value: string, event: FormEvent<HTMLTextAreaElement>) => void;
label?: string;
/** 헬퍼 메시지. 색은 상태를 따라간다. */
message?: string;
/** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */
messageIcon?: ReactNode;
/** 글자 수를 오른쪽 아래에 보여준다. `maxLength`가 있으면 `현재/최대`로 적는다. */
counter?: boolean;
/** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼·카운터 색이 바뀐다. */
invalid?: boolean;
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
state?: FoxTextAreaState;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
/**
* @fox 여러 줄 입력. 시안에 크기 축이 없어 높이는 `rows`가 정한다(기본 5줄).
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxTextArea({
onChange,
onInput,
label,
message,
messageIcon,
counter = false,
invalid = false,
state,
hidden = false,
className,
id,
rows = 5,
value,
defaultValue,
maxLength,
...rest
}: FoxTextAreaProps) {
const autoId = useId();
// 카운터는 현재 길이를 알아야 한다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다.
const [typedLength, setTypedLength] = useState(() => String(defaultValue ?? "").length);
if (hidden) {
return null;
}
const fieldId = id ?? `${autoId}-field`;
const messageId = `${autoId}-message`;
const length = value !== undefined ? String(value).length : typedLength;
const errored = invalid || state === "error";
const showFooter = Boolean(message) || counter;
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
if (value === undefined) {
setTypedLength(event.target.value.length);
}
onChange?.(event.target.value, event);
};
const handleInput = (event: FormEvent<HTMLTextAreaElement>) => {
onInput?.((event.target as HTMLTextAreaElement).value, event);
};
return (
<div className={cx("fox-text-area", className)} data-state={state}>
{label && (
<label className="fox-text-area__label" htmlFor={fieldId}>
{label}
</label>
)}
<div className="fox-text-area__box">
<textarea
{...rest}
id={fieldId}
className="fox-text-area__field"
rows={rows}
value={value}
defaultValue={defaultValue}
maxLength={maxLength}
aria-invalid={errored || undefined}
aria-describedby={message ? messageId : undefined}
onChange={handleChange}
onInput={handleInput}
/>
</div>
{showFooter && (
<div className="fox-text-area__footer">
{message && (
<p className="fox-text-area__message" id={messageId}>
{messageIcon && (
<span className="fox-text-area__message-icon" aria-hidden="true">
{messageIcon}
</span>
)}
{message}
</p>
)}
{counter && (
<p className="fox-text-area__counter">
<span className="fox-text-area__counter-current">{length}</span>
{maxLength !== undefined && `/${maxLength}`}
</p>
)}
</div>
)}
</div>
);
}