임동욱 임동욱 08-13
refactor: 입력 계열 공용 조각을 form-field 믹스인으로 추출
FoxInput·TextArea·PhoneNumber·Certified·Email·Address·FileUpload가 라벨·
헬퍼·입력 상자·상태 규칙을 같은 값으로 되풀이하고 있었다. 시안에서 이들이 모두
같은 공용 컴포넌트라 값이 갈라질 이유가 없다.

- @fox/styles/_form-field.scss 추가 — root·label·message·message-icon·
  box·box-size·field·auto-states·fixed-states
- 믹스인만 있어 @use 해도 CSS가 늘지 않는다(파티셜 단독·묶음 모두 확인)
- 컴포넌트마다 다른 것만 인자로: 상자 gap·교차정렬, 알맹이 글자 크기·줄높이,
  알맹이 element 이름(field/segment), 오류 판정 범위
- PhoneNumber unit은 조각이 다른 컴포넌트라 헬퍼 오류를 넓게 본다 —
  $invalid-within으로 그대로 유지

값은 하나도 바꾸지 않았다. 컴파일 결과를 규칙 단위로 대조해 304개 규칙의
선택자·선언·순서가 모두 동일함을 확인했다(선언 순서만 5개 규칙에서 다르며
서로 다른 속성이라 렌더링 동일).

Co-Authored-By: Claude Opus 5 
@4494a7386fac9424618ccbe27b4dab8f76dad322
 
@fox/styles/_form-field.scss (added)
+++ @fox/styles/_form-field.scss
@@ -0,0 +1,232 @@
+// 입력 계열 공용 조각.
+//
+// 시안에서 form-label · helper-message · 입력 상자가 모두 같은 공용 컴포넌트라
+// FoxInput · FoxTextArea · FoxPhoneNumber · FoxCertified · FoxEmail · FoxAddress ·
+// FoxFileUpload가 같은 선언을 되풀이했다. 그 되풀이를 여기로 모은다.
+//
+// 믹스인만 있고 규칙이 없어 @use 해도 CSS가 늘지 않는다 — 부르는 자리에서만 나온다.
+// 그래서 파티셜을 따로 쓰든 묶음(components.scss)으로 쓰든 출력은 그대로다.
+
+@use "abstracts" as fox;
+
+/// 비활성·읽기전용 상자. 두 상태의 모양이 같아 한곳에 둔다.
+@mixin _disabled-box {
+  border-width: fox.border(1);
+  border-color: fox.color(input-border-disabled);
+  background: fox.color(input-surface-disabled);
+}
+
+/// 필드 한 벌의 바깥 틀. 라벨·상자·헬퍼가 세로로 쌓인다.
+@mixin root($gap: 3) {
+  display: flex;
+  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. 오류·포커스 색은 상태 규칙이 덮는다.
+@mixin message {
+  display: flex;
+  gap: fox.number(3);
+  align-items: flex-start;
+  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;
+}
+
+/// helper-message 왼쪽 아이콘. 글줄 첫 줄에 맞춰 살짝 내린다.
+@mixin 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);
+  }
+}
+
+/// 입력 상자 껍데기. 높이·좌우 여백·모서리는 부르는 쪽이 정한다 — FoxInput은 크기
+/// 수식어가, 나머지는 md 하나가 정한다. textarea는 세로 가운데 정렬을 쓰지 않으므로
+/// `$align: null`로 끈다.
+@mixin box($gap: 3, $align: center) {
+  box-sizing: border-box;
+  display: flex;
+  align-items: $align;
+  gap: fox.gap($gap);
+  inline-size: 100%;
+  border: fox.border(1) solid fox.color(input-border);
+  background: fox.color(input-surface);
+}
+
+/// 상자 하나짜리 크기(시안에 크기 축이 없는 컴포넌트용).
+@mixin box-size($size: md) {
+  block-size: fox.form(height-#{$size});
+  padding-inline: fox.form(padding-#{$size});
+  border-radius: fox.form(radius-#{$size});
+}
+
+/// 상자 속 알맹이. 테두리·배경은 상자가 그리므로 여기선 지운다.
+/// 글자 크기는 FoxInput만 크기 수식어가 정하므로 기본값이 없다.
+///
+/// 뒤에 중첩 규칙이 붙으므로 부르는 쪽이 선언을 더할 때는 `@include`보다 앞에 적는다 —
+/// 뒤에 적으면 Sass가 같은 선택자를 한 번 더 낸다.
+@mixin field($font-size: null, $line-height: 1) {
+  flex: 1 0 0;
+  min-inline-size: 0;
+  margin: 0;
+  padding: 0;
+  border: none;
+  background: none;
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(body);
+
+  @if $font-size {
+    font-size: fox.font-size($font-size);
+  }
+
+  font-weight: fox.font-weight(regular);
+  line-height: $line-height;
+  letter-spacing: -0.025em;
+
+  &::placeholder {
+    color: fox.color(font-neutral-subtler);
+  }
+
+  // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다.
+  &:focus {
+    outline: none;
+  }
+
+  &:disabled {
+    color: fox.color(font-neutral-disabled-strong);
+    cursor: default;
+  }
+}
+
+/// 상태 · 자동. 브라우저가 판단한다 — 포커스는 `:focus-within`, 비활성·읽기전용은
+/// 네이티브 속성, 오류는 `aria-invalid="true"`. 뒤에 오는 규칙이 이긴다:
+/// 포커스 < 오류 < 읽기전용 < 비활성.
+///
+/// $field         알맹이의 element 이름(`__field` · `__segment`).
+/// $invalid-within `:has()`로 필드 전체가 오류인지 볼 때 찾을 것. 조각을 다른 컴포넌트에
+///                 맡기는 경우(FoxPhoneNumber unit)는 알맹이가 없으므로 넓게 본다.
+/// @content        컴포넌트 고유 규칙. `:not([data-state])` 안에 붙는다.
+@mixin auto-states($block, $field: "field", $invalid-within: null) {
+  $box: ".#{$block}__box";
+  $input: ".#{$block}__#{$field}";
+  $message: ".#{$block}__message";
+  $within: "#{$input}[aria-invalid=\"true\"]";
+
+  @if $invalid-within {
+    $within: $invalid-within;
+  }
+
+  .#{$block}:not([data-state]) {
+    #{$box}:focus-within {
+      border-width: fox.border(2);
+      border-color: fox.color(input-border-active);
+    }
+
+    #{$box}:has(#{$input}[aria-invalid="true"]) {
+      border-width: fox.border(2);
+      border-color: fox.color(input-border-error);
+    }
+
+    // 비활성도 `:read-only`에 걸리므로 떼어낸다.
+    #{$box}:has(#{$input}:read-only:not(:disabled)) {
+      @include _disabled-box;
+    }
+
+    #{$box}:has(#{$input}:disabled) {
+      @include _disabled-box;
+    }
+
+    &:focus-within #{$message} {
+      color: fox.color(font-system-information);
+    }
+
+    &:has(#{$within}) #{$message} {
+      color: fox.color(font-system-danger);
+    }
+
+    @content;
+  }
+}
+
+/// 상태 · 고정. 루트에 `data-state`를 주면 그 모양으로 못박히고 자동 규칙은 꺼진다.
+/// 카탈로그·스냅샷처럼 상태를 눈으로 비교해야 할 때 쓴다.
+///
+/// 시안의 focused·completed·error·view는 값이 들어찬 모습이라 플레이스홀더도 진하게 둔다.
+@mixin fixed-states($block, $field: "field") {
+  $root: ".#{$block}";
+  $box: ".#{$block}__box";
+  $input: ".#{$block}__#{$field}";
+  $message: ".#{$block}__message";
+
+  #{$root}[data-state="default"] #{$box},
+  #{$root}[data-state="completed"] #{$box} {
+    border-width: fox.border(1);
+    border-color: fox.color(input-border);
+    background: fox.color(input-surface);
+  }
+
+  #{$root}[data-state="focused"] #{$box} {
+    border-width: fox.border(2);
+    border-color: fox.color(input-border-active);
+    background: fox.color(input-surface);
+  }
+
+  #{$root}[data-state="error"] #{$box} {
+    border-width: fox.border(2);
+    border-color: fox.color(input-border-error);
+    background: fox.color(input-surface);
+  }
+
+  #{$root}[data-state="view"] #{$box},
+  #{$root}[data-state="disabled"] #{$box} {
+    @include _disabled-box;
+  }
+
+  #{$root}[data-state="completed"] #{$input}::placeholder,
+  #{$root}[data-state="focused"] #{$input}::placeholder,
+  #{$root}[data-state="error"] #{$input}::placeholder,
+  #{$root}[data-state="view"] #{$input}::placeholder {
+    color: fox.color(font-neutral-default);
+  }
+
+  #{$root}[data-state="disabled"] #{$input},
+  #{$root}[data-state="disabled"] #{$input}::placeholder {
+    color: fox.color(font-neutral-disabled-strong);
+  }
+
+  #{$root}[data-state="focused"] #{$message} {
+    color: fox.color(font-system-information);
+  }
+
+  #{$root}[data-state="completed"] #{$message} {
+    color: fox.color(font-system-success);
+  }
+
+  #{$root}[data-state="error"] #{$message} {
+    color: fox.color(font-system-danger);
+  }
+
+  @content;
+}
@fox/styles/_fox-address.scss
--- @fox/styles/_fox-address.scss
+++ @fox/styles/_fox-address.scss
@@ -22,20 +22,13 @@
 //     export하지 않는다.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-address {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   &__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;
+    @include field.label;
   }
 
   // 우편번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다.
@@ -52,28 +45,11 @@
   }
 
   &__message {
-    display: flex;
-    gap: fox.number(3);
-    align-items: flex-start;
-    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;
+    @include field.message;
   }
 
   &__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);
-    }
+    @include field.message-icon;
   }
 
   &:has([aria-invalid="true"]) .fox-address__message {
@fox/styles/_fox-certified.scss
--- @fox/styles/_fox-certified.scss
+++ @fox/styles/_fox-certified.scss
@@ -27,26 +27,22 @@
 // 인증번호 상자의 상태는 FoxInput과 같은 방식으로 브라우저가 판단한다 — 포커스는
 // `:focus-within`, 비활성은 네이티브 속성. 시안에 상태 변형이 없어 고정 표기는 두지 않는다.
 //
+// 라벨·상자·알맹이는 `form-field` 믹스인이 갖는다. 상태만 여기서 직접 적는다 —
+// `data-state` 고정 표기가 없어 자동 규칙 두 줄이면 끝난다.
+//
 // 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목):
 //   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
 //     export하지 않는다.
-//   - box-sizing / flex: 구조 규칙.
+//   - flex: 구조 규칙.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-certified {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   &__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;
+    @include field.label;
   }
 
   // 전화번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다.
@@ -63,45 +59,12 @@
   }
 
   &__box {
-    box-sizing: border-box;
-    display: flex;
-    align-items: center;
-    gap: fox.gap(3);
-    inline-size: 100%;
-    block-size: fox.form(height-md);
-    padding-inline: fox.form(padding-md);
-    border: fox.border(1) solid fox.color(input-border);
-    border-radius: fox.form(radius-md);
-    background: fox.color(input-surface);
+    @include field.box;
+    @include field.box-size(md);
   }
 
   &__field {
-    flex: 1 0 0;
-    min-inline-size: 0;
-    margin: 0;
-    padding: 0;
-    border: none;
-    background: none;
-    color: fox.color(font-neutral-default);
-    font-family: fox.font-family(body);
-    font-size: fox.font-size(label-md);
-    font-weight: fox.font-weight(regular);
-    line-height: 1;
-    letter-spacing: -0.025em;
-
-    &::placeholder {
-      color: fox.color(font-neutral-subtler);
-    }
-
-    // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다.
-    &:focus {
-      outline: none;
-    }
-
-    &:disabled {
-      color: fox.color(font-neutral-disabled-strong);
-      cursor: default;
-    }
+    @include field.field($font-size: label-md);
   }
 
   &__timer {
@fox/styles/_fox-email.scss
--- @fox/styles/_fox-email.scss
+++ @fox/styles/_fox-email.scss
@@ -24,20 +24,13 @@
 //     export하지 않는다.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-email {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   &__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;
+    @include field.label;
   }
 
   // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다.
@@ -65,28 +58,11 @@
   }
 
   &__message {
-    display: flex;
-    gap: fox.number(3);
-    align-items: flex-start;
-    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;
+    @include field.message;
   }
 
   &__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);
-    }
+    @include field.message-icon;
   }
 
   &:has([aria-invalid="true"]) .fox-email__message {
@fox/styles/_fox-file-upload.scss
--- @fox/styles/_fox-file-upload.scss
+++ @fox/styles/_fox-file-upload.scss
@@ -27,12 +27,10 @@
 //   - 목록의 margin/padding/list-style 0: `<ul>`의 브라우저 기본값 지우기.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-file-upload {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   // area 모드는 상자와 목록 사이가 한 단계 넓다.
   &--area {
@@ -40,12 +38,7 @@
   }
 
   &__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;
+    @include field.label;
   }
 
   &__description {
@fox/styles/_fox-input.scss
--- @fox/styles/_fox-input.scss
+++ @fox/styles/_fox-input.scss
@@ -20,70 +20,34 @@
 //   - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다. 자동 규칙은 전부 꺼진다.
 //     카탈로그·스냅샷처럼 상태를 눈으로 비교해야 할 때 쓴다.
 //
-// 라벨은 입력 크기와 무관하게 항상 label/md다(시안의 form-label이 공용 컴포넌트라 크기를
-// 따라가지 않는다).
+// 라벨·헬퍼·상자·알맹이와 두 갈래 상태는 `form-field` 믹스인이 갖는다 — 입력 계열 여섯
+// 컴포넌트가 같은 값을 쓴다. 이 파일에는 FoxInput만의 것(크기 축, 아이콘, 지우기 버튼)이
+// 남는다.
 //
 // 아이콘 크기 24/20/16은 시안이 변수를 걸지 않았지만 상자의 나머지 치수가 전부 `form/*`
 // 반응형 토큰이라 아이콘도 `form-icon-*`(PC 24/20/16 · 모바일 20/16/12)에 맞춘다.
 //
 // 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목):
-//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다.
-//   - 헬퍼 메시지의 line-height 1.5: 위와 같다.
-//   - box-sizing / cursor / flex / appearance 관련: 구조·상호작용 규칙.
+//   - cursor / flex / appearance 관련: 구조·상호작용 규칙.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-input {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   &__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;
+    @include field.label;
   }
 
+  // 크기(높이·좌우 여백·모서리)는 아래 크기 수식어가 정한다.
   &__box {
-    box-sizing: border-box;
-    display: flex;
-    align-items: center;
-    gap: fox.gap(3);
-    inline-size: 100%;
-    background: fox.color(input-surface);
-    border: fox.border(1) solid fox.color(input-border);
+    @include field.box;
   }
 
+  // 글자 크기도 크기 수식어가 정한다.
   &__field {
-    flex: 1 0 0;
-    min-inline-size: 0;
-    margin: 0;
-    padding: 0;
-    border: none;
-    background: none;
-    color: fox.color(font-neutral-default);
-    font-family: fox.font-family(body);
-    font-weight: fox.font-weight(regular);
-    line-height: 1;
-    letter-spacing: -0.025em;
-
-    &::placeholder {
-      color: fox.color(font-neutral-subtler);
-    }
-
-    // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다.
-    &:focus {
-      outline: none;
-    }
-
-    &:disabled {
-      color: fox.color(font-neutral-disabled-strong);
-      cursor: default;
-    }
+    @include field.field;
   }
 
   &__icon,
@@ -110,28 +74,11 @@
   }
 
   &__message {
-    display: flex;
-    gap: fox.number(3);
-    align-items: flex-start;
-    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;
+    @include field.message;
   }
 
   &__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);
-    }
+    @include field.message-icon;
   }
 
   // ── 크기 ──────────────────────────────────────────────────────────────────
@@ -193,93 +140,10 @@
   }
 }
 
-// ── 상태 · 자동 ─────────────────────────────────────────────────────────────
-// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성.
-.fox-input:not([data-state]) {
-  .fox-input__box:focus-within {
-    border-width: fox.border(2);
-    border-color: fox.color(input-border-active);
+@include field.auto-states("fox-input");
+
+@include field.fixed-states("fox-input") {
+  .fox-input[data-state="focused"] .fox-input__clear {
+    display: inline-flex;
   }
-
-  .fox-input__box:has(.fox-input__field[aria-invalid="true"]) {
-    border-width: fox.border(2);
-    border-color: fox.color(input-border-error);
-  }
-
-  // 비활성도 `:read-only`에 걸리므로 떼어낸다.
-  .fox-input__box:has(.fox-input__field:read-only:not(:disabled)) {
-    border-width: fox.border(1);
-    border-color: fox.color(input-border-disabled);
-    background: fox.color(input-surface-disabled);
-  }
-
-  .fox-input__box:has(.fox-input__field:disabled) {
-    border-width: fox.border(1);
-    border-color: fox.color(input-border-disabled);
-    background: fox.color(input-surface-disabled);
-  }
-
-  &:focus-within .fox-input__message {
-    color: fox.color(font-system-information);
-  }
-
-  &:has(.fox-input__field[aria-invalid="true"]) .fox-input__message {
-    color: fox.color(font-system-danger);
-  }
-}
-
-// ── 상태 · 고정 ─────────────────────────────────────────────────────────────
-// 시안의 focused·completed·error·view는 값이 들어찬 모습이라 플레이스홀더도 진하게 둔다.
-.fox-input[data-state="default"] .fox-input__box,
-.fox-input[data-state="completed"] .fox-input__box {
-  border-width: fox.border(1);
-  border-color: fox.color(input-border);
-  background: fox.color(input-surface);
-}
-
-.fox-input[data-state="focused"] .fox-input__box {
-  border-width: fox.border(2);
-  border-color: fox.color(input-border-active);
-  background: fox.color(input-surface);
-}
-
-.fox-input[data-state="error"] .fox-input__box {
-  border-width: fox.border(2);
-  border-color: fox.color(input-border-error);
-  background: fox.color(input-surface);
-}
-
-.fox-input[data-state="view"] .fox-input__box,
-.fox-input[data-state="disabled"] .fox-input__box {
-  border-width: fox.border(1);
-  border-color: fox.color(input-border-disabled);
-  background: fox.color(input-surface-disabled);
-}
-
-.fox-input[data-state="completed"] .fox-input__field::placeholder,
-.fox-input[data-state="focused"] .fox-input__field::placeholder,
-.fox-input[data-state="error"] .fox-input__field::placeholder,
-.fox-input[data-state="view"] .fox-input__field::placeholder {
-  color: fox.color(font-neutral-default);
-}
-
-.fox-input[data-state="disabled"] .fox-input__field,
-.fox-input[data-state="disabled"] .fox-input__field::placeholder {
-  color: fox.color(font-neutral-disabled-strong);
-}
-
-.fox-input[data-state="focused"] .fox-input__message {
-  color: fox.color(font-system-information);
-}
-
-.fox-input[data-state="completed"] .fox-input__message {
-  color: fox.color(font-system-success);
-}
-
-.fox-input[data-state="error"] .fox-input__message {
-  color: fox.color(font-system-danger);
-}
-
-.fox-input[data-state="focused"] .fox-input__clear {
-  display: inline-flex;
 }
@fox/styles/_fox-phone-number.scss
--- @fox/styles/_fox-phone-number.scss
+++ @fox/styles/_fox-phone-number.scss
@@ -29,28 +29,23 @@
 // 상태는 FoxInput과 같은 두 갈래다(자동 / `data-state` 고정). combine 상자에만 규칙이
 // 필요하다 — unit은 조각이 알아서 따라온다.
 //
+// 라벨·헬퍼·상자·알맹이와 두 갈래 상태는 `form-field` 믹스인이 갖는다.
+//
 // 시안에 크기 축이 없다. 상자는 md 하나뿐이다.
 //
 // 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목):
 //   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
 //     export하지 않는다.
-//   - box-sizing / flex / text-align: 구조 규칙.
+//   - flex / text-align: 구조 규칙.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-phone-number {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   &__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;
+    @include field.label;
   }
 
   // ── unit ──────────────────────────────────────────────────────────────────
@@ -79,156 +74,32 @@
 
   // ── combine ───────────────────────────────────────────────────────────────
   &__box {
-    box-sizing: border-box;
-    display: flex;
-    align-items: center;
-    gap: fox.gap(2);
-    inline-size: 100%;
-    block-size: fox.form(height-md);
-    padding-inline: fox.form(padding-md);
-    border: fox.border(1) solid fox.color(input-border);
-    border-radius: fox.form(radius-md);
-    background: fox.color(input-surface);
+    @include field.box($gap: 2);
+    @include field.box-size(md);
   }
 
   &__segment {
-    flex: 1 0 0;
-    min-inline-size: 0;
-    margin: 0;
-    padding: 0;
-    border: none;
-    background: none;
-    color: fox.color(font-neutral-default);
-    font-family: fox.font-family(body);
-    font-size: fox.font-size(label-md);
-    font-weight: fox.font-weight(regular);
-    line-height: 1;
-    letter-spacing: -0.025em;
     text-align: center;
 
-    &::placeholder {
-      color: fox.color(font-neutral-subtler);
-    }
-
-    // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다.
-    &:focus {
-      outline: none;
-    }
-
-    &:disabled {
-      color: fox.color(font-neutral-disabled-strong);
-      cursor: default;
-    }
+    @include field.field($font-size: label-md);
   }
 
   &__message {
-    display: flex;
-    gap: fox.number(3);
-    align-items: flex-start;
-    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;
+    @include field.message;
   }
 
   &__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);
-    }
+    @include field.message-icon;
   }
 }
 
-// ── 상태 · 자동 (combine 상자) ──────────────────────────────────────────────
-// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성.
-.fox-phone-number:not([data-state]) {
-  .fox-phone-number__box:focus-within {
-    border-width: fox.border(2);
-    border-color: fox.color(input-border-active);
-  }
+// unit 모양은 조각(FoxSelect·FoxInput)이 제 상태를 갖고 있어 combine 상자에만 규칙이
+// 필요하다. 다만 헬퍼 색은 두 모양 모두 물들어야 하므로 오류 표시를 넓게 본다 — unit에는
+// `__segment`가 없다.
+@include field.auto-states(
+  "fox-phone-number",
+  $field: "segment",
+  $invalid-within: "[aria-invalid=\"true\"]"
+);
 
-  .fox-phone-number__box:has(.fox-phone-number__segment[aria-invalid="true"]) {
-    border-width: fox.border(2);
-    border-color: fox.color(input-border-error);
-  }
-
-  // 비활성도 `:read-only`에 걸리므로 떼어낸다.
-  .fox-phone-number__box:has(.fox-phone-number__segment:read-only:not(:disabled)) {
-    border-width: fox.border(1);
-    border-color: fox.color(input-border-disabled);
-    background: fox.color(input-surface-disabled);
-  }
-
-  .fox-phone-number__box:has(.fox-phone-number__segment:disabled) {
-    border-width: fox.border(1);
-    border-color: fox.color(input-border-disabled);
-    background: fox.color(input-surface-disabled);
-  }
-
-  &:focus-within .fox-phone-number__message {
-    color: fox.color(font-system-information);
-  }
-
-  &:has([aria-invalid="true"]) .fox-phone-number__message {
-    color: fox.color(font-system-danger);
-  }
-}
-
-// ── 상태 · 고정 ─────────────────────────────────────────────────────────────
-.fox-phone-number[data-state="default"] .fox-phone-number__box,
-.fox-phone-number[data-state="completed"] .fox-phone-number__box {
-  border-width: fox.border(1);
-  border-color: fox.color(input-border);
-  background: fox.color(input-surface);
-}
-
-.fox-phone-number[data-state="focused"] .fox-phone-number__box {
-  border-width: fox.border(2);
-  border-color: fox.color(input-border-active);
-  background: fox.color(input-surface);
-}
-
-.fox-phone-number[data-state="error"] .fox-phone-number__box {
-  border-width: fox.border(2);
-  border-color: fox.color(input-border-error);
-  background: fox.color(input-surface);
-}
-
-.fox-phone-number[data-state="view"] .fox-phone-number__box,
-.fox-phone-number[data-state="disabled"] .fox-phone-number__box {
-  border-width: fox.border(1);
-  border-color: fox.color(input-border-disabled);
-  background: fox.color(input-surface-disabled);
-}
-
-.fox-phone-number[data-state="completed"] .fox-phone-number__segment::placeholder,
-.fox-phone-number[data-state="focused"] .fox-phone-number__segment::placeholder,
-.fox-phone-number[data-state="error"] .fox-phone-number__segment::placeholder,
-.fox-phone-number[data-state="view"] .fox-phone-number__segment::placeholder {
-  color: fox.color(font-neutral-default);
-}
-
-.fox-phone-number[data-state="disabled"] .fox-phone-number__segment,
-.fox-phone-number[data-state="disabled"] .fox-phone-number__segment::placeholder {
-  color: fox.color(font-neutral-disabled-strong);
-}
-
-.fox-phone-number[data-state="focused"] .fox-phone-number__message {
-  color: fox.color(font-system-information);
-}
-
-.fox-phone-number[data-state="completed"] .fox-phone-number__message {
-  color: fox.color(font-system-success);
-}
-
-.fox-phone-number[data-state="error"] .fox-phone-number__message {
-  color: fox.color(font-system-danger);
-}
+@include field.fixed-states("fox-phone-number", $field: "segment");
@fox/styles/_fox-text-area.scss
--- @fox/styles/_fox-text-area.scss
+++ @fox/styles/_fox-text-area.scss
@@ -21,71 +21,40 @@
 //     플레이스홀더와 입력값의 네이티브 차이라 규칙이 필요 없다.
 //   - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다.
 //
+// 라벨·헬퍼·상자·알맹이와 두 갈래 상태는 `form-field` 믹스인이 갖는다. 이 파일에는
+// FoxTextArea만의 것(여러 줄 여백, 카운터)이 남는다.
+//
 // 시안에 크기 축이 없다 — 상자 하나뿐이고 높이는 줄 수가 정한다(시안은 5줄).
 //
 // 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목):
-//   - line-height(본문·헬퍼 1.5, 라벨·카운터 1) / letter-spacing: 시안 텍스트 스타일의
-//     값이나 Figma가 변수로 export하지 않는다.
-//   - box-sizing / resize / flex 관련: 구조·상호작용 규칙. 시안에 리사이즈 핸들이 없어
-//     막아 둔다 — 높이가 필요하면 `rows`로 정한다.
+//   - line-height(카운터 1) / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - resize / flex 관련: 구조·상호작용 규칙. 시안에 리사이즈 핸들이 없어 막아 둔다 —
+//     높이가 필요하면 `rows`로 정한다.
 
 @use "abstracts" as fox;
+@use "form-field" as field;
 
 .fox-text-area {
-  display: flex;
-  flex-direction: column;
-  gap: fox.gap(3);
-  inline-size: 100%;
+  @include field.root;
 
   &__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;
+    @include field.label;
   }
 
+  // 여러 줄이라 세로 가운데 정렬을 끄고, 크기 축이 없어 md 여백을 직접 준다.
   &__box {
-    box-sizing: border-box;
-    display: flex;
-    gap: fox.gap(3);
-    inline-size: 100%;
+    @include field.box($align: null);
+
     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: none;
-    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;
-    }
+    @include field.field($font-size: body-md, $line-height: 1.5);
   }
 
   // 헬퍼는 왼쪽에서 남는 폭을 먹고 카운터는 오른쪽에 붙는다.
@@ -97,31 +66,16 @@
     inline-size: 100%;
   }
 
+  // 카운터와 한 줄에 서므로 남는 폭을 먹는다.
   &__message {
-    display: flex;
+    @include field.message;
+
     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);
-    }
+    @include field.message-icon;
   }
 
   &__counter {
@@ -143,98 +97,15 @@
   }
 }
 
-// ── 상태 · 자동 ─────────────────────────────────────────────────────────────
-// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성.
-.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);
-  }
-
+@include field.auto-states("fox-text-area") {
   // 시안의 오류 변형에서만 카운터 현재값이 빨갛다.
   &: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);
+@include field.fixed-states("fox-text-area") {
+  .fox-text-area[data-state="error"] .fox-text-area__counter-current {
+    color: fox.color(font-system-danger);
+  }
 }
Add a comment
List