민혜린 민혜린 08-19
디자인시스템 FoxHelperText 추가
@83acf5bb0003e3289c746562118a8e7346e360b5
 
@fox/core/components/fox-helper-text/fox-helper-text.tsx (added)
+++ @fox/core/components/fox-helper-text/fox-helper-text.tsx
@@ -0,0 +1,105 @@
+import type { ReactNode, Ref } from "react";
+import {
+  FoxChatDotsIcon,
+  FoxCheckCircleIcon,
+  FoxInfoIcon,
+  FoxProhibitIcon,
+  FoxWarningIcon,
+} from "../../icons";
+import { cx } from "../../utils";
+
+/** `FoxAlert`와 같은 다섯 계열이다 — 시안이 두 컴포넌트에 같은 축을 쓴다. */
+export type FoxHelperTextType =
+  | "default"
+  | "information"
+  | "success"
+  | "warning"
+  | "danger";
+
+export interface FoxHelperTextProps {
+  /** 글자색과 아이콘이 여기서 갈린다. */
+  type?: FoxHelperTextType;
+  /** 보여 줄 문구. */
+  message: ReactNode;
+  /**
+   * 아이콘을 그릴지. 시안은 다섯 계열 모두 아이콘을 달지만, 글자만 필요한 자리가 있어
+   * 끌 수 있게 둔다 — 폼 컴포넌트의 헬퍼가 지금 아이콘 없이 쓰이는 자리가 많다
+   * (`FoxInput.messageIcon`이 선택값인 것과 같은 사정).
+   */
+  showIcon?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /**
+   * 입력의 `aria-describedby`가 가리킬 값. 폼 옆에 놓을 때 넘겨서 이어 준다 —
+   * 이어 주지 않으면 화면에만 보이고 소리로는 전달되지 않는다.
+   */
+  id?: string;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 쓴다. */
+  className?: string;
+  ref?: Ref<HTMLParagraphElement>;
+}
+
+/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const TYPE_CLASS: Record<FoxHelperTextType, string> = {
+  default: "fox-helper-text--default",
+  information: "fox-helper-text--information",
+  success: "fox-helper-text--success",
+  warning: "fox-helper-text--warning",
+  danger: "fox-helper-text--danger",
+};
+
+/**
+ * 계열이 정하는 아이콘. 글리프는 `FoxAlert`와 같은 것을 쓰고 **굵기만 다르다** —
+ * 알럿은 `duotone`, 여기는 `regular`다(시안 확인).
+ */
+const TYPE_ICON: Record<FoxHelperTextType, ReactNode> = {
+  default: <FoxChatDotsIcon />,
+  information: <FoxInfoIcon />,
+  success: <FoxCheckCircleIcon />,
+  warning: <FoxWarningIcon />,
+  danger: <FoxProhibitIcon />,
+};
+
+/**
+ * @fox 헬퍼 텍스트. 입력 아래 한 줄로 붙는 안내·오류 문구다.
+ *
+ * 상태를 갖지 않는다 — 어떤 계열로 보일지는 호출부가 정한다.
+ *
+ * TODO(폼 통합, 적용 후 이 문단 삭제): 폼 컴포넌트 여섯 곳(`FoxInput`·`FoxTextArea`·
+ * `FoxEmail`·`FoxPhoneNumber`·`FoxAddress`의 `__message`, `FoxSelect`의 `__hint`)이
+ * 아직 각자 그린다. 이걸로 모으는 절차와 주의점은 `@fox/styles/_fox-helper-text.scss`
+ * 상단에 적어 두었다 — `FoxSelect`만 간격과 오류 아이콘 색이 달라지므로 확인이 필요하다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxHelperText({
+  type = "default",
+  message,
+  showIcon = true,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxHelperTextProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <p
+      ref={ref}
+      id={id}
+      className={cx("fox-helper-text", TYPE_CLASS[type], className)}
+    >
+      {showIcon ? (
+        <span className="fox-helper-text__icon" aria-hidden="true">
+          {TYPE_ICON[type]}
+        </span>
+      ) : null}
+      <span className="fox-helper-text__message">{message}</span>
+    </p>
+  );
+}
 
@fox/core/components/fox-helper-text/index.ts (added)
+++ @fox/core/components/fox-helper-text/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxHelperText,
+  type FoxHelperTextProps,
+  type FoxHelperTextType,
+} from "./fox-helper-text";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -23,6 +23,7 @@
 export * from "./fox-email";
 export * from "./fox-file-upload";
 export * from "./fox-form-label";
+export * from "./fox-helper-text";
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -19,6 +19,7 @@
 import { FoxEmail } from "../core/components/fox-email";
 import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
 import { FoxFormLabel } from "../core/components/fox-form-label";
+import { FoxHelperText } from "../core/components/fox-helper-text";
 import { FoxIconButton } from "../core/components/fox-icon-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
 import { FoxPagination } from "../core/components/fox-pagination";
@@ -3369,4 +3370,52 @@
       },
     ],
   },
+  {
+    id: "fox-helper-text",
+    name: "FoxHelperText",
+    description:
+      "Figma 시안(helper-txt) 입력 아래 붙는 안내·오류 한 줄입니다. 계열은 FoxAlert와 같은 다섯 개이고 글리프도 같은 것을 쓰되 굵기만 regular입니다(알럿은 duotone). 계열이 붙으면 글자는 font-system-*, 아이콘은 한 단계 진한 icon-system-*-strong입니다 — default만 글자·아이콘이 같은 회색입니다. 모양 값을 새로 적지 않았습니다 — 시안 값이 _form-field.scss의 message 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm · 150% · -0.025em) 그대로 부릅니다. 차이는 display 하나뿐이라(시안 inline-flex, 폼 안 flex) 믹스인에 인자로 넘깁니다. ⚠️ 폼 컴포넌트 안의 헬퍼는 아직 이 클래스를 쓰지 않습니다 — FoxInput·FoxTextArea·FoxEmail·FoxPhoneNumber·FoxAddress는 각자 __message를, FoxSelect는 __hint를 그리고, 그쪽 색은 상자의 포커스·오류를 :has()로 따라갑니다(auto-states 믹스인). 모으는 것은 별도 작업입니다.",
+    variants: [
+      {
+        label: "계열 5종 — 아이콘이 글자보다 한 단계 진합니다 (icon-system-*-strong)",
+        node: (
+          <>
+            <FoxHelperText message="기본 안내 문구입니다." />
+            <FoxHelperText type="information" message="입력 형식을 확인해 주세요." />
+            <FoxHelperText type="success" message="사용할 수 있는 아이디입니다." />
+            <FoxHelperText type="warning" message="곧 만료되는 항목입니다." />
+            <FoxHelperText type="danger" message="필수 항목입니다." />
+          </>
+        ),
+      },
+      {
+        label: "showIcon={false} — 글자만 (폼 헬퍼가 아이콘 없이 쓰이는 자리를 위해)",
+        node: (
+          <>
+            <FoxHelperText showIcon={false} message="2자 이상 입력해 주세요." />
+            <FoxHelperText type="danger" showIcon={false} message="필수 항목입니다." />
+          </>
+        ),
+      },
+      {
+        label: "inline-flex라 폭이 글자만큼입니다 (점선은 자리 확인용)",
+        node: (
+          <div style={{ outline: "1px dashed currentColor" }}>
+            <FoxHelperText type="information" message="내용만큼만 차지합니다." />
+          </div>
+        ),
+      },
+      {
+        label: "여러 줄 — 아이콘이 첫 줄에 맞춰 고정됩니다",
+        node: (
+          <div style={{ inlineSize: "24rem" }}>
+            <FoxHelperText
+              type="warning"
+              message="문구가 길어져 두 줄이 되어도 아이콘은 위로 붙어 첫 글줄과 나란히 남습니다."
+            />
+          </div>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-helper-text.scss (added)
+++ @fox/styles/_fox-helper-text.scss
@@ -0,0 +1,105 @@
+// FoxHelperText — 시안: 통합관리자페이지 디자인시스템 Figma helper-txt
+//
+// 입력 아래 한 줄로 붙는 안내·오류 문구다. 아이콘 하나와 글자 한 덩어리로 끝난다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <p class="fox-helper-text fox-helper-text--danger">
+//     <span class="fox-helper-text__icon">…아이콘 svg…</span>
+//     <span class="fox-helper-text__message">필수 항목입니다.</span>
+//   </p>
+//
+// 아이콘은 없어도 된다(글자만 있는 자리가 많다). 계열은 `FoxAlert`와 같은 다섯 개이고,
+// 글리프도 같은 것을 쓰되 **굵기만 regular**다(알럿은 duotone).
+//
+// **모양 값을 여기서 새로 적지 않는다.** 시안 값이 `_form-field.scss`의 `message`·
+// `message-icon` 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm ·
+// line-height 150% · letter-spacing -0.025em) 그대로 부른다. 폼 컴포넌트의 `__message`가
+// 같은 믹스인을 쓰므로, 두 경로가 갈라질 수 없다.
+//
+// 유일한 차이가 `display`다 — 시안이 `inline-flex`를 준다(폼 안에서는 `flex`). 문구가
+// 글줄 안에 놓일 수 있어서 폭을 내용만큼만 갖는 쪽이 맞다.
+//
+// 계열은 **글자와 아이콘의 색만** 바꾼다. `default`는 믹스인이 정한 회색
+// (`font-neutral-subtle`)이고, 나머지 넷은 알럿 제목과 같은 `font-system-*`이다. 시안 확인 완료.
+//
+// 아이콘 크기도 믹스인이 정한 16px(`icon-3`)이 시안 값이다 — 폼 안 헬퍼와 같다(시안 확인 완료).
+//
+// TODO(폼 통합, 적용 후 이 주석 삭제): 폼 컴포넌트 여섯 곳의 헬퍼를 이 클래스로 모은다.
+// 지금은 각자 그린다 — FoxInput · FoxTextArea · FoxEmail · FoxPhoneNumber · FoxAddress가
+// `__message`, FoxSelect가 `__hint`다. 모양은 `_form-field.scss`의 `message` 믹스인 한
+// 곳에서 나와 어긋나지는 않지만 마크업이 여섯 벌이다.
+//
+// 옮길 때 할 일:
+//   1) `FoxHelperText`에 `icon` 오버라이드 prop을 더한다 — 폼은 호출부가 준 아이콘을 쓰고
+//      계열 글리프를 쓰지 않는다.
+//   2) tsx의 `<p class="fox-X__message">`를 `<FoxHelperText className="fox-X__message" …>`로
+//      바꾼다. **클래스를 남기는 것이 요점이다** — 그래야 상태 색 규칙(`auto-states`의
+//      `:focus-within`·`:has([aria-invalid])`)이 선택자를 고치지 않고 그대로 먹는다.
+//   3) 각 컴포넌트 scss에서 `&__message { @include field.message; }`와
+//      `&__message-icon { … }` 블록을 지운다 — `.fox-helper-text`가 대신 준다.
+//   4) 아이콘은 `showIcon={Boolean(messageIcon)}`으로 넘겨 기존 동작(아이콘을 안 주면 안
+//      그린다)을 유지한다.
+//
+// ⚠️ 옮기면 **FoxSelect만 모양이 달라진다.** 진행 전에 확인받을 것:
+//   - 힌트 간격이 `gap 8px` · `align-items: center`에서 `4px` · `flex-start`로 바뀐다
+//     (helper-txt 시안 값이 이쪽이다).
+//   - 오류일 때 아이콘 색이 `icon-system-danger`(danger-50)에서, 글자색을 상속한
+//     `font-system-danger`(danger-60)로 한 단계 진해진다.
+//
+// 나머지 다섯은 값이 같아 화면이 바뀌지 않는다. 앱·다른 컴포넌트가 `__message`·`__hint`
+// 클래스를 직접 참조하는 곳은 없다(확인 완료).
+
+@use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/form-field" as field;
+
+.fox-helper-text {
+  // 시안이 inline-flex다(폼 안에서는 flex) — 믹스인에 인자로 넘겨 한 번만 적는다.
+  @include field.message($display: inline-flex);
+
+  &__icon {
+    @include field.message-icon;
+  }
+
+  // ── 계열 ────────────────────────────────────────────────────────────────────
+  // 글자와 아이콘의 색이 **다르다** — 글자는 `font-system-*`, 아이콘은 한 단계 진한
+  // `icon-system-*-strong`이다(시안 확인). 아이콘 글리프가 `fill: currentColor`라 슬롯에
+  // 색을 주면 그대로 입는다.
+  //
+  // `default`만 예외다 — 글자도 아이콘도 믹스인이 정한 회색(`font-neutral-subtle`)을 함께
+  // 쓰므로 따로 덮을 것이 없다.
+  &--default {
+    // 시안 그대로 — 믹스인에 있는 값이 전부다.
+  }
+
+  &--information {
+    color: fox.color(font-system-information);
+  }
+
+  &--information &__icon {
+    color: fox.color(icon-system-information-strong);
+  }
+
+  &--success {
+    color: fox.color(font-system-success);
+  }
+
+  &--success &__icon {
+    color: fox.color(icon-system-success-strong);
+  }
+
+  &--warning {
+    color: fox.color(font-system-warning);
+  }
+
+  &--warning &__icon {
+    color: fox.color(icon-system-warning-strong);
+  }
+
+  &--danger {
+    color: fox.color(font-system-danger);
+  }
+
+  &--danger &__icon {
+    color: fox.color(icon-system-danger-strong);
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -20,6 +20,7 @@
 @use "fox-email";
 @use "fox-file-upload";
 @use "fox-form-label";
+@use "fox-helper-text";
 @use "fox-input";
 @use "fox-phone-number";
 @use "fox-radio";
Add a comment
List