임동욱 임동욱 08-19
Merge branch 'develop' of http://git.dataeum.kr/ldu0009/edupay-admin into hub
@32521b124752df5083cefea3693c9b3e68ee237c
 
@fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx (added)
+++ @fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx
@@ -0,0 +1,108 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+/** 시안의 state. 지난 단계 · 지금 단계 · 아직 오지 않은 단계다. */
+export type FoxStepIndicatorItemState = "completion" | "ongoing" | "before";
+
+export interface FoxStepIndicatorItemProps {
+  state?: FoxStepIndicatorItemState;
+  /** 단계 번호 줄(작고 흐린 글자). 넘기지 않으면 렌더링하지 않는다. */
+  step?: ReactNode;
+  /** 단계 제목 줄. 넘기지 않으면 렌더링하지 않는다. */
+  title?: ReactNode;
+  /**
+   * 동그라미 오른쪽으로 뻗는 선. **마지막 단계에서는 꺼야 한다** — 켜 두면 선이 묶음 밖으로
+   * 삐져나간다. 선 색은 `state`가 정한다(지난 단계만 진하다).
+   */
+  line?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 시안의 폭(80px)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/** `Record`로 고정해 상태를 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const STATE_CLASS: Record<FoxStepIndicatorItemState, string> = {
+  completion: "fox-step-indicator-item--completion",
+  ongoing: "fox-step-indicator-item--ongoing",
+  before: "fox-step-indicator-item--before",
+};
+
+/**
+ * @fox 단계 하나. 위에 동그라미와 선, 아래에 단계 번호와 제목이 놓인다.
+ * `FoxStepIndicator` 안에 여러 개를 늘어놓아 쓴다.
+ *
+ * 상태를 갖지 않는다 — 어디까지 왔는지는 호출부가 각 단계의 `state`로 정한다.
+ *
+ * **모바일에서는 글자가 사라지고 동그라미와 선만 남는다.** 그 판단은 CSS가 한다(화면 폭) —
+ * 기기를 prop으로 받지 않는다. 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고,
+ * React를 쓰지 않는 소비자는 같은 결과를 얻지 못한다(`FoxChipSelectOption`의 확인 버튼과
+ * 같은 근거).
+ *
+ * ⚠️ 진행 상태를 소리로도 전해야 하면 감싼 쪽이 알린다 — 이 조각의 동그라미·선은 장식이라
+ * 보조기술에 아무것도 말하지 않고, 읽히는 것은 단계 번호와 제목뿐이다. 모바일에서는 그
+ * 글자마저 사라지므로, 그 화면에서 순서를 전해야 하면 호출부가 이름을 따로 준다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStepIndicatorItem({
+  state = "before",
+  step,
+  title,
+  line = true,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxStepIndicatorItemProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      className={cx("fox-step-indicator-item", STATE_CLASS[state], className)}
+    >
+      <div className="fox-step-indicator-item__indicator" aria-hidden="true">
+        <span className="fox-step-indicator-item__mark">
+          {state === "completion" ? <CheckMark /> : null}
+          {state === "ongoing" ? <span className="fox-step-indicator-item__dot" /> : null}
+        </span>
+        {line ? <span className="fox-step-indicator-item__line" /> : null}
+      </div>
+
+      {step === undefined && title === undefined ? null : (
+        <div className="fox-step-indicator-item__text">
+          {step === undefined ? null : (
+            <span className="fox-step-indicator-item__step">{step}</span>
+          )}
+          {title === undefined ? null : (
+            <span className="fox-step-indicator-item__title">{title}</span>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 지난 단계의 체크 표시. `@fox/core/icons`(Phosphor)를 쓰지 않는다 — 시안이 준 것은 선으로
+// 그린 10×8 글리프이고, Phosphor의 Check는 면으로 채운 다른 그림이라 크기·굵기가 맞지 않는다.
+// `stroke`를 시안의 리터럴(흰색) 대신 `currentColor`로 두면 색을 상태 규칙이 정한다.
+function CheckMark() {
+  return (
+    <svg viewBox="0 0 12 10" fill="none" aria-hidden="true">
+      <path
+        d="M0.75 4.75006L4.96726 8.75006L10.75 0.750061"
+        stroke="currentColor"
+        strokeWidth="1.5"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
 
@fox/core/components/fox-step-indicator-item/index.ts (added)
+++ @fox/core/components/fox-step-indicator-item/index.ts
@@ -0,0 +1,1 @@
+export { FoxStepIndicatorItem, type FoxStepIndicatorItemProps, type FoxStepIndicatorItemState } from "./fox-step-indicator-item";
 
@fox/core/components/fox-step-indicator/fox-step-indicator.tsx (added)
+++ @fox/core/components/fox-step-indicator/fox-step-indicator.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxStepIndicatorProps {
+  /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */
+  label?: string;
+  /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
+  labelledBy?: string;
+  /** `FoxStepIndicatorItem`들. */
+  children?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. 시안의 폭(400px = 80px × 5단계)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 단계 묶음. `FoxStepIndicatorItem`들을 가로로 늘어놓는다. 배치만 책임지고 상태를 갖지 않는다 —
+ * 어디까지 왔는지는 각 단계의 `state`가 말한다.
+ *
+ * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다). 단계마다 `state`가
+ * 다른 것이 정상이고, **마지막 단계는 `line={false}`로 선을 꺼야 한다** — 묶음이 자식의
+ * 순서를 알고 대신 꺼 주지 않는 것은, 자식을 조건부로 렌더하거나 프래그먼트로 감싸는
+ * 호출부에서 "마지막"의 판단이 어긋나기 때문이다.
+ *
+ * `role="list"`를 쓰지 않는다. 목록이 아니라 하나의 진행 상황이고, 읽히는 것은 각 단계의
+ * 글자다 — 진행률을 소리로 전해야 하면 호출부가 `label`에 담는다("전체 5단계 중 2단계" 등).
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStepIndicator({
+  label,
+  labelledBy,
+  children,
+  hidden = false,
+  className,
+  ref,
+}: FoxStepIndicatorProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      role="group"
+      aria-label={labelledBy ? undefined : label}
+      aria-labelledby={labelledBy}
+      className={cx("fox-step-indicator", className)}
+    >
+      {children}
+    </div>
+  );
+}
 
@fox/core/components/fox-step-indicator/index.ts (added)
+++ @fox/core/components/fox-step-indicator/index.ts
@@ -0,0 +1,4 @@
+export {
+  FoxStepIndicator,
+  type FoxStepIndicatorProps,
+} from "./fox-step-indicator";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -47,6 +47,8 @@
 export * from "./fox-snackbar-list";
 export * from "./fox-spinner";
 export * from "./fox-status-indicator";
+export * from "./fox-step-indicator-item";
+export * from "./fox-step-indicator";
 export * from "./fox-tab";
 export * from "./fox-tab-item";
 export * from "./fox-table";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -57,6 +57,8 @@
 import { FoxSnackbar } from "../core/components/fox-snackbar";
 import { FoxSnackbarList } from "../core/components/fox-snackbar-list";
 import { FoxStatusIndicator } from "../core/components/fox-status-indicator";
+import { FoxStepIndicatorItem } from "../core/components/fox-step-indicator-item";
+import { FoxStepIndicator } from "../core/components/fox-step-indicator";
 import { FoxChip, type FoxChipItem } from "../core/components/fox-chip";
 import { FoxChipArea } from "../core/components/fox-chip-area";
 import { FoxSelectText } from "../core/components/fox-select-text";
@@ -3833,4 +3835,62 @@
       },
     ],
   },
+  {
+    id: "fox-step-indicator-item",
+    name: "FoxStepIndicatorItem",
+    description:
+      "Figma 시안(step 251:1539) 단계 하나입니다. 위에 동그라미와 선, 아래에 단계 번호(body/xsm)와 제목(body/sm)이 놓입니다. state 세 가지가 동그라미 안과 선 색을 정합니다: completion은 회색으로 채우고 체크를 얹으며 선도 진합니다(여기까지 왔다는 표시), ongoing은 테두리만 강조색이고 가운데 점이 자리를 채우며 선은 흐립니다, before는 흐린 테두리에 연한 면이고 안이 비었습니다. 마지막 단계는 line={false}로 선을 꺼야 합니다 — 켜 두면 묶음 밖으로 삐져나갑니다. 동그라미와 선은 aria-hidden 장식이고 읽히는 것은 글자뿐입니다. 제목 굵기는 시안이 700인데 토큰 세트에 700이 없어(400 regular · 600 medium · 800 bold) 시스템의 bold(800)로 올렸습니다 — Pretendard 기준 Bold보다 한 단계 굵은 ExtraBold입니다. 너무 굵어 보이면 medium으로 내립니다.",
+    variants: [
+      {
+        label: "상태 3종",
+        node: (
+          <>
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="정보 입력" />
+            <FoxStepIndicatorItem state="before" step="STEP 03" title="가입 완료" />
+          </>
+        ),
+      },
+      {
+        label: "글자 없이 — 동그라미와 선만 (모바일에서 자동으로 이 모양이 됩니다)",
+        node: (
+          <>
+            <FoxStepIndicatorItem state="completion" />
+            <FoxStepIndicatorItem state="ongoing" />
+            <FoxStepIndicatorItem state="before" />
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-step-indicator",
+    name: "FoxStepIndicator",
+    description:
+      "Figma 시안(step-indicator 708:1623) FoxStepIndicatorItem들을 가로로 늘어놓는 묶음입니다. 배치만 갖고 상태는 없습니다 — 어디까지 왔는지는 각 단계의 state가 말합니다. 자식에게 덮어쓰는 값이 없어서 마지막 단계의 line={false}는 호출부가 줍니다: 묶음이 '마지막'을 대신 판단하면 자식을 조건부로 렌더하거나 프래그먼트로 감싼 호출부에서 어긋납니다. role=list가 아니라 role=group입니다 — 목록이 아니라 하나의 진행 상황이고, 진행률을 소리로 전해야 하면 label에 담습니다. ⚠️ 폭 400px은 80px × 5단계라 단계가 다섯 개일 때의 값입니다. 개수가 다르면 className으로 덮습니다. 화면을 좁히면 글자가 사라지고 동그라미 줄만 남습니다.",
+    variants: [
+      {
+        label: "5단계 — 시안 폭(400px)에 맞는 개수",
+        node: (
+          <FoxStepIndicator label="가입 절차 (전체 5단계 중 3단계)">
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" />
+            <FoxStepIndicatorItem state="completion" step="STEP 02" title="본인 인증" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 03" title="정보 입력" />
+            <FoxStepIndicatorItem state="before" step="STEP 04" title="결제 수단" />
+            <FoxStepIndicatorItem state="before" step="STEP 05" title="가입 완료" line={false} />
+          </FoxStepIndicator>
+        ),
+      }, 
+      {
+        label: "3단계 — 폭이 고정이라 오른쪽이 남습니다 (className으로 덮는 자리)",
+        node: (
+          <FoxStepIndicator label="결제 절차">
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="장바구니" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="결제" />
+            <FoxStepIndicatorItem state="before" step="STEP 03" title="완료" line={false} />
+          </FoxStepIndicator>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-step-indicator-item.scss (added)
+++ @fox/styles/_fox-step-indicator-item.scss
@@ -0,0 +1,168 @@
+// FoxStepIndicatorItem — 시안: 통합관리자페이지 디자인시스템 Figma step
+//
+// 단계 하나. 위에 동그라미와 선(인디케이터), 아래에 단계 번호와 제목이 놓인다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-step-indicator-item fox-step-indicator-item--ongoing">
+//     <div class="fox-step-indicator-item__indicator" aria-hidden="true">
+//       <span class="fox-step-indicator-item__mark"><span class="fox-step-indicator-item__dot"></span></span>
+//       <span class="fox-step-indicator-item__line"></span>                          <!-- 마지막 단계는 뺀다 -->
+//     </div>
+//     <div class="fox-step-indicator-item__text">                                    <!-- 선택 -->
+//       <span class="fox-step-indicator-item__step">STEP 01</span>
+//       <span class="fox-step-indicator-item__title">약관 동의</span>
+//     </div>
+//   </div>
+//
+// 상태별로 동그라미 안이 다르다: completion은 체크 글리프, ongoing은 `__dot`, before는 비었다.
+// 상태 클래스가 색·테두리를 정하므로 마크업은 그대로 두고 클래스만 바꾸면 된다.
+//
+// **모바일에서는 `__text`가 사라진다.** 시안의 device 변형을 CSS 브레이크포인트로 옮긴
+// 것이다 — 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, React를 쓰지 않는
+// 소비자는 같은 결과를 얻지 못한다(`fox-chip-select-option`의 확인 버튼과 같은 근거).
+//
+// 동그라미 안쪽 여백을 시안대로 적지 않는다. 시안은 `padding: 6px 5px`로 20px 안에 10×8
+// 글리프를 밀어 넣는데, 그 값은 **가운데 정렬을 여백으로 계산한 것**이라(20-5-5=10,
+// 20-6-6=8) `justify-content`·`align-items`로 바꿔 적는다. 결과는 같고 5px·6px이라는
+// 토큰 없는 값이 사라진다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 6.1rem`(선 61px): 시안 값 그대로다. 토큰 스케일에 61이 없고, 동그라미
+//     (20px)와 합치면 81px이라 단계 폭(80px)보다 1px 길다 — 다음 동그라미까지 틈 없이
+//     닿게 하려고 겹치는 값으로 보인다. 단계 폭을 바꾸면 이 값도 함께 봐야 한다.
+//   - ⚠️ `block-size: 0.15rem`(선 굵기 1.5px) / `border-width: 0.15rem`(ongoing 테두리):
+//     시안 값이나 border 토큰은 1px·2px·4px뿐이라 1.5px이 없다. 토큰이 생기면 바꾼다.
+//   - ⚠️ 체크 글리프의 `10px × 8px`: 시안이 준 비율(5:4)이 다른 아이콘과 달라 `icon()` 스케일에
+//     없다. `number()`의 같은 값(10px·8px)으로 지목한다.
+//   - box-sizing / display / flex / aspect-ratio: 구조 규칙.
+//
+// ⚠️ 단계 제목의 굵기: 시안 CSS가 `var(--font-weight-regular, 700)`으로 이름과 값이
+// 어긋나는데, **값(굵게)이 맞다**(사용자 확인). 700은 이 토큰 세트에 없어서
+// (400 regular · 600 medium · 800 bold) 시스템의 `bold`를 쓴다 — Pretendard 기준으로 시안이
+// Bold(700)라면 한 단계 더 굵은 ExtraBold(800)로 그려진다. 너무 굵어 보이면 `medium`으로
+// 내린다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-step-indicator-item {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: fox.number(20);
+
+  // ── 인디케이터(동그라미 + 선) ───────────────────────────────────────────────
+  &__indicator {
+    display: flex;
+    align-items: center;
+  }
+
+  // 20px 정원. 안에 든 것(체크·점)은 상태가 정하고, 여기서는 자리만 만든다.
+  &__mark {
+    box-sizing: border-box;
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    inline-size: fox.number(9);
+    block-size: fox.number(9);
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+  }
+
+  // 체크 글리프. 크기는 슬롯이 정하고 SVG는 따라온다.
+  &__mark > svg {
+    inline-size: fox.number(6);
+    block-size: fox.number(5);
+  }
+
+  // ongoing의 가운데 점.
+  &__dot {
+    inline-size: fox.number(7);
+    block-size: fox.number(7);
+    flex-shrink: 0;
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+    background: fox.color(element-theme-primary);
+  }
+
+  &__line {
+    flex-shrink: 0;
+    inline-size: 6.1rem;
+    block-size: 0.15rem;
+  }
+
+  // ── 글자 ────────────────────────────────────────────────────────────────────
+  &__text {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    align-self: stretch;
+    padding-inline-end: fox.padding(3);
+
+    // 모바일에서는 동그라미와 선만 남는다(파일 상단 주석 참고).
+    @include fox.mobile {
+      display: none;
+    }
+  }
+
+  &__step {
+    align-self: stretch;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-xsm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__title {
+    align-self: stretch;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    // 시안의 700은 토큰에 없어 시스템의 bold(800)로 올린다 — 파일 상단 주석 참고.
+    font-weight: fox.font-weight(bold);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── 상태 ────────────────────────────────────────────────────────────────────
+  // 지난 단계. 동그라미가 회색으로 채워지고 체크가 반전색으로 얹힌다. 선도 함께 진하다 —
+  // 여기까지 왔다는 표시다.
+  &--completion {
+    .fox-step-indicator-item__mark {
+      background: fox.color(element-neutral-default);
+      color: fox.color(element-neutral-inverse-static);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-default);
+    }
+  }
+
+  // 지금 단계. 테두리만 강조색이고 안은 비어 있으며, 가운데 점이 그 자리를 채운다.
+  // 선은 앞으로 갈 길이라 흐리다.
+  &--ongoing {
+    .fox-step-indicator-item__mark {
+      border: 0.15rem solid fox.color(element-theme-primary);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-disabled);
+    }
+  }
+
+  // 아직 오지 않은 단계. 흐린 테두리에 연한 면이고 안은 비었다.
+  &--before {
+    .fox-step-indicator-item__mark {
+      border: fox.border(1) solid fox.color(element-neutral-disabled);
+      background: fox.color(element-neutral-subtle);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-disabled);
+    }
+  }
+}
 
@fox/styles/_fox-step-indicator.scss (added)
+++ @fox/styles/_fox-step-indicator.scss
@@ -0,0 +1,34 @@
+// FoxStepIndicator — 시안: 통합관리자페이지 디자인시스템 Figma step-indicator
+//
+// `fox-step-indicator-item`들을 가로로 늘어놓는 묶음이다. 배치만 갖는다 — 어디까지 왔는지는 각 단계가 말한다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-step-indicator" role="group" aria-label="가입 절차">
+//     …fox-step-indicator-item (completion)…
+//     …fox-step-indicator-item (ongoing)…
+//     …fox-step-indicator-item (before, 마지막이면 선 없이)…
+//   </div>
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 40rem`(400px): 시안 값 그대로다. 80px × 5단계라 **단계가 다섯 개일 때의
+//     폭**이고, 개수가 다르면 맞지 않는다. 토큰 스케일에도 400이 없다. 단계 수가 다른 화면은
+//     호출부가 `className`으로 덮는다 — 시안에서 폭 규칙을 받으면 그 값으로 바꾼다.
+//   - display / align-items: 구조 규칙.
+//
+// 세로 정렬이 `center`라 글자가 있는 화면과 없는 화면(모바일)에서 동그라미 줄이 같은 높이에
+// 놓인다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-step-indicator {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  inline-size: 40rem;
+
+  // 모바일에서는 글자가 사라져 폭이 남는다 — 고정 폭을 풀어 화면에 맞춘다
+  // (`fox-step-indicator-item`의 `__text`가 숨는 것과 짝이다).
+  @include fox.mobile {
+    inline-size: 100%;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -55,6 +55,8 @@
 @use "fox-snackbar-list";
 @use "fox-spinner";
 @use "fox-status-indicator";
+@use "fox-step-indicator-item";
+@use "fox-step-indicator";
 @use "fox-toast";
 @use "fox-toggle-quantity";
 @use "fox-page-header";
Add a comment
List