민혜린 민혜린 08-14
feat: 디자인시스템 FoxFormlabel 추가
@015d073fbf9d8b99a783ab39855514d996beb8d7
 
@fox/core/components/fox-form-label/fox-form-label.tsx (added)
+++ @fox/core/components/fox-form-label/fox-form-label.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import type { ComponentPropsWithRef } from "react";
+import { cx } from "../../utils";
+
+/**
+ * 필수·선택 표시. 둘은 배타적이라 boolean 두 개가 아니라 하나의 값으로 받는다 — 그래야
+ * "필수이면서 선택"이라는 있을 수 없는 조합이 타입에서 막힌다.
+ */
+export type FoxFormLabelRequirement = "required" | "optional";
+
+/**
+ * 네이티브 `<label>`이 받는 것을 그대로 넘겨받는다 — htmlFor·id·title 등. 이름이 겹치는
+ * 것만 걷어내고 아래에서 다시 정의한다.
+ */
+type NativeLabelProps = Omit<ComponentPropsWithRef<"label">, "className" | "hidden">;
+
+export interface FoxFormLabelProps extends NativeLabelProps {
+  requirement?: FoxFormLabelRequirement;
+  /** "(선택)" 대신 쓸 문구. */
+  optionalText?: string;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+/**
+ * @fox 입력 칸 위에 붙는 라벨. 상태를 갖지 않는다.
+ *
+ * 필수는 글자 뒤에 `*`, 선택은 "(선택)"을 붙인다. `*`는 `aria-hidden`이라 스크린리더가 읽지
+ * 않는다 — 필수 여부는 입력 요소의 `required`가 알리는 것이고 별표는 그 눈에 보이는 짝이라,
+ * 둘 다 읽히면 같은 말이 두 번 나온다. 반대로 "(선택)"은 대신 알려 줄 속성이 없어 읽히게 둔다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxFormLabel({
+  requirement,
+  optionalText = "(선택)",
+  hidden = false,
+  className,
+  children,
+  ...rest
+}: FoxFormLabelProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <label {...rest} className={cx("fox-form-label", className)}>
+      {children}
+      {requirement === "required" ? (
+        <span className="fox-form-label__required" aria-hidden="true">
+          *
+        </span>
+      ) : null}
+      {requirement === "optional" ? (
+        <span className="fox-form-label__optional">{optionalText}</span>
+      ) : null}
+    </label>
+  );
+}
 
@fox/core/components/fox-form-label/index.ts (added)
+++ @fox/core/components/fox-form-label/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxFormLabel,
+  type FoxFormLabelProps,
+  type FoxFormLabelRequirement,
+} from "./fox-form-label";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -6,6 +6,7 @@
 export * from "./fox-conditional-option";
 export * from "./fox-email";
 export * from "./fox-file-upload";
+export * from "./fox-form-label";
 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
@@ -11,6 +11,7 @@
 import { FoxCertified } from "../core/components/fox-certified";
 import { FoxConditionalOption } from "../core/components/fox-conditional-option";
 import { FoxEmail } from "../core/components/fox-email";
+import { FoxFormLabel } from "../core/components/fox-form-label";
 import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
 import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
@@ -756,6 +757,48 @@
       },
     ],
   },
+    {
+    id: "fox-form-label",
+    name: "FoxFormLabel",
+    description:
+      "Figma 시안(form-label 180:609) 입력 칸 위에 붙는 라벨입니다. 글자 자체는 form-field의 label 믹스인이 갖습니다 — 시안에서 이 컴포넌트와 입력 계열의 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없습니다. 필수는 글자 뒤에 *, 선택은 (선택)을 붙이고 둘은 배타적이라 boolean 두 개가 아니라 값 하나로 받습니다. *는 aria-hidden이라 스크린리더가 읽지 않습니다 — 필수 여부는 입력 요소의 required가 알리는 것이고 별표는 그 눈에 보이는 짝이라, 둘 다 읽히면 같은 말이 두 번 나옵니다. 반대로 (선택)은 대신 알려 줄 속성이 없어 읽히게 둡니다.",
+    variants: [
+      {
+        label: "기본 / 필수 / 선택",
+        node: (
+          <div className={styles.demoStack}>
+            <FoxFormLabel>레이블</FoxFormLabel>
+            <FoxFormLabel requirement="required">레이블</FoxFormLabel>
+            <FoxFormLabel requirement="optional">레이블</FoxFormLabel>
+          </div>
+        ),
+      },
+      {
+        label: "optionalText — 문구를 바꿀 수 있다",
+        node: (
+          <div className={styles.demoStack}>
+            <FoxFormLabel requirement="optional">기본 문구</FoxFormLabel>
+            <FoxFormLabel requirement="optional" optionalText="(입력하지 않아도 됩니다)">
+              직접 지정
+            </FoxFormLabel>
+          </div>
+        ),
+      },
+      {
+        label: "htmlFor — 눌러 보면 아래 입력칸에 커서가 간다",
+        node: (
+          <InputStage>
+            <div className={styles.demoStack}>
+              <FoxFormLabel htmlFor="demo-form-label" requirement="required">
+                라벨을 눌러 보세요
+              </FoxFormLabel>
+              <FoxInput size="md" id="demo-form-label" placeholder="여기로 커서가 옵니다" />
+            </div>
+          </InputStage>
+        ),
+      },
+    ],
+  },
   {
     id: "fox-input",
     name: "FoxInput",
@@ -1125,7 +1168,7 @@
       "Figma 시안(conditional-option 190:2163) 라디오 한 칸과 select가 합쳐 하나의 답을 이룹니다. select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.",
     variants: [
       {
-        label: "vertical (기본) — select가 아래로 내려가고 라벨 왼쪽 끝에 맞는다",
+        label: "vertical (기본) — select가 아래로 내려가고 왼쪽 끝에 붙는다",
         node: (
           <FoxConditionalOption
             name="co-v"
 
@fox/styles/_fox-form-label.scss (added)
+++ @fox/styles/_fox-form-label.scss
@@ -0,0 +1,49 @@
+// FoxFormLabel — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                       Figma form-label
+//
+// 입력 칸 위에 붙는 라벨. 필수·선택 표시를 뒤에 달 수 있다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <label class="fox-form-label" for="…">
+//     이름
+//     <span class="fox-form-label__required" aria-hidden="true">*</span>   <!-- 필수일 때 -->
+//     <span class="fox-form-label__optional">(선택)</span>                 <!-- 선택일 때 -->
+//   </label>
+//
+// 글자 자체는 `form-field`의 `label` 믹스인이 갖는다 — 시안에서 이 컴포넌트와 입력 계열의
+// 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없다. 이 파일에는 표시 두 개만 남는다.
+//
+// 필수의 `*`는 `aria-hidden`이다. 필수 여부는 입력 요소의 `required`(또는 `aria-required`)가
+// 알리는 것이고, 별표는 그 눈에 보이는 짝일 뿐이라 두 번 읽히면 안 된다. 반대로 "(선택)"은
+// 대신 알려 줄 속성이 없어 그대로 읽히게 둔다.
+//
+// 표시는 서로 배타적이다 — 필수이면서 선택일 수는 없어 React 래퍼가 하나만 받는다.
+//
+// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지
+//     않는다. 자간은 px이 아니라 em이라 브레이크포인트가 바뀌어도 비율이 유지된다.
+//   - display / align-items: 구조 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/form-field" as field;
+
+.fox-form-label {
+  @include field.label;
+
+  display: inline-flex;
+  align-items: center;
+
+  // 시안 label/md-em — 기본 라벨보다 굵고 강조 색이다.
+  &__required {
+    color: fox.color(font-theme-accent);
+    font-weight: fox.font-weight(bold);
+  }
+
+  // 시안 label/sm — 기본 라벨보다 한 단계 작고 흐리다.
+  &__optional {
+    color: fox.color(font-neutral-subtle);
+    font-size: fox.font-size(label-sm);
+    font-weight: fox.font-weight(regular);
+    letter-spacing: -0.025em;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -12,6 +12,7 @@
 @use "fox-conditional-option";
 @use "fox-email";
 @use "fox-file-upload";
+@use "fox-form-label";
 @use "fox-input";
 @use "fox-phone-number";
 @use "fox-radio";
Add a comment
List