File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
import { useState, type CSSProperties, type ReactNode } from "react";
import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
import { FoxButton } from "../core/components/fox-button";
import { FoxButtonGroup } from "../core/components/fox-button-group";
import { FoxButtonPanel } from "../core/components/fox-button-panel";
import { FoxCarouselArea } from "../core/components/fox-carousel-area";
import { FoxCarouselButton } from "../core/components/fox-carousel-button";
import { FoxCarouselIndicator } from "../core/components/fox-carousel-indicator";
import { FoxCertified } from "../core/components/fox-certified";
import { FoxCheckIcon } from "../core/components/fox-check-icon";
import { FoxCheckbox } from "../core/components/fox-checkbox";
import { FoxCheckboxGroup } from "../core/components/fox-checkbox-group";
import { FoxConditionalOption } from "../core/components/fox-conditional-option";
import { FoxEmail } from "../core/components/fox-email";
import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
import { FoxFormLabel } from "../core/components/fox-form-label";
import { FoxIconButton } from "../core/components/fox-icon-button";
import { FoxLinkButton } from "../core/components/fox-link-button";
import { FoxPagination } from "../core/components/fox-pagination";
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
import { FoxRadio } from "../core/components/fox-radio";
import { FoxRadioGroup } from "../core/components/fox-radio-group";
import { FoxRadioIcon } from "../core/components/fox-radio-icon";
import { FoxTag } from "../core/components/fox-tag";
import { FoxTagList } from "../core/components/fox-tag-list";
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
import { FoxTextButton } from "../core/components/fox-text-button";
import { FoxToggleSwitch } from "../core/components/fox-toggle-switch";
import {
FoxCaretLeftIcon,
FoxCaretRightIcon,
FoxPauseIcon,
FoxPlayIcon,
FoxPlusIcon,
} from "../core/icons";
import { FoxBulletList } from "../core/components/fox-bullet-list";
import { FoxBulletListItem } from "../core/components/fox-bullet-list-item";
import { FoxBadge } from "../core/components/fox-badge";
import { FoxBadgeDot } from "../core/components/fox-badge-dot";
import { FoxBadgeGroup } from "../core/components/fox-badge-group";
import styles from "./dev-test.module.scss";
export interface ComponentExample {
/** 해시 링크에 쓰이는 kebab-case 식별자. */
id: string;
name: string;
description?: string;
/** 눈으로 비교해야 하는 조합을 빠짐없이 넣는다 — 예제가 곧 회귀 확인 수단이다. */
variants: { label: string; node: ReactNode }[];
}
// 자리 확인용 도형. `currentColor`로 그려야 계열별 색이 입혀진다. style은 FoxSelect
// 힌트 아이콘처럼 크기를 줄여 써야 하는 자리에 쓴다.
function DemoIcon({ style }: { style?: CSSProperties } = {}) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
style={style}
>
<path d="M6 3l5 5-5 5" />
</svg>
);
}
function DemoStar() {
return (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 1.6l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.5l-3.9 2 .8-4.3-3.2-3 4.4-.6z" />
</svg>
);
}
// overlay 계열은 어두운 반투명 배경이라 밝은 바탕 위에서는 대비를 볼 수 없다.
function OnPhoto({ children }: { children: ReactNode }) {
return (
<span
style={{
display: "inline-flex",
gap: "0.8rem",
padding: "0.8rem",
borderRadius: "0.8rem",
backgroundImage: "url(https://picsum.photos/seed/picsum/200/300)",
backgroundSize: "cover",
}}
>
{children}
</span>
);
}
// 입력 예제는 폭이 있어야 시안(360px)처럼 보인다.
function InputStage({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
return <div className={wide ? styles.demoFieldWide : styles.demoField}>{children}</div>;
}
// 지우기 버튼과 자동 상태 판정을 실제로 눌러 보려면 예제가 값을 들고 있어야 한다.
function InputLiveDemo() {
const [text, setText] = useState("지우기를 눌러보세요");
return (
<>
<InputStage>
<FoxInput
size="md"
label="자동 상태"
placeholder="입력하면 글자가 진해집니다"
message="포커스하면 도움말이 파랗게 바뀝니다"
value={text}
onChange={setText}
clearable
/>
</InputStage>
<InputStage>
<FoxInput
size="md"
label="오류 (invalid)"
placeholder="내용을 입력하세요."
message="필수 항목입니다"
invalid
/>
</InputStage>
</>
);
}
// 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다.
function TextAreaLiveDemo() {
const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다.");
return (
<>
<InputStage>
<FoxTextArea
label="자동 상태 · 카운터"
placeholder="내용을 입력하세요."
message="포커스하면 도움말이 파랗게 바뀝니다"
value={text}
onChange={setText}
maxLength={100}
counter
/>
</InputStage>
<InputStage>
<FoxTextArea
label="오류 (invalid)"
placeholder="내용을 입력하세요."
message="100자를 넘길 수 없습니다"
defaultValue={"x".repeat(100)}
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>
</>
);
}
const EMAIL_DOMAINS = ["naver.com", "gmail.com", "daum.net", "kakao.com"];
// 값이 합쳐진 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다.
function EmailLiveDemo() {
const [email, setEmail] = useState("hong@naver.com");
return (
<InputStage wide>
<FoxEmail
label={`value="${email}"`}
domainOptions={EMAIL_DOMAINS}
value={email}
onChange={setEmail}
message="도메인을 고르면 옆 칸이 채워지고 잠깁니다. 직접입력을 고르면 풀립니다."
/>
</InputStage>
);
}
// 어떤 우편번호 서비스를 쓰든 이 모양으로 돌려주면 된다 — 여기서는 지연만 흉내낸다.
const FAKE_SEARCH_RESULT: FoxAddressSearchResult = {
zipCode: "06236",
address: "서울 강남구 테헤란로 152",
detail: "강남파이낸스센터",
};
function AddressLiveDemo({ cancel = false }) {
const [value, setValue] = useState<FoxAddressValue>({ zipCode: "", address: "", detail: "" });
return (
<>
<InputStage>
<FoxAddress
label={cancel ? "검색을 취소하면 값이 그대로 남습니다" : "주소 검색을 눌러보세요"}
value={value}
onChange={setValue}
onSearch={async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
return cancel ? null : FAKE_SEARCH_RESULT;
}}
/>
</InputStage>
<pre className={styles.code}>{JSON.stringify(value, null, 2)}</pre>
</>
);
}
// 목록은 업로드 결과에 따라 바뀌므로, 오류 줄까지 보려면 예제가 값을 들고 있어야 한다.
const SAMPLE_FILES: FoxFileItem[] = [
{ id: "a", name: "사업자등록증", meta: ".PDF (20KB)" },
{ id: "b", name: "아주 긴 파일 이름이라 가운데에서 말줄임되어야 하는 첨부물", meta: ".XLSX (1.2MB)" },
{
id: "c",
name: "용량초과파일",
meta: ".ZIP (32MB)",
error: true,
message: "등록 가능한 파일 용량을 초과하였습니다. 20MB 미만의 파일만 등록할 수 있습니다.",
},
];
function FileUploadLiveDemo({ mode }: { mode: "default" | "area" }) {
const [files, setFiles] = useState<FoxFileItem[]>(SAMPLE_FILES);
return (
<InputStage wide>
<FoxFileUpload
mode={mode}
label={mode === "area" ? "끌어다 놓거나 버튼으로 고릅니다" : "고른 파일이 칸에 적힙니다"}
files={files}
onSelect={(picked) =>
setFiles((prev) => [
...prev,
...picked.map((file) => ({ id: file.name, name: file.name, meta: `(${file.size}B)` })),
])
}
onRemove={(id) => setFiles((prev) => prev.filter((item) => item.id !== id))}
description={mode === "default" ? "JPG, PNG 파일만 가능 (최대 5MB)" : undefined}
multiple
/>
</InputStage>
);
}
// 이벤트가 실제로 도는 걸 보려면 예제가 결과를 적어 둬야 한다. 서버 대신 지연만 흉내낸다.
function CertifiedLiveDemo({ failSend = false, failVerify = false }) {
const [log, setLog] = useState<string[]>([]);
const write = (line: string) => setLog((prev) => [line, ...prev].slice(0, 5));
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
return (
<>
<InputStage>
<FoxCertified
prefixOptions={PHONE_PREFIXES}
defaultPhone="01012345678"
expiresIn={180}
onSend={async (phone) => {
await wait(400);
if (failSend) throw new Error("발송 한도를 넘었습니다");
write(`보내기 요청 → ${phone}`);
}}
onVerify={async (_phone, code) => {
await wait(400);
if (failVerify || code !== "123456") throw new Error("번호가 맞지 않습니다");
}}
onSendSuccess={() => write("onSendSuccess")}
onSendFail={(error) => write(`onSendFail — ${(error as Error).message}`)}
onVerifySuccess={() => write("onVerifySuccess — 이제 전부 잠깁니다")}
onVerifyFail={(error) => write(`onVerifyFail — ${(error as Error).message}`)}
onExpire={() => write("onExpire")}
/>
</InputStage>
<pre className={styles.code}>{log.length ? log.join("\n") : "이벤트가 여기에 쌓입니다 (인증번호는 123456)"}</pre>
</>
);
}
// active는 호출부가 소유하므로 켜고 끄는 걸 보려면 예제가 상태를 들고 있어야 한다.
function IconToggleDemo() {
const [on, setOn] = useState(false);
return (
<>
<FoxIconButton
size="md"
type="default"
label={on ? "즐겨찾기 해제" : "즐겨찾기"}
icon={<DemoStar />}
active={on}
onAction={() => setOn((prev) => !prev)}
/>
<FoxIconButton
size="md"
type="ghost"
label={on ? "즐겨찾기 해제" : "즐겨찾기"}
icon={<DemoStar />}
active={on}
onAction={() => setOn((prev) => !prev)}
/>
<OnPhoto>
<FoxIconButton
size="md"
type="overlay"
label={on ? "즐겨찾기 해제" : "즐겨찾기"}
icon={<DemoStar />}
active={on}
onAction={() => setOn((prev) => !prev)}
/>
</OnPhoto>
</>
);
}
const SELECT_ITEMS: FoxSelectItem[] = [
{ value: "a", label: "옵션 A" },
{ value: "b", label: "옵션 B" },
{ value: "c", label: "옵션 C" },
];
// 옵션 아이콘은 별도 목록으로 둔다 — 위 목록에 섞으면 크기·상태 예제가 전부 같이 바뀌어
// 무엇을 보는 예제인지 흐려진다. 아이콘은 시안 미정이라 자리 확인용 도형이다.
const SELECT_ITEMS_RICH: FoxSelectItem[] = [
{ value: "a", label: "아이콘 있는 옵션", icon: <DemoIcon /> },
{ value: "b", label: "아이콘 없는 옵션" },
{ value: "c", label: "아주 긴 옵션 라벨 — 트리거에서는 말줄임으로 잘린다" },
];
const BREADCRUMB_ITEMS = [
{ label: "홈", href: "#" },
{ label: "1Depth", href: "#" },
{ label: "2Depth", href: "#" },
{ label: "3Depth" },
];
function PaginationLiveDemo() {
const [page, setPage] = useState(1);
return (
<div className={styles.demoStack}>
<FoxPagination currentPage={page} totalPages={12} onPageChange={setPage} ellipsis />
<p>
현재 {page} / 12 페이지
</p>
</div>
);
}
function CarouselDotPickDemo() {
const [page, setPage] = useState(1);
return <FoxCarouselIndicator current={page} total={5} onSelect={setPage} />;
}
function CarouselLiveDemo() {
const total = 5;
const [page, setPage] = useState(1);
const [playing, setPlaying] = useState(true);
const move = (step: number) => setPage((prev) => ((prev - 1 + step + total) % total) + 1);
return (
<FoxCarouselArea>
<FoxCarouselIndicator current={page} total={total} onSelect={setPage} />
<FoxCarouselButton
type="plain"
label="이전"
icon={<FoxCaretLeftIcon weight="bold" />}
onAction={() => move(-1)}
/>
<FoxCarouselButton
type="plain"
label="다음"
icon={<FoxCaretRightIcon weight="bold" />}
onAction={() => move(1)}
/>
<FoxCarouselButton
type="plain"
label={playing ? "정지" : "재생"}
icon={playing ? <FoxPauseIcon weight="fill" /> : <FoxPlayIcon weight="fill" />}
onAction={() => setPlaying((prev) => !prev)}
/>
</FoxCarouselArea>
);
}
// 태그 목록은 상태를 갖지 않으므로, 닫기 아이콘이 실제로 뭔가를 떼는지 보려면 예제가 목록을
// 들고 있어야 한다. 전부 떼면 다시 채울 방법이 없어 초기화 버튼을 같이 둔다.
const TAG_FILTERS = ["서울", "경기", "인천", "부산"];
function TagListLiveDemo() {
const [filters, setFilters] = useState(TAG_FILTERS);
return (
<>
<FoxTagList size="md" label="걸어 둔 조건">
{filters.map((name) => (
<FoxTag
key={name}
label={name}
ariaLabel={`${name} 조건 제거`}
onAction={() => setFilters((prev) => prev.filter((it) => it !== name))}
/>
))}
</FoxTagList>
<FoxButton
size="xsm"
label="초기화"
onAction={() => setFilters(TAG_FILTERS)}
disabled={filters.length === TAG_FILTERS.length}
/>
</>
);
}
const BADGE_COLORS = [
"neutral",
"primary",
"secondary",
"accent",
"danger",
"warning",
"success",
"information",
"disabled",
] as const;
/** 시안의 9색 × 3계열 표를 그대로 펼친다 — 눈으로 비교해야 하는 조합이라 빠짐없이 낸다. */
function BadgeMatrix({ size }: { size: "lg" | "md" | "sm" }) {
return (
<div className={styles.demoStack}>
{(["outline", "solid", "pastel"] as const).map((type) => (
<div className={styles.demoRow} key={type}>
{BADGE_COLORS.map((color) => (
<FoxBadge key={color} color={color} type={type} size={size}>
레이블
</FoxBadge>
))}
</div>
))}
</div>
);
}
/** 컴포넌트를 만들 때마다 항목 하나를 추가한다 — 다른 파일은 고치지 않는다. */
export const COMPONENT_EXAMPLES: ComponentExample[] = [
{
id: "fox-button",
name: "FoxButton",
description:
"Figma 시안(btn, 컴포넌트 세트 106:1606)을 토큰만으로 옮겼습니다. 너비는 내용에 따라 가변이고 높이만 고정입니다. hover·pressed는 마우스를 올리거나 눌러 확인하세요. error 계열은 시안 미정이라 비어 있습니다.",
variants: [
{
label: "계열 (type) — hover·pressed 확인 가능",
node: (
<>
<FoxButton size="md" label="default" />
<FoxButton size="md" type="primary" label="primary" />
<FoxButton size="md" type="secondary" label="secondary" />
<FoxButton size="md" type="error" label="error (시안 미정)" />
</>
),
},
{
label: "크기 (size)",
node: (
<>
<FoxButton size="lg" label="lg" />
<FoxButton size="md" label="md" />
<FoxButton size="sm" label="sm" />
<FoxButton size="xsm" label="xsm" />
</>
),
},
{
label: "아이콘",
node: (
<>
<FoxButton size="md" label="leading" leadingIcon={<DemoIcon />} />
<FoxButton size="md" label="trailing" trailingIcon={<DemoIcon />} />
<FoxButton size="md" label="both" leadingIcon={<DemoIcon />} trailingIcon={<DemoIcon />} />
</>
),
},
{
label: "상태 — disabled / loading (둘 다 클릭이 막힌다)",
node: (
<>
<FoxButton size="md" label="normal" onAction={() => undefined} />
<FoxButton size="md" label="disabled" disabled />
<FoxButton size="md" label="loading" loading />
<FoxButton size="md" label="loading + icon" loading leadingIcon={<DemoIcon />} />
</>
),
},
{
label: "fullWidth",
node: <FoxButton size="md" label="fullWidth" fullWidth />,
},
{
label: "htmlType — 폼 안에서의 역할",
node: (
<>
<FoxButton size="md" label="button (기본)" />
<FoxButton size="md" label="submit" htmlType="submit" />
<FoxButton size="md" label="reset" htmlType="reset" />
</>
),
},
{
label: "hidden — 렌더되지 않는다(아래는 비어 있는 것이 정상)",
node: <FoxButton size="md" label="hidden" hidden />,
},
],
},
{
id: "fox-text-button",
name: "FoxTextButton",
description:
"Figma 시안(btn-txt, 컴포넌트 세트 106:2468)을 토큰만으로 옮겼습니다. 높이를 고정하지 않고 패딩과 line-height로만 크기를 잡습니다. lg만 상하 패딩과 간격이 한 단계 넓습니다.",
variants: [
{
label: "계열 (type) — primary는 굵기가 한 단계 더 굵다",
node: (
<>
<FoxTextButton size="md" type="primary" label="primary" />
<FoxTextButton size="md" type="secondary" label="secondary" />
</>
),
},
{
label: "크기 (size)",
node: (
<>
<FoxTextButton size="lg" label="lg" />
<FoxTextButton size="md" label="md" />
<FoxTextButton size="sm" label="sm" />
<FoxTextButton size="xsm" label="xsm" />
</>
),
},
{
label: "아이콘",
node: (
<>
<FoxTextButton size="md" label="leading" leadingIcon={<DemoIcon />} />
<FoxTextButton size="md" label="trailing" trailingIcon={<DemoIcon />} />
<FoxTextButton
size="md"
label="both"
leadingIcon={<DemoIcon />}
trailingIcon={<DemoIcon />}
/>
</>
),
},
{
label: "상태 — disabled",
node: (
<>
<FoxTextButton size="md" label="normal" onAction={() => undefined} />
<FoxTextButton size="md" label="disabled" disabled />
<FoxTextButton size="md" type="secondary" label="disabled (secondary)" disabled />
</>
),
},
],
},
{
id: "fox-link-button",
name: "FoxLinkButton",
description:
"Figma 시안(btn-lnk, 컴포넌트 세트 107:207)을 토큰만으로 옮겼습니다. href를 주면 <a>, 없으면 <button>으로 렌더합니다. 방문 색은 브라우저가 실제 방문 기록으로만 켜므로 아래에서 링크를 한 번 눌러 돌아오면 색이 바뀝니다. tertiary만 평소에 밑줄이 없고 hover에서만 생깁니다.",
variants: [
{
label: "계열 (type) — hover하면 tertiary에 밑줄이 생긴다",
node: (
<>
<FoxLinkButton size="md" type="primary" label="primary" href="https://example.com" />
<FoxLinkButton
size="md"
type="secondary"
label="secondary"
href="https://example.com"
/>
<FoxLinkButton size="md" type="tertiary" label="tertiary" href="https://example.com" />
</>
),
},
{
label: "크기 (size)",
node: (
<>
<FoxLinkButton size="lg" label="lg" href="https://example.com" />
<FoxLinkButton size="md" label="md" href="https://example.com" />
<FoxLinkButton size="sm" label="sm" href="https://example.com" />
</>
),
},
{
label: "아이콘 — 라벨 뒤에만 놓인다",
node: (
<>
<FoxLinkButton size="lg" label="lg" href="https://example.com" icon={<DemoIcon />} />
<FoxLinkButton size="md" label="md" href="https://example.com" icon={<DemoIcon />} />
<FoxLinkButton size="sm" label="sm" href="https://example.com" icon={<DemoIcon />} />
</>
),
},
{
label: "href 없음 — <button>으로 렌더된다(방문 색이 켜지지 않는다)",
node: (
<>
<FoxLinkButton size="md" label="onAction만" onAction={() => undefined} />
<FoxLinkButton size="md" type="tertiary" label="tertiary" onAction={() => undefined} />
</>
),
},
{
label: "상태 — disabled (앵커는 href가 빠져 포커스·클릭이 함께 사라진다)",
node: (
<>
<FoxLinkButton size="md" label="disabled (a)" href="https://example.com" disabled />
<FoxLinkButton size="md" label="disabled (button)" disabled />
</>
),
},
],
},
{
id: "fox-icon-button",
name: "FoxIconButton",
description:
"Figma 시안(btn-ico 400:6312 + btn-ico-toggle 404:5247)을 토큰만으로 옮겼습니다. 두 시안이 계열·크기·모양이 같고 켜짐 상태만 달라 한 컴포넌트로 합쳤습니다 — active를 주면 토글(aria-pressed)이 됩니다. ghost는 테두리가 없는 대신 아이콘이 한 단계 큽니다.",
variants: [
{
label: "계열 (type) — overlay는 사진 위에서 봐야 한다",
node: (
<>
<FoxIconButton size="md" type="default" label="default" icon={<DemoIcon />} />
<FoxIconButton size="md" type="ghost" label="ghost" icon={<DemoIcon />} />
<OnPhoto>
<FoxIconButton size="md" type="overlay" label="overlay" icon={<DemoIcon />} />
</OnPhoto>
</>
),
},
{
label: "크기 (size) — default 계열",
node: (
<>
<FoxIconButton size="lg" label="lg" icon={<DemoIcon />} />
<FoxIconButton size="md" label="md" icon={<DemoIcon />} />
<FoxIconButton size="sm" label="sm" icon={<DemoIcon />} />
<FoxIconButton size="xsm" label="xsm" icon={<DemoIcon />} />
</>
),
},
{
label: "크기 (size) — ghost는 같은 상자에 아이콘만 한 단계 크다",
node: (
<>
<FoxIconButton size="lg" type="ghost" label="lg" icon={<DemoIcon />} />
<FoxIconButton size="md" type="ghost" label="md" icon={<DemoIcon />} />
<FoxIconButton size="sm" type="ghost" label="sm" icon={<DemoIcon />} />
<FoxIconButton size="xsm" type="ghost" label="xsm" icon={<DemoIcon />} />
</>
),
},
{
label: "토글 — 눌러서 켜고 끈다 (active)",
node: <IconToggleDemo />,
},
{
label: "토글 켜짐 고정 — 계열별 색 비교",
node: (
<>
<FoxIconButton size="md" type="default" label="on" icon={<DemoStar />} active />
<FoxIconButton size="md" type="ghost" label="on" icon={<DemoStar />} active />
<OnPhoto>
<FoxIconButton size="md" type="overlay" label="on" icon={<DemoStar />} active />
</OnPhoto>
</>
),
},
{
label: "상태 — disabled (켜짐보다 우선한다)",
node: (
<>
<FoxIconButton size="md" label="disabled" icon={<DemoIcon />} disabled />
<FoxIconButton size="md" type="ghost" label="disabled" icon={<DemoIcon />} disabled />
<FoxIconButton
size="md"
type="overlay"
label="disabled"
icon={<DemoIcon />}
disabled
/>
<FoxIconButton
size="md"
label="active + disabled"
icon={<DemoStar />}
active
disabled
/>
</>
),
},
],
},
{
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>
),
},
],
},
{
id: "fox-button-panel",
name: "FoxButtonPanel",
description:
"Figma 시안(btn-page, 컴포넌트 세트 404:5121)을 옮겼습니다. 자식은 버튼 그룹이고, 여럿이면 양끝으로 벌어지고 하나면 가운데로 갑니다 — 자식 수는 CSS :only-child가 판단합니다. 패널 안에서는 그룹 간격이 gap-3(8px)으로 덮어써지고(단독으로 쓰면 gap-2), 버튼에 최소 폭 160px이 붙습니다. 창을 768px 아래로 줄이면 패널도 그룹도 세로로 쌓이고 버튼이 폭을 채웁니다.",
variants: [
{
label: "그룹 둘 — 양끝 (시안 type=flow)",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel offset={false}>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="취소" />
</FoxButtonGroup>
<FoxButtonGroup size="lg">
<FoxButton size="lg" type="secondary" label="임시 저장" />
<FoxButton size="lg" type="primary" label="다음" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
{
label: "그룹 하나 — 가운데 (시안 type=default)",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel offset={false}>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="취소" />
<FoxButton size="lg" type="primary" label="확인" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
{
label: "상단 여백 (offset) — 기본 켜짐, 80px",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel>
<FoxButtonGroup size="lg">
<FoxButton size="lg" type="primary" label="확인" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
{
label: "그룹 셋 이상도 양끝 기준으로 벌어진다",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel offset={false}>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="삭제" />
</FoxButtonGroup>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="미리보기" />
</FoxButtonGroup>
<FoxButtonGroup size="lg">
<FoxButton size="lg" type="primary" label="저장" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
],
},
{
id: "fox-form-label",
name: "FoxFormLabel",
description:
"Figma 시안(form-label 180:609) 입력 칸 위에 붙는 라벨입니다. 글자 자체는 form-field의 label 믹스인이 갖습니다 — 시안에서 이 컴포넌트와 입력 계열의 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없습니다. 필수는 글자 뒤에 *, 선택은 (선택)을 붙이고 둘은 배타적이라 boolean 두 개가 아니라 값 하나로 받습니다. *는 aria-hidden이라 스크린리더가 읽지 않습니다 — 필수 여부는 입력 요소의 required가 알리는 것이고 별표는 그 눈에 보이는 짝이라, 둘 다 읽히면 같은 말이 두 번 나옵니다. 반대로 (선택)은 대신 알려 줄 속성이 없어 읽히게 둡니다.",
variants: [
{
label: "기본 / 필수 / 선택",
node: (
<div className={styles.demoStack}>
<FoxFormLabel>레이블</FoxFormLabel>
<FoxFormLabel requirement="required">레이블</FoxFormLabel>
<FoxFormLabel requirement="optional">레이블</FoxFormLabel>
</div>
),
},
{
label: "optionalText — 문구를 바꿀 수 있다",
node: (
<div className={styles.demoStack}>
<FoxFormLabel requirement="optional">기본 문구</FoxFormLabel>
<FoxFormLabel requirement="optional" optionalText="(입력하지 않아도 됩니다)">
직접 지정
</FoxFormLabel>
</div>
),
},
{
label: "htmlFor — 눌러 보면 아래 입력칸에 커서가 간다",
node: (
<InputStage>
<div className={styles.demoStack}>
<FoxFormLabel htmlFor="demo-form-label" requirement="required">
라벨을 눌러 보세요
</FoxFormLabel>
<FoxInput size="md" id="demo-form-label" placeholder="여기로 커서가 옵니다" />
</div>
</InputStage>
),
},
],
},
{
id: "fox-input",
name: "FoxInput",
description:
"Figma 시안(input, 컴포넌트 세트 135:621)을 옮겼습니다. 네이티브 <input>을 감싸므로 시안에 없는 password·email·number 등 모든 타입을 그대로 받습니다. 상태는 기본적으로 브라우저가 판단하고(포커스·값·비활성·aria-invalid), state를 주면 그 모양으로 고정됩니다. 라벨은 입력 크기와 무관하게 항상 같은 크기입니다.",
variants: [
{
label: "직접 조작 — 입력·포커스·지우기",
node: <InputLiveDemo />,
},
{
label: "크기 (size) — 상자·글자·아이콘이 함께 줄고 라벨만 고정",
node: (
<>
{(["lg", "md", "sm"] as const).map((size) => (
<InputStage key={size}>
<FoxInput size={size} label={`레이블 (${size})`} placeholder="내용을 입력하세요." />
</InputStage>
))}
</>
),
},
{
label: "상태 고정 (state) — 시안 6종을 그대로 비교",
node: (
<>
{(
["default", "focused", "completed", "error", "disabled", "view"] as FoxInputState[]
).map((state) => (
<InputStage key={state}>
<FoxInput
size="md"
state={state}
label={state}
placeholder="내용을 입력하세요."
message="힌트 메시지"
/>
</InputStage>
))}
</>
),
},
{
label: "타입 — 시안에 없는 것도 네이티브 그대로 동작한다",
node: (
<>
{(["text", "date", "password", "email", "number", "time"] as const).map((type) => (
<InputStage key={type}>
<FoxInput size="md" type={type} label={type} placeholder="내용을 입력하세요." />
</InputStage>
))}
</>
),
},
{
label: "아이콘 · 헬퍼 아이콘 슬롯",
node: (
<>
<InputStage>
<FoxInput
size="md"
label="오른쪽 아이콘"
placeholder="내용을 입력하세요."
icon={<DemoIcon />}
/>
</InputStage>
<InputStage>
<FoxInput
size="md"
label="헬퍼 아이콘"
placeholder="내용을 입력하세요."
message="아이콘은 호출부가 넣습니다"
messageIcon={<DemoIcon />}
/>
</InputStage>
</>
),
},
{
label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정",
node: (
<>
<InputStage>
<FoxInput size="md" label="disabled" defaultValue="수정할 수 없음" disabled />
</InputStage>
<InputStage>
<FoxInput size="md" label="readOnly" defaultValue="읽기 전용" readOnly />
</InputStage>
</>
),
},
{
label: "라벨·헬퍼 없이도 쓸 수 있다",
node: (
<InputStage>
<FoxInput size="md" placeholder="내용을 입력하세요." />
</InputStage>
),
},
],
},
{
id: "fox-select",
name: "FoxSelect",
description:
"Figma 시안(select, 컴포넌트 세트 111:272). 네이티브 <select>가 아니라 트리거 버튼 + 리스트박스라 옵션 목록까지 시안대로 그립니다. 상태는 전부 ARIA 속성이 스타일 훅을 겸합니다 — 열림·오류·조회 전용·옵션 선택에 별도 클래스가 없습니다.",
variants: [
{
label: "크기 (size)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "완료(값 선택됨)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "error",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "disabled",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "view (조회 전용)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "옵션 아이콘(맨 뒤) · 긴 라벨 — 열어서 확인한다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS_RICH} />
</div>
),
},
{
label: "라벨 없음 · 힌트 없음 — 해당 영역이 렌더되지 않는다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" options={SELECT_ITEMS} />
<FoxSelect size="md" label="라벨만" options={SELECT_ITEMS} />
</div>
),
},
{
label: "placeholder — 기본값은 “선택해주세요”",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="기본" options={SELECT_ITEMS} />
<FoxSelect size="md" label="직접 지정" options={SELECT_ITEMS} placeholder="등급을 고르세요" />
</div>
),
},
{
label: "requirement — 라벨을 FoxFormLabel이 그린다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="표시 없음" options={SELECT_ITEMS} />
<FoxSelect size="md" label="필수" options={SELECT_ITEMS} requirement="required" />
<FoxSelect size="md" label="선택" options={SELECT_ITEMS} requirement="optional" />
</div>
),
},
],
},
{
id: "fox-select-option",
name: "FoxSelectOption",
description:
"Figma 시안(select, 컴포넌트 세트 732:5879) FoxSelect가 열렸을 때 뜨는 옵션 목록입니다. 셀렉트에서 떼어내 단독으로 올려두었습니다. 목록은 position: absolute라 위치 기준이 되는 조상이 필요합니다",
variants: [
{
label: "목록",
node: (
<div className={styles.demoListboxStage}>
<FoxSelectOption>
<FoxSelectOptionItem label="셀렉트 옵션" />
<FoxSelectOptionItem label="셀렉트 옵션" />
<FoxSelectOptionItem label="셀렉트 옵션" />
<FoxSelectOptionItem label="셀렉트 옵션" />
</FoxSelectOption>
</div>
),
},
{
label: "스크롤 — 항목이 많으면 240px에서 스크롤된다 (⚠️ 높이 미확정)",
node: (
<div className={styles.demoListboxStage}>
<FoxSelectOption>
{Array.from({ length: 12 }, (_, i) => (
<FoxSelectOptionItem key={i} label={`항목 ${i + 1}`} selected={i === 2} />
))}
</FoxSelectOption>
</div>
),
},
],
},
{
id: "fox-select-option-item",
name: "FoxSelectOptionItem",
description:
"Figma 시안(select, 컴포넌트 세트 251:396) 옵션 목록의 항목 하나입니다. 선택 여부는 aria-selected가 스타일 훅을 겸하고, 키보드로 이동 중인 항목만 네이티브 대응이 없어 --active 클래스를 씁니다. 항목은 포커스를 받지 않습니다 — 콤보박스는 포커스가 트리거에 남아야 aria-activedescendant가 성립하기 때문입니다. 눌림(pressed)은 마우스로 누르고 있는 동안 보입니다.",
variants: [
{
label: "상태 — 기본 / 선택됨 / hover / pressed 클릭",
node: (
<div className={styles.demoListboxStage}>
<FoxSelectOption>
<FoxSelectOptionItem label="셀렉트 옵션" />
<FoxSelectOptionItem label="셀렉트 옵션-selected" selected />
<FoxSelectOptionItem label="셀렉트 옵션-hover" active />
<FoxSelectOptionItem label="셀렉트 옵션-pressed (클릭으로 확인)" />
<FoxSelectOptionItem label="셀렉트 옵션-danger" danger />
</FoxSelectOption>
</div>
),
},
{
label: "아이콘 — 체크는 맨 앞(선택됐을 때만), 아이콘은 맨 뒤",
node: (
<div className={styles.demoListboxStage}>
<FoxSelectOption>
<FoxSelectOptionItem label="셀렉트 옵션" icon={<DemoIcon />} />
<FoxSelectOptionItem label="셀렉트 옵션-selected" selected icon={<DemoIcon />} />
</FoxSelectOption>
</div>
),
},
],
},
{
id: "fox-radio-icon",
name: "FoxRadioIcon",
description:
"Figma 시안(radio-icon 107:551) 라디오의 동그란 표시만 담당합니다. SVG가 아니라 CSS로 그립니다 — 시안이 바깥 원과 안쪽 점을 별개 레이어로 두었고, 네 상태가 모양이 아니라 색만 다르기 때문입니다(테두리 두께는 전부 border-1). FoxRadio 안에서는 네이티브 input의 :checked·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.",
variants: [
{
label: "lg — 기본 / 선택 / 비활성 / 선택+비활성",
node: (
<>
<FoxRadioIcon size="lg" />
<FoxRadioIcon size="lg" checked />
<FoxRadioIcon size="lg" disabled />
<FoxRadioIcon size="lg" checked disabled />
</>
),
},
{
label: "md — 기본 / 선택 / 비활성 / 선택+비활성",
node: (
<>
<FoxRadioIcon size="md" />
<FoxRadioIcon size="md" checked />
<FoxRadioIcon size="md" disabled />
<FoxRadioIcon size="md" checked disabled />
</>
),
},
],
},
{
id: "fox-radio",
name: "FoxRadio",
description:
"Figma 시안(radio 180:278) 동그란 표시 + 라벨 + 부가설명을 묶은 라디오 한 칸입니다.",
variants: [
{
label: "size-lg",
node: (
<div className={styles.demoStack}>
<FoxRadio name="demo-plan" value="basic" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." />
<FoxRadio name="demo-plan" value="pro" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." defaultChecked/>
<FoxRadio name="demo-plan" value="team" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." disabled />
<FoxRadio name="demo-plan" value="team" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." disabled defaultChecked />
</div>
),
},
{
label: "size-md",
node: (
<div className={styles.demoStack}>
<FoxRadio name="demo-plan" value="basic" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." />
<FoxRadio name="demo-plan" value="pro" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." defaultChecked/>
<FoxRadio name="demo-plan" value="team" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." disabled />
<FoxRadio name="demo-plan" value="team" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." disabled defaultChecked />
</div>
),
},
],
},
{
id: "fox-radio-group",
name: "FoxRadioGroup",
description:
"Figma 시안(radio-group 194:4327)라디오 여러 칸의 배치와 name·size 통일을 책임집니다. 어느 칸이 선택됐는지는 각 FoxRadio의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. name은 그룹이 단일 진실 공급원이라 자식마다 적지 않아도 되고, 한 칸만 빼먹어 두 개가 선택되는 실수가 막힙니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(direction=row가 세로로 쌓습니다) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.",
variants: [
{
label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬",
node: (
<FoxRadioGroup name="vertical" label="vertical">
<FoxRadio label="라디오버튼" defaultChecked />
<FoxRadio label="라디오버튼" />
</FoxRadioGroup>
),
},
{
label: "horizontal — 행 gap-7 / 열 gap-9, 폭이 모자라면 줄바꿈",
node: (
<FoxRadioGroup orientation="horizontal" name="horizontal" label="horizontal">
<FoxRadio label="라디오버튼" defaultChecked />
<FoxRadio label="라디오버튼" />
<FoxRadio label="라디오버튼" />
</FoxRadioGroup>
),
},
{
label: "horizontal + 줄바꿈 — 다음 줄로 넘어간다",
node: (
<div className={styles.demoNarrow}>
<FoxRadioGroup orientation="horizontal" name="rg-wrap" label="줄바꿈 확인">
{["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label, i) => (
<FoxRadio key={label} value={label} label={label} defaultChecked={i === 0} />
))}
</FoxRadioGroup>
</div>
),
},
{
label: "size — 자식이 준 값은 무시되고 그룹 값으로 통일된다",
node: (
<FoxRadioGroup orientation="horizontal" name="rg-md" size="md" label="md 크기">
{/* 일부러 어긋난 size를 준다 — 둘 다 md로 나와야 정상 */}
<FoxRadio value="a" label="첫째" size="lg" defaultChecked />
<FoxRadio value="b" label="둘째" />
</FoxRadioGroup>
),
},
],
},
{
id: "fox-conditional-option",
name: "FoxConditionalOption",
description:
"Figma 시안(conditional-option 190:2163) 컨트롤 한 칸과 select가 합쳐 하나의 답을 이룹니다. control로 라디오·체크박스를 고릅니다(시안의 trigger — 이 패키지에서 trigger는 fox-select__trigger라는 다른 뜻으로 이미 쓰여 이름을 바꿨습니다). select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.",
variants: [
{
label: "vertical (기본) — select가 아래로 내려가고 왼쪽 끝에 붙는다",
node: (
<FoxConditionalOption
name="co-v"
value="other"
label="다른 주소로 배송"
description="주소를 고르면 이 항목이 자동으로 선택됩니다."
options={SELECT_ITEMS}
placeholder="주소를 고르세요"
/>
),
},
{
label: "horizontal — select가 라디오 옆에 선다",
node: (
<FoxConditionalOption
orientation="horizontal"
name="co-h"
value="other"
label="다른 주소로 배송"
options={SELECT_ITEMS}
placeholder="주소를 고르세요"
/>
),
},
{
label: "",
node: (
<FoxRadioGroup name="co-auto" label="배송지">
<FoxRadio value="default" label="기본 배송지" defaultChecked />
<FoxConditionalOption
value="other"
label="다른 주소로 배송"
options={SELECT_ITEMS}
placeholder="주소를 고르세요"
/>
</FoxRadioGroup>
),
},
{
label: "FoxRadioGroup 안 — name·size를 그룹이 넣어 준다(md로 통일된다)",
node: (
<FoxRadioGroup name="co-group" size="md" label="결제 수단">
<FoxRadio value="card" label="카드" defaultChecked />
<FoxConditionalOption
value="bank"
label="계좌 이체"
description="은행을 고르면 이 항목이 선택됩니다."
options={SELECT_ITEMS}
placeholder="은행을 고르세요"
/>
</FoxRadioGroup>
),
},
{
label: "disabled — 컨트롤과 select가 함께 잠긴다",
node: (
<FoxConditionalOption
name="co-off"
value="other"
label="다른 주소로 배송"
description="선택할 수 없습니다."
options={SELECT_ITEMS}
placeholder="주소를 고르세요"
disabled
/>
),
},
{
label: "control=\"checkbox\" — 여러 개를 동시에 켤 수 있다",
node: (
<FoxCheckboxGroup name="co-cb" label="추가 옵션">
<FoxCheckbox value="gift" label="선물 포장" />
<FoxConditionalOption
control="checkbox"
value="card"
label="카드 동봉"
description="문구를 고르면 이 항목이 자동으로 켜집니다."
options={SELECT_ITEMS}
placeholder="문구를 고르세요"
/>
</FoxCheckboxGroup>
),
},
{
label: "control 비교 — 라디오는 하나만, 체크박스는 여럿",
node: (
<div className={styles.demoStack}>
<FoxConditionalOption
orientation="horizontal"
name="co-cmp-r"
value="a"
label="radio (기본)"
options={SELECT_ITEMS}
placeholder="고르세요"
/>
<FoxConditionalOption
orientation="horizontal"
control="checkbox"
name="co-cmp-c"
value="a"
label="checkbox"
options={SELECT_ITEMS}
placeholder="고르세요"
/>
</div>
),
},
],
},
{
id: "fox-text-area",
name: "FoxTextArea",
description:
"Figma 시안(textarea 135:1655)을 옮겼습니다. 시안에 크기 축이 없어 상자는 하나뿐이고 높이는 rows가 정합니다(기본 5줄, 시안과 같음). 상태 판정은 FoxInput과 같은 방식입니다 — 기본은 브라우저가 판단하고 state를 주면 고정됩니다. 하단은 왼쪽 헬퍼와 오른쪽 글자수 카운터로 나뉘고, 오류일 때 현재 글자수가 빨개집니다.",
variants: [
{
label: "직접 조작 — 입력·포커스·카운터",
node: <TextAreaLiveDemo />,
},
{
label: "상태 고정 (state) — 시안 6종을 그대로 비교",
node: (
<>
{(
["default", "focused", "completed", "error", "disabled", "view"] as FoxTextAreaState[]
).map((state) => (
<InputStage key={state}>
<FoxTextArea
state={state}
label={state}
placeholder="내용을 입력하세요."
message="힌트 메시지"
defaultValue={""}
maxLength={100}
counter
/>
</InputStage>
))}
</>
),
},
{
label: "줄 수 (rows)",
node: (
<>
{[3, 5, 8].map((rows) => (
<InputStage key={rows}>
<FoxTextArea rows={rows} label={`rows=${rows}`} placeholder="내용을 입력하세요." />
</InputStage>
))}
</>
),
},
{
label: "헬퍼·카운터는 따로 켠다",
node: (
<>
<InputStage>
<FoxTextArea label="헬퍼만" placeholder="내용을 입력하세요." message="힌트 메시지" />
</InputStage>
<InputStage>
<FoxTextArea label="카운터만" placeholder="내용을 입력하세요." maxLength={100} counter />
</InputStage>
<InputStage>
<FoxTextArea label="최대 없이 개수만" placeholder="내용을 입력하세요." counter />
</InputStage>
<InputStage>
<FoxTextArea label="둘 다 없음" placeholder="내용을 입력하세요." />
</InputStage>
</>
),
},
{
label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정",
node: (
<>
<InputStage>
<FoxTextArea label="disabled" defaultValue="수정할 수 없음" disabled />
</InputStage>
<InputStage>
<FoxTextArea label="readOnly" defaultValue="읽기 전용" readOnly />
</InputStage>
</>
),
},
],
},
{
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>
</>
),
},
],
},
{
id: "fox-certified",
name: "FoxCertified",
description:
"Figma 시안(form-certified 190:2017)을 옮겼습니다. 전화번호는 FoxPhoneNumber(combine), 두 버튼은 FoxButton(secondary·primary)을 그대로 씁니다. 보내기·확인은 호출부가 Promise로 넘기고, resolve면 성공 reject면 실패입니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, 인증에 성공하면 전부 잠깁니다.",
variants: [
{
label: "직접 조작 — 인증번호 123456이면 성공, 아니면 실패",
node: <CertifiedLiveDemo />,
},
{
label: "보내기가 실패하는 경우 — 칸이 열리지 않고 이벤트만 옵니다",
node: <CertifiedLiveDemo failSend />,
},
{
label: "타이머를 호출부가 넘길 때 (remainingSeconds)",
node: (
<InputStage>
<FoxCertified
prefixOptions={PHONE_PREFIXES}
defaultPhone="01012345678"
remainingSeconds={1456}
onSend={async () => undefined}
onVerify={async () => undefined}
/>
</InputStage>
),
},
{
label: "전체 잠금 (disabled)",
node: (
<InputStage>
<FoxCertified
prefixOptions={PHONE_PREFIXES}
defaultPhone="01012345678"
disabled
onSend={async () => undefined}
onVerify={async () => undefined}
/>
</InputStage>
),
},
],
},
{
id: "fox-email",
name: "FoxEmail",
description:
"Figma 시안(form-email 136:3092)을 옮겼습니다. 세 칸이 모두 input·select 인스턴스라 FoxInput·FoxSelect를 그대로 조합했습니다. 값은 아이디@도메인이 합쳐진 문자열 하나입니다. 도메인을 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 되며, 직접입력을 고르면 풀립니다. 칸은 최소 240px을 지키며 남는 폭을 나눠 갖고, 좁아지면 줄이 넘어갑니다.",
variants: [
{
label: "직접 조작 — 도메인을 바꿔보세요",
node: <EmailLiveDemo />,
},
{
label: "확인 버튼 — onVerify를 넘겼을 때만 생깁니다",
node: (
<>
<InputStage wide>
<FoxEmail
label="onVerify 있음"
domainOptions={EMAIL_DOMAINS}
defaultValue="hong@naver.com"
onVerify={async () => new Promise((resolve) => setTimeout(resolve, 400))}
/>
</InputStage>
<InputStage wide>
<FoxEmail label="onVerify 없음" domainOptions={EMAIL_DOMAINS} />
</InputStage>
</>
),
},
{
label: "좁은 곳에서는 줄이 넘어갑니다",
node: (
<InputStage>
<FoxEmail domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" />
</InputStage>
),
},
{
label: "오류 · 비활성",
node: (
<>
<InputStage wide>
<FoxEmail
label="invalid"
domainOptions={EMAIL_DOMAINS}
defaultValue="hong@naver.com"
message="이미 쓰고 있는 주소입니다"
invalid
/>
</InputStage>
<InputStage wide>
<FoxEmail label="disabled" domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" disabled />
</InputStage>
</>
),
},
],
},
{
id: "fox-address",
name: "FoxAddress",
description:
"Figma 시안(form-address 5346:5823)을 옮겼습니다. 세 상자가 모두 input 인스턴스라 FoxInput을 그대로 조합했습니다. 우편번호와 주소는 검색 결과로만 채워지는 읽기 전용이고 상세주소만 손으로 적습니다. 어떤 우편번호 서비스를 쓸지는 호출부가 정합니다 — onSearch에서 결과를 FoxAddressSearchResult 모양으로 돌려주면 되고, 취소는 null입니다.",
variants: [
{
label: "직접 조작 — 검색하면 두 칸이 채워집니다",
node: <AddressLiveDemo />,
},
{
label: "검색을 취소하면(null) 값이 그대로 남습니다",
node: <AddressLiveDemo cancel />,
},
{
label: "오류 · 비활성",
node: (
<>
<InputStage>
<FoxAddress
label="invalid"
message="주소를 입력해 주세요"
invalid
onSearch={async () => null}
/>
</InputStage>
<InputStage>
<FoxAddress
label="disabled"
defaultValue={{ zipCode: "06236", address: "서울 강남구 테헤란로 152", detail: "10층" }}
disabled
onSearch={async () => null}
/>
</InputStage>
</>
),
},
],
},
{
id: "fox-file-upload",
name: "FoxFileUpload",
description:
"Figma 시안 다섯(file-upload 391:3677 · file-upload-area 392:5968 · file-upload-img 391:3732 · file-list 392:5938 · file-list-item 391:3509)을 한 컴포넌트로 옮겼습니다. 버튼·입력이 모두 기존 컴포넌트의 인스턴스라 FoxButton·FoxTextButton·FoxIconButton·FoxInput을 그대로 조합했습니다. 업로드는 하지 않고 고른 원본 File만 넘깁니다 — 목록은 files로 주면 제어, 안 주면 안에서 들고 있습니다. default·area는 목록을 붙일 수 있고 image는 목록이 없습니다.",
variants: [
{
label: "default 모드 — 읽기 전용 칸 + 파일선택, 목록 지원",
node: <FileUploadLiveDemo mode="default" />,
},
{
label: "area 모드 — 끌어다 놓기가 항상 켜져 있고 목록을 지원합니다",
node: <FileUploadLiveDemo mode="area" />,
},
{
label: "image 모드 — 목록이 없고 썸네일 하나입니다",
node: (
<>
<InputStage wide>
<FoxFileUpload
mode="image"
label="비었을 때"
accept="image/*"
description="JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)"
/>
</InputStage>
<InputStage wide>
<FoxFileUpload
mode="image"
label="채워졌을 때 — 가운데 버튼으로 지웁니다"
accept="image/*"
defaultFiles={[
{ id: "p", name: "미리보기", previewUrl: "https://picsum.photos/seed/picsum/426/240" },
]}
description="JPG, PNG 파일만 가능 (권장 사이즈: 1280x720px, 최대 5MB)"
/>
</InputStage>
</>
),
},
{
label: "default 모드 · 파일 하나 (시안 upload=true) — 칸이 잠기고 버튼이 삭제로 바뀝니다",
node: (
<>
<InputStage wide>
<FoxFileUpload label="비었을 때" description="JPG, PNG 파일만 가능 (최대 5MB)" />
</InputStage>
<InputStage wide>
<FoxFileUpload
label="파일이 있을 때"
defaultFiles={[{ id: "one", name: "파일명", meta: ".PDF (20KB)" }]}
description="JPG, PNG 파일만 가능 (최대 5MB)"
/>
</InputStage>
</>
),
},
{
label: "목록 없이 · 크기 바꾸기",
node: (
<>
<InputStage wide>
<FoxFileUpload label="showList={false}" defaultFiles={SAMPLE_FILES} showList={false} multiple />
</InputStage>
<InputStage wide>
<FoxFileUpload label="inputWidth=440" inputWidth={440} />
</InputStage>
<InputStage wide>
<FoxFileUpload
mode="image"
label="thumbnail 160×90"
thumbnailWidth={160}
thumbnailHeight={90}
/>
</InputStage>
</>
),
},
{
label: "비활성",
node: (
<InputStage wide>
<FoxFileUpload label="disabled" defaultFiles={SAMPLE_FILES} multiple disabled />
</InputStage>
),
},
],
}, {
id: "fox-check-icon",
name: "FoxCheckIcon",
description:
"Figma 시안(check-icon 108:234) 체크박스의 네모 표시만 담당합니다. 상자는 CSS로 그리고 안쪽 표시만 SVG입니다 — 상자는 테두리·배경·모서리로 끝나지만 체크는 임의의 path라 CSS로 그릴 수 없습니다(FoxRadioIcon은 안쪽이 원이라 전부 CSS였습니다). 선택과 부분선택은 상자 모양이 같고 안쪽 표시만 다릅니다. FoxCheckbox 안에서는 네이티브 input의 :checked·:indeterminate·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.",
variants: [
{
label: "lg — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성",
node: (
<>
<FoxCheckIcon size="lg" />
<FoxCheckIcon size="lg" checked />
<FoxCheckIcon size="lg" indeterminate />
<FoxCheckIcon size="lg" disabled />
<FoxCheckIcon size="lg" checked disabled />
<FoxCheckIcon size="lg" indeterminate disabled />
</>
),
},
{
label: "md — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성",
node: (
<>
<FoxCheckIcon size="md" />
<FoxCheckIcon size="md" checked />
<FoxCheckIcon size="md" indeterminate />
<FoxCheckIcon size="md" disabled />
<FoxCheckIcon size="md" checked disabled />
<FoxCheckIcon size="md" indeterminate disabled />
</>
),
},
],
},
{
id: "fox-checkbox",
name: "FoxCheckbox",
description:
"Figma 시안(checkbox 108:469) 네모 표시 + 라벨 + 부가설명을 묶은 체크박스 한 칸입니다. 루트가 <label>이라 라벨이나 설명을 눌러도 켜집니다. 라디오와 달리 name이 같아도 서로를 끄지 않아 여러 개를 동시에 고를 수 있고, 폼 전송 시 고른 것들이 함께 나갑니다. ⚠️ 부분선택은 HTML 속성이 아니라 DOM 프로퍼티라 마크업만으로는 켤 수 없습니다 — 컴포넌트가 대신 넣어 줍니다.",
variants: [
{
label: "여러 개를 동시에 고를 수 있다 (같은 name이어도 서로 끄지 않는다)",
node: (
<div className={styles.demoStack}>
<FoxCheckbox name="cb-terms" value="service" label="서비스 이용약관" defaultChecked />
<FoxCheckbox name="cb-terms" value="privacy" label="개인정보 처리방침" defaultChecked />
<FoxCheckbox name="cb-terms" value="marketing" label="마케팅 정보 수신" />
</div>
),
},
{
label: "부분선택 — 전체 동의처럼 자식 일부만 켜진 상태",
node: (
<div className={styles.demoStack}>
<FoxCheckbox name="cb-ind" value="all" label="전체 동의" indeterminate />
<FoxCheckbox name="cb-ind" value="a" label="필수 약관" defaultChecked />
<FoxCheckbox name="cb-ind" value="b" label="선택 약관" />
</div>
),
},
{
label: "부가설명 — 여러 줄이어도 네모 표시는 첫 줄에 붙는다",
node: (
<div className={styles.demoStack}>
<FoxCheckbox
name="cb-desc"
value="a"
label="마케팅 정보 수신"
description="이벤트·혜택 소식을 받아봅니다."
defaultChecked
/>
<FoxCheckbox
name="cb-desc"
value="b"
label="야간 알림"
description="밤 9시 이후에도 알림을 받습니다. 설명이 길어지면 이렇게 줄이 넘어가고, 네모 표시는 첫 줄에 붙어 있어야 정상입니다."
/>
</div>
),
},
{
label: "크기 (size)",
node: (
<div className={styles.demoStack}>
<FoxCheckbox name="cb-lg" value="a" label="lg" description="부가설명" defaultChecked />
<FoxCheckbox name="cb-md" value="a" label="md" description="부가설명" size="md" defaultChecked />
</div>
),
},
{
label: "비활성 — 글자색도 흐려지고 클릭이 막힌다",
node: (
<div className={styles.demoStack}>
<FoxCheckbox name="cb-off" value="a" label="비활성" description="선택할 수 없습니다." disabled />
<FoxCheckbox name="cb-off2" value="a" label="비활성 + 선택됨" description="체크가 남고 색만 흐려집니다." disabled defaultChecked />
<FoxCheckbox name="cb-off3" value="a" label="비활성 + 부분선택" description="대시가 남고 색만 흐려집니다." disabled indeterminate />
</div>
),
},
],
},
{
id: "fox-checkbox-group",
name: "FoxCheckboxGroup",
description:
"Figma 시안(checkbox-group 194:4000) 체크박스 여러 칸의 배치와 name·size 통일을 책임집니다. 켜짐 여부는 각 FoxCheckbox의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. role이 radiogroup이 아니라 group입니다 — 체크박스는 서로를 끄지 않아 하나만 고르는 묶음이 아니고, 화살표 이동으로 옮겨 다니지도 않아 각 칸이 독립적으로 Tab을 받습니다. 배치 값은 FoxRadioGroup과 같습니다.",
variants: [
{
label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬",
node: (
<FoxCheckboxGroup name="cbg-v" label="약관 동의">
<FoxCheckbox value="service" label="서비스 이용약관" defaultChecked />
<FoxCheckbox value="privacy" label="개인정보 처리방침" />
<FoxCheckbox value="marketing" label="마케팅 정보 수신" />
</FoxCheckboxGroup>
),
},
{
label: "horizontal — 행 gap-7 / 열 gap-9, 폭이 모자라면 줄바꿈",
node: (
<FoxCheckboxGroup orientation="horizontal" name="cbg-h" label="관심 분야">
<FoxCheckbox value="dev" label="개발" defaultChecked />
<FoxCheckbox value="design" label="디자인" />
<FoxCheckbox value="pm" label="기획" />
</FoxCheckboxGroup>
),
},
{
label: "horizontal + 줄바꿈 — 좁은 무대에서 다음 줄로 넘어간다",
node: (
<div className={styles.demoNarrow}>
<FoxCheckboxGroup orientation="horizontal" name="cbg-wrap" label="줄바꿈 확인">
{["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label, i) => (
<FoxCheckbox key={label} value={label} label={label} defaultChecked={i === 0} />
))}
</FoxCheckboxGroup>
</div>
),
},
{
label: "size — 자식이 준 값은 무시되고 그룹 값으로 통일된다",
node: (
<FoxCheckboxGroup orientation="horizontal" name="cbg-md" size="md" label="md 크기">
{/* 일부러 어긋난 size를 준다 — 둘 다 md로 나와야 정상 */}
<FoxCheckbox value="a" label="첫째" size="lg" defaultChecked />
<FoxCheckbox value="b" label="둘째" />
</FoxCheckboxGroup>
),
},
],
},
{
id: "fox-tag",
name: "FoxTag",
description:
"Figma 시안(tag 132:195) 아이콘만 누르는 게 아니라 조각 전체가 버튼입니다 — 시안이 hover 배경을 태그 전체에 줍니다. type이 아이콘의 종류와 자리를 정합니다: sorting은 글자 뒤에 닫기 아이콘(걸어 둔 조건을 뗄 때), button은 글자 앞에 택 아이콘입니다. 기본 배경은 action-secondary, 즉 투명이라 아래 무대 색이 그대로 보이는 것이 정상입니다 — hover(연한 파랑)·pressed(진한 파랑)는 마우스를 올리고 눌러서 확인해야 합니다. button만 hover·pressed에서 글자에 밑줄이 붙습니다.",
variants: [
{
label: "sorting (기본) — 크기 lg / md / sm",
node: (
<>
<FoxTag size="lg" label="태그" ariaLabel="태그 조건 제거" />
<FoxTag size="md" label="태그" ariaLabel="태그 조건 제거" />
<FoxTag size="sm" label="태그" ariaLabel="태그 조건 제거" />
</>
),
},
{
label: "button — 크기 lg / md / sm (마우스를 올리면 글자에 밑줄)",
node: (
<>
<FoxTag type="button" size="lg" label="태그" />
<FoxTag type="button" size="md" label="태그" />
<FoxTag type="button" size="sm" label="태그" />
</>
),
},
],
},
{
id: "fox-tag-list",
name: "FoxTagList",
description:
"Figma 시안(tag-list 190:2565) 태그 여러 개의 배치와 size·type 통일을 책임집니다. 무엇이 걸려 있는지는 호출부가 소유하므로 목록은 상태를 갖지 않습니다. 방향 수식어가 없습니다 — 태그는 가로로 흐르다 폭이 모자라면 다음 줄로 넘어가는 것이 유일한 배치입니다. role이 radiogroup이 아니라 group입니다 — 하나만 고르는 묶음이 아니고 각 태그가 독립적으로 Tab을 받는 버튼입니다. 태그 사이 간격은 없습니다 — 각 태그의 좌우 padding이 여백 역할을 하고, 배경이 투명해서 서로 닿아도 경계가 겹쳐 보이지 않습니다.",
variants: [
{
label: "닫기를 누르면 실제로 떨어진다 — 목록은 호출부가 소유한다",
node: <TagListLiveDemo />,
},
{
label: "size — 자식이 준 값은 무시되고 묶음 값으로 통일된다",
node: (
<FoxTagList size="sm" label="sm 크기">
{/* 일부러 어긋난 size를 준다 — 셋 다 sm으로 나와야 정상 */}
<FoxTag label="서울" size="lg" />
<FoxTag label="경기" size="md" />
<FoxTag label="인천" />
</FoxTagList>
),
},
{
label: "type — 묶음이 계열도 통일한다",
node: (
<FoxTagList type="button" size="md" label="button 계열">
<FoxTag label="공지사항" />
<FoxTag label="1:1 문의" />
<FoxTag label="FAQ" />
</FoxTagList>
),
},
{
label: "줄바꿈 — 좁은 무대에서 다음 줄로 넘어간다",
node: (
<div className={styles.demoNarrow}>
<FoxTagList size="md" label="줄바꿈 확인">
{["서울", "경기", "인천", "부산", "대구", "광주"].map((name) => (
<FoxTag key={name} label={name} ariaLabel={`${name} 조건 제거`} />
))}
</FoxTagList>
</div>
),
},
],
},
{
id: "fox-toggle-switch",
name: "FoxToggleSwitch",
description:
"Figma 시안(toggle-switch 134:442) 토글 스위치 입니다. 마크업은 체크박스와 같고 role=\"switch\"만 얹어 선택됨이 아니라 켜짐/꺼짐으로 읽히게 합니다 — 폼에도 체크박스처럼 참여합니다. 루트가 <label>이라 글자를 눌러도 바뀌고, 켜짐 여부는 네이티브 input이 소유하므로 아래 예제는 전부 실제로 눌러서 확인할 수 있습니다. 동그라미는 꺼짐이면 왼쪽·켜짐이면 오른쪽이고, 표시 색은 언제나 트랙 배경과 같은 값이라 흰 동그라미에 트랙 색으로 글리프를 파낸 모양이 됩니다. 크기는 트랙·동그라미·표시·글자를 함께 바꿉니다 — lg는 트랙 40×24, md는 트랙 32×20입니다.",
variants: [
{
label: "꺼짐 / 켜짐 — 눌러서 바꿔 보세요",
node: (
<div className={styles.demoStack}>
<FoxToggleSwitch name="ts-off" label="알림 받기" />
<FoxToggleSwitch name="ts-on" label="알림 받기" defaultChecked />
</div>
),
},
{
label: "크기 — lg 트랙 40×24 / md 트랙 32×20, 꺼짐·켜짐 모두",
node: (
<div className={styles.demoStack}>
<FoxToggleSwitch name="ts-lg-off" label="lg 꺼짐" />
<FoxToggleSwitch name="ts-lg" label="lg 켜짐" defaultChecked />
<FoxToggleSwitch name="ts-md-off" size="md" label="md 꺼짐" />
<FoxToggleSwitch name="ts-md" size="md" label="md 켜짐" defaultChecked />
</div>
),
},
{
label: "label 없음 — 글자 영역을 렌더하지 않는다 (읽어 줄 이름은 aria-label로 준다)",
node: (
<>
<FoxToggleSwitch name="ts-bare-off" aria-label="알림 받기" />
<FoxToggleSwitch name="ts-bare-on" aria-label="알림 받기" defaultChecked />
</>
),
},
{
label: "비활성 — 꺼짐 / 켜짐",
node: (
<div className={styles.demoStack}>
<FoxToggleSwitch name="ts-off-dis" label="비활성 + 꺼짐" disabled />
<FoxToggleSwitch name="ts-on-dis" label="비활성 + 켜짐" disabled defaultChecked />
</div>
),
},
],
},
{
id: "fox-breadcrumb",
name: "FoxBreadcrumb",
description:
"Figma 시안(breadcrumb 141:3204)을 옮겼습니다. 마지막 항목이 현재 위치라 링크가 되지 않고 aria-current=\"page\"가 붙습니다. href를 주면 <a>, 없으면 <button>입니다. 항목 바탕이 투명이라 hover·pressed는 마우스를 올려 확인하세요.",
variants: [
{
label: "깊이 — 1 · 2 · 3 · 4단계",
node: (
<div className={styles.demoStack}>
<FoxBreadcrumb items={[{ label: "홈" }]} />
<FoxBreadcrumb items={[{ label: "홈", href: "#" }, { label: "1Depth" }]} />
<FoxBreadcrumb
items={[
{ label: "홈", href: "#" },
{ label: "1Depth", href: "#" },
{ label: "2Depth" },
]}
/>
<FoxBreadcrumb items={BREADCRUMB_ITEMS} />
</div>
),
},
{
label: "집 아이콘 끄기",
node: <FoxBreadcrumb items={BREADCRUMB_ITEMS} homeIcon={false} />,
},
{
label: "긴 이름 — 좁아지면 줄이 넘어갑니다",
node: (
<div className={styles.demoNarrow}>
<FoxBreadcrumb
items={[
{ label: "홈", href: "#" },
{ label: "회원관리", href: "#" },
{ label: "학생 회원 목록", href: "#" },
{ label: "상세 조회" },
]}
/>
</div>
),
},
],
},
{
id: "fox-pagination",
name: "FoxPagination",
description:
"Figma 시안(pagination 162:374)을 옮겼습니다. 시안의 device 축은 프롭이 아니라 화면 폭이 정합니다 — 768px 미만에서 이전·다음이 첫 줄로 빠지고 번호가 아래 줄에 섭니다(창을 좁혀 확인하세요). 숫자칸은 PC 32px / 모바일 24px입니다.",
variants: [
{
label: "눌러서 확인",
node: <PaginationLiveDemo />,
},
{
label: "위치 — 첫 · 중간 · 마지막",
node: (
<div className={styles.demoStack}>
<FoxPagination currentPage={1} totalPages={5} />
<FoxPagination currentPage={3} totalPages={5} />
<FoxPagination currentPage={5} totalPages={5} />
</div>
),
},
{
label: "한 쪽만 있을 때 — 이전·다음이 잠깁니다",
node: (
<div className={styles.demoStack}>
<FoxPagination currentPage={1} totalPages={1} />
</div>
),
},
{
label: "말줄임 (ellipsis) — 창 밖에 페이지가 더 있을 때",
node: (
<div className={styles.demoStack}>
<FoxPagination currentPage={1} totalPages={20} ellipsis />
<FoxPagination currentPage={10} totalPages={20} ellipsis />
<FoxPagination currentPage={20} totalPages={20} ellipsis />
</div>
),
},
{
label: "링크형 (buildHref) — 번호가 <a>가 됩니다",
node: <FoxPagination currentPage={2} totalPages={5} buildHref={(page) => `#page-${page}`} />,
},
{
label: "보이는 개수 (windowSize)",
node: (
<div className={styles.demoStack}>
<FoxPagination currentPage={5} totalPages={20} windowSize={3} />
<FoxPagination currentPage={5} totalPages={20} windowSize={10} />
</div>
),
},
],
},
{
id: "fox-carousel-indicator",
name: "FoxCarouselIndicator",
description:
"Figma 시안(carousel-dot 162:403 · carousel-number 162:408)을 한 컴포넌트로 두었습니다. 흰 알약이 같고 안에 든 것만 달라서입니다. 흰 조각이라 아래 예제는 회색 판 위에 올렸습니다.",
variants: [
{
label: "형태 (type) — dot · number",
node: (
<div className={styles.carouselStage}>
<FoxCarouselIndicator current={1} total={5} />
<FoxCarouselIndicator type="number" current={1} total={3} />
</div>
),
},
{
label: "현재 위치",
node: (
<div className={styles.carouselStage}>
<FoxCarouselIndicator current={1} total={4} />
<FoxCarouselIndicator current={2} total={4} />
<FoxCarouselIndicator current={4} total={4} />
</div>
),
},
{
label: "점을 눌러 옮기기 (onSelect) — 점이 <button>이 됩니다",
node: (
<div className={styles.carouselStage}>
<CarouselDotPickDemo />
</div>
),
},
],
},
{
id: "fox-carousel-button",
name: "FoxCarouselButton",
description:
"Figma 시안의 carousel-arrow(163:420)와 carousel-atomic(680:5710)을 한 컴포넌트로 두었습니다. 원형·흰 바탕·아이콘 하나까지 같고 테두리와 그림자만 달라 그 차이를 type으로 뒀습니다. outlined만 hover·pressed에서 떠오르고 아이콘이 진해집니다 — 마우스를 올려 확인하세요.",
variants: [
{
label: "계열 (type) — outlined · plain",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
</div>
),
},
{
label: "크기 (size) — lg · md · sm",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton size="lg" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton size="md" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton size="sm" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton size="lg" label="다음" icon={<FoxCaretRightIcon weight="bold" />} />
<FoxCarouselButton size="md" label="다음" icon={<FoxCaretRightIcon weight="bold" />} />
<FoxCarouselButton size="sm" label="다음" icon={<FoxCaretRightIcon weight="bold" />} />
</div>
),
},
{
label: "시안의 아이콘 5종 (plain)",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton type="plain" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="다음" icon={<FoxCaretRightIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="재생" icon={<FoxPlayIcon weight="fill" />} />
<FoxCarouselButton type="plain" label="정지" icon={<FoxPauseIcon weight="fill" />} />
<FoxCarouselButton type="plain" label="더보기" icon={<FoxPlusIcon weight="bold" />} />
</div>
),
},
{
label: "비활성",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton size="lg" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} disabled />
<FoxCarouselButton size="md" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} disabled />
<FoxCarouselButton size="sm" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} disabled />
<FoxCarouselButton type="plain" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} disabled />
</div>
),
},
],
},
{
id: "fox-carousel-area",
name: "FoxCarouselArea",
description:
"Figma 시안(carousel-area 683:5902 · 683:5923)의 두 조합입니다. 담는 그릇만 갖고 무엇이 들어갈지는 정하지 않습니다 — 시안에서도 안에 든 것이 전부 다른 컴포넌트의 인스턴스입니다.",
variants: [
{
label: "눌러서 확인",
node: (
<div className={styles.carouselStage}>
<CarouselLiveDemo />
</div>
),
},
{
label: "시안 조합 — 점 + 이전·다음·정지",
node: (
<div className={styles.carouselStage}>
<FoxCarouselArea>
<FoxCarouselIndicator current={1} total={5} />
<FoxCarouselButton type="plain" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="다음" icon={<FoxCaretRightIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="정지" icon={<FoxPauseIcon weight="fill" />} />
</FoxCarouselArea>
</div>
),
},
{
label: "시안 조합 — 숫자 + 이전·다음·더보기",
node: (
<div className={styles.carouselStage}>
<FoxCarouselArea>
<FoxCarouselIndicator type="number" current={1} total={3} />
<FoxCarouselButton type="plain" label="이전" icon={<FoxCaretLeftIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="다음" icon={<FoxCaretRightIcon weight="bold" />} />
<FoxCarouselButton type="plain" label="더보기" icon={<FoxPlusIcon weight="bold" />} />
</FoxCarouselArea>
</div>
),
},
],
},
{
id: "fox-bullet-list",
name: "FoxBulletList",
description:
"Figma 시안(bullet 391:4049 · bullet-list-item 391:4081 · bullet-list 484:5722)을 옮겼습니다. 호출부는 단계를 평평하게 늘어놓고 level만 적으면 되고, 목록이 중첩과 번호를 스스로 만듭니다. 나가는 마크업은 <ul>/<ol> 안에 <ul>/<ol>이 든 진짜 중첩입니다.",
variants: [
{
label: "기호 목록 — 시안 조합 그대로",
node: (
<div className={styles.demoField}>
<FoxBulletList>
<FoxBulletListItem>텍스트 목록 레벨1 텍스트 목록 레벨1텍스트 목록 레벨1</FoxBulletListItem>
<FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem>
<FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem>
<FoxBulletListItem level={2}>텍스트 목록 레벨2텍스트 목록 레벨2 텍스트 목록 레벨2</FoxBulletListItem>
<FoxBulletListItem level={2}>텍스트 목록 레벨2</FoxBulletListItem>
<FoxBulletListItem level={3}>텍스트 목록 레벨3 텍스트 목록 레벨3 텍스트 목록 레벨3</FoxBulletListItem>
<FoxBulletListItem level={3}>텍스트 목록 레벨3</FoxBulletListItem>
<FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem>
</FoxBulletList>
</div>
),
},
{
label: "번호 목록 — 같은 묶음 안에서 1부터 다시 셉니다",
node: (
<div className={styles.demoField}>
<FoxBulletList ordered>
<FoxBulletListItem>텍스트 목록 레벨1 텍스트 목록 레벨1텍스트 목록 레벨1</FoxBulletListItem>
<FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem>
<FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem>
<FoxBulletListItem level={2}>텍스트 목록 레벨2텍스트 목록 레벨2 텍스트 목록 레벨2</FoxBulletListItem>
<FoxBulletListItem level={2}>텍스트 목록 레벨2</FoxBulletListItem>
<FoxBulletListItem level={3}>텍스트 목록 레벨3 텍스트 목록 레벨3 텍스트 목록 레벨3</FoxBulletListItem>
<FoxBulletListItem level={3}>텍스트 목록 레벨3</FoxBulletListItem>
<FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem>
</FoxBulletList>
</div>
),
},
{
label: "마커 6종 — 단계별로 나란히",
node: (
<div className={styles.demoFields}>
<div className={styles.demoField}>
<FoxBulletList>
<FoxBulletListItem>1단계 — 채운 원</FoxBulletListItem>
<FoxBulletListItem level={2}>2단계 — 짧은 선</FoxBulletListItem>
<FoxBulletListItem level={3}>3단계 — 빈 원</FoxBulletListItem>
</FoxBulletList>
</div>
<div className={styles.demoField}>
<FoxBulletList ordered>
<FoxBulletListItem>1단계 — 1.</FoxBulletListItem>
<FoxBulletListItem level={2}>2단계 — a.</FoxBulletListItem>
<FoxBulletListItem level={3}>3단계 — ①</FoxBulletListItem>
</FoxBulletList>
</div>
</div>
),
},
{
label: "번호가 이어질 때 — 같은 단계가 여러 줄",
node: (
<div className={styles.demoField}>
<FoxBulletList ordered>
<FoxBulletListItem>첫째</FoxBulletListItem>
<FoxBulletListItem level={2}>가</FoxBulletListItem>
<FoxBulletListItem level={2}>나</FoxBulletListItem>
<FoxBulletListItem level={3}>하나</FoxBulletListItem>
<FoxBulletListItem level={3}>둘</FoxBulletListItem>
<FoxBulletListItem level={3}>셋</FoxBulletListItem>
<FoxBulletListItem>둘째</FoxBulletListItem>
<FoxBulletListItem>셋째</FoxBulletListItem>
</FoxBulletList>
</div>
),
},
{
label: "항목 안에 링크·강조를 넣어도 됩니다",
node: (
<div className={styles.demoField}>
<FoxBulletList>
<FoxBulletListItem>
자세한 내용은 <a href="#fox-bullet-list">이 문서</a>를 확인하세요.
</FoxBulletListItem>
<FoxBulletListItem level={2}>
<strong>굵은 글씨</strong>도 그대로 들어갑니다.
</FoxBulletListItem>
</FoxBulletList>
</div>
),
},
{
label: "2단계로 시작할 때 — 빈 칸에 매달아 목록이 유효하게 유지됩니다",
node: (
<div className={styles.demoField}>
<FoxBulletList>
<FoxBulletListItem level={2}>앞에 1단계가 없습니다</FoxBulletListItem>
<FoxBulletListItem level={2}>그래도 들여쓰기는 유지됩니다</FoxBulletListItem>
</FoxBulletList>
</div>
),
},
],
},
{
id: "fox-badge",
name: "FoxBadge",
description:
"Figma 시안(badge 190:3054 · 섹션 391:4804)을 옮겼습니다. 색 9종 × 계열 3종 × 크기 3종이 모두 시안에 있습니다. disabled는 잠긴 상태가 아니라 색 이름입니다 — 배지는 누를 것이 없어 상태가 없고 시안도 색 축에 두었습니다. warning만 solid 글자가 검정인데 노란 바탕에 흰 글자가 읽히지 않아서입니다.",
variants: [
{ label: "lg — 계열 3종 × 색 9종", node: <BadgeMatrix size="lg" /> },
{ label: "md", node: <BadgeMatrix size="md" /> },
{ label: "sm", node: <BadgeMatrix size="sm" /> },
{
label: "글자가 길어질 때 — 폭은 내용을 따르고 줄바꿈은 하지 않습니다",
node: (
<div className={styles.demoRow}>
<FoxBadge color="primary" type="pastel">
아주 긴 배지 레이블입니다
</FoxBadge>
<FoxBadge color="success" type="solid" size="sm">
짧게
</FoxBadge>
</div>
),
},
],
},
{
id: "fox-badge-dot",
name: "FoxBadgeDot",
description:
"Figma 시안의 badge-dot(190:3359)과 badge-number(190:3354)를 한 컴포넌트로 두었습니다. 둘 다 알림 표시이고 색 축이 같아서, 개수를 주면 숫자 배지가 되고 안 주면 점으로 나옵니다.",
variants: [
{
label: "점 — 개수를 모를 때",
node: (
<div className={styles.demoRow}>
<FoxBadgeDot />
<FoxBadgeDot color="new" />
</div>
),
},
{
label: "숫자 — 개수를 알 때",
node: (
<div className={styles.demoRow}>
<FoxBadgeDot count={5} />
<FoxBadgeDot color="new" count={5} />
</div>
),
},
{
label: "자릿수가 늘면 옆으로만 늘어납니다",
node: (
<div className={styles.demoRow}>
<FoxBadgeDot count={1} />
<FoxBadgeDot count={12} />
<FoxBadgeDot count={128} />
<FoxBadgeDot count={1280} max={99} />
</div>
),
},
],
},
{
id: "fox-badge-group",
name: "FoxBadgeGroup",
description:
"Figma 시안(badge-group 190:3375)을 옮겼습니다. 배지를 가로로 늘어놓고 폭이 모자라면 줄을 넘깁니다. size를 주면 안의 배지 크기가 그 값으로 통일되고, 자식이 따로 준 size는 무효화됩니다(FoxButtonGroup과 같은 방식).",
variants: [
{
label: "크기 통일 (size) — 자식이 준 값은 무시됩니다",
node: (
<div className={styles.demoStack}>
<FoxBadgeGroup size="lg">
<FoxBadge color="primary" type="pastel" size="sm">
lg로 덮임
</FoxBadge>
<FoxBadge color="success" type="pastel">
lg로 덮임
</FoxBadge>
</FoxBadgeGroup>
<FoxBadgeGroup size="md">
<FoxBadge color="primary" type="pastel">
md
</FoxBadge>
<FoxBadge color="success" type="pastel">
md
</FoxBadge>
</FoxBadgeGroup>
<FoxBadgeGroup size="sm">
<FoxBadge color="primary" type="pastel">
sm
</FoxBadge>
<FoxBadge color="success" type="pastel">
sm
</FoxBadge>
</FoxBadgeGroup>
</div>
),
},
{
label: "size를 안 주면 배지가 각자 정합니다",
node: (
<FoxBadgeGroup>
<FoxBadge color="primary" type="pastel" size="lg">
lg
</FoxBadge>
<FoxBadge color="success" type="pastel" size="md">
md
</FoxBadge>
<FoxBadge color="warning" type="pastel" size="sm">
sm
</FoxBadge>
</FoxBadgeGroup>
),
},
{
label: "좁아지면 줄이 넘어갑니다",
node: (
<div className={styles.demoNarrow}>
<FoxBadgeGroup size="md">
{BADGE_COLORS.map((color) => (
<FoxBadge key={color} color={color} type="pastel">
{color}
</FoxBadge>
))}
</FoxBadgeGroup>
</div>
),
},
],
},
];