임동욱 임동욱 08-12
feat: FoxButtonGroup 추가
Figma btn-group(398:4854)에서 가져온 건 간격뿐이다 — 크기·방향과 무관하게
gap-2(4px) 하나여서 그룹에 크기별 스타일이 없다. 배치 규칙(방향·정렬·줄바꿈)은
지시받은 사양이다.

크기는 그룹이 단일 진실 공급원이다. 자식을 cloneElement로 덮어써서 자식이
저마다 다른 size를 줘도 그룹 값으로 통일된다. 세로일 때는 fullWidth까지 함께
내려주고 CSS도 stretch라, React를 안 쓰는 소비자도 같은 결과를 얻는다.

Children.map이 프래그먼트 안으로 들어가지 않아 직접 내려간다. 그러지 않으면
<>…로 감싼 버튼이 덮어쓰기를 피해 가고 Fragment에 size가 붙어 경고가 난다.
호스트 요소(div·span)는 DOM에 없는 속성이 넘어가지 않도록 건드리지 않는다.

Co-Authored-By: Claude Opus 5 
@e4020c605d0e663c71244bc1514489195a9ae172
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -90,12 +90,20 @@
 <button class="fox-icon-button fox-icon-button--ghost fox-icon-button--md" aria-label="공유">
   <span class="fox-icon-button__icon"><!-- svg --></span>
 </button>
+
+<div class="fox-button-group fox-button-group--horizontal fox-button-group--start">
+  <button class="fox-button fox-button--default fox-button--md">취소</button>
+  <button class="fox-button fox-button--primary fox-button--md">확인</button>
+</div>
 ```
 
 - 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). 앵커에는 `disabled`가
   없으므로 `aria-disabled="true"`를 쓰고 `href`를 뺍니다.
 - 로딩은 앞 아이콘 자리에 `fox-button__spinner`를 두고 `disabled`를 함께 겁니다.
 - 토글은 `fox-icon-button`에 `aria-pressed="true|false"`를 더합니다.
+- 버튼 묶음은 간격·방향·정렬만 담당합니다. 자식 버튼의 크기 클래스를 그룹 크기로 맞추는 건
+  작성자 몫입니다(React 래퍼는 `size` prop으로 자동화합니다). 세로(`--vertical`)는 자식이
+  폭을 채웁니다.
 - 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다.
 
 `core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도
 
@fox/core/components/fox-button-group/fox-button-group.tsx (added)
+++ @fox/core/components/fox-button-group/fox-button-group.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import {
+  Children,
+  Fragment,
+  cloneElement,
+  isValidElement,
+  type ReactElement,
+  type ReactNode,
+} from "react";
+import { cx } from "../../utils";
+
+export type FoxButtonGroupOrientation = "horizontal" | "vertical";
+export type FoxButtonGroupAlign = "start" | "center" | "end";
+export type FoxButtonGroupSize = "lg" | "md" | "sm" | "xsm";
+
+export interface FoxButtonGroupProps {
+  orientation?: FoxButtonGroupOrientation;
+  /** 주축 정렬. 세로에서는 그룹 높이가 정해져 있을 때만 눈에 띈다. */
+  align?: FoxButtonGroupAlign;
+  /** 자식 버튼의 `size`를 전부 이 값으로 덮어쓴다. 자식이 따로 준 값은 무시된다. */
+  size: FoxButtonGroupSize;
+  wrap?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+  children?: ReactNode;
+}
+
+const ORIENTATION_CLASS: Record<FoxButtonGroupOrientation, string> = {
+  horizontal: "fox-button-group--horizontal",
+  vertical: "fox-button-group--vertical",
+};
+
+const ALIGN_CLASS: Record<FoxButtonGroupAlign, string> = {
+  start: "fox-button-group--start",
+  center: "fox-button-group--center",
+  end: "fox-button-group--end",
+};
+
+type Overrides = { size: FoxButtonGroupSize; fullWidth?: true };
+
+/**
+ * 자식 버튼에 그룹 값을 강제로 덮어쓴다.
+ *
+ * - 호스트 요소(`div` 등)는 건드리지 않는다 — DOM에 없는 속성이 넘어가면 React가 경고한다.
+ * - `Children.map`은 프래그먼트 안으로 들어가지 않아 직접 내려간다. 그러지 않으면
+ *   `<>…</>`로 감싼 버튼들이 덮어쓰기를 피해 가고 Fragment에 `size`가 붙어 경고가 난다.
+ */
+function overrideChildren(children: ReactNode, overrides: Overrides): ReactNode {
+  return Children.map(children, (child) => {
+    if (!isValidElement(child)) {
+      return child;
+    }
+
+    if (child.type === Fragment) {
+      const fragment = child as ReactElement<{ children?: ReactNode }>;
+      return overrideChildren(fragment.props.children, overrides);
+    }
+
+    if (typeof child.type === "string") {
+      return child;
+    }
+
+    return cloneElement(child as ReactElement<Record<string, unknown>>, overrides);
+  });
+}
+
+/**
+ * @fox 버튼 묶음. 간격·방향·정렬만 책임지고 상태를 갖지 않는다.
+ *
+ * 크기는 **그룹이 단일 진실 공급원**이다 — 자식이 저마다 다른 `size`를 줘도 그룹 값으로
+ * 통일된다. 세로일 때는 자식이 항상 폭을 채운다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxButtonGroup({
+  orientation = "horizontal",
+  align = "start",
+  size,
+  wrap = false,
+  className,
+  children,
+}: FoxButtonGroupProps) {
+  const overrides: Overrides =
+    orientation === "vertical" ? { size, fullWidth: true } : { size };
+
+  return (
+    <div
+      className={cx(
+        "fox-button-group",
+        ORIENTATION_CLASS[orientation],
+        ALIGN_CLASS[align],
+        wrap && "fox-button-group--wrap",
+        className
+      )}
+    >
+      {overrideChildren(children, overrides)}
+    </div>
+  );
+}
 
@fox/core/components/fox-button-group/index.ts (added)
+++ @fox/core/components/fox-button-group/index.ts
@@ -0,0 +1,7 @@
+export {
+  FoxButtonGroup,
+  type FoxButtonGroupProps,
+  type FoxButtonGroupOrientation,
+  type FoxButtonGroupAlign,
+  type FoxButtonGroupSize,
+} from "./fox-button-group";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -2,3 +2,4 @@
 export * from "./fox-text-button";
 export * from "./fox-link-button";
 export * from "./fox-icon-button";
+export * from "./fox-button-group";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -5,6 +5,8 @@
 import { FoxTextButton } from "../core/components/fox-text-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
 import { FoxIconButton } from "../core/components/fox-icon-button";
+import { FoxButtonGroup } from "../core/components/fox-button-group";
+import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
   /** 해시 링크에 쓰이는 kebab-case 식별자. */
@@ -351,4 +353,96 @@
       },
     ],
   },
+  {
+    id: "fox-button-group",
+    name: "FoxButtonGroup",
+    description:
+      "간격만 Figma 시안(btn-group, 컴포넌트 세트 398:4854)에서 가져왔습니다 — 크기·방향과 무관하게 gap-2(4px) 하나뿐이라 그룹에는 크기별 스타일이 없습니다. 크기는 그룹이 단일 진실 공급원이라 자식이 저마다 다른 size를 줘도 그룹 값으로 통일됩니다. 세로일 때는 자식이 항상 폭을 채웁니다. 블록 레벨 flex라 부모 폭을 채웁니다.",
+    variants: [
+      {
+        label: "가로 · 정렬 (align) — 회색 테두리가 그룹 영역",
+        node: (
+          <div className={styles.demoStack}>
+            {(["start", "center", "end"] as const).map((align) => (
+              <div key={align} className={styles.demoOutline}>
+                <FoxButtonGroup size="md" align={align}>
+                  <FoxButton size="md" label={align} />
+                  <FoxButton size="md" type="primary" label="확인" />
+                </FoxButtonGroup>
+              </div>
+            ))}
+          </div>
+        ),
+      },
+      {
+        label: "크기 (size) — 자식이 준 size는 무시된다",
+        node: (
+          <div className={styles.demoStack}>
+            {(["lg", "md", "sm", "xsm"] as const).map((size) => (
+              <FoxButtonGroup key={size} size={size}>
+                {/* 일부러 어긋난 size를 준다 — 전부 그룹 값으로 통일되어야 정상 */}
+                <FoxButton size="lg" label={size} />
+                <FoxButton size="xsm" type="primary" label="확인" />
+                <FoxIconButton size="lg" label="공유" icon={<DemoIcon />} />
+              </FoxButtonGroup>
+            ))}
+          </div>
+        ),
+      },
+      {
+        label: "세로 — 자식이 항상 폭을 채운다",
+        node: (
+          <div className={styles.demoOutline}>
+            <FoxButtonGroup size="md" orientation="vertical">
+              <FoxButton size="md" label="취소" />
+              <FoxButton size="md" type="primary" label="확인" />
+            </FoxButtonGroup>
+          </div>
+        ),
+      },
+      {
+        label: "wrap — 폭이 모자라면 다음 줄로 넘어간다",
+        node: (
+          <div className={styles.demoStack}>
+            <div className={styles.demoNarrow}>
+              <FoxButtonGroup size="md" wrap>
+                {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label) => (
+                  <FoxButton key={label} size="md" label={label} />
+                ))}
+              </FoxButtonGroup>
+            </div>
+            <div className={styles.demoNarrow}>
+              <FoxButtonGroup size="md">
+                {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label) => (
+                  <FoxButton key={label} size="md" label={label} />
+                ))}
+              </FoxButtonGroup>
+            </div>
+          </div>
+        ),
+      },
+      {
+        label: "버튼이 아닌 자식 — 호스트 요소는 건드리지 않는다",
+        node: (
+          <FoxButtonGroup size="sm" align="center">
+            <FoxButton size="sm" label="이전" />
+            <span className={styles.demoText}>1 / 10</span>
+            <FoxButton size="sm" label="다음" />
+          </FoxButtonGroup>
+        ),
+      },
+      {
+        label: "프래그먼트로 감싼 자식도 덮어쓴다",
+        node: (
+          <FoxButtonGroup size="xsm">
+            <>
+              <FoxButton size="lg" label="프래그먼트 안" />
+              <FoxButton size="lg" type="primary" label="확인" />
+            </>
+            <FoxButton size="lg" label="프래그먼트 밖" />
+          </FoxButtonGroup>
+        ),
+      },
+    ],
+  },
 ];
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -415,3 +415,27 @@
   border-radius: fox.radius(3);
   background: fox.color(surface-neutral-default);
 }
+
+// 그룹 예제용. 그룹이 블록 레벨이라 정렬·줄바꿈을 보려면 폭이 정해진 무대가 필요하다.
+.demoStack {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(4);
+  inline-size: 100%;
+}
+
+.demoOutline {
+  padding: fox.padding(3);
+  border: fox.border(1) dashed fox.color(border-neutral-subtle);
+  border-radius: fox.radius(2);
+  inline-size: 100%;
+}
+
+// wrap 예제는 자식이 확실히 넘치도록 좁아야 한다.
+.demoNarrow {
+  padding: fox.padding(3);
+  border: fox.border(1) dashed fox.color(border-neutral-subtle);
+  border-radius: fox.radius(2);
+  inline-size: 20rem;
+  max-inline-size: 100%;
+}
 
@fox/styles/_fox-button-group.scss (added)
+++ @fox/styles/_fox-button-group.scss
@@ -0,0 +1,51 @@
+// FoxButtonGroup — 시안: 관리자페이지 Figma btn-group (컴포넌트 세트 398:4854)
+//
+// 시안에서 가져온 건 간격뿐이다 — 크기·방향과 무관하게 `gap/2` 하나다. 그래서 크기별
+// 모디파이어가 없다(React 래퍼의 `size`는 자식 버튼에 내려주는 값이지 그룹의 모양이 아니다).
+// 나머지 배치 규칙(정렬·줄바꿈)은 시안이 아니라 지시받은 사양이다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-button-group fox-button-group--horizontal fox-button-group--start">
+//     <button class="fox-button fox-button--default fox-button--md">버튼</button>
+//     <button class="fox-button fox-button--default fox-button--md">버튼</button>
+//   </div>
+// 자식 버튼의 크기 클래스를 그룹 크기로 맞추는 건 작성자 몫이다 — React 래퍼는 이걸
+// `size` prop으로 자동화한다.
+//
+// 블록 레벨 `flex`다(`inline-flex`가 아니다). 정렬이 의미를 가지려면 그룹이 부모 폭을
+// 채워야 하고, 세로일 때 자식이 폭을 채우려면 stretch할 자리가 있어야 한다.
+
+@use "abstracts" as fox;
+
+.fox-button-group {
+  display: flex;
+  gap: fox.gap(2);
+
+  &--horizontal {
+    flex-direction: row;
+    align-items: center;
+  }
+
+  // 세로는 자식이 항상 폭을 채운다(시안의 w-full).
+  &--vertical {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  // 주축 정렬. 세로에서는 그룹 높이가 정해져 있을 때만 눈에 띈다.
+  &--start {
+    justify-content: flex-start;
+  }
+
+  &--center {
+    justify-content: center;
+  }
+
+  &--end {
+    justify-content: flex-end;
+  }
+
+  &--wrap {
+    flex-wrap: wrap;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -5,3 +5,4 @@
 @use "fox-text-button";
 @use "fox-link-button";
 @use "fox-icon-button";
+@use "fox-button-group";
Add a comment
List