임동욱 임동욱 08-13
feat: FoxPhoneNumber 추가
Figma form-phonenumber(391:3156)의 두 모양을 옮긴다. unit은 상자 셋을
하이픈으로 잇고, combine은 상자 하나에 세 칸을 둔다.

값은 숫자만 담긴 문자열 하나다 — 하이픈은 화면에만 있고 값에는 없다. 화면의
세 칸이 진실 공급원이라 입력 도중 "0101"처럼 어디까지가 앞자리인지 알 수 없는
값이 생겨도 칸이 흔들리지 않고, 밖에서 value를 바꿨을 때만 다시 나눈다.

나눌 때 앞자리는 prefixOptions 중 가장 긴 일치를, 뒷자리는 항상 마지막 네
자리를 쓴다 — 010-123-4567과 010-1234-5678이 둘 다 맞게 갈린다. 목록이 없으면
maxLengths[0]만큼을 앞자리로 보므로 지역번호 02는 목록에 넣어야 한다.

unit의 조각은 시안에서도 select·input 인스턴스라 FoxSelect·FoxInput을 그대로
조합한다. 상태·포커스가 저절로 따라오고 이 파일은 줄 배치와 하이픈만 갖는다.
combine 상자만 자체 규칙을 둔다.

칸을 다 채우면 다음 칸으로 옮기고 빈 칸에서 지우면 앞칸으로 돌아간다
(autoAdvance로 끌 수 있다).

Co-Authored-By: Claude Opus 5 
@43030faabe9da217c0f5768156b768e42775d963
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -110,6 +110,17 @@
   <p class="fox-input__message"><!-- 헬퍼 (선택) --></p>
 </div>
 
+<div class="fox-phone-number" role="group" aria-labelledby="phone-label">
+  <span class="fox-phone-number__label" id="phone-label">전화번호</span>
+  <div class="fox-phone-number__box">          <!-- combine -->
+    <input class="fox-phone-number__segment" aria-label="전화번호 앞자리">
+    <span class="fox-phone-number__separator">-</span>
+    <input class="fox-phone-number__segment" aria-label="전화번호 가운데자리">
+    <span class="fox-phone-number__separator">-</span>
+    <input class="fox-phone-number__segment" aria-label="전화번호 뒷자리">
+  </div>
+</div>
+
 <div class="fox-text-area">
   <label class="fox-text-area__label" for="memo">레이블</label>
   <div class="fox-text-area__box">
@@ -138,6 +149,9 @@
   비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에
   `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`).
 - 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다.
+- 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지
+  않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은
+  상자 하나에 세 칸을 둡니다.
 - 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다.
 
 `core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도
 
@fox/core/components/fox-phone-number/fox-phone-number.tsx (added)
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import {
+  Fragment,
+  useId,
+  useRef,
+  useState,
+  type ChangeEvent,
+  type KeyboardEvent,
+  type ReactNode,
+} from "react";
+import { cx } from "../../utils";
+import { FoxInput, type FoxInputState } from "../fox-input";
+import { FoxSelect, type FoxSelectItem } from "../fox-select";
+
+export type FoxPhoneNumberType = "unit" | "combine";
+export type FoxPhoneNumberState = FoxInputState;
+
+type Parts = [string, string, string];
+type Lengths = [number, number, number];
+
+export interface FoxPhoneNumberProps {
+  /** `unit`은 상자 셋을 하이픈으로 잇고, `combine`은 상자 하나에 세 칸을 둔다. */
+  type?: FoxPhoneNumberType;
+  /**
+   * 숫자만 담긴 값. 하이픈은 화면에만 있고 값에는 들어가지 않는다 — `"01012345678"`.
+   * 주면 제어 컴포넌트가 된다.
+   */
+  value?: string;
+  defaultValue?: string;
+  onChange?: (value: string) => void;
+  /**
+   * 앞자리 목록. `unit`에서는 선택 목록이 되고, 두 모양 모두 **밖에서 받은 값을 나눌 때**
+   * 앞자리 길이를 알아내는 데 쓴다 — 없으면 `maxLengths[0]`만큼을 앞자리로 본다.
+   * 지역번호 `02`처럼 짧은 앞자리를 쓰면 목록에 넣거나 `maxLengths`를 함께 조정한다.
+   */
+  prefixOptions?: string[];
+  label?: string;
+  /** 헬퍼 메시지. 색은 상태를 따라간다. */
+  message?: string;
+  /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */
+  messageIcon?: ReactNode;
+  placeholders?: Parts;
+  /** 칸별 최대 자릿수. 자동 이동의 기준이기도 하다. */
+  maxLengths?: Lengths;
+  /** 칸을 다 채우면 다음 칸으로 옮기고, 빈 칸에서 지우면 앞칸으로 돌아간다. */
+  autoAdvance?: boolean;
+  /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */
+  invalid?: boolean;
+  disabled?: boolean;
+  readOnly?: boolean;
+  /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
+  state?: FoxPhoneNumberState;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+  name?: string;
+  id?: string;
+}
+
+const DEFAULT_PLACEHOLDERS: Parts = ["010", "1234", "5678"];
+const DEFAULT_MAX_LENGTHS: Lengths = [3, 4, 4];
+const SEGMENT_LABELS: Parts = ["전화번호 앞자리", "전화번호 가운데자리", "전화번호 뒷자리"];
+
+/**
+ * 숫자만 담긴 값을 화면의 세 칸으로 나눈다.
+ *
+ * 앞자리는 선택 목록 중 가장 긴 일치를 쓴다 — `02`와 `021`이 함께 있어도 흔들리지 않는다.
+ * 뒷자리는 항상 마지막 네 자리다(시안 기준). 그래서 `010-123-4567`과 `010-1234-5678`이
+ * 둘 다 맞게 갈린다. 남은 글자가 그보다 짧으면 아직 입력 중이라 보고 가운데에 둔다.
+ */
+function splitValue(value: string, prefixOptions: string[], maxLengths: Lengths): Parts {
+  const digits = value ?? "";
+  const matched = prefixOptions
+    .filter((option) => option && digits.startsWith(option))
+    .sort((a, b) => b.length - a.length)[0];
+  const head = matched ?? digits.slice(0, maxLengths[0]);
+  const rest = digits.slice(head.length);
+
+  if (rest.length > maxLengths[2]) {
+    return [head, rest.slice(0, rest.length - maxLengths[2]), rest.slice(-maxLengths[2])];
+  }
+  return [head, rest, ""];
+}
+
+/**
+ * @fox 전화번호 입력. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나다.
+ *
+ * 화면의 세 칸이 값의 진실 공급원이다 — 입력 도중 `"0101"`처럼 어디까지가 앞자리인지
+ * 알 수 없는 값이 생겨도 칸이 흔들리지 않는다. 밖에서 `value`를 바꿨을 때만 다시 나눈다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxPhoneNumber({
+  type = "unit",
+  value,
+  defaultValue,
+  onChange,
+  prefixOptions = [],
+  label,
+  message,
+  messageIcon,
+  placeholders = DEFAULT_PLACEHOLDERS,
+  maxLengths = DEFAULT_MAX_LENGTHS,
+  autoAdvance = true,
+  invalid = false,
+  disabled = false,
+  readOnly = false,
+  state,
+  hidden = false,
+  className,
+  name,
+  id,
+}: FoxPhoneNumberProps) {
+  const autoId = useId();
+  const [parts, setParts] = useState<Parts>(() =>
+    splitValue(value ?? defaultValue ?? "", prefixOptions, maxLengths)
+  );
+  const [seenValue, setSeenValue] = useState(value);
+  const tailRefs = useRef<(HTMLInputElement | null)[]>([]);
+
+  // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다.
+  if (value !== undefined && value !== seenValue) {
+    setSeenValue(value);
+    if (value !== parts.join("")) {
+      setParts(splitValue(value, prefixOptions, maxLengths));
+    }
+  }
+
+  if (hidden) {
+    return null;
+  }
+
+  const rootId = id ?? autoId;
+  const labelId = `${rootId}-label`;
+  const messageId = `${rootId}-message`;
+  const errored = invalid || state === "error";
+
+  const commit = (index: number, next: string) => {
+    const nextParts = [...parts] as Parts;
+    nextParts[index] = next;
+    setParts(nextParts);
+    const joined = nextParts.join("");
+    setSeenValue(joined);
+    onChange?.(joined);
+  };
+
+  const focusSegment = (index: number) => {
+    tailRefs.current[index]?.focus();
+  };
+
+  const handleSegmentChange = (index: number) => (next: string) => {
+    commit(index, next);
+    if (autoAdvance && index < 2 && next.length >= maxLengths[index]) {
+      focusSegment(index + 1);
+    }
+  };
+
+  // 빈 칸에서 백스페이스하면 앞칸으로 돌아간다.
+  const handleSegmentKeyDown = (index: number) => (event: KeyboardEvent<HTMLInputElement>) => {
+    if (autoAdvance && event.key === "Backspace" && index > 0 && parts[index] === "") {
+      event.preventDefault();
+      focusSegment(index - 1);
+    }
+  };
+
+  const prefixItems: FoxSelectItem[] = prefixOptions.map((option) => ({
+    value: option,
+    label: option,
+  }));
+
+  const body =
+    type === "unit" ? (
+      <div className="fox-phone-number__row">
+        <div className="fox-phone-number__part">
+          <FoxSelect
+            size="md"
+            options={prefixItems}
+            value={parts[0]}
+            onValueChange={(next) => {
+              commit(0, next);
+              if (autoAdvance) {
+                focusSegment(1);
+              }
+            }}
+            placeholder={placeholders[0]}
+            error={errored}
+            disabled={disabled || state === "disabled"}
+            readOnly={readOnly || state === "view"}
+          />
+        </div>
+
+        {[1, 2].map((index) => (
+          <Fragment key={index}>
+            <span className="fox-phone-number__separator" aria-hidden="true">
+              -
+            </span>
+            <div className="fox-phone-number__part">
+              <FoxInput
+                size="md"
+                inputMode="numeric"
+                aria-label={SEGMENT_LABELS[index]}
+                value={parts[index]}
+                onChange={handleSegmentChange(index)}
+                onKeyDown={handleSegmentKeyDown(index)}
+                placeholder={placeholders[index]}
+                maxLength={maxLengths[index]}
+                invalid={invalid}
+                disabled={disabled}
+                readOnly={readOnly}
+                state={state}
+                ref={(node) => {
+                  tailRefs.current[index] = node;
+                }}
+              />
+            </div>
+          </Fragment>
+        ))}
+      </div>
+    ) : (
+      <div className="fox-phone-number__box">
+        {[0, 1, 2].map((index) => (
+          <Fragment key={index}>
+            {index > 0 && (
+              <span className="fox-phone-number__separator" aria-hidden="true">
+                -
+              </span>
+            )}
+            <input
+              className="fox-phone-number__segment"
+              inputMode="numeric"
+              aria-label={SEGMENT_LABELS[index]}
+              aria-invalid={errored || undefined}
+              aria-describedby={message ? messageId : undefined}
+              value={parts[index]}
+              placeholder={placeholders[index]}
+              maxLength={maxLengths[index]}
+              disabled={disabled}
+              readOnly={readOnly}
+              onChange={(event: ChangeEvent<HTMLInputElement>) =>
+                handleSegmentChange(index)(event.target.value)
+              }
+              onKeyDown={handleSegmentKeyDown(index)}
+              ref={(node) => {
+                tailRefs.current[index] = node;
+              }}
+            />
+          </Fragment>
+        ))}
+      </div>
+    );
+
+  return (
+    <div
+      className={cx("fox-phone-number", className)}
+      data-state={state}
+      role="group"
+      aria-labelledby={label ? labelId : undefined}
+    >
+      {label && (
+        <span className="fox-phone-number__label" id={labelId}>
+          {label}
+        </span>
+      )}
+
+      {body}
+
+      {/* 폼 전송용. 화면의 세 칸은 이름을 갖지 않는다 — 값은 언제나 합쳐진 하나다. */}
+      {name && <input type="hidden" name={name} value={parts.join("")} />}
+
+      {message && (
+        <p className="fox-phone-number__message" id={messageId}>
+          {messageIcon && (
+            <span className="fox-phone-number__message-icon" aria-hidden="true">
+              {messageIcon}
+            </span>
+          )}
+          {message}
+        </p>
+      )}
+    </div>
+  );
+}
 
@fox/core/components/fox-phone-number/index.ts (added)
+++ @fox/core/components/fox-phone-number/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxPhoneNumber,
+  type FoxPhoneNumberProps,
+  type FoxPhoneNumberType,
+  type FoxPhoneNumberState,
+} from "./fox-phone-number";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -4,6 +4,7 @@
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
+export * from "./fox-phone-number";
 export * from "./fox-select";
 export * from "./fox-select-option";
 export * from "./fox-select-option-item";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -7,6 +7,7 @@
 import { FoxButtonPanel } from "../core/components/fox-button-panel";
 import { FoxIconButton } from "../core/components/fox-icon-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
+import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import { FoxTextButton } from "../core/components/fox-text-button";
 import styles from "./dev-test.module.scss";
@@ -122,6 +123,37 @@
           maxLength={100}
           counter
           invalid
+        />
+      </InputStage>
+    </>
+  );
+}
+
+const PHONE_PREFIXES = ["010", "011", "016", "017", "018", "019", "02", "031", "051"];
+
+// 값이 숫자만 담긴 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다.
+function PhoneNumberLiveDemo() {
+  const [unit, setUnit] = useState("01012345678");
+  const [combine, setCombine] = useState("0212345678");
+  return (
+    <>
+      <InputStage>
+        <FoxPhoneNumber
+          label={`unit — value="${unit}"`}
+          prefixOptions={PHONE_PREFIXES}
+          value={unit}
+          onChange={setUnit}
+          message="칸을 다 채우면 다음 칸으로 넘어갑니다"
+        />
+      </InputStage>
+      <InputStage>
+        <FoxPhoneNumber
+          type="combine"
+          label={`combine — value="${combine}"`}
+          prefixOptions={PHONE_PREFIXES}
+          value={combine}
+          onChange={setCombine}
+          message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다"
         />
       </InputStage>
     </>
@@ -933,4 +965,85 @@
       },
     ],
   },
+  {
+    id: "fox-phone-number",
+    name: "FoxPhoneNumber",
+    description:
+      "Figma 시안(form-phonenumber 391:3156)을 옮겼습니다. unit은 상자 셋을 하이픈으로 잇고 앞자리가 선택이며, combine은 상자 하나에 세 칸을 둡니다. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 않습니다. 칸을 다 채우면 다음 칸으로 넘어가고, 빈 칸에서 지우면 앞칸으로 돌아갑니다.",
+    variants: [
+      {
+        label: "직접 조작 — 값이 그대로 라벨에 보입니다",
+        node: <PhoneNumberLiveDemo />,
+      },
+      {
+        label: "모양 (type)",
+        node: (
+          <>
+            <InputStage>
+              <FoxPhoneNumber label="unit" prefixOptions={PHONE_PREFIXES} />
+            </InputStage>
+            <InputStage>
+              <FoxPhoneNumber type="combine" label="combine" prefixOptions={PHONE_PREFIXES} />
+            </InputStage>
+          </>
+        ),
+      },
+      {
+        label: "상태 고정 (state) — combine 기준",
+        node: (
+          <>
+            {(
+              ["default", "focused", "completed", "error", "disabled", "view"] as FoxPhoneNumberState[]
+            ).map((state) => (
+              <InputStage key={state}>
+                <FoxPhoneNumber
+                  type="combine"
+                  state={state}
+                  label={state}
+                  message="힌트 메시지"
+                />
+              </InputStage>
+            ))}
+          </>
+        ),
+      },
+      {
+        label: "자동 이동 끄기 (autoAdvance)",
+        node: (
+          <InputStage>
+            <FoxPhoneNumber
+              label="autoAdvance={false}"
+              prefixOptions={PHONE_PREFIXES}
+              autoAdvance={false}
+              message="칸을 다 채워도 커서가 그대로 있습니다"
+            />
+          </InputStage>
+        ),
+      },
+      {
+        label: "비활성 · 읽기 전용",
+        node: (
+          <>
+            <InputStage>
+              <FoxPhoneNumber
+                label="disabled"
+                prefixOptions={PHONE_PREFIXES}
+                defaultValue="01012345678"
+                disabled
+              />
+            </InputStage>
+            <InputStage>
+              <FoxPhoneNumber
+                type="combine"
+                label="readOnly"
+                prefixOptions={PHONE_PREFIXES}
+                defaultValue="01012345678"
+                readOnly
+              />
+            </InputStage>
+          </>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-phone-number.scss (added)
+++ @fox/styles/_fox-phone-number.scss
@@ -0,0 +1,234 @@
+// FoxPhoneNumber — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                         Figma form-phonenumber (섹션 391:3156)
+//
+// 두 모양이 있다.
+//   - unit(136:2926): 상자 셋을 하이픈으로 잇는다. 앞자리는 선택, 뒤 둘은 입력이며
+//     시안에서도 select·input 컴포넌트의 인스턴스라 그대로 조합한다. 그래서 이 파일은
+//     줄 배치와 하이픈만 갖고, 상자 모양·상태는 FoxSelect·FoxInput이 가진 것을 쓴다.
+//   - combine(190:1772): 상자 하나 안에 세 칸을 두고 각 칸을 가운데 정렬한다. 이건
+//     시안이 별도 상자라 여기서 직접 그린다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-phone-number" role="group" aria-labelledby="…">
+//     <span class="fox-phone-number__label" id="…">레이블</span>
+//     <div class="fox-phone-number__row">                    <!-- unit -->
+//       <div class="fox-phone-number__part">…select…</div>
+//       <span class="fox-phone-number__separator">-</span>
+//       <div class="fox-phone-number__part">…input…</div>
+//       <span class="fox-phone-number__separator">-</span>
+//       <div class="fox-phone-number__part">…input…</div>
+//     </div>
+//     <div class="fox-phone-number__box">                    <!-- combine -->
+//       <input class="fox-phone-number__segment">
+//       <span class="fox-phone-number__separator">-</span>
+//       …
+//     </div>
+//     <p class="fox-phone-number__message">…</p>
+//   </div>
+//
+// 상태는 FoxInput과 같은 두 갈래다(자동 / `data-state` 고정). combine 상자에만 규칙이
+// 필요하다 — unit은 조각이 알아서 따라온다.
+//
+// 시안에 크기 축이 없다. 상자는 md 하나뿐이다.
+//
+// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - box-sizing / flex / text-align: 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-phone-number {
+  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;
+  }
+
+  // ── unit ──────────────────────────────────────────────────────────────────
+  &__row {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(2);
+    inline-size: 100%;
+  }
+
+  // 세 조각이 남는 폭을 똑같이 나눈다.
+  &__part {
+    flex: 1 0 0;
+    min-inline-size: 0;
+  }
+
+  &__separator {
+    flex-shrink: 0;
+    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;
+  }
+
+  // ── 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);
+  }
+
+  &__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;
+    }
+  }
+
+  &__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;
+  }
+
+  &__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);
+    }
+  }
+}
+
+// ── 상태 · 자동 (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);
+  }
+
+  .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);
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -8,6 +8,7 @@
 @use "fox-button-group";
 @use "fox-button-panel";
 @use "fox-input";
+@use "fox-phone-number";
 @use "fox-select";
 @use "fox-select-option";
 @use "fox-select-option-item";
Add a comment
List