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 { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
import { FoxAlert } from "../core/components/fox-alert";
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 { FoxContextualTrigger } from "../core/components/fox-contextual-trigger";
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 { FoxHelperText } from "../core/components/fox-helper-text";
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 { FoxPopover } from "../core/components/fox-popover";
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 { FoxToast } from "../core/components/fox-toast";
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 { 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 { FoxTable } from "../core/components/fox-table";
import { FoxTableCell } from "../core/components/fox-table-cell";
import { FoxTableRow } from "../core/components/fox-table-row";
import { FoxTab } from "../core/components/fox-tab";
import { FoxTabItem } from "../core/components/fox-tab-item";
import { FoxSpinner } from "../core/components/fox-spinner";
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";
import { FoxSegmentedControl } from "../core/components/fox-segmented-control";
import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
import { FoxTooltip } from "../core/components/fox-tooltip";
import { FoxTooltipRich } from "../core/components/fox-tooltip-rich";
import { FoxToggleQuantity } from "../core/components/fox-toggle-quantity";
import { FoxPageHeader } from "../core/components/fox-page-header";
import { FoxModal } from "../core/components/fox-modal";
import { FoxAccordion } from "../core/components/fox-accordion";
import { FoxCard } from "../core/components/fox-card";
import { FoxDisclosure } from "../core/components/fox-disclosure";
import { FoxSortableList } from "../core/components/fox-sortable-list";
import styles from "./dev-test.module.scss";
export interface ComponentExample {
/** 해시 링크에 쓰이는 kebab-case 식별자. */
id: string;
name: string;
description?: string;
/** 눈으로 비교해야 하는 조합을 빠짐없이 넣는다 — 예제가 곧 회귀 확인 수단이다. */
variants: { label: string; node: ReactNode }[];
}
// 자리 확인용 아이콘. 이 예시안이 곧 사용법 안내라 아이콘 폰트(`fox-ico`)를 그대로 쓴다 —
// 손으로 그린 SVG를 두면 "아이콘은 이렇게 넣는다"로 잘못 읽힌다.
// 크기는 `font-size`가 정한다. style은 FoxSelect 힌트처럼 작게 써야 하는 자리에 쓴다.
function DemoIcon({ style }: { style?: CSSProperties } = {}) {
return <i className="fox-ico fox-ico-CaretRight" aria-hidden="true" style={style} />;
}
function DemoStar() {
return <i className="fox-ico fox-ico--fill fox-ico-Star" aria-hidden="true" />;
}
// 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 SortableListLiveDemo({ direction }: { direction: "horizontal" | "vertical" }) {
const [items, setItems] = useState(["첫째", "둘째", "셋째", "넷째"]);
return (
<FoxSortableList
items={items}
getKey={(item) => item}
direction={direction}
ariaLabel="정렬 예제"
onReorder={setItems}
renderItem={(item) => (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
inlineSize: "8rem",
blockSize: "8rem",
border: "var(--fox-border-1) solid var(--fox-color-border-neutral-subtle)",
borderRadius: "var(--fox-radius-3)",
background: "var(--fox-color-surface-neutral-gray)",
}}
>
{item}
</div>
)}
/>
);
}
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={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />}
onAction={() => move(-1)}
/>
<FoxCarouselButton
type="plain"
label="다음"
icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />}
onAction={() => move(1)}
/>
<FoxCarouselButton
type="plain"
label={playing ? "정지" : "재생"}
icon={playing ? <i className="fox-ico fox-ico--fill fox-ico-Pause" aria-hidden="true" /> : <i className="fox-ico fox-ico--fill fox-ico-Play" aria-hidden="true" />}
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>
);
}
/** 시안 tbl-list(699:6304) 그대로 — 체크 열·정렬 머리·제목 강조·버튼·더보기·고른 줄. */
function TableListSample() {
const [selected, setSelected] = useState<number[]>([2]);
const rows = [1, 2, 3];
const allOn = selected.length === rows.length;
const toggle = (n: number) =>
setSelected((prev) => (prev.includes(n) ? prev.filter((v) => v !== n) : [...prev, n]));
return (
<FoxTable type="list" caption="목록 표 예시">
<FoxTableRow head>
<FoxTableCell width={64}>
<FoxCheckbox
size="md"
hideLabel
label="전체 선택"
checked={allOn}
indeterminate={selected.length > 0 && !allOn}
onChange={(on) => setSelected(on ? rows : [])}
/>
</FoxTableCell>
<FoxTableCell align="start">
<span className={styles.tableSortHead}>
제목
<i className="fox-ico fox-ico-CaretUpDown" aria-hidden="true" />
</span>
</FoxTableCell>
<FoxTableCell width={96}>제목</FoxTableCell>
<FoxTableCell width={96}>제목</FoxTableCell>
<FoxTableCell width={160}>제목</FoxTableCell>
<FoxTableCell width={64} />
</FoxTableRow>
{rows.map((n) => (
<FoxTableRow key={n} selected={selected.includes(n)}>
<FoxTableCell label="선택">
<FoxCheckbox
size="md"
hideLabel
label={`${n}번째 줄 선택`}
checked={selected.includes(n)}
onChange={() => toggle(n)}
/>
</FoxTableCell>
<FoxTableCell label="제목" align="start" emphasis>
내용
</FoxTableCell>
<FoxTableCell label="제목">내용</FoxTableCell>
<FoxTableCell label="제목">내용</FoxTableCell>
<FoxTableCell label="제목">
<FoxButtonGroup size="sm">
<FoxButton size="sm" label="버튼" />
<FoxButton size="sm" label="버튼" />
</FoxButtonGroup>
</FoxTableCell>
<FoxTableCell label="더보기">
<FoxIconButton
size="sm"
type="ghost"
label={`${n}번째 줄 더보기`}
icon={<i className="fox-ico fox-ico-DotsThreeVertical" aria-hidden="true" />}
/>
</FoxTableCell>
</FoxTableRow>
))}
</FoxTable>
);
}
/** 시안 tab(391:4497) — 계열·크기 조합을 눌러 확인한다. */
function TabLiveDemo({ type, size }: { type: "line" | "fill"; size: "lg" | "md" }) {
const [value, setValue] = useState("a");
return (
<FoxTab type={type} size={size} value={value} onValueChange={setValue} label={`${type} ${size} 탭`}>
<FoxTabItem value="a">label</FoxTabItem>
<FoxTabItem value="b">label</FoxTabItem>
<FoxTabItem value="c">label</FoxTabItem>
</FoxTab>
);
}
const CHIP_PERIOD_OPTIONS: FoxChipItem[] = [
{ value: "7d", label: "최근 7일" },
{ value: "1m", label: "최근 1개월" },
{ value: "3m", label: "최근 3개월" },
{ value: "custom", label: "직접 입력" },
];
const SELECT_TEXT_MONTHS: FoxSelectItem[] = [
{ value: "2026-06", label: "2026년 6월" },
{ value: "2026-07", label: "2026년 7월" },
{ value: "2026-08", label: "2026년 8월" },
];
const SELECT_TEXT_SORTS: FoxSelectItem[] = [
{ value: "latest", label: "최신순" },
{ value: "oldest", label: "오래된순" },
{ value: "amount", label: "금액 높은순" },
];
// FoxChip select 계열의 앞 아이콘 자리(시안이 그 자리에 달력을 쓴다).
function DemoCalendar() {
return <i className="fox-ico fox-ico-CalendarBlank" aria-hidden="true" />;
}
function ModalDemo({
size,
title,
withUtility = false,
withActions = true,
closable = true,
withImage = false,
openLabel,
}: {
size: "sm" | "md" | "lg" | "full";
title?: string;
withUtility?: boolean;
withActions?: boolean;
closable?: boolean;
withImage?: boolean;
openLabel?: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
<FoxButton size="md" type="secondary" label={openLabel ?? `${size} 열기`} onAction={() => setOpen(true)} />
<FoxModal
open={open}
onClose={() => setOpen(false)}
size={size}
title={title}
closable={closable}
utility={
withUtility ? (
<FoxCheckbox size="md" label="오늘 하루 보지 않기" />
) : undefined
}
actions={
withActions ? (
<>
<FoxButton size="md" label="취소" onAction={() => setOpen(false)} />
<FoxButton size="md" type="primary" label="확인" onAction={() => setOpen(false)} />
</>
) : undefined
}
>
{/* 시안의 contents 슬롯에는 그림이 들어간 예가 있다 — 슬롯이라 무엇이든 놓을 수 있다. */}
{withImage && <DemoThumb />}
<p>
대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나
여러 작업을 포함할 수 있습니다.
</p>
</FoxModal>
</>
);
}
function DemoThumb() {
return (
<svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="180" fill="var(--fox-color-surface-neutral-gray-strong)" />
<circle cx="232" cy="52" r="26" fill="var(--fox-color-element-theme-primary)" opacity="0.35" />
<path
d="M0 148l72-52 54 38 60-46 134 92v0H0z"
fill="var(--fox-color-element-neutral-default)"
opacity="0.45"
/>
</svg>
);
}
function ToastDemo() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => setOpen(false), 3000);
return () => window.clearTimeout(timer);
}, [open]);
return (
<>
<FoxButton
size="md"
type="primary"
label="토스트 띄우기"
onAction={() => setOpen(true)}
disabled={open}
/>
{open ? (
<div
style={{
position: "fixed",
insetInlineStart: "50%",
insetBlockEnd: "4rem",
transform: "translateX(-50%)",
zIndex: 100,
}}
>
<FoxToast message="저장되었습니다." />
</div>
) : null}
</>
);
}
/**
* 트리거와 말풍선을 잇는 예제. 트리거는 상태를 갖지 않으므로 무엇을 언제 보여 줄지와
* 보조기술 배선(`aria-describedby` / `aria-controls`+`aria-expanded`)은 호출부 몫이다.
*/
function ContextualTriggerDemo() {
const [tipOpen, setTipOpen] = useState(false);
const [popOpen, setPopOpen] = useState(false);
const [richOpen, setRichOpen] = useState(false);
return (
<>
{/* 툴팁: 가리키거나 포커스가 닿는 동안 보인다. */}
<span style={{ position: "relative", display: "inline-flex" }}>
<FoxContextualTrigger
type="help"
ariaLabel="배송비 도움말"
describedBy="demo-tooltip"
onHoverChange={setTipOpen}
onFocus={() => setTipOpen(true)}
onBlur={() => setTipOpen(false)}
/>
{/* 꼬리가 가운데라 트리거 가운데에 맞춰 세운다. */}
{tipOpen ? (
<span
style={{
position: "absolute",
insetBlockStart: "100%",
insetInlineStart: "50%",
transform: "translateX(-50%)",
zIndex: 10,
}}
>
<FoxTooltip
id="demo-tooltip"
direction="top"
message="3만원 이상 무료"
/>
</span>
) : null}
</span>
{/* 팝오버: 눌러서 연다. */}
<span style={{ position: "relative", display: "inline-flex" }}>
<FoxContextualTrigger
type="information"
ariaLabel="정산 기준 설명"
controls="demo-popover"
expanded={popOpen}
onAction={() => setPopOpen((open) => !open)}
/>
{/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
{popOpen ? (
<span
style={{
position: "absolute",
insetBlockStart: "100%",
insetInlineStart: "50%",
marginInlineStart: "-3.5rem",
zIndex: 10,
}}
>
<FoxPopover
id="demo-popover"
direction="top"
arrow="left"
title="정산 기준"
message="매월 말일 마감하여 다음 달 10일에 지급합니다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => setPopOpen(false)}
/>
</span>
) : null}
</span>
{/* 리치 툴팁: 툴팁과 같이 가리키는 동안 보이지만, 제목과 긴 본문을 담는다. */}
<span style={{ position: "relative", display: "inline-flex" }}>
<FoxContextualTrigger
type="information"
ariaLabel="정산 주기 안내"
describedBy="demo-tooltip-rich"
onHoverChange={setRichOpen}
onFocus={() => setRichOpen(true)}
onBlur={() => setRichOpen(false)}
/>
{/* 꼬리가 왼쪽 24px 자리에 서므로(꼬리 가운데 35px) 그만큼 당긴다. */}
{richOpen ? (
<span
style={{
position: "absolute",
insetBlockStart: "100%",
insetInlineStart: "50%",
marginInlineStart: "-3.5rem",
zIndex: 10,
}}
>
<FoxTooltipRich
id="demo-tooltip-rich"
direction="top"
arrow="left"
title="정산 주기"
message="매월 말일에 마감하고 다음 달 10일에 지급합니다. 공휴일이 겹치면 다음 영업일로 밀립니다."
/>
</span>
) : null}
</span>
</>
);
}
/** 컴포넌트를 만들 때마다 항목 하나를 추가한다 — 다른 파일은 고치지 않는다. */
const ICON_FONT_WEIGHTS = [
"thin",
"light",
"regular",
"bold",
"fill",
"duotone",
] as const;
const ICON_FONT_SIZES = [2, 3, 4, 5, 6, 7] as const;
const ICON_FONT_COLORS = [
"icon-neutral-strong",
"icon-theme-primary",
"icon-system-danger",
"icon-system-success",
"icon-system-warning",
] as const;
/** 시안 이름을 그대로 쓴다는 것을 보이려고 대소문자가 눈에 띄는 것들을 골랐다. */
const ICON_FONT_SAMPLES = [
"House",
"CaretCircleUp",
"MagnifyingGlass",
"BellSimple",
"UsersThree",
"IdentificationCard",
"ShieldCheck",
"SquaresFour",
] as const;
export const COMPONENT_EXAMPLES: ComponentExample[] = [
{
id: "fox-ico",
name: "fox-ico (아이콘 폰트)",
description:
"시안 ico/* 1,512종을 woff2 6벌로 담았습니다. React 컴포넌트가 아니라 클래스명으로 씁니다 — <i class=\"fox-ico fox-ico-House\"> 처럼요. 이름 부분은 Figma의 ico/House를 대소문자까지 그대로 옮긴 것이라 시안과 정확히 일치합니다. 크기는 font-size, 색은 color가 정합니다(상자가 1em이라 font-size: fox.icon(4)면 한 변이 2rem). 굵기별 @font-face가 따로라 화면이 실제로 쓰는 굵기의 파일만 내려받습니다.",
variants: [
{
label: "굵기 6종 — thin · light · regular · bold · fill · duotone",
node: (
<div className={styles.demoRow}>
{ICON_FONT_WEIGHTS.map((weight) => (
<span key={weight} className={styles.demoLabelled}>
<i
className={`fox-ico fox-ico--${weight} fox-ico-House`}
style={{ fontSize: "3.2rem" }}
aria-hidden="true"
/>
<small>{weight}</small>
</span>
))}
</div>
),
},
{
label: "크기 — icon 토큰이 그대로 font-size가 됩니다",
node: (
<div className={styles.demoRow}>
{ICON_FONT_SIZES.map((size) => (
<span key={size} className={styles.demoLabelled}>
<i
className="fox-ico fox-ico--fill fox-ico-Star"
style={{ fontSize: `var(--fox-icon-${size})` }}
aria-hidden="true"
/>
<small>icon({size})</small>
</span>
))}
</div>
),
},
{
label: "색은 감싼 쪽의 color를 따릅니다",
node: (
<div className={styles.demoRow}>
{ICON_FONT_COLORS.map((token) => (
<i
key={token}
className="fox-ico fox-ico--fill fox-ico-Heart"
style={{ fontSize: "3.2rem", color: `var(--fox-color-${token})` }}
aria-hidden="true"
/>
))}
</div>
),
},
{
label: "시안 이름 그대로 — ico/CaretCircleUp → fox-ico-CaretCircleUp",
node: (
<div className={styles.demoRow}>
{ICON_FONT_SAMPLES.map((name) => (
<span key={name} className={styles.demoLabelled}>
<i
className={`fox-ico fox-ico-${name}`}
style={{ fontSize: "2.4rem" }}
aria-hidden="true"
/>
<small>{name}</small>
</span>
))}
</div>
),
},
],
},
{
id: "fox-scroll",
name: "fox-scroll (스크롤바)",
description:
"Figma 시안(scroll 7341:2675)을 옮겼습니다. React 컴포넌트가 아니라 스크롤이 생기는 요소에 붙이는 클래스입니다 — <div class=\"fox-scroll\"> 처럼요. 넘침 여부는 정하지 않고 외형만 입힙니다. 막대는 4px이고 둘레 4px 여백은 막대의 투명 테두리로 만듭니다(트랙에 padding을 주면 막대가 끝까지 가지 못합니다). 이 앱에서는 globals.scss가 `*`에 걸어 두어 문서·본문·사이드바를 포함한 모든 스크롤에 이미 적용돼 있습니다 — 클래스를 따로 붙일 일은 @fox를 다른 프로젝트로 복사했을 때뿐입니다. 컴포넌트 안에서는 클래스 대신 fox.scroll() 믹스인을 부르고, 셀렉트 목록·칩 셀렉트·모달·파일목록 네 곳이 그렇게 쓰고 있습니다. ⚠️ Firefox는 ::-webkit-scrollbar가 없어 표준 속성으로 근사합니다 — 두께가 thin 한 단계뿐이고 모서리는 정할 수 없습니다.",
variants: [
{
label: "세로 (시안 col) — 상자 안을 굴려 보세요",
node: (
<div className={`fox-scroll ${styles.scrollDemo}`}>
{Array.from({ length: 12 }, (_, index) => (
<p key={index}>스크롤 내용 {index + 1}번째 줄입니다.</p>
))}
</div>
),
},
{
label: "가로 (시안 row)",
node: (
<div className={`fox-scroll ${styles.scrollDemoRow}`}>
<p>가로로 넘치는 내용입니다. 오른쪽 끝까지 굴려 보세요.</p>
</div>
),
},
],
},
{
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={{ fontSize: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
</div>
),
},
{
label: "완료(값 선택됨)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
</div>
),
},
{
label: "error",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
</div>
),
},
{
label: "disabled",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
</div>
),
},
{
label: "view (조회 전용)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ fontSize: 12 }} />} />
</div>
),
},
{
label: "옵션 아이콘(맨 뒤) · 긴 라벨 — 열어서 확인한다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS_RICH} />
</div>
),
},
{
label: "라벨 없음 · 힌트 없음 — 이름은 ariaLabel로 준다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" ariaLabel="등급" 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>
),
},
{
label:
"selectionMode=\"multiple\" — 고른 라벨이 쉼표로 이어지고, 목록은 바깥을 눌러야 닫힙니다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="아무것도 안 고름" selectionMode="multiple" options={SELECT_ITEMS} />
<FoxSelect
size="md"
label="여러 개 고름"
selectionMode="multiple"
options={SELECT_ITEMS}
defaultValues={SELECT_ITEMS.slice(0, 2).map((option) => option.value)}
/>
</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: "글자 감추기 (hideLabel) — 표의 선택 열처럼 상자만 필요할 때",
node: (
<div className={styles.demoRow}>
<FoxRadio hideLabel label="라디오 선택" />
<FoxRadio hideLabel label="라디오 선택 (켜짐)" defaultChecked />
<FoxRadio label="비교용 — 글자가 보입니다" />
</div>
),
},
{
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: "글자 감추기 (hideLabel) — 표의 선택 열처럼 상자만 필요할 때",
node: (
<div className={styles.demoRow}>
<FoxCheckbox hideLabel label="체크박스 선택" />
<FoxCheckbox hideLabel label="체크박스 선택 (켜짐)" defaultChecked />
<FoxCheckbox label="비교용 — 글자가 보입니다" />
</div>
),
},
{
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={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
</div>
),
},
{
label: "크기 (size) — lg · md · sm",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton size="lg" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton size="md" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton size="sm" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton size="lg" label="다음" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />} />
<FoxCarouselButton size="md" label="다음" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />} />
<FoxCarouselButton size="sm" label="다음" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />} />
</div>
),
},
{
label: "시안의 아이콘 5종 (plain)",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton type="plain" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="다음" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="재생" icon={<i className="fox-ico fox-ico--fill fox-ico-Play" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="정지" icon={<i className="fox-ico fox-ico--fill fox-ico-Pause" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="더보기" icon={<i className="fox-ico fox-ico--bold fox-ico-Plus" aria-hidden="true" />} />
</div>
),
},
{
label: "비활성",
node: (
<div className={styles.carouselStage}>
<FoxCarouselButton size="lg" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} disabled />
<FoxCarouselButton size="md" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} disabled />
<FoxCarouselButton size="sm" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} disabled />
<FoxCarouselButton type="plain" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} 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={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="다음" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="정지" icon={<i className="fox-ico fox-ico--fill fox-ico-Pause" aria-hidden="true" />} />
</FoxCarouselArea>
</div>
),
},
{
label: "시안 조합 — 숫자 + 이전·다음·더보기",
node: (
<div className={styles.carouselStage}>
<FoxCarouselArea>
<FoxCarouselIndicator type="number" current={1} total={3} />
<FoxCarouselButton type="plain" label="이전" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretLeft" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="다음" icon={<i className="fox-ico fox-ico--bold fox-ico-CaretRight" aria-hidden="true" />} />
<FoxCarouselButton type="plain" label="더보기" icon={<i className="fox-ico fox-ico--bold fox-ico-Plus" aria-hidden="true" />} />
</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>
),
},
],
},
{
id: "fox-table",
name: "FoxTable",
description:
"Figma 시안 9종(tbl-basic · tbl-list · tbl-form과 각각의 행·칸)을 FoxTable / FoxTableRow / FoxTableCell 셋으로 옮겼습니다. 진짜 <table>이라 스크린리더가 행·열을 읽어 주고 colSpan·rowSpan이 그대로 동작합니다. 머리행은 head만 주면 <thead>로 알아서 갈립니다. 창을 768px 아래로 좁히면 list는 카드로 접히고 form은 레이블이 입력 위로 올라갑니다. 열 정렬은 디자인팀 규칙대로 가운데가 기본이고, 제목 열과 긴 글 열(주소·설명·내용)만 align=\"start\"로 왼쪽입니다.",
variants: [
{
label: "list 표 예시 — 시안 699:6304 그대로. 체크·정렬·버튼·고른 줄",
node: <TableListSample />,
},
{
label: "basic — 제목 열이 있는 상세표",
node: (
<FoxTable caption="basic 표 예시">
<FoxTableRow head>
<FoxTableCell>제목</FoxTableCell>
<FoxTableCell>제목</FoxTableCell>
<FoxTableCell>제목</FoxTableCell>
<FoxTableCell>제목</FoxTableCell>
</FoxTableRow>
<FoxTableRow>
<FoxTableCell head>제목</FoxTableCell>
<FoxTableCell>길어서 2줄 되는 내용 긴 내용</FoxTableCell>
<FoxTableCell>내용</FoxTableCell>
<FoxTableCell>승인</FoxTableCell>
</FoxTableRow>
<FoxTableRow>
<FoxTableCell head>제목</FoxTableCell>
<FoxTableCell colSpan={2}>두 칸을 합친 내용 (colSpan)</FoxTableCell>
<FoxTableCell>검토</FoxTableCell>
</FoxTableRow>
</FoxTable>
),
},
{
label: "list — 목록표. 좁히면 카드로 접힙니다",
node: (
<FoxTable type="list" caption="list 표 예시">
<FoxTableRow head>
<FoxTableCell width={64}>번호</FoxTableCell>
<FoxTableCell>제목</FoxTableCell>
<FoxTableCell width={120}>작성자</FoxTableCell>
<FoxTableCell width={120}>등록일</FoxTableCell>
</FoxTableRow>
{[1, 2, 3].map((n) => (
<FoxTableRow key={n}>
<FoxTableCell label="번호">{n}</FoxTableCell>
<FoxTableCell label="제목" align="start">
목록 제목 {n}
</FoxTableCell>
<FoxTableCell label="작성자">홍길동</FoxTableCell>
<FoxTableCell label="등록일">2026-08-18</FoxTableCell>
</FoxTableRow>
))}
</FoxTable>
),
},
{
label: "form — 레이블 + 입력. 좁히면 위아래로 쌓입니다",
node: (
<FoxTable type="form" caption="form 표 예시">
<FoxTableRow>
<FoxTableCell head>레이블</FoxTableCell>
<FoxTableCell>내용</FoxTableCell>
</FoxTableRow>
<FoxTableRow>
<FoxTableCell head>
<FoxFormLabel as="span" requirement="required">
레이블
</FoxFormLabel>
</FoxTableCell>
<FoxTableCell>
<InputStage>
<FoxInput size="md" placeholder="내용을 입력하세요." aria-label="내용" />
</InputStage>
</FoxTableCell>
</FoxTableRow>
<FoxTableRow>
<FoxTableCell head>레이블</FoxTableCell>
<FoxTableCell>
<FoxBadgeGroup size="md">
<FoxBadge color="primary" type="pastel">
태그
</FoxBadge>
<FoxBadge color="success" type="pastel">
태그
</FoxBadge>
</FoxBadgeGroup>
</FoxTableCell>
</FoxTableRow>
</FoxTable>
),
},
{
label: "칸 정렬 (align) — 표 기본값을 이깁니다",
node: (
<FoxTable type="list" caption="정렬 예시">
<FoxTableRow head>
<FoxTableCell align="start">start</FoxTableCell>
<FoxTableCell align="center">center</FoxTableCell>
<FoxTableCell align="end">end</FoxTableCell>
</FoxTableRow>
<FoxTableRow>
<FoxTableCell align="start">왼쪽</FoxTableCell>
<FoxTableCell align="center">가운데</FoxTableCell>
<FoxTableCell align="end">오른쪽</FoxTableCell>
</FoxTableRow>
</FoxTable>
),
},
],
},
{
id: "fox-tab",
name: "FoxTab",
description:
"Figma 시안(tab 171:14016 · tab-item 171:13927, 관리자페이지 파일)을 FoxTab / FoxTabItem 둘로 옮겼습니다. lg는 폭을 고르게 나눠 갖고 md는 글자만큼만 차지합니다. 좌우 화살표·Home·End로 옮길 수 있고 탭키는 고른 탭에만 닿습니다(ARIA 탭 규약). 고른 탭에는 hover·pressed가 걸리지 않습니다 — 이미 제 모양이 있어서입니다.",
variants: [
{ label: "line · lg — 폭을 고르게 나눠 갖고 목록에 밑줄이 깔립니다", node: <TabLiveDemo type="line" size="lg" /> },
{ label: "line · md — 글자만큼만 차지하고 8px 간격", node: <TabLiveDemo type="line" size="md" /> },
{ label: "fill · lg — 세그먼트 컨트롤. 목록이 테두리와 모서리를 갖습니다", node: <TabLiveDemo type="fill" size="lg" /> },
{ label: "fill · md — 항목 하나하나가 알약입니다", node: <TabLiveDemo type="fill" size="md" /> },
{
label: "잠긴 탭 — 화살표 이동에서도 건너뜁니다",
node: (
<FoxTab type="line" size="md" value="a" label="잠긴 탭 예시">
<FoxTabItem value="a">label</FoxTabItem>
<FoxTabItem value="b" disabled>
label
</FoxTabItem>
<FoxTabItem value="c">label</FoxTabItem>
</FoxTab>
),
},
{
label: "주소가 바뀌는 탭 (href) — <a>가 되고 탭 역할을 갖지 않습니다",
node: (
<FoxTab type="fill" size="md" value="a">
<FoxTabItem value="a" href="#fox-tab">
label
</FoxTabItem>
<FoxTabItem value="b" href="#fox-tab">
label
</FoxTabItem>
<FoxTabItem value="c" href="#fox-tab">
label
</FoxTabItem>
</FoxTab>
),
},
],
},
{
id: "fox-spinner",
name: "FoxSpinner",
description:
"Figma 시안(img-loading 5384:9710, 관리자페이지 파일)을 옮겼습니다. 시안은 80px 그림 한 장으로 왔지만 래스터를 그대로 쓰지 않고 픽셀을 재서 같은 도형을 CSS로 다시 그렸습니다 — 크기를 바꿔도 뭉개지지 않고 색이 토큰을 따릅니다. 색은 기본이 테마 기본색이고, 버튼 안에서는 계열별 글자색을 따라갑니다.",
variants: [
{
label: "시안 크기 (80px)",
node: <FoxSpinner />,
},
{
label: "크기 (size) — 굵기와 앞머리가 비율로 함께 줄어듭니다",
node: (
<div className={styles.demoRow}>
<FoxSpinner size={80} />
<FoxSpinner size={48} />
<FoxSpinner size={32} />
<FoxSpinner size={20} />
</div>
),
},
{
label: "기본은 테마 기본색 — 버튼 안에서는 글자색을 따라갑니다",
node: (
<div className={styles.demoRow}>
<FoxSpinner size={40} />
</div>
),
},
{
label: "버튼 로딩 — 앞 아이콘 자리에 들어갑니다",
node: (
<div className={styles.demoRow}>
<FoxButton size="lg" type="primary" label="저장" loading />
<FoxButton size="md" type="secondary" label="저장" loading />
<FoxButton size="sm" label="저장" loading />
</div>
),
},
],
},
{
id: "fox-chip",
name: "FoxChip",
description:
"Figma 시안(chip 264:424) 켜짐 여부를 갖는 작은 조각입니다 — type은 아이콘의 유무와 누를 때 벌어지는 일만 정하고, 상태별 모양(기본·hover·켜짐·비활성)은 세 계열이 똑같습니다. check는 켜졌을 때만 앞에 체크가 붙고, select는 뒤 화살표가 늘 있으며 앞 아이콘은 icon을 넘긴 때만 나옵니다. action은 아이콘이 없습니다. select는 selectionMode로 단일·다중을 고릅니다 — 단일은 하나 고르면 그 자리에서 반영되고 닫히지만, 다중은 열린 채로 남고 바깥을 눌러야 닫힙니다(닫기가 곧 확정이 아니라 체크할 때마다 이미 반영됩니다). 열리는 목록은 FoxSelectOption이 아니라 전용 FoxChipSelectOption이고, 항목이 단일이면 라디오·다중이면 체크박스입니다. 그래서 칩은 콤보박스가 아니라 목록을 여닫는 버튼(aria-expanded)일 뿐이고, 항목 사이 이동은 네이티브 입력이 알아서 합니다. 아래 예제는 전부 실제로 눌러서 확인할 수 있습니다 — hover(파란 테두리 + 연한 파랑 배경)는 마우스를 올려야 보이고, 켜짐 위에서는 hover가 걸리지 않습니다(시안에 없어 임의로 만들지 않았습니다).",
variants: [
{
label: "check — 눌러서 켜고 끕니다. 켜지면 앞에 체크가 붙고 남색으로 채워집니다",
node: (
<>
<FoxChip label="레이블" />
<FoxChip label="레이블" defaultChecked />
</>
),
},
{
label: "select — 눌러 목록을 엽니다. 고르면 켜짐이 되고 글자가 고른 값으로 바뀝니다",
node: (
<>
<FoxChip type="select" label="기간" icon={<DemoCalendar />} options={CHIP_PERIOD_OPTIONS} />
<FoxChip type="select" label="기간" options={CHIP_PERIOD_OPTIONS} />
<FoxChip
type="select"
label="기간"
icon={<DemoCalendar />}
options={CHIP_PERIOD_OPTIONS}
defaultValue="1m"
/>
</>
),
},
{
label:
"select · 다중 선택 — 켜고 꺼도 목록이 열린 채로 남고, 바깥을 눌러야 닫힙니다 (칩 글자는 그대로)",
node: (
<>
<FoxChip
type="select"
selectionMode="multiple"
label="기간"
icon={<DemoCalendar />}
options={CHIP_PERIOD_OPTIONS}
/>
<FoxChip
type="select"
selectionMode="multiple"
label="기간"
icon={<DemoCalendar />}
options={CHIP_PERIOD_OPTIONS}
defaultValues={["7d", "3m"]}
/>
</>
),
},
{
label: "action — 아이콘 없이 켜고 끕니다 (켜져도 체크가 붙지 않습니다)",
node: (
<>
<FoxChip type="action" label="레이블" />
<FoxChip type="action" label="레이블" defaultChecked />
</>
),
},
{
label: "크기 — lg 높이 40 / md 높이 28. 아이콘도 20 / 16으로 함께 바뀝니다",
node: (
<>
<FoxChip size="lg" label="레이블" defaultChecked />
<FoxChip size="md" label="레이블" defaultChecked />
<FoxChip size="lg" type="select" label="기간" icon={<DemoCalendar />} options={CHIP_PERIOD_OPTIONS} />
<FoxChip size="md" type="select" label="기간" icon={<DemoCalendar />} options={CHIP_PERIOD_OPTIONS} />
</>
),
},
{
label: "비활성 — 꺼짐 / 켜짐. 켜짐 여부와 무관하게 한 벌입니다(시안 확인 완료)",
node: (
<>
<FoxChip label="레이블" disabled />
<FoxChip label="레이블" disabled defaultChecked />
<FoxChip type="select" label="기간" icon={<DemoCalendar />} options={CHIP_PERIOD_OPTIONS} disabled />
<FoxChip type="action" label="레이블" disabled defaultChecked />
</>
),
},
],
},
{
id: "fox-chip-area",
name: "FoxChipArea",
description:
"Figma 시안(chip-area 264:1671) 칩 여러 개의 배치와 size 통일만 책임집니다. 시안이 주는 규칙이 inline-flex · align-items: center · gap 4px 세 줄뿐이라 스타일 파일도 그만큼입니다. FoxTagList와 달리 type은 덮어쓰지 않습니다 — 한 줄에 계열이 섞이는 것이 칩에서는 정상이기 때문입니다(기간 select 옆에 action 칩들). role이 radiogroup이 아니라 group입니다 — 하나만 고르는 묶음이 아니고 각 칩이 독립적으로 Tab을 받는 버튼입니다. 줄바꿈이 없습니다(flex-wrap 없음) — 시안이 한 줄만 그리므로 폭이 모자라면 넘칩니다.",
variants: [
{
label: "기본 — 칩 사이 간격 4px, 계열이 섞여도 됩니다",
node: (
<FoxChipArea size="md" label="거래 구분">
<FoxChip type="select" label="기간" icon={<DemoCalendar />} options={CHIP_PERIOD_OPTIONS} />
<FoxChip type="action" label="전체" defaultChecked />
<FoxChip type="action" label="입금" />
<FoxChip type="action" label="출금" />
<FoxChip type="action" label="환불" />
</FoxChipArea>
),
},
{
label: "size — 자식이 준 값은 무시되고 묶음 값으로 통일된다",
node: (
<FoxChipArea size="md" label="md 크기">
{/* 일부러 어긋난 size를 준다 — 셋 다 md로 나와야 정상 */}
<FoxChip label="레이블" size="lg" />
<FoxChip label="레이블" size="lg" defaultChecked />
<FoxChip label="레이블" />
</FoxChipArea>
),
},
{
label: "size를 넘기지 않으면 — 자식이 각자 정한 값이 그대로 남는다",
node: (
<FoxChipArea label="크기 섞임">
<FoxChip label="lg" size="lg" />
<FoxChip label="md" size="md" />
</FoxChipArea>
),
},
{
label: "프래그먼트로 감싼 자식에도 통일이 걸린다",
node: (
<FoxChipArea size="lg" label="프래그먼트">
<>
<FoxChip label="레이블" size="md" />
<FoxChip label="레이블" size="md" defaultChecked />
</>
</FoxChipArea>
),
},
],
},
{
id: "fox-select-text",
name: "FoxSelectText",
description:
"Figma 시안(select-txt 112:508) 글자만 있는 셀렉트입니다. FoxSelect와 같은 콤보박스이고 열리는 목록도 같은 FoxSelectOption을 쓰지만, 폼 한 칸이 아닙니다 — 라벨·힌트·에러·필수 표시가 없고 테두리도 고정 높이도 없습니다. 배경 계열도 다릅니다: FoxSelect는 중립(흰 바탕 + 테두리)인데 이쪽은 강조 계열이라 기본이 투명이고 hover·pressed에서만 연한 파랑이 깔립니다 — 그래서 아래 예제의 기본 상태는 배경이 없는 것이 정상이고, 마우스를 올리고 눌러야 색이 보입니다. 크기가 글자 계열까지 바꾸는 것도 여기뿐입니다: lg는 heading/md(제목용 20px bold), md·sm은 label/*-strong(본문용 medium)입니다. '지금 무엇을 보고 있는지'를 글자로 보여 주면서 그 자리에서 바꾸게 하는 자리에 씁니다.",
variants: [
{
label: "크기 — lg(제목 20px bold) / md(15px medium) / sm(13px medium)",
node: (
<>
<FoxSelectText size="lg" ariaLabel="조회 기간" options={SELECT_TEXT_MONTHS} defaultValue="2026-08" />
<FoxSelectText size="md" ariaLabel="정렬 기준" options={SELECT_TEXT_SORTS} defaultValue="latest" />
<FoxSelectText size="sm" ariaLabel="정렬 기준" options={SELECT_TEXT_SORTS} defaultValue="latest" />
</>
),
},
{
label: "아무것도 고르지 않으면 placeholder — 골라 보세요",
node: (
<>
<FoxSelectText size="lg" ariaLabel="조회 기간" options={SELECT_TEXT_MONTHS} placeholder="기간 선택" />
<FoxSelectText size="md" ariaLabel="정렬 기준" options={SELECT_TEXT_SORTS} />
</>
),
},
{
label: "제목 옆에 놓기 — inline이라 줄 전체를 차지하지 않습니다",
node: (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.8rem" }}>
<FoxSelectText size="lg" ariaLabel="조회 기간" options={SELECT_TEXT_MONTHS} defaultValue="2026-08" />
<FoxSelectText size="sm" ariaLabel="정렬 기준" options={SELECT_TEXT_SORTS} defaultValue="amount" />
</span>
),
},
{
label: "비활성 — 시안에 없어 색은 기본과 같고 hover·pressed만 죽습니다",
node: (
<>
<FoxSelectText size="lg" ariaLabel="조회 기간" options={SELECT_TEXT_MONTHS} defaultValue="2026-08" disabled />
<FoxSelectText size="md" ariaLabel="정렬 기준" options={SELECT_TEXT_SORTS} defaultValue="latest" disabled />
</>
),
},
{
label: "세로 flex 안에 놓아도 목록 폭이 늘어나지 않는다 (트리거는 부모가 늘립니다)",
node: (
<div className={styles.demoStack}>
<FoxSelectText size="md" ariaLabel="조회 기간" options={SELECT_TEXT_MONTHS} defaultValue="2026-08" />
<FoxSelectText size="sm" ariaLabel="정렬 기준" options={SELECT_TEXT_SORTS} defaultValue="latest" />
</div>
),
},
],
},
{
id: "fox-segmented-control-item",
name: "FoxSegmentedControlItem",
description:
"Figma 시안(segmented-control-item) 세그먼티드 컨트롤의 칸 하나입니다. 상태를 갖지 않습니다 — 켜짐 여부는 네이티브 input[type=radio]가 소유하므로, name만 같으면 단일 선택·화살표 이동·폼 전송을 브라우저가 처리합니다(FoxRadio와 같은 구조). 루트가 label이라 글자든 아이콘이든 어디를 눌러도 선택됩니다. 아래 예제는 묶는 테두리 없이 칸만 보여 주는 것이라 실제 사용 모습은 FoxSegmentedControl 쪽을 보세요 — 칸 오른쪽의 구분선이 이 컴포넌트 것이고, 감싸는 테두리와 모서리는 묶음 것입니다. 켜짐과 비활성은 구분선을 지웁니다 hover·pressed는 꺼져 있을 때만 걸립니다.",
variants: [
{
label: "크기 — lg 56 / md 40 / sm 28 / xsm 24 (아이콘도 24/20/16/12로 함께)",
node: (
<>
<FoxSegmentedControlItem name="sci-lg" size="lg" label="목록" icon={<DemoStar />} defaultChecked />
<FoxSegmentedControlItem name="sci-md" size="md" label="목록" icon={<DemoStar />} defaultChecked />
<FoxSegmentedControlItem name="sci-sm" size="sm" label="목록" icon={<DemoStar />} defaultChecked />
<FoxSegmentedControlItem name="sci-xsm" size="xsm" label="목록" icon={<DemoStar />} defaultChecked />
</>
),
},
{
label: "상태 — 꺼짐 / 켜짐 / 비활성+꺼짐 / 비활성+켜짐 (마우스를 올리고 눌러 보세요)",
node: (
<>
<FoxSegmentedControlItem name="sci-a" size="md" label="꺼짐" />
<FoxSegmentedControlItem name="sci-b" size="md" label="켜짐" defaultChecked />
<FoxSegmentedControlItem name="sci-c" size="md" label="비활성" disabled />
<FoxSegmentedControlItem name="sci-d" size="md" label="비활성 켜짐" disabled defaultChecked />
</>
),
},
{
label: "아이콘 없이 — 넘기지 않으면 자리를 만들지 않습니다",
node: (
<>
<FoxSegmentedControlItem name="sci-e" size="md" label="글자만" />
<FoxSegmentedControlItem name="sci-f" size="md" label="아이콘" icon={<DemoStar />} />
</>
),
},
],
},
{
id: "fox-segmented-control",
name: "FoxSegmentedControl",
description:
"Figma 시안(segmented-control) 칸들을 테두리 하나로 감싸고 name·size를 통일합니다. 상태를 갖지 않습니다 — 어느 칸이 켜졌는지는 각 칸의 네이티브 라디오가 소유하므로 아래 예제는 전부 실제로 눌러서 바꿀 수 있고, 화살표 키로도 이동합니다. size는 묶음의 모서리와 칸의 높이를 함께 정합니다: 둘이 어긋나면 칸 배경이 둥근 모서리와 맞지 않아서, 자식이 준 size는 무시되고 묶음 값으로 통일됩니다. overflow: hidden으로 채워진 칸 배경을 모서리에 맞춰 잘라 냅니다. name도 묶음이 단일 진실 공급원입니다 — 한 칸만 빼먹어 두 개가 켜지는 실수가 생기지 않습니다.",
variants: [
{
label: "크기 — lg(모서리 12) / md(8) / sm(4) / xsm(4)",
node: (
<div className={styles.demoStackStart}>
<FoxSegmentedControl size="lg" name="sc-lg" label="보기 방식 lg">
<FoxSegmentedControlItem label="전체" value="all" defaultChecked />
<FoxSegmentedControlItem label="입금" value="in" />
<FoxSegmentedControlItem label="출금" value="out" />
</FoxSegmentedControl>
<FoxSegmentedControl size="md" name="sc-md" label="보기 방식 md">
<FoxSegmentedControlItem label="전체" value="all" defaultChecked />
<FoxSegmentedControlItem label="입금" value="in" />
<FoxSegmentedControlItem label="출금" value="out" />
</FoxSegmentedControl>
<FoxSegmentedControl size="sm" name="sc-sm" label="보기 방식 sm">
<FoxSegmentedControlItem label="전체" value="all" defaultChecked />
<FoxSegmentedControlItem label="입금" value="in" />
<FoxSegmentedControlItem label="출금" value="out" />
</FoxSegmentedControl>
<FoxSegmentedControl size="xsm" name="sc-xsm" label="보기 방식 xsm">
<FoxSegmentedControlItem label="전체" value="all" defaultChecked />
<FoxSegmentedControlItem label="입금" value="in" />
<FoxSegmentedControlItem label="출금" value="out" />
</FoxSegmentedControl>
</div>
),
},
{
label: "아이콘 + 글자 — 첫 칸·마지막 칸이 켜져도 배경이 모서리를 넘지 않습니다",
node: (
<FoxSegmentedControl size="md" name="sc-icon" label="정렬 방식">
<FoxSegmentedControlItem label="목록" value="list" icon={<DemoStar />} defaultChecked />
<FoxSegmentedControlItem label="카드" value="card" icon={<DemoStar />} />
<FoxSegmentedControlItem label="표" value="table" icon={<DemoStar />} />
</FoxSegmentedControl>
),
},
{
label: "size — 자식이 준 값은 무시되고 묶음 값으로 통일된다",
node: (
<FoxSegmentedControl size="sm" name="sc-override" label="크기 통일">
{/* 일부러 어긋난 size를 준다 — 셋 다 sm으로 나와야 정상 */}
<FoxSegmentedControlItem label="전체" value="all" size="lg" defaultChecked />
<FoxSegmentedControlItem label="입금" value="in" size="xsm" />
<FoxSegmentedControlItem label="출금" value="out" />
</FoxSegmentedControl>
),
},
{
label: "칸 하나만 비활성 — 나머지는 그대로 고를 수 있습니다",
node: (
<FoxSegmentedControl size="md" name="sc-disabled" label="일부 비활성">
<FoxSegmentedControlItem label="전체" value="all" defaultChecked />
<FoxSegmentedControlItem label="입금" value="in" />
<FoxSegmentedControlItem label="준비중" value="soon" disabled />
</FoxSegmentedControl>
),
},
{
label: "칸 두 개 / 다섯 개 — 개수와 무관하게 규칙이 같습니다",
node: (
<div className={styles.demoStackStart}>
<FoxSegmentedControl size="md" name="sc-two" label="두 칸">
<FoxSegmentedControlItem label="켜짐" value="on" defaultChecked />
<FoxSegmentedControlItem label="꺼짐" value="off" />
</FoxSegmentedControl>
<FoxSegmentedControl size="md" name="sc-five" label="다섯 칸">
<FoxSegmentedControlItem label="1월" value="1" />
<FoxSegmentedControlItem label="2월" value="2" />
<FoxSegmentedControlItem label="3월" value="3" defaultChecked />
<FoxSegmentedControlItem label="4월" value="4" />
<FoxSegmentedControlItem label="5월" value="5" />
</FoxSegmentedControl>
</div>
),
},
],
},
{
id: "fox-toggle-quantity",
name: "FoxToggleQuantity",
description:
"Figma 시안(toggle-quantity) 수량 조절기입니다. 회색 판 위에 빼기 버튼·숫자·더하기 버튼이 놓이고, 크기 수식어가 없습니다 — 시안이 한 벌만 줍니다. 값은 제어·비제어 둘 다 되고(FoxSelect와 같은 방식) min·max에 닿으면 해당 버튼이 네이티브 disabled가 되므로 누를 수 없다는 것이 커서와 보조기술에 함께 전달됩니다. 숫자는 입력칸이 아니라 읽기 전용 글자입니다 — 시안에 입력칸의 테두리도 배경도 없어서 role=spinbutton을 붙이지 않고, 대신 값에 aria-live를 걸어 바뀐 숫자를 읽어 줍니다. 숫자가 flex: 1 0 0이라 남는 자리를 혼자 가져가므로 값이 한 자리든 세 자리든 버튼 위치가 흔들리지 않습니다. 버튼은 FoxIconButton이 아닙니다 — 크기·모서리·아이콘 슬롯은 sm과 같지만 배경이 투명이 아니라 흰색이어야 회색 판 위에서 보입니다.",
variants: [
{
label: "기본 — 눌러서 바꿔 보세요 (min 0, 상한 없음)",
node: <FoxToggleQuantity label="수량" defaultValue={1} />,
},
{
label: "min에 닿으면 빼기가, max에 닿으면 더하기가 비활성됩니다 (min 1 / max 3)",
node: (
<>
<FoxToggleQuantity label="최소값" min={1} max={3} defaultValue={1} />
<FoxToggleQuantity label="중간값" min={1} max={3} defaultValue={2} />
<FoxToggleQuantity label="최대값" min={1} max={3} defaultValue={3} />
</>
),
},
{
label: "step — 한 번에 10씩",
node: <FoxToggleQuantity label="10단위" step={10} max={100} defaultValue={10} />,
},
{
label: "자릿수가 늘어도 버튼 위치가 흔들리지 않습니다",
node: (
<>
<FoxToggleQuantity label="한 자리" defaultValue={9} />
<FoxToggleQuantity label="세 자리" defaultValue={999} />
</>
),
},
{
label: "disabled — 둘 다 막습니다 (min·max에 의한 개별 비활성과는 별개)",
node: <FoxToggleQuantity label="비활성" defaultValue={5} disabled />,
},
],
},
{
id: "fox-page-header",
name: "FoxPageHeader",
description:
"Figma 시안(page-tit-area 3002:6823, 관리자페이지 파일)을 옮겼습니다. 현재 위치와 제목을 얹고 본문과의 간격(아래 여백)까지 갖습니다 — 시안이 그렇게 묶어 놓아 화면마다 따로 띄우지 않아도 됩니다. 제목은 화면에 하나뿐인 이름이라 <h1>으로 나갑니다.",
variants: [
{
label: "시안 그대로 — 현재 위치 + 제목",
node: <FoxPageHeader breadcrumb={BREADCRUMB_ITEMS} title="리스트 페이지" />,
},
{
label: "현재 위치 없이 제목만",
node: <FoxPageHeader title="리스트 페이지" />,
},
{
label: "제목 아래 설명 — 가맹몰 시안(2805:635)의 한 줄. 이 화면이 무엇을 하는 곳인지 적습니다",
node: (
<FoxPageHeader
breadcrumb={BREADCRUMB_ITEMS}
title="업체 등록"
description="지역상생몰에 노출할 업체를 등록한다"
/>
),
},
{
label: "오른쪽 자리 (actions) — 시안엔 없지만 배치가 자리를 비워 둡니다",
node: (
<FoxPageHeader
breadcrumb={BREADCRUMB_ITEMS}
title="리스트 페이지"
actions={
<FoxButtonGroup size="md">
<FoxButton size="md" type="secondary" label="엑셀 다운로드" />
<FoxButton size="md" type="primary" label="등록" />
</FoxButtonGroup>
}
/>
),
},
{
label: "제목이 길어질 때 — 오른쪽 자리를 침범하지 않고 접힙니다",
node: (
<FoxPageHeader
breadcrumb={BREADCRUMB_ITEMS}
title="아주 긴 화면 제목이 들어가면 이렇게 여러 줄로 접힙니다 아주 긴 화면 제목"
actions={<FoxButton size="md" type="primary" label="등록" />}
/>
),
},
],
},
{
id: "fox-modal",
name: "FoxModal",
description:
"Figma 시안(modal 190:1136)을 옮겼습니다. 네이티브 <dialog>를 showModal()로 띄워 초점 가두기·Esc 닫기·뒷배경·맨 위 레이어를 브라우저가 처리하게 합니다. 열림 여부는 호출부가 소유하고, Esc나 바깥 클릭에도 모달이 스스로 닫지 않고 onClose만 부릅니다. 창을 768px 아래로 좁히면 네 크기가 같은 폭으로 모이고 여백·모서리·닫기 버튼도 함께 줄어듭니다.",
variants: [
{
label: "크기 — sm 400 · md 560 · lg 760 · full",
node: (
<div className={styles.demoRow}>
<ModalDemo size="sm" title="타이틀" />
<ModalDemo size="md" title="타이틀" />
<ModalDemo size="lg" title="타이틀" />
<ModalDemo size="full" title="타이틀" />
</div>
),
},
{
label: "그림이 들어갈 때 — contents는 슬롯이라 무엇이든 놓입니다",
node: (
<div className={styles.demoRow}>
<ModalDemo size="md" title="타이틀" withImage openLabel="그림 있는 모달 열기" />
<ModalDemo size="lg" title="타이틀" withImage openLabel="lg 그림 모달 열기" />
</div>
),
},
{
label: "아래쪽 왼편 (utility) — 시안의 체크박스 자리",
node: (
<div className={styles.demoRow}>
<ModalDemo size="md" title="타이틀" withUtility />
</div>
),
},
{
label: "제목 없이 / 버튼 없이 / 닫기 없이",
node: (
<div className={styles.demoRow}>
<ModalDemo size="sm" />
<ModalDemo size="sm" title="타이틀" withActions={false} />
<ModalDemo size="sm" title="타이틀" closable={false} />
</div>
),
},
],
},
{
id: "fox-accordion",
name: "FoxAccordion",
description:
"Figma 시안(accordion 194:3563 box · 194:3680 line)을 옮겼습니다. box는 색을 깐 상자로 제목이 강조 색이고, line은 위쪽 선 하나로 나뉘는 목록형입니다. 열림을 밖에서 쥐면 여러 개 중 하나만 열리게 하는 것도 호출부가 정할 수 있고, 안 넘기면 스스로 여닫습니다. 닫히면 내용이 DOM에서 빠져 탭 순서에 남지 않습니다.",
variants: [
{
label: "box — lg · md",
node: (
<div className={styles.demoStack}>
<FoxAccordion size="lg" title="아코디언 타이틀 영역" defaultOpen
badge={<FoxBadgeGroup size="md"><FoxBadge size="md">레이블</FoxBadge></FoxBadgeGroup>}>
<p>펼쳐진 내용이 들어가는 자리입니다.</p>
</FoxAccordion>
<FoxAccordion size="md" title="아코디언 타이틀 영역"
badge={<FoxBadgeGroup size="sm"><FoxBadge size="sm">레이블</FoxBadge></FoxBadgeGroup>}>
<p>펼쳐진 내용이 들어가는 자리입니다.</p>
</FoxAccordion>
</div>
),
},
{
label: "line — 이어 붙이면 위쪽 선이 칸막이가 됩니다",
node: (
<div className={styles.demoField}>
<FoxAccordion type="line" size="lg" title="아코디언 타이틀 영역" defaultOpen>
<p>펼쳐진 내용이 들어가는 자리입니다.</p>
</FoxAccordion>
<FoxAccordion type="line" size="lg" title="아코디언 타이틀 영역" />
<FoxAccordion type="line" size="md" title="아코디언 타이틀 영역" />
</div>
),
},
{
label: "배지 없이",
node: (
<div className={styles.demoStack}>
<FoxAccordion title="아코디언 타이틀 영역">
<p>펼쳐진 내용이 들어가는 자리입니다.</p>
</FoxAccordion>
</div>
),
},
],
},
{
id: "fox-disclosure",
name: "FoxDisclosure",
description:
"Figma 시안(disclosure 391:4082 닫힘 · 391:4083 열림)을 옮겼습니다. 캐럿이 제목 앞에 오고 바탕도 테두리도 없어, 글 사이에 끼우는 가벼운 접기입니다 — 목록을 나누는 FoxAccordion과는 다른 컴포넌트입니다. 시안에 크기 축도 계열 축도 없어 변형이 없고 상태는 닫힘·열림 둘뿐입니다(캐럿이 오른쪽 → 위로 바뀝니다). 열림을 밖에서 쥘 수 있고 안 넘기면 스스로 여닫습니다. 닫히면 내용이 DOM에서 빠져 탭 순서에 남지 않습니다.",
variants: [
{
label: "시안 그대로 — 닫힘 · 열림 (눌러서 여닫아 보세요)",
node: (
<div className={styles.demoStack}>
<FoxDisclosure title="디스클로저 레이블">
<p>펼쳐진 내용이 들어가는 자리입니다.</p>
</FoxDisclosure>
<FoxDisclosure title="디스클로저 레이블" defaultOpen>
<p>펼쳐진 내용이 들어가는 자리입니다.</p>
</FoxDisclosure>
</div>
),
},
{
label: "내용은 슬롯이라 무엇이든 놓입니다",
node: (
<div className={styles.demoStack}>
<FoxDisclosure title="첨부된 그림 보기" defaultOpen>
<DemoThumb />
</FoxDisclosure>
</div>
),
},
],
},
{
id: "fox-card",
name: "FoxCard",
description:
"Figma 시안(card 441:6006)을 옮겼습니다. 크기 넷은 여백과 모서리만 바꿉니다 — 폭은 놓이는 자리가 정합니다(시안은 320px 자리에 뒀습니다). 그림은 16:9로 잘리고 상자가 모서리를 넘지 않게 자릅니다.",
variants: [
{
label: "그림이 있을 때 — 16:9로 잘리고 위쪽 모서리를 넘지 않습니다",
node: (
<div className={styles.demoRow}>
{(["xlg", "md"] as const).map((size) => (
<div key={size} style={{ inlineSize: 320 }}>
<FoxCard
size={size}
image={<DemoThumb />}
title="타이틀 영역"
description="간단한 설명이 들어가는 영역입니다."
actions={<FoxTextButton size="md" label="액션1" />}
/>
</div>
))}
</div>
),
},
{
label: "크기 — xlg · lg · md · sm (여백과 모서리가 달라집니다)",
node: (
<div className={styles.demoRow}>
{(["xlg", "lg", "md", "sm"] as const).map((size) => (
<div key={size} style={{ inlineSize: 320 }}>
<FoxCard
size={size}
title="타이틀 영역"
description="간단한 설명이 들어가는 영역입니다."
actions={<FoxTextButton size="md" label="액션1" />}
/>
</div>
))}
</div>
),
},
{
label: "그림 없이 / 동작 없이 / 설명 없이",
node: (
<div className={styles.demoRow}>
<div style={{ inlineSize: 320 }}>
<FoxCard title="타이틀 영역" description="간단한 설명이 들어가는 영역입니다."
actions={<FoxTextButton size="md" label="액션1" />} />
</div>
<div style={{ inlineSize: 320 }}>
<FoxCard title="타이틀 영역" description="간단한 설명이 들어가는 영역입니다." />
</div>
<div style={{ inlineSize: 320 }}>
<FoxCard title="타이틀 영역" actions={<FoxTextButton size="md" label="액션1" />} />
</div>
</div>
),
},
{
label: "내용과 함께 — 제목·설명이 머리말로 서고 그 아래에 내용이 놓입니다 (폼 구역을 카드로 묶을 때)",
node: (
<div style={{ inlineSize: 480 }}>
<FoxCard
size="lg"
title="기본 정보"
description="머리말 안은 4px, 머리말과 내용 사이는 16px입니다."
>
<FoxInput size="md" label="업체명" requirement="required" placeholder="예) 타볼라타" />
</FoxCard>
</div>
),
},
],
},
{
id: "fox-toast",
name: "FoxToast",
description:
"Figma 시안(toast 441:5471) 토스트메세지 — 상태를 갖지 않습니다: 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정합니다. 타이머와 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈립니다. 배경·글자색이 *-inverse 토큰이라 다크 모드에서는 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 됩니다.",
variants: [
{
label: "기본",
node: <FoxToast message="저장되었습니다." />,
},
{
label: "문구가 길면 폭이 내용만큼 늘어납니다 (시안에 상한이 없어 넣지 않았습니다)",
node: <FoxToast message="입력하신 내용을 저장했습니다. 목록에서 확인할 수 있습니다." />,
},
{
label: "urgent — 모양은 같고 role만 status에서 alert로 바뀝니다 (읽던 것을 끊고 바로 읽음)",
node: <FoxToast message="네트워크 연결이 끊겼습니다." urgent />,
},
{
label: "띄우고 지우는 것은 호출부 몫입니다 — 눌러 보세요 (3초 뒤 사라짐, 화면 하단 고정)",
node: <ToastDemo />,
},
],
},
{
id: "fox-alert",
name: "FoxAlert",
description:
"Figma 시안(alert 258:433) 폭 720px은 시안 값 그대로지만 좁은 자리에서 부모를 뚫지 않게 max-inline-size: 100%를 함께 걸었습니다. 계열은 배경·테두리·제목 글자색을 바꿉니다 — 테두리만 -inverse 토큰이라 연한 배경(5) 위에 한 단계 진한 선(10)이 얹힙니다. 본문 글자색과 아이콘 상자의 흰 배경은 계열과 무관합니다.",
variants: [
{
label: "default",
node: (
<FoxAlert
title="알림 제목이 들어갑니다"
message="콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다 콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다 콘텐츠 내 알럿은 최대 2줄 이내로 작성합니다"
/>
),
},
{
label: "제목만 / 본문만 — 넘기지 않은 쪽은 렌더하지 않습니다",
node: (
<>
<FoxAlert title="제목만 있는 알림" />
<FoxAlert message="본문만 있는 알림입니다." />
</>
),
},
{
label: "type 5종",
node: (
<>
<FoxAlert type="default" title="default" message="기본" />
<FoxAlert type="information" title="information" message="안내" />
<FoxAlert type="success" title="success" message="완료" />
<FoxAlert type="warning" title="warning" message="주의" />
<FoxAlert type="danger" title="danger" message="위험" />
</>
),
},
{
label: "좁은 자리(360px)에 넣으면 폭이 부모에 맞춰 줄어듭니다",
node: (
<div style={{ inlineSize: "36rem", outline: "1px dashed currentColor" }}>
<FoxAlert
title="좁은 단 안의 알림"
message="720px이 아니라 부모 폭까지만 늘어납니다."
/>
</div>
),
},
],
},
{
id: "fox-helper-text",
name: "FoxHelperText",
description:
"Figma 시안(helper-txt) 입력 아래 붙는 안내·오류 한 줄입니다. 계열은 FoxAlert와 같은 다섯 개이고 글리프도 같은 것을 쓰되 굵기만 regular입니다(알럿은 duotone). 계열이 붙으면 글자는 font-system-*, 아이콘은 한 단계 진한 icon-system-*-strong입니다 — default만 글자·아이콘이 같은 회색입니다. 모양 값을 새로 적지 않았습니다 — 시안 값이 _form-field.scss의 message 믹스인과 한 자도 다르지 않아(gap 4px · 아이콘 padding-top 2px · body/sm · 150% · -0.025em) 그대로 부릅니다. 차이는 display 하나뿐이라(시안 inline-flex, 폼 안 flex) 믹스인에 인자로 넘깁니다. ⚠️ 폼 컴포넌트 안의 헬퍼는 아직 이 클래스를 쓰지 않습니다 — FoxInput·FoxTextArea·FoxEmail·FoxPhoneNumber·FoxAddress는 각자 __message를, FoxSelect는 __hint를 그리고, 그쪽 색은 상자의 포커스·오류를 :has()로 따라갑니다(auto-states 믹스인). 모으는 것은 별도 작업입니다.",
variants: [
{
label: "계열 5종 — 아이콘이 글자보다 한 단계 진합니다 (icon-system-*-strong)",
node: (
<>
<FoxHelperText message="기본 안내 문구입니다." />
<FoxHelperText type="information" message="입력 형식을 확인해 주세요." />
<FoxHelperText type="success" message="사용할 수 있는 아이디입니다." />
<FoxHelperText type="warning" message="곧 만료되는 항목입니다." />
<FoxHelperText type="danger" message="필수 항목입니다." />
</>
),
},
{
label: "showIcon={false} — 글자만 (폼 헬퍼가 아이콘 없이 쓰이는 자리를 위해)",
node: (
<>
<FoxHelperText showIcon={false} message="2자 이상 입력해 주세요." />
<FoxHelperText type="danger" showIcon={false} message="필수 항목입니다." />
</>
),
},
{
label: "inline-flex라 폭이 글자만큼입니다 (점선은 자리 확인용)",
node: (
<div style={{ outline: "1px dashed currentColor" }}>
<FoxHelperText type="information" message="내용만큼만 차지합니다." />
</div>
),
},
{
label: "여러 줄 — 아이콘이 첫 줄에 맞춰 고정됩니다",
node: (
<div style={{ inlineSize: "24rem" }}>
<FoxHelperText
type="warning"
message="문구가 길어져 두 줄이 되어도 아이콘은 위로 붙어 첫 글줄과 나란히 남습니다."
/>
</div>
),
},
],
},
{
id: "fox-status-indicator",
name: "FoxStatusIndicator",
description:
"Figma 시안(status-indicator 404:6328) 글자로 상태를 알립니다. 점은 장식이라 aria-hidden이고 뜻은 글자가 전부 담습니다 — 그래서 label이 선택값이 아니라 필수값입니다. 색만으로 상태를 전하면 색을 구분하지 못하는 사용자에게는 아무것도 전달되지 않습니다.",
variants: [
{
label: "계열 5종",
node: (
<>
<FoxStatusIndicator label="시작전, 중립적" />
<FoxStatusIndicator type="danger" label="중단, 실패, 위험" />
<FoxStatusIndicator type="information" label="진행 중" />
<FoxStatusIndicator type="warning" label="잠재적 위험, 주의" />
<FoxStatusIndicator type="success" label="진행중, 처리중간단계" />
</>
),
},
{
label: "inline-flex라 글줄 안에 그대로 놓입니다",
node: (
<p style={{ margin: 0 }}>
현재 상태는 <FoxStatusIndicator type="success" label="정상" /> 입니다.
</p>
),
},
],
},
{
id: "fox-snackbar",
name: "FoxSnackbar",
description:
"Figma 시안(snackbar 441:5880) 흰 판 위에 굵은 한 줄과 글자 버튼이 서고 오른쪽 끝에 닫기가 붙습니다. FoxToast와 형제지만 누를 것이 있어서, 읽고 지나가는 토스트와 달리 손이 닿을 때까지 남습니다. 상태를 갖지 않습니다 — onClose는 '닫아 달라'는 알림이지 스스로 사라지는 것이 아니고, 위치도 호출부가 정합니다. 글자 버튼은 FoxTextButton의 primary·md, 닫기는 오른쪽에 붙습니다 — 판의 오른쪽 여백 64px은 글자가 그 아래로 들어가지 않게 비워 두는 자리입니다.",
variants: [
{
label: "기본 — 제목 + 본문 + 액션 + 닫기",
node: (
<FoxSnackbar
title="메세지 타이틀"
message="스낵바는 화면 하단에 제공되는 안내 메시지입니다."
actionLabel="텍스트 버튼"
onAction={() => {}}
onClose={() => {}}
/>
),
},
{
label: "아이콘 — 호출부가 fox-ico 클래스로 골라 넘깁니다 (ico/CheckCircle, weight=fill / 색은 icon-theme-primary)",
node: (
<FoxSnackbar
icon={<i className="fox-ico fox-ico--fill fox-ico-CheckCircle" aria-hidden="true" />}
title="변경 사항을 저장했습니다"
message="3분 안에는 되돌릴 수 있습니다."
actionLabel="실행 취소"
onAction={() => {}}
onClose={() => {}}
/>
),
},
{
label: "제목 없이 / 액션 없이 — 넘기지 않은 쪽은 그리지 않습니다 (닫기는 항상 있습니다)",
node: (
<>
<FoxSnackbar title="제목만 있는 스낵바" actionLabel="보기" onAction={() => {}} onClose={() => {}} />
<FoxSnackbar message="제목 없이 본문만 있는 스낵바입니다." actionLabel="보기" onAction={() => {}} onClose={() => {}} />
<FoxSnackbar title="액션이 없는 스낵바" message="닫기만 있습니다." onClose={() => {}} />
</>
),
},
{
label: "제목이 길면 줄바꿈되고 버튼은 오른쪽에 남습니다",
node: (
<FoxSnackbar
title="제목이 길어져 두 줄이 되어도"
message="글자 버튼과 닫기는 자리를 지킵니다. 판은 640px에서 시작하고 오른쪽 64px은 닫기 자리로 비워 둡니다."
actionLabel="자세히"
onAction={() => {}}
onClose={() => {}}
/>
),
},
],
},
{
id: "fox-snackbar-list",
name: "FoxSnackbarList",
description:
"Figma 시안(snackbar-list 467:5958) 스낵바가 여러 개일 때 세로로 쌓는 묶음입니다. — 무엇이 몇 개 떠 있는지, 어떤 순서로 쌓을지는 호출부가 소유합니다. 자식에게 덮어쓰는 값이 없다는 점이 FoxTagList·FoxChipArea와 다릅니다.",
variants: [
{
label: "세 개가 쌓인 모습 (간격 8px)",
node: (
<FoxSnackbarList label="알림">
<FoxSnackbar
icon={<i className="fox-ico fox-ico--fill fox-ico-CheckCircle" aria-hidden="true" />}
title="변경 사항을 저장했습니다"
message="3분 안에는 되돌릴 수 있습니다."
actionLabel="실행 취소"
onAction={() => {}}
onClose={() => {}}
/>
<FoxSnackbar
title="파일 3개를 올렸습니다"
actionLabel="보기"
onAction={() => {}}
onClose={() => {}}
/>
<FoxSnackbar message="제목 없이 본문만 있는 스낵바입니다." onClose={() => {}} />
</FoxSnackbarList>
),
},
{
label: "하나만 있어도 그대로 씁니다",
node: (
<FoxSnackbarList label="알림">
<FoxSnackbar title="하나만 떠 있는 경우" onClose={() => {}} />
</FoxSnackbarList>
),
},
],
},
{
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>
),
},
],
},
{
id: "fox-popover",
name: "FoxPopover",
description:
"Figma 시안(popover 251:1813) 꼬리가 달린 말풍선 판입니다. direction이 꼬리가 판의 위(top)에 붙는지 아래(bottom)에 붙는지, arrow가 가로 어디(left/center/right)에 붙는지 정합니다 — top이면 트리거 아래에 뜨는 말풍선입니다. 자리를 스스로 잡지 않습니다: 트리거 옆 어디에 놓을지는 className으로 주고, 자리가 모자랄 때 위아래를 뒤집는 자동 배치도 없습니다(시안이 방향을 값으로 주므로 그 판단은 호출부 몫입니다). 액션은 FoxTextButton의 secondary·sm에 오른쪽 아이콘입니다. 꼬리의 가로 위치는 루트의 align-items가 정합니다(시안 지정) — 꼬리 영역이 좌우 24px 여백을 달고 다녀서 왼쪽·오른쪽에서는 그만큼 안쪽에 서고, 가운데에서는 여백이 대칭이라 정확히 가운데에 섭니다. 판은 align-self: stretch라 이 값에 영향받지 않고 언제나 폭을 꽉 채웁니다.",
variants: [
{
label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)",
node: (
<>
<FoxPopover
direction="bottom"
title="팝오버 제목"
message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => {}}
/>
<FoxPopover
direction="bottom"
arrow="center"
title="팝오버 제목"
message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => {}}
/>
<FoxPopover
direction="bottom"
arrow="right"
title="팝오버 제목"
message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => {}}
/>
</>
),
},
{
label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)",
node: (
<>
<FoxPopover
direction="top"
arrow="left"
title="팝오버 제목"
message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => {}}
/>
<FoxPopover
direction="top"
arrow="center"
title="팝오버 제목"
message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => {}}
/>
<FoxPopover
direction="top"
arrow="right"
title="팝오버 제목"
message="컴포넌트 주변에 배치되어 해당 컴포넌트의 상태나 관련된 상세 정보를 제공하는 컴포넌트이다."
actionLabel="바로가기"
onAction={() => {}}
onClose={() => {}}
/>
</>
),
},
],
},
{
id: "fox-tooltip-rich",
name: "FoxTooltipRich",
description:
"Figma 시안(tooltip-rich 256:233) 제목과 본문을 담은 말풍선입니다. 150자 내외의 글자만 담습니다. 그래서 여닫는 것은 전적으로 트리거의 몫입니다(가리키거나 포커스가 닿는 동안 보이는 식). 팝오버와 다른 점은 셋뿐입니다: 테두리가 한 단계 옅고(border-neutral-default), 제목과 본문 사이가 좁으며(8px ↔ 16px), 그림자가 없습니다. 꼬리는 팝오버와 같은 그림이라 _bubble.scss의 arrow() 믹스인을 함께 씁니다 — 회전한 정사각형의 절반을 판에 걸쳐 이음새를 없애는 방식입니다. ⚠️ 이 조각은 보조기술에 아무 역할도 주지 않습니다: 툴팁으로 읽히려면 트리거가 aria-describedby로 id를 가리켜야 하고, 그 배선은 트리거 쪽 몫입니다.",
variants: [
{
label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)",
node: (
<>
<FoxTooltipRich
direction="bottom"
title="툴팁제목"
message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
/>
<FoxTooltipRich
direction="bottom"
arrow="center"
title="툴팁제목"
message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
/>
<FoxTooltipRich
direction="bottom"
arrow="right"
title="툴팁제목"
message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
/>
</>
),
},
{
label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)",
node: (
<>
<FoxTooltipRich
direction="top"
title="툴팁제목"
message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
/>
<FoxTooltipRich
direction="top"
arrow="center"
title="툴팁제목"
message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
/>
<FoxTooltipRich
direction="top"
arrow="right"
title="툴팁제목"
message="툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.툴팁은 150자 내외의 텍스트만 제공되어야 합니다. 내부에 닫기 버튼을 포함한 대화형 요소를 사용하지 않습니다.본문을 가리지 않도록 주의합니다."
/>
</>
),
},
],
},
{
id: "fox-tooltip",
name: "FoxTooltip",
description:
"Figma 시안(tooltip) 어두운 판에 짧은 글 한 줄을 담은 말풍선입니다. FoxTooltipRich의 작은 형제로 제목이 없고, 꼬리가 언제나 가운데이며(가로 위치를 고르는 값이 없습니다), 판이 글자만큼만 넓어집니다. 여기도 누를 것이 하나도 없습니다 — 잠깐 떴다 사라지는 판 안의 버튼은 누를 수 없습니다. 꼬리는 팝오버·리치 툴팁과 달리 시안 SVG를 그대로 씁니다: 저쪽은 테두리 있는 판의 1px 이음새를 지우려고 CSS로 그렸지만 이 판은 테두리 없이 한 색이라 그럴 일이 없고, 시안 꼬리가 12×6에 끝이 둥근 다른 그림이라 CSS 삼각형으로는 모양이 나오지 않습니다. 같은 그림을 180° 돌려 두 방향을 만듭니다. ⚠️ 판이 글자만큼 좁아져도 꼬리 영역의 좌우 24px 여백 때문에 가장 좁을 때가 60px입니다(시안 값). ⚠️ 이 조각도 보조기술에 역할을 주지 않습니다 — 트리거가 aria-describedby로 id를 가리켜야 합니다.",
variants: [
{
label: "direction=top — 꼬리가 위 (트리거 아래에 뜨는 말풍선)",
node: <FoxTooltip direction="top" message="툴팁 내용" />,
},
{
label: "direction=bottom — 꼬리가 아래 (트리거 위에 뜨는 말풍선)",
node: <FoxTooltip direction="bottom" message="툴팁 내용" />,
},
{
label: "글자 길이에 따라 판이 넓어집니다 (가장 좁을 때 60px)",
node: (
<>
<FoxTooltip message="짧게" />
<FoxTooltip message="조금 더 긴 안내 문구입니다" />
</>
),
},
],
},
{
id: "fox-contextual-trigger",
name: "FoxContextualTrigger",
description:
"Figma 시안(contextual-trigger) 툴팁이나 팝오버를 여는 24px짜리 아이콘 단추입니다. 계열이 아이콘을 정하고(help는 ico/Question, information은 ico/Info, 둘 다 weight=regular) 상태는 배경만 바꿉니다 — 기본이 action-secondary(투명)라 아래 배경이 그대로 비치고 hover·pressed에서만 연한 판이 깔립니다(FoxTag와 같은 규칙). FoxIconButton과는 다른 조각입니다: 저쪽은 폼 컨트롤과 높이를 맞추느라 가장 작은 xsm도 32px인데 이쪽은 글줄 옆에 붙는 24px 고정이고 모서리도 4px입니다. 상태를 갖지 않으므로 무엇을 언제 보여 줄지는 호출부가 정하고, 이 단추는 배선을 걸 자리(describedBy / controls·expanded)와 손이 닿는 자리를 줍니다. 아이콘만 있는 단추라 aria-label이 유일한 이름입니다 — 한 화면에 여럿이면 '배송비 도움말'처럼 무엇에 대한 것인지 적어 줍니다. ⚠️ 비활성은 시안에 없어 색을 만들지 않고 hover·pressed만 막았습니다.",
variants: [
{
label: "계열 2종 — hover·pressed는 마우스를 올리고 눌러서 확인하세요",
node: (
<>
<FoxContextualTrigger type="help" />
<FoxContextualTrigger type="information" />
</>
),
},
{
label: "실제 배선 — 가리키면 툴팁 / 누르면 팝오버 / 가리키면 리치 툴팁",
node: <ContextualTriggerDemo />,
},
],
},
{
id: "fox-sortable-list",
name: "FoxSortableList",
description:
"항목 순서를 끌어서 바꾸는 목록입니다. 시안에 정렬 목록 컴포넌트가 없어 기획 문구(추가 이미지는 드래그로 순서를 바꾼다)만으로 만들었습니다 — 손잡이 없이 항목 자체를 집어 끌고, 끌고 있는 항목·놓을 자리 표시가 전부이며 본문은 호출부가 그립니다. 항목에 초점을 두고 방향키(가로는 ←→, 세로는 ↑↓)·Home·End로도 옮길 수 있어 마우스 없이도 됩니다. 목록은 상태를 갖지 않고 onReorder로 새 배열만 돌려줍니다.",
variants: [
{
label: "가로 — 타일을 끌거나 Tab으로 초점을 두고 ←→",
node: <SortableListLiveDemo direction="horizontal" />,
},
{
label: "세로 — ↑↓",
node: <SortableListLiveDemo direction="vertical" />,
},
],
},
];