File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
import { useState, type CSSProperties, type ReactNode } from "react";
import { FoxButton } from "../core/components/fox-button";
import { FoxButtonGroup } from "../core/components/fox-button-group";
import { FoxButtonPanel } from "../core/components/fox-button-panel";
import { FoxIconButton } from "../core/components/fox-icon-button";
import { FoxLinkButton } from "../core/components/fox-link-button";
import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
import { FoxCertified } from "../core/components/fox-certified";
import { FoxEmail } from "../core/components/fox-email";
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
import { FoxTextButton } from "../core/components/fox-text-button";
import styles from "./dev-test.module.scss";
export interface ComponentExample {
/** 해시 링크에 쓰이는 kebab-case 식별자. */
id: string;
name: string;
description?: string;
/** 눈으로 비교해야 하는 조합을 빠짐없이 넣는다 — 예제가 곧 회귀 확인 수단이다. */
variants: { label: string; node: ReactNode }[];
}
// 자리 확인용 도형. `currentColor`로 그려야 계열별 색이 입혀진다. style은 FoxSelect
// 힌트 아이콘처럼 크기를 줄여 써야 하는 자리에 쓴다.
function DemoIcon({ style }: { style?: CSSProperties } = {}) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
style={style}
>
<path d="M6 3l5 5-5 5" />
</svg>
);
}
function DemoStar() {
return (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 1.6l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.5l-3.9 2 .8-4.3-3.2-3 4.4-.6z" />
</svg>
);
}
// overlay 계열은 어두운 반투명 배경이라 밝은 바탕 위에서는 대비를 볼 수 없다.
function OnPhoto({ children }: { children: ReactNode }) {
return (
<span
style={{
display: "inline-flex",
gap: "0.8rem",
padding: "0.8rem",
borderRadius: "0.8rem",
backgroundImage: "url(https://picsum.photos/seed/picsum/200/300)",
backgroundSize: "cover",
}}
>
{children}
</span>
);
}
// 입력 예제는 폭이 있어야 시안(360px)처럼 보인다.
function InputStage({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
return <div className={wide ? styles.demoFieldWide : styles.demoField}>{children}</div>;
}
// 지우기 버튼과 자동 상태 판정을 실제로 눌러 보려면 예제가 값을 들고 있어야 한다.
function InputLiveDemo() {
const [text, setText] = useState("지우기를 눌러보세요");
return (
<>
<InputStage>
<FoxInput
size="md"
label="자동 상태"
placeholder="입력하면 글자가 진해집니다"
message="포커스하면 도움말이 파랗게 바뀝니다"
value={text}
onChange={setText}
clearable
/>
</InputStage>
<InputStage>
<FoxInput
size="md"
label="오류 (invalid)"
placeholder="내용을 입력하세요."
message="필수 항목입니다"
invalid
/>
</InputStage>
</>
);
}
// 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다.
function TextAreaLiveDemo() {
const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다.");
return (
<>
<InputStage>
<FoxTextArea
label="자동 상태 · 카운터"
placeholder="내용을 입력하세요."
message="포커스하면 도움말이 파랗게 바뀝니다"
value={text}
onChange={setText}
maxLength={100}
counter
/>
</InputStage>
<InputStage>
<FoxTextArea
label="오류 (invalid)"
placeholder="내용을 입력하세요."
message="100자를 넘길 수 없습니다"
defaultValue={"x".repeat(100)}
maxLength={100}
counter
invalid
/>
</InputStage>
</>
);
}
const PHONE_PREFIXES = ["010", "011", "016", "017", "018", "019", "02", "031", "051"];
// 값이 숫자만 담긴 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다.
function PhoneNumberLiveDemo() {
const [unit, setUnit] = useState("01012345678");
const [combine, setCombine] = useState("0212345678");
return (
<>
<InputStage>
<FoxPhoneNumber
label={`unit — value="${unit}"`}
prefixOptions={PHONE_PREFIXES}
value={unit}
onChange={setUnit}
message="칸을 다 채우면 다음 칸으로 넘어갑니다"
/>
</InputStage>
<InputStage>
<FoxPhoneNumber
type="combine"
label={`combine — value="${combine}"`}
prefixOptions={PHONE_PREFIXES}
value={combine}
onChange={setCombine}
message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다"
/>
</InputStage>
</>
);
}
const EMAIL_DOMAINS = ["naver.com", "gmail.com", "daum.net", "kakao.com"];
// 값이 합쳐진 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다.
function EmailLiveDemo() {
const [email, setEmail] = useState("hong@naver.com");
return (
<InputStage wide>
<FoxEmail
label={`value="${email}"`}
domainOptions={EMAIL_DOMAINS}
value={email}
onChange={setEmail}
message="도메인을 고르면 옆 칸이 채워지고 잠깁니다. 직접입력을 고르면 풀립니다."
/>
</InputStage>
);
}
// 어떤 우편번호 서비스를 쓰든 이 모양으로 돌려주면 된다 — 여기서는 지연만 흉내낸다.
const FAKE_SEARCH_RESULT: FoxAddressSearchResult = {
zipCode: "06236",
address: "서울 강남구 테헤란로 152",
detail: "강남파이낸스센터",
};
function AddressLiveDemo({ cancel = false }) {
const [value, setValue] = useState<FoxAddressValue>({ zipCode: "", address: "", detail: "" });
return (
<>
<InputStage>
<FoxAddress
label={cancel ? "검색을 취소하면 값이 그대로 남습니다" : "주소 검색을 눌러보세요"}
value={value}
onChange={setValue}
onSearch={async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
return cancel ? null : FAKE_SEARCH_RESULT;
}}
/>
</InputStage>
<pre className={styles.code}>{JSON.stringify(value, null, 2)}</pre>
</>
);
}
// 이벤트가 실제로 도는 걸 보려면 예제가 결과를 적어 둬야 한다. 서버 대신 지연만 흉내낸다.
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: "아주 긴 옵션 라벨 — 트리거에서는 말줄임으로 잘린다" },
];
/** 컴포넌트를 만들 때마다 항목 하나를 추가한다 — 다른 파일은 고치지 않는다. */
export const COMPONENT_EXAMPLES: ComponentExample[] = [
{
id: "fox-button",
name: "FoxButton",
description:
"Figma 시안(btn, 컴포넌트 세트 106:1606)을 토큰만으로 옮겼습니다. 너비는 내용에 따라 가변이고 높이만 고정입니다. hover·pressed는 마우스를 올리거나 눌러 확인하세요. error 계열은 시안 미정이라 비어 있습니다.",
variants: [
{
label: "계열 (type) — hover·pressed 확인 가능",
node: (
<>
<FoxButton size="md" label="default" />
<FoxButton size="md" type="primary" label="primary" />
<FoxButton size="md" type="secondary" label="secondary" />
<FoxButton size="md" type="error" label="error (시안 미정)" />
</>
),
},
{
label: "크기 (size)",
node: (
<>
<FoxButton size="lg" label="lg" />
<FoxButton size="md" label="md" />
<FoxButton size="sm" label="sm" />
<FoxButton size="xsm" label="xsm" />
</>
),
},
{
label: "아이콘",
node: (
<>
<FoxButton size="md" label="leading" leadingIcon={<DemoIcon />} />
<FoxButton size="md" label="trailing" trailingIcon={<DemoIcon />} />
<FoxButton size="md" label="both" leadingIcon={<DemoIcon />} trailingIcon={<DemoIcon />} />
</>
),
},
{
label: "상태 — disabled / loading (둘 다 클릭이 막힌다)",
node: (
<>
<FoxButton size="md" label="normal" onAction={() => undefined} />
<FoxButton size="md" label="disabled" disabled />
<FoxButton size="md" label="loading" loading />
<FoxButton size="md" label="loading + icon" loading leadingIcon={<DemoIcon />} />
</>
),
},
{
label: "fullWidth",
node: <FoxButton size="md" label="fullWidth" fullWidth />,
},
{
label: "htmlType — 폼 안에서의 역할",
node: (
<>
<FoxButton size="md" label="button (기본)" />
<FoxButton size="md" label="submit" htmlType="submit" />
<FoxButton size="md" label="reset" htmlType="reset" />
</>
),
},
{
label: "hidden — 렌더되지 않는다(아래는 비어 있는 것이 정상)",
node: <FoxButton size="md" label="hidden" hidden />,
},
],
},
{
id: "fox-text-button",
name: "FoxTextButton",
description:
"Figma 시안(btn-txt, 컴포넌트 세트 106:2468)을 토큰만으로 옮겼습니다. 높이를 고정하지 않고 패딩과 line-height로만 크기를 잡습니다. lg만 상하 패딩과 간격이 한 단계 넓습니다.",
variants: [
{
label: "계열 (type) — primary는 굵기가 한 단계 더 굵다",
node: (
<>
<FoxTextButton size="md" type="primary" label="primary" />
<FoxTextButton size="md" type="secondary" label="secondary" />
</>
),
},
{
label: "크기 (size)",
node: (
<>
<FoxTextButton size="lg" label="lg" />
<FoxTextButton size="md" label="md" />
<FoxTextButton size="sm" label="sm" />
<FoxTextButton size="xsm" label="xsm" />
</>
),
},
{
label: "아이콘",
node: (
<>
<FoxTextButton size="md" label="leading" leadingIcon={<DemoIcon />} />
<FoxTextButton size="md" label="trailing" trailingIcon={<DemoIcon />} />
<FoxTextButton
size="md"
label="both"
leadingIcon={<DemoIcon />}
trailingIcon={<DemoIcon />}
/>
</>
),
},
{
label: "상태 — disabled",
node: (
<>
<FoxTextButton size="md" label="normal" onAction={() => undefined} />
<FoxTextButton size="md" label="disabled" disabled />
<FoxTextButton size="md" type="secondary" label="disabled (secondary)" disabled />
</>
),
},
],
},
{
id: "fox-link-button",
name: "FoxLinkButton",
description:
"Figma 시안(btn-lnk, 컴포넌트 세트 107:207)을 토큰만으로 옮겼습니다. href를 주면 <a>, 없으면 <button>으로 렌더합니다. 방문 색은 브라우저가 실제 방문 기록으로만 켜므로 아래에서 링크를 한 번 눌러 돌아오면 색이 바뀝니다. tertiary만 평소에 밑줄이 없고 hover에서만 생깁니다.",
variants: [
{
label: "계열 (type) — hover하면 tertiary에 밑줄이 생긴다",
node: (
<>
<FoxLinkButton size="md" type="primary" label="primary" href="https://example.com" />
<FoxLinkButton
size="md"
type="secondary"
label="secondary"
href="https://example.com"
/>
<FoxLinkButton size="md" type="tertiary" label="tertiary" href="https://example.com" />
</>
),
},
{
label: "크기 (size)",
node: (
<>
<FoxLinkButton size="lg" label="lg" href="https://example.com" />
<FoxLinkButton size="md" label="md" href="https://example.com" />
<FoxLinkButton size="sm" label="sm" href="https://example.com" />
</>
),
},
{
label: "아이콘 — 라벨 뒤에만 놓인다",
node: (
<>
<FoxLinkButton size="lg" label="lg" href="https://example.com" icon={<DemoIcon />} />
<FoxLinkButton size="md" label="md" href="https://example.com" icon={<DemoIcon />} />
<FoxLinkButton size="sm" label="sm" href="https://example.com" icon={<DemoIcon />} />
</>
),
},
{
label: "href 없음 — <button>으로 렌더된다(방문 색이 켜지지 않는다)",
node: (
<>
<FoxLinkButton size="md" label="onAction만" onAction={() => undefined} />
<FoxLinkButton size="md" type="tertiary" label="tertiary" onAction={() => undefined} />
</>
),
},
{
label: "상태 — disabled (앵커는 href가 빠져 포커스·클릭이 함께 사라진다)",
node: (
<>
<FoxLinkButton size="md" label="disabled (a)" href="https://example.com" disabled />
<FoxLinkButton size="md" label="disabled (button)" disabled />
</>
),
},
],
},
{
id: "fox-icon-button",
name: "FoxIconButton",
description:
"Figma 시안(btn-ico 400:6312 + btn-ico-toggle 404:5247)을 토큰만으로 옮겼습니다. 두 시안이 계열·크기·모양이 같고 켜짐 상태만 달라 한 컴포넌트로 합쳤습니다 — active를 주면 토글(aria-pressed)이 됩니다. ghost는 테두리가 없는 대신 아이콘이 한 단계 큽니다.",
variants: [
{
label: "계열 (type) — overlay는 사진 위에서 봐야 한다",
node: (
<>
<FoxIconButton size="md" type="default" label="default" icon={<DemoIcon />} />
<FoxIconButton size="md" type="ghost" label="ghost" icon={<DemoIcon />} />
<OnPhoto>
<FoxIconButton size="md" type="overlay" label="overlay" icon={<DemoIcon />} />
</OnPhoto>
</>
),
},
{
label: "크기 (size) — default 계열",
node: (
<>
<FoxIconButton size="lg" label="lg" icon={<DemoIcon />} />
<FoxIconButton size="md" label="md" icon={<DemoIcon />} />
<FoxIconButton size="sm" label="sm" icon={<DemoIcon />} />
<FoxIconButton size="xsm" label="xsm" icon={<DemoIcon />} />
</>
),
},
{
label: "크기 (size) — ghost는 같은 상자에 아이콘만 한 단계 크다",
node: (
<>
<FoxIconButton size="lg" type="ghost" label="lg" icon={<DemoIcon />} />
<FoxIconButton size="md" type="ghost" label="md" icon={<DemoIcon />} />
<FoxIconButton size="sm" type="ghost" label="sm" icon={<DemoIcon />} />
<FoxIconButton size="xsm" type="ghost" label="xsm" icon={<DemoIcon />} />
</>
),
},
{
label: "토글 — 눌러서 켜고 끈다 (active)",
node: <IconToggleDemo />,
},
{
label: "토글 켜짐 고정 — 계열별 색 비교",
node: (
<>
<FoxIconButton size="md" type="default" label="on" icon={<DemoStar />} active />
<FoxIconButton size="md" type="ghost" label="on" icon={<DemoStar />} active />
<OnPhoto>
<FoxIconButton size="md" type="overlay" label="on" icon={<DemoStar />} active />
</OnPhoto>
</>
),
},
{
label: "상태 — disabled (켜짐보다 우선한다)",
node: (
<>
<FoxIconButton size="md" label="disabled" icon={<DemoIcon />} disabled />
<FoxIconButton size="md" type="ghost" label="disabled" icon={<DemoIcon />} disabled />
<FoxIconButton
size="md"
type="overlay"
label="disabled"
icon={<DemoIcon />}
disabled
/>
<FoxIconButton
size="md"
label="active + disabled"
icon={<DemoStar />}
active
disabled
/>
</>
),
},
],
},
{
id: "fox-button-group",
name: "FoxButtonGroup",
description:
"간격만 Figma 시안(btn-group, 컴포넌트 세트 398:4854)에서 가져왔습니다 — 크기·방향과 무관하게 gap-2(4px) 하나뿐이라 그룹에는 크기별 스타일이 없습니다. 크기는 그룹이 단일 진실 공급원이라 자식이 저마다 다른 size를 줘도 그룹 값으로 통일됩니다. 세로일 때는 자식이 항상 폭을 채웁니다. 블록 레벨 flex라 부모 폭을 채웁니다.",
variants: [
{
label: "가로 · 정렬 (align) — 회색 테두리가 그룹 영역",
node: (
<div className={styles.demoStack}>
{(["start", "center", "end"] as const).map((align) => (
<div key={align} className={styles.demoOutline}>
<FoxButtonGroup size="md" align={align}>
<FoxButton size="md" label={align} />
<FoxButton size="md" type="primary" label="확인" />
</FoxButtonGroup>
</div>
))}
</div>
),
},
{
label: "크기 (size) — 자식이 준 size는 무시된다",
node: (
<div className={styles.demoStack}>
{(["lg", "md", "sm", "xsm"] as const).map((size) => (
<FoxButtonGroup key={size} size={size}>
{/* 일부러 어긋난 size를 준다 — 전부 그룹 값으로 통일되어야 정상 */}
<FoxButton size="lg" label={size} />
<FoxButton size="xsm" type="primary" label="확인" />
<FoxIconButton size="lg" label="공유" icon={<DemoIcon />} />
</FoxButtonGroup>
))}
</div>
),
},
{
label: "세로 — 자식이 항상 폭을 채운다",
node: (
<div className={styles.demoOutline}>
<FoxButtonGroup size="md" orientation="vertical">
<FoxButton size="md" label="취소" />
<FoxButton size="md" type="primary" label="확인" />
</FoxButtonGroup>
</div>
),
},
{
label: "wrap — 폭이 모자라면 다음 줄로 넘어간다",
node: (
<div className={styles.demoStack}>
<div className={styles.demoNarrow}>
<FoxButtonGroup size="md" wrap>
{["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label) => (
<FoxButton key={label} size="md" label={label} />
))}
</FoxButtonGroup>
</div>
<div className={styles.demoNarrow}>
<FoxButtonGroup size="md">
{["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label) => (
<FoxButton key={label} size="md" label={label} />
))}
</FoxButtonGroup>
</div>
</div>
),
},
{
label: "버튼이 아닌 자식 — 호스트 요소는 건드리지 않는다",
node: (
<FoxButtonGroup size="sm" align="center">
<FoxButton size="sm" label="이전" />
<span className={styles.demoText}>1 / 10</span>
<FoxButton size="sm" label="다음" />
</FoxButtonGroup>
),
},
{
label: "프래그먼트로 감싼 자식도 덮어쓴다",
node: (
<FoxButtonGroup size="xsm">
<>
<FoxButton size="lg" label="프래그먼트 안" />
<FoxButton size="lg" type="primary" label="확인" />
</>
<FoxButton size="lg" label="프래그먼트 밖" />
</FoxButtonGroup>
),
},
],
},
{
id: "fox-button-panel",
name: "FoxButtonPanel",
description:
"Figma 시안(btn-page, 컴포넌트 세트 404:5121)을 옮겼습니다. 자식은 버튼 그룹이고, 여럿이면 양끝으로 벌어지고 하나면 가운데로 갑니다 — 자식 수는 CSS :only-child가 판단합니다. 패널 안에서는 그룹 간격이 gap-3(8px)으로 덮어써지고(단독으로 쓰면 gap-2), 버튼에 최소 폭 160px이 붙습니다. 창을 768px 아래로 줄이면 패널도 그룹도 세로로 쌓이고 버튼이 폭을 채웁니다.",
variants: [
{
label: "그룹 둘 — 양끝 (시안 type=flow)",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel offset={false}>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="취소" />
</FoxButtonGroup>
<FoxButtonGroup size="lg">
<FoxButton size="lg" type="secondary" label="임시 저장" />
<FoxButton size="lg" type="primary" label="다음" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
{
label: "그룹 하나 — 가운데 (시안 type=default)",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel offset={false}>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="취소" />
<FoxButton size="lg" type="primary" label="확인" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
{
label: "상단 여백 (offset) — 기본 켜짐, 80px",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel>
<FoxButtonGroup size="lg">
<FoxButton size="lg" type="primary" label="확인" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
{
label: "그룹 셋 이상도 양끝 기준으로 벌어진다",
node: (
<div className={styles.demoOutline}>
<FoxButtonPanel offset={false}>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="삭제" />
</FoxButtonGroup>
<FoxButtonGroup size="lg">
<FoxButton size="lg" label="미리보기" />
</FoxButtonGroup>
<FoxButtonGroup size="lg">
<FoxButton size="lg" type="primary" label="저장" />
</FoxButtonGroup>
</FoxButtonPanel>
</div>
),
},
],
},
{
id: "fox-input",
name: "FoxInput",
description:
"Figma 시안(input, 컴포넌트 세트 135:621)을 옮겼습니다. 네이티브 <input>을 감싸므로 시안에 없는 password·email·number 등 모든 타입을 그대로 받습니다. 상태는 기본적으로 브라우저가 판단하고(포커스·값·비활성·aria-invalid), state를 주면 그 모양으로 고정됩니다. 라벨은 입력 크기와 무관하게 항상 같은 크기입니다.",
variants: [
{
label: "직접 조작 — 입력·포커스·지우기",
node: <InputLiveDemo />,
},
{
label: "크기 (size) — 상자·글자·아이콘이 함께 줄고 라벨만 고정",
node: (
<>
{(["lg", "md", "sm"] as const).map((size) => (
<InputStage key={size}>
<FoxInput size={size} label={`레이블 (${size})`} placeholder="내용을 입력하세요." />
</InputStage>
))}
</>
),
},
{
label: "상태 고정 (state) — 시안 6종을 그대로 비교",
node: (
<>
{(
["default", "focused", "completed", "error", "disabled", "view"] as FoxInputState[]
).map((state) => (
<InputStage key={state}>
<FoxInput
size="md"
state={state}
label={state}
placeholder="내용을 입력하세요."
message="힌트 메시지"
/>
</InputStage>
))}
</>
),
},
{
label: "타입 — 시안에 없는 것도 네이티브 그대로 동작한다",
node: (
<>
{(["text", "date", "password", "email", "number", "time"] as const).map((type) => (
<InputStage key={type}>
<FoxInput size="md" type={type} label={type} placeholder="내용을 입력하세요." />
</InputStage>
))}
</>
),
},
{
label: "아이콘 · 헬퍼 아이콘 슬롯",
node: (
<>
<InputStage>
<FoxInput
size="md"
label="오른쪽 아이콘"
placeholder="내용을 입력하세요."
icon={<DemoIcon />}
/>
</InputStage>
<InputStage>
<FoxInput
size="md"
label="헬퍼 아이콘"
placeholder="내용을 입력하세요."
message="아이콘은 호출부가 넣습니다"
messageIcon={<DemoIcon />}
/>
</InputStage>
</>
),
},
{
label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정",
node: (
<>
<InputStage>
<FoxInput size="md" label="disabled" defaultValue="수정할 수 없음" disabled />
</InputStage>
<InputStage>
<FoxInput size="md" label="readOnly" defaultValue="읽기 전용" readOnly />
</InputStage>
</>
),
},
{
label: "라벨·헬퍼 없이도 쓸 수 있다",
node: (
<InputStage>
<FoxInput size="md" placeholder="내용을 입력하세요." />
</InputStage>
),
},
],
},
{
id: "fox-select",
name: "FoxSelect",
description:
"Figma 시안(select, 컴포넌트 세트 111:272). 네이티브 <select>가 아니라 트리거 버튼 + 리스트박스라 옵션 목록까지 시안대로 그립니다. 상태는 전부 ARIA 속성이 스타일 훅을 겸합니다 — 열림·오류·조회 전용·옵션 선택에 별도 클래스가 없습니다.",
variants: [
{
label: "크기 (size)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "완료(값 선택됨)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "error",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "disabled",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "view (조회 전용)",
node: (
<div className={styles.demoFields}>
<FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
<FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
</div>
),
},
{
label: "옵션 아이콘(맨 뒤) · 긴 라벨 — 열어서 확인한다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="레이블" options={SELECT_ITEMS_RICH} />
</div>
),
},
{
label: "라벨 없음 · 힌트 없음 — 해당 영역이 렌더되지 않는다",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" options={SELECT_ITEMS} />
<FoxSelect size="md" label="라벨만" options={SELECT_ITEMS} />
</div>
),
},
{
label: "placeholder — 기본값은 “선택해주세요”",
node: (
<div className={styles.demoFields}>
<FoxSelect size="md" label="기본" options={SELECT_ITEMS} />
<FoxSelect size="md" label="직접 지정" options={SELECT_ITEMS} placeholder="등급을 고르세요" />
</div>
),
},
],
},
{
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-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>
</>
),
},
],
},
];