임동욱 임동욱 08-13
feat: FoxTextArea 추가
Figma textarea(135:1655)의 6상태를 옮긴다. 시안에 크기 축이 없어 상자는
하나뿐이고 높이는 rows가 정한다(기본 5줄, 시안과 같음).

상태 판정은 FoxInput과 같은 두 갈래다 — 기본은 브라우저가 판단하고(포커스·
값·네이티브 속성·aria-invalid), data-state를 주면 그 모양으로 고정된다.
상태 매트릭스도 FoxInput과 동일해 규칙을 그대로 옮겼다.

하단은 왼쪽 헬퍼와 오른쪽 글자수 카운터로 나뉜다. 카운터는 현재 길이만 굵고
색이 다르며, 오류일 때 빨개진다(시안의 error 변형이 100/100을 빨갛게 둔다).
maxLength가 없으면 개수만 적는다. 비제어 입력의 길이는 직접 따라간다.

리사이즈는 시안에 신호가 없어 폭이 흔들리지 않게 세로만 허용한다.

Co-Authored-By: Claude Opus 5 
@037705883a4caf1a31b54abb18e098334c8c6401
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -109,6 +109,19 @@
   </div>
   <p class="fox-input__message"><!-- 헬퍼 (선택) --></p>
 </div>
+
+<div class="fox-text-area">
+  <label class="fox-text-area__label" for="memo">레이블</label>
+  <div class="fox-text-area__box">
+    <textarea class="fox-text-area__field" id="memo" rows="5" placeholder="내용을 입력하세요."></textarea>
+  </div>
+  <div class="fox-text-area__footer">
+    <p class="fox-text-area__message"><!-- 헬퍼 (선택) --></p>
+    <p class="fox-text-area__counter">
+      <span class="fox-text-area__counter-current">0</span>/100
+    </p>
+  </div>
+</div>
 ```
 
 - 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). 앵커에는 `disabled`가
@@ -121,9 +134,10 @@
 - 버튼 패널은 그룹이 여럿이면 양끝으로, 하나면 가운데로 둡니다 — 자식 수는 CSS `:only-child`가
   판단하므로 따로 표시할 게 없습니다. 패널 안에서는 그룹 간격이 넓어지고 버튼에 최소 폭이
   붙으며, 모바일에서는 패널·그룹이 모두 세로로 쌓입니다.
-- 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`, 비활성·읽기전용은
-  네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 `data-state`를 줍니다
-  (`default` `focused` `completed` `error` `disabled` `view`).
+- 입력과 여러 줄 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`,
+  비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에
+  `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`).
+- 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다.
 - 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다.
 
 `core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도
 
@fox/core/components/fox-text-area/fox-text-area.tsx (added)
+++ @fox/core/components/fox-text-area/fox-text-area.tsx
@@ -0,0 +1,142 @@
+"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>
+  );
+}
 
@fox/core/components/fox-text-area/index.ts (added)
+++ @fox/core/components/fox-text-area/index.ts
@@ -0,0 +1,1 @@
+export { FoxTextArea, type FoxTextAreaProps, type FoxTextAreaState } from "./fox-text-area";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -5,3 +5,4 @@
 export * from "./fox-button-group";
 export * from "./fox-button-panel";
 export * from "./fox-input";
+export * from "./fox-text-area";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -8,6 +8,7 @@
 import { FoxButtonGroup } from "../core/components/fox-button-group";
 import { FoxButtonPanel } from "../core/components/fox-button-panel";
 import { FoxInput, type FoxInputState } from "../core/components/fox-input";
+import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -81,6 +82,37 @@
           label="오류 (invalid)"
           placeholder="내용을 입력하세요."
           message="필수 항목입니다"
+          invalid
+        />
+      </InputStage>
+    </>
+  );
+}
+
+// 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다.
+function TextAreaLiveDemo() {
+  const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다.");
+  return (
+    <>
+      <InputStage>
+        <FoxTextArea
+          label="자동 상태 · 카운터"
+          placeholder="내용을 입력하세요."
+          message="포커스하면 도움말이 파랗게 바뀝니다"
+          value={text}
+          onChange={setText}
+          maxLength={100}
+          counter
+        />
+      </InputStage>
+      <InputStage>
+        <FoxTextArea
+          label="오류 (invalid)"
+          placeholder="내용을 입력하세요."
+          message="100자를 넘길 수 없습니다"
+          defaultValue={"x".repeat(100)}
+          maxLength={100}
+          counter
           invalid
         />
       </InputStage>
@@ -649,4 +681,82 @@
       },
     ],
   },
+  {
+    id: "fox-text-area",
+    name: "FoxTextArea",
+    description:
+      "Figma 시안(textarea 135:1655)을 옮겼습니다. 시안에 크기 축이 없어 상자는 하나뿐이고 높이는 rows가 정합니다(기본 5줄, 시안과 같음). 상태 판정은 FoxInput과 같은 방식입니다 — 기본은 브라우저가 판단하고 state를 주면 고정됩니다. 하단은 왼쪽 헬퍼와 오른쪽 글자수 카운터로 나뉘고, 오류일 때 현재 글자수가 빨개집니다.",
+    variants: [
+      {
+        label: "직접 조작 — 입력·포커스·카운터",
+        node: <TextAreaLiveDemo />,
+      },
+      {
+        label: "상태 고정 (state) — 시안 6종을 그대로 비교",
+        node: (
+          <>
+            {(
+              ["default", "focused", "completed", "error", "disabled", "view"] as FoxTextAreaState[]
+            ).map((state) => (
+              <InputStage key={state}>
+                <FoxTextArea
+                  state={state}
+                  label={state}
+                  placeholder="내용을 입력하세요."
+                  message="힌트 메시지"
+                  defaultValue={""}
+                  maxLength={100}
+                  counter
+                />
+              </InputStage>
+            ))}
+          </>
+        ),
+      },
+      {
+        label: "줄 수 (rows)",
+        node: (
+          <>
+            {[3, 5, 8].map((rows) => (
+              <InputStage key={rows}>
+                <FoxTextArea rows={rows} label={`rows=${rows}`} placeholder="내용을 입력하세요." />
+              </InputStage>
+            ))}
+          </>
+        ),
+      },
+      {
+        label: "헬퍼·카운터는 따로 켠다",
+        node: (
+          <>
+            <InputStage>
+              <FoxTextArea label="헬퍼만" placeholder="내용을 입력하세요." message="힌트 메시지" />
+            </InputStage>
+            <InputStage>
+              <FoxTextArea label="카운터만" placeholder="내용을 입력하세요." maxLength={100} counter />
+            </InputStage>
+            <InputStage>
+              <FoxTextArea label="최대 없이 개수만" placeholder="내용을 입력하세요." counter />
+            </InputStage>
+            <InputStage>
+              <FoxTextArea label="둘 다 없음" placeholder="내용을 입력하세요." />
+            </InputStage>
+          </>
+        ),
+      },
+      {
+        label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정",
+        node: (
+          <>
+            <InputStage>
+              <FoxTextArea label="disabled" defaultValue="수정할 수 없음" disabled />
+            </InputStage>
+            <InputStage>
+              <FoxTextArea label="readOnly" defaultValue="읽기 전용" readOnly />
+            </InputStage>
+          </>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-text-area.scss (added)
+++ @fox/styles/_fox-text-area.scss
@@ -0,0 +1,240 @@
+// FoxTextArea — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                      Figma textarea (135:1655 · 섹션 391:3107)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-text-area">
+//     <label class="fox-text-area__label" for="…">레이블</label>
+//     <div class="fox-text-area__box">
+//       <textarea class="fox-text-area__field" id="…" rows="5" placeholder="…"></textarea>
+//     </div>
+//     <div class="fox-text-area__footer">
+//       <p class="fox-text-area__message">…</p>                                <!-- 선택 -->
+//       <p class="fox-text-area__counter">
+//         <span class="fox-text-area__counter-current">80</span>/100
+//       </p>                                                                   <!-- 선택 -->
+//     </div>
+//   </div>
+//
+// 상태는 FoxInput과 같은 두 갈래다.
+//   - 기본(자동): 포커스는 `:focus-within`, 비활성·읽기전용은 네이티브 속성, 오류는
+//     `aria-invalid="true"`. 시안의 completed는 default와 글자색만 다른데 그건
+//     플레이스홀더와 입력값의 네이티브 차이라 규칙이 필요 없다.
+//   - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다.
+//
+// 시안에 크기 축이 없다 — 상자 하나뿐이고 높이는 줄 수가 정한다(시안은 5줄).
+//
+// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목):
+//   - line-height(본문·헬퍼 1.5, 라벨·카운터 1) / letter-spacing: 시안 텍스트 스타일의
+//     값이나 Figma가 변수로 export하지 않는다.
+//   - box-sizing / resize / flex 관련: 구조·상호작용 규칙. 시안은 리사이즈 핸들을 보여주지
+//     않지만 폭이 흔들리지 않도록 세로만 허용한다.
+
+@use "abstracts" as fox;
+
+.fox-text-area {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+  inline-size: 100%;
+
+  &__label {
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(medium);
+    line-height: 1;
+    letter-spacing: -0.025em;
+  }
+
+  &__box {
+    box-sizing: border-box;
+    display: flex;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    padding-inline: fox.form(padding-md);
+    padding-block: fox.padding(5);
+    border: fox.border(1) solid fox.color(input-border);
+    border-radius: fox.form(radius-lg);
+    background: fox.color(input-surface);
+  }
+
+  &__field {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    margin: 0;
+    padding: 0;
+    border: none;
+    background: none;
+    resize: vertical;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+
+    &::placeholder {
+      color: fox.color(font-neutral-subtler);
+    }
+
+    // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다.
+    &:focus {
+      outline: none;
+    }
+
+    &:disabled {
+      color: fox.color(font-neutral-disabled-strong);
+      cursor: default;
+    }
+  }
+
+  // 헬퍼는 왼쪽에서 남는 폭을 먹고 카운터는 오른쪽에 붙는다.
+  &__footer {
+    display: flex;
+    gap: fox.gap(5);
+    align-items: center;
+    justify-content: flex-end;
+    inline-size: 100%;
+  }
+
+  &__message {
+    display: flex;
+    flex: 1 0 0;
+    gap: fox.number(3);
+    align-items: flex-start;
+    min-inline-size: 0;
+    margin: 0;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__message-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: flex-start;
+    padding-block-start: fox.padding(1);
+
+    > * {
+      inline-size: fox.icon(3);
+      block-size: fox.icon(3);
+    }
+  }
+
+  &__counter {
+    flex-shrink: 0;
+    margin: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1;
+    letter-spacing: -0.025em;
+    text-align: end;
+  }
+
+  // 현재 글자 수만 굵고 색이 다르다.
+  &__counter-current {
+    color: fox.color(font-theme-primary);
+    font-weight: fox.font-weight(bold);
+  }
+}
+
+// ── 상태 · 자동 ─────────────────────────────────────────────────────────────
+// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성.
+.fox-text-area:not([data-state]) {
+  .fox-text-area__box:focus-within {
+    border-width: fox.border(2);
+    border-color: fox.color(input-border-active);
+  }
+
+  .fox-text-area__box:has(.fox-text-area__field[aria-invalid="true"]) {
+    border-width: fox.border(2);
+    border-color: fox.color(input-border-error);
+  }
+
+  // 비활성도 `:read-only`에 걸리므로 떼어낸다.
+  .fox-text-area__box:has(.fox-text-area__field:read-only:not(:disabled)) {
+    border-width: fox.border(1);
+    border-color: fox.color(input-border-disabled);
+    background: fox.color(input-surface-disabled);
+  }
+
+  .fox-text-area__box:has(.fox-text-area__field:disabled) {
+    border-width: fox.border(1);
+    border-color: fox.color(input-border-disabled);
+    background: fox.color(input-surface-disabled);
+  }
+
+  &:focus-within .fox-text-area__message {
+    color: fox.color(font-system-information);
+  }
+
+  &:has(.fox-text-area__field[aria-invalid="true"]) .fox-text-area__message {
+    color: fox.color(font-system-danger);
+  }
+
+  // 시안의 오류 변형에서만 카운터 현재값이 빨갛다.
+  &:has(.fox-text-area__field[aria-invalid="true"]) .fox-text-area__counter-current {
+    color: fox.color(font-system-danger);
+  }
+}
+
+// ── 상태 · 고정 ─────────────────────────────────────────────────────────────
+// 시안의 focused·completed·error·view는 값이 들어찬 모습이라 플레이스홀더도 진하게 둔다.
+.fox-text-area[data-state="default"] .fox-text-area__box,
+.fox-text-area[data-state="completed"] .fox-text-area__box {
+  border-width: fox.border(1);
+  border-color: fox.color(input-border);
+  background: fox.color(input-surface);
+}
+
+.fox-text-area[data-state="focused"] .fox-text-area__box {
+  border-width: fox.border(2);
+  border-color: fox.color(input-border-active);
+  background: fox.color(input-surface);
+}
+
+.fox-text-area[data-state="error"] .fox-text-area__box {
+  border-width: fox.border(2);
+  border-color: fox.color(input-border-error);
+  background: fox.color(input-surface);
+}
+
+.fox-text-area[data-state="view"] .fox-text-area__box,
+.fox-text-area[data-state="disabled"] .fox-text-area__box {
+  border-width: fox.border(1);
+  border-color: fox.color(input-border-disabled);
+  background: fox.color(input-surface-disabled);
+}
+
+.fox-text-area[data-state="completed"] .fox-text-area__field::placeholder,
+.fox-text-area[data-state="focused"] .fox-text-area__field::placeholder,
+.fox-text-area[data-state="error"] .fox-text-area__field::placeholder,
+.fox-text-area[data-state="view"] .fox-text-area__field::placeholder {
+  color: fox.color(font-neutral-default);
+}
+
+.fox-text-area[data-state="disabled"] .fox-text-area__field,
+.fox-text-area[data-state="disabled"] .fox-text-area__field::placeholder {
+  color: fox.color(font-neutral-disabled-strong);
+}
+
+.fox-text-area[data-state="focused"] .fox-text-area__message {
+  color: fox.color(font-system-information);
+}
+
+.fox-text-area[data-state="completed"] .fox-text-area__message {
+  color: fox.color(font-system-success);
+}
+
+.fox-text-area[data-state="error"] .fox-text-area__message {
+  color: fox.color(font-system-danger);
+}
+
+.fox-text-area[data-state="error"] .fox-text-area__counter-current {
+  color: fox.color(font-system-danger);
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -8,3 +8,4 @@
 @use "fox-button-group";
 @use "fox-button-panel";
 @use "fox-input";
+@use "fox-text-area";
Add a comment
List