임동욱 임동욱 08-18
refactor: 폼 라벨을 FoxFormLabel로 일원화
develop에서 온 FoxFormLabel과 내가 두었던 form-field의 label 믹스인이 같은 일을
두 갈래로 하고 있었다. 라벨 모양을 아는 곳을 하나로 줄인다.

- FoxFormLabel에 `as` 프롭 추가 — 칸 하나를 가리킬 때는 label, 칸 여러 개를 묶은
  필드에서는 span. 가리킬 대상이 하나가 아니라 htmlFor를 쓸 수 없고, 아무것도
  가리키지 않는 
@7b3a7e5c93cbfdae977abaee1f314d2a55236448
@fox/core/components/fox-address/fox-address.tsx
--- @fox/core/components/fox-address/fox-address.tsx
+++ @fox/core/components/fox-address/fox-address.tsx
@@ -1,6 +1,7 @@
 "use client";
 
 import { useId, useState, type ReactNode } from "react";
+import { FoxFormLabel } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxInput } from "../fox-input";
@@ -131,11 +132,15 @@
   };
 
   return (
-    <div className={cx("fox-address", className)}>
+    <div
+      className={cx("fox-address", className)}
+      role="group"
+      aria-labelledby={label ? `${autoId}-label` : undefined}
+    >
       {label && (
-        <span className="fox-address__label" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`}>
           {label}
-        </span>
+        </FoxFormLabel>
       )}
 
       <div className="fox-address__row">
@fox/core/components/fox-certified/fox-certified.tsx
--- @fox/core/components/fox-certified/fox-certified.tsx
+++ @fox/core/components/fox-certified/fox-certified.tsx
@@ -1,6 +1,7 @@
 "use client";
 
 import { useEffect, useId, useRef, useState, type ChangeEvent } from "react";
+import { FoxFormLabel } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxPhoneNumber } from "../fox-phone-number";
@@ -190,11 +191,15 @@
   };
 
   return (
-    <div className={cx("fox-certified", className)}>
+    <div
+      className={cx("fox-certified", className)}
+      role="group"
+      aria-labelledby={label ? `${autoId}-label` : undefined}
+    >
       {label && (
-        <span className="fox-certified__label" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`}>
           {label}
-        </span>
+        </FoxFormLabel>
       )}
 
       <div className="fox-certified__row">
@fox/core/components/fox-email/fox-email.tsx
--- @fox/core/components/fox-email/fox-email.tsx
+++ @fox/core/components/fox-email/fox-email.tsx
@@ -1,6 +1,7 @@
 "use client";
 
 import { useId, useState, type ReactNode } from "react";
+import { FoxFormLabel } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxInput } from "../fox-input";
@@ -156,11 +157,15 @@
   };
 
   return (
-    <div className={cx("fox-email", className)}>
+    <div
+      className={cx("fox-email", className)}
+      role="group"
+      aria-labelledby={label ? `${autoId}-label` : undefined}
+    >
       {label && (
-        <span className="fox-email__label" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`}>
           {label}
-        </span>
+        </FoxFormLabel>
       )}
 
       <div className="fox-email__row">
@fox/core/components/fox-file-upload/fox-file-upload.tsx
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
@@ -9,6 +9,7 @@
   type CSSProperties,
   type DragEvent,
 } from "react";
+import { FoxFormLabel } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxIconButton } from "../fox-icon-button";
@@ -310,14 +311,16 @@
   return (
     <div
       className={cx("fox-file-upload", mode === "area" && "fox-file-upload--area", className)}
+      role="group"
+      aria-labelledby={label ? `${autoId}-label` : undefined}
       onDragOver={handleDragOver}
       onDragLeave={handleDragLeave}
       onDrop={handleDrop}
     >
       {label && (
-        <span className="fox-file-upload__label" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`}>
           {label}
-        </span>
+        </FoxFormLabel>
       )}
 
       {/* 파일 고르기는 네이티브 입력이 맡고 화면에는 버튼만 보인다. */}
@fox/core/components/fox-form-label/fox-form-label.tsx
--- @fox/core/components/fox-form-label/fox-form-label.tsx
+++ @fox/core/components/fox-form-label/fox-form-label.tsx
@@ -16,6 +16,13 @@
 type NativeLabelProps = Omit<ComponentPropsWithRef<"label">, "className" | "hidden">;
 
 export interface FoxFormLabelProps extends NativeLabelProps {
+  /**
+   * 그릴 태그. 칸 하나를 가리킬 때는 `label`이고, 칸 여러 개를 묶은 필드
+   * (전화번호·주소처럼)에서는 `span`이다 — 가리킬 대상이 하나가 아니라 `htmlFor`를 쓸 수
+   * 없고, 아무것도 가리키지 않는 `<label>`을 두면 의미가 어긋난다. 그때 이름 연결은
+   * 감싼 쪽이 `aria-labelledby`로 한다.
+   */
+  as?: "label" | "span";
   requirement?: FoxFormLabelRequirement;
   /** "(선택)" 대신 쓸 문구. */
   optionalText?: string;
@@ -36,6 +43,7 @@
  * (또는 개별 파티셜)로 한 번 불러와야 한다.
  */
 export function FoxFormLabel({
+  as: Tag = "label",
   requirement,
   optionalText = "(선택)",
   hidden = false,
@@ -48,7 +56,7 @@
   }
 
   return (
-    <label {...rest} className={cx("fox-form-label", className)}>
+    <Tag {...rest} className={cx("fox-form-label", className)}>
       {children}
       {requirement === "required" ? (
         <span className="fox-form-label__required" aria-hidden="true">
@@ -58,6 +66,6 @@
       {requirement === "optional" ? (
         <span className="fox-form-label__optional">{optionalText}</span>
       ) : null}
-    </label>
+    </Tag>
   );
 }
@fox/core/components/fox-phone-number/fox-phone-number.tsx
--- @fox/core/components/fox-phone-number/fox-phone-number.tsx
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
@@ -9,6 +9,7 @@
   type KeyboardEvent,
   type ReactNode,
 } from "react";
+import { FoxFormLabel } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxInput, type FoxInputState } from "../fox-input";
 import { FoxSelect, type FoxSelectItem } from "../fox-select";
@@ -269,9 +270,9 @@
       aria-labelledby={label ? labelId : undefined}
     >
       {label && (
-        <span className="fox-phone-number__label" id={labelId}>
+        <FoxFormLabel as="span" id={labelId}>
           {label}
-        </span>
+        </FoxFormLabel>
       )}
 
       {body}
@fox/styles/_form-field.scss
--- @fox/styles/_form-field.scss
+++ @fox/styles/_form-field.scss
@@ -1,8 +1,10 @@
 // 입력 계열 공용 조각.
 //
-// 시안에서 form-label · helper-message · 입력 상자가 모두 같은 공용 컴포넌트라
+// 시안에서 helper-message · 입력 상자가 모두 같은 공용 컴포넌트라
 // FoxInput · FoxTextArea · FoxPhoneNumber · FoxCertified · FoxEmail · FoxAddress ·
 // FoxFileUpload가 같은 선언을 되풀이했다. 그 되풀이를 여기로 모은다.
+//
+// 라벨은 여기 없다 — FoxFormLabel이 컴포넌트로 갖는다.
 //
 // 믹스인만 있고 규칙이 없어 @use 해도 CSS가 늘지 않는다 — 부르는 자리에서만 나온다.
 // 그래서 파티셜을 따로 쓰든 묶음(components.scss)으로 쓰든 출력은 그대로다.
@@ -22,17 +24,6 @@
   flex-direction: column;
   gap: fox.gap($gap);
   inline-size: 100%;
-}
-
-/// 시안 form-label. 입력 크기와 무관하게 항상 label/md다(공용 컴포넌트라 크기를 따라가지
-/// 않는다).
-@mixin 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;
 }
 
 /// 시안 helper-message. 오류·포커스 색은 상태 규칙이 덮는다.
@fox/styles/_fox-address.scss
--- @fox/styles/_fox-address.scss
+++ @fox/styles/_fox-address.scss
@@ -27,9 +27,6 @@
 .fox-address {
   @include field.root;
 
-  &__label {
-    @include field.label;
-  }
 
   // 우편번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다.
   &__row {
@fox/styles/_fox-certified.scss
--- @fox/styles/_fox-certified.scss
+++ @fox/styles/_fox-certified.scss
@@ -41,9 +41,6 @@
 .fox-certified {
   @include field.root;
 
-  &__label {
-    @include field.label;
-  }
 
   // 전화번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다.
   &__row {
@fox/styles/_fox-email.scss
--- @fox/styles/_fox-email.scss
+++ @fox/styles/_fox-email.scss
@@ -29,9 +29,6 @@
 .fox-email {
   @include field.root;
 
-  &__label {
-    @include field.label;
-  }
 
   // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다.
   &__row {
@fox/styles/_fox-file-upload.scss
--- @fox/styles/_fox-file-upload.scss
+++ @fox/styles/_fox-file-upload.scss
@@ -37,9 +37,6 @@
     gap: fox.gap(7);
   }
 
-  &__label {
-    @include field.label;
-  }
 
   &__description {
     margin: 0;
@fox/styles/_fox-form-label.scss
--- @fox/styles/_fox-form-label.scss
+++ @fox/styles/_fox-form-label.scss
@@ -10,8 +10,8 @@
 //     <span class="fox-form-label__optional">(선택)</span>                 <!-- 선택일 때 -->
 //   </label>
 //
-// 글자 자체는 `form-field`의 `label` 믹스인이 갖는다 — 시안에서 이 컴포넌트와 입력 계열의
-// 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없다. 이 파일에는 표시 두 개만 남는다.
+// 시안에서 이 컴포넌트와 입력 계열의 라벨이 같은 공용 조각이라, 라벨 모양은 여기 한 곳에만
+// 둔다. 칸을 여러 개 묶은 필드(전화번호·주소 등)도 자기 라벨을 그리지 않고 이 클래스를 쓴다.
 //
 // 필수의 `*`는 `aria-hidden`이다. 필수 여부는 입력 요소의 `required`(또는 `aria-required`)가
 // 알리는 것이고, 별표는 그 눈에 보이는 짝일 뿐이라 두 번 읽히면 안 된다. 반대로 "(선택)"은
@@ -25,13 +25,16 @@
 //   - 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;
+  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;
 
   // 시안 label/md-em — 기본 라벨보다 굵고 강조 색이다.
   &__required {
@fox/styles/_fox-phone-number.scss
--- @fox/styles/_fox-phone-number.scss
+++ @fox/styles/_fox-phone-number.scss
@@ -44,9 +44,6 @@
 .fox-phone-number {
   @include field.root;
 
-  &__label {
-    @include field.label;
-  }
 
   // ── unit ──────────────────────────────────────────────────────────────────
   &__row {
Add a comment
List