nnuer
강희태@gmlxo9917

버튼·입력·선택·카드·토스트·모달을 모은 컴포넌트 시스템 문서

콘텐츠 블록

버튼 4종 x 3크기 x 기본/비활성, 입력·텍스트영역·선택 상자의 4가지 상태, 체크박스·라디오, 카드 3종, 토스트 4종, 탭, 모달 3크기를 점선 패널에 모아 보여 주는 컴포넌트 문서 섹션입니다. 체크박스·라디오·탭·토스트 닫기가 실제로 동작합니다.

블록 신고

운영자가 확인한 뒤 필요하면 블록을 숨깁니다.

사유
Claude Code · 모름 · React (TSX)
결과 코드펼치기
import { useState } from "react";

const FONT_URL = "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap";

type Size = "SM" | "MD" | "LG";
type BtnType = "Primary" | "Secondary" | "Ghost" | "Danger";
type FieldState = "Default" | "Focused" | "Error" | "Disabled";

const SIZE_CLASS: Record<Size, string> = {
  SM: "h-8 px-3 rounded-md text-[12px]",
  MD: "h-10 px-4 rounded-lg text-[14px]",
  LG: "h-12 px-5 rounded-lg text-[15px]",
};

const TYPE_CLASS: Record<BtnType, string> = {
  Primary: "bg-[#5b8def] text-white hover:bg-[#3b82f6] disabled:bg-[#e5e7eb] disabled:text-[#d1d5db]",
  Secondary: "border-[1.5px] border-[#5b8def] text-[#5b8def] hover:bg-[#eff6ff] disabled:border-[#e5e7eb] disabled:text-[#d1d5db] disabled:bg-transparent",
  Ghost: "text-[#111827] hover:bg-[#f3f4f6] disabled:text-[#d1d5db] disabled:bg-transparent",
  Danger: "bg-[#ff4d4f] text-white hover:bg-[#e53e40] disabled:bg-[#e5e7eb] disabled:text-[#d1d5db]",
};

const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5b8def]";

function Button({ type, size, disabled }: { type: BtnType; size: Size; disabled?: boolean }) {
  return (
    <button
      type="button"
      disabled={disabled}
      className={`flex w-[120px] items-center justify-center font-semibold leading-normal transition-colors duration-150 disabled:cursor-not-allowed ${SIZE_CLASS[size]} ${TYPE_CLASS[type]} ${focusRing}`}
    >
      Label
    </button>
  );
}

function fieldBorder(state: FieldState) {
  if (state === "Disabled") return "bg-[#f3f4f6] border border-[#e5e7eb] text-[#d1d5db] placeholder:text-[#d1d5db] cursor-not-allowed";
  if (state === "Error") return "bg-white border-[1.5px] border-[#ff4d4f] text-[#111827] placeholder:text-[#9ca3af]";
  if (state === "Focused") return "bg-white border-2 border-[#5b8def] ring-4 ring-[#5b8def]/20 text-[#111827] placeholder:text-[#9ca3af]";
  return "bg-white border border-[#e5e7eb] text-[#111827] placeholder:text-[#9ca3af] focus:border-2 focus:border-[#5b8def] focus:ring-4 focus:ring-[#5b8def]/20";
}

function ErrorDot() {
  return <span className="pointer-events-none absolute right-3 size-2 rounded-full bg-[#ff4d4f]" aria-hidden="true" />;
}

function Field({ state, multiline }: { state: FieldState; multiline?: boolean }) {
  const common = `w-full rounded-lg px-2.5 text-[14px] leading-normal outline-none transition-[border-color,box-shadow] ${fieldBorder(state)}`;
  const placeholder = state === "Disabled" ? "Disabled" : "Placeholder";
  const defaultValue = state === "Focused" ? "Value text" : undefined;
  const label = `${multiline ? "Textarea" : "Input"} ${state}`;
  return (
    <div className="relative flex w-full items-center">
      {multiline ? (
        <textarea aria-label={label} rows={3} disabled={state === "Disabled"} placeholder={placeholder} defaultValue={defaultValue} aria-invalid={state === "Error" || undefined} className={`${common} h-[88px] resize-none py-2.5`} />
      ) : (
        <input aria-label={label} disabled={state === "Disabled"} placeholder={placeholder} defaultValue={defaultValue} aria-invalid={state === "Error" || undefined} className={`${common} h-10`} />
      )}
      {state === "Error" && <ErrorDot />}
    </div>
  );
}

function Select({ state }: { state: FieldState }) {
  const isOpen = state === "Focused";
  return (
    <div className="relative flex w-full items-center">
      <select
        aria-label={`Select ${state}`}
        disabled={state === "Disabled"}
        defaultValue={isOpen ? "selected" : ""}
        className={`h-10 w-full cursor-pointer appearance-none rounded-lg px-2.5 pr-8 text-[14px] leading-normal outline-none ${fieldBorder(state)} ${isOpen ? "" : "text-[#9ca3af]"}`}
      >
        <option value="" disabled>
          {state === "Disabled" ? "Disabled" : "Select option"}
        </option>
        <option value="selected">Selected value</option>
        <option value="other">Other value</option>
      </select>
      <svg className="pointer-events-none absolute right-3" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={state === "Disabled" ? "#d1d5db" : isOpen ? "#111827" : "#9ca3af"} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <path d="m6 9 6 6 6-6" />
      </svg>
    </div>
  );
}

function Panel({ children, className = "" }: { children: React.ReactNode; className?: string }) {
  return <div className={`rounded-xl border border-dashed border-[#e5e7eb] bg-[#f9fafb] p-4 sm:p-5 ${className}`}>{children}</div>;
}

function SectionTitle({ title, desc }: { title: string; desc: string }) {
  return (
    <div className="flex flex-col gap-1">
      <div className="flex items-center gap-3">
        <span className="h-6 w-1 shrink-0 rounded-[2px] bg-[#5b8def]" aria-hidden="true" />
        <h3 className="text-[18px] font-bold leading-normal text-[#111827] sm:text-[20px]">{title}</h3>
      </div>
      <p className="text-[14px] leading-normal text-[#6b7280]">{desc}</p>
    </div>
  );
}

type CheckState = "unchecked" | "checked" | "indeterminate";

function CheckboxRow({ initial, disabled }: { initial: CheckState; disabled?: boolean }) {
  const [state, setState] = useState<CheckState>(initial);
  const on = state !== "unchecked";
  return (
    <label className={`flex items-center gap-2 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
      <input
        type="checkbox"
        className="peer sr-only"
        checked={state === "checked"}
        disabled={disabled}
        ref={(el) => {
          if (el) el.indeterminate = state === "indeterminate";
        }}
        onChange={() => setState(state === "checked" ? "unchecked" : "checked")}
      />
      <span
        className={`flex size-4 items-center justify-center rounded-[4px] transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#5b8def] ${
          disabled ? "bg-[#e5e7eb]" : on ? "bg-[#5b8def]" : "border-[1.5px] border-[#e5e7eb] bg-white"
        }`}
        aria-hidden="true"
      >
        {state === "checked" && !disabled && (
          <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round">
            <path d="M20 6 9 17l-5-5" />
          </svg>
        )}
        {state === "indeterminate" && !disabled && <span className="h-[2px] w-2 rounded bg-white" />}
      </span>
      <span className={`text-[14px] leading-normal ${disabled ? "text-[#d1d5db]" : "text-[#111827]"}`}>Checkbox label</span>
    </label>
  );
}

const TOASTS = [
  { type: "Success", text: "Action completed successfully.", color: "#27ae60", bg: "#f0fdf4" },
  { type: "Error", text: "Something went wrong.", color: "#ff4d4f", bg: "#fef2f2" },
  { type: "Warning", text: "Please review your input.", color: "#f2c94c", bg: "#fefce8" },
  { type: "Info", text: "New update is available.", color: "#5b8def", bg: "#eff6ff" },
];

const MODAL_SIZES: { size: Size; width: string; body: string }[] = [
  { size: "SM", width: "max-w-[400px]", body: "h-20" },
  { size: "MD", width: "max-w-[520px]", body: "h-[100px]" },
  { size: "LG", width: "max-w-[640px]", body: "h-[120px]" },
];

export default function ComponentSystemDoc() {
  const [radio, setRadio] = useState("b");
  const [tab, setTab] = useState(0);
  const [toasts, setToasts] = useState(TOASTS.map((t) => t.type));

  const BUTTON_TYPES: BtnType[] = ["Primary", "Secondary", "Ghost", "Danger"];
  const SIZES: Size[] = ["SM", "MD", "LG"];
  const STATES: FieldState[] = ["Default", "Focused", "Error", "Disabled"];

  return (
    <section className="w-full bg-white px-4 py-12 font-['Geist',sans-serif] sm:px-10 sm:py-20 lg:px-20">
      <link rel="stylesheet" href={FONT_URL} precedence="default" />
      <div className="mx-auto flex w-full max-w-[1520px] flex-col gap-14">
        <h2 className="border-b border-[#e5e7eb] pb-5 text-[24px] font-bold leading-normal text-[#111827] sm:text-[28px]">07 · COMPONENT SYSTEM</h2>

        <div className="flex flex-col gap-6">
          <SectionTitle title="Form Controls" desc="버튼·입력·선택 컨트롤" />
          <div className="flex flex-wrap items-start gap-4">
            <Panel>
              <div className="flex flex-col gap-6">
                {BUTTON_TYPES.map((t) => (
                  <div key={t} className="flex flex-col gap-2" role="group" aria-label={`${t} buttons`}>
                    {SIZES.map((s) => (
                      <div key={s} className="flex items-center gap-2">
                        <Button type={t} size={s} />
                        <Button type={t} size={s} disabled />
                      </div>
                    ))}
                  </div>
                ))}
              </div>
            </Panel>
            <Panel className="w-full sm:w-[280px]">
              <div className="flex flex-col gap-2">
                {STATES.map((s) => (
                  <Field key={s} state={s} />
                ))}
              </div>
            </Panel>
            <Panel className="w-full sm:w-[280px]">
              <div className="flex flex-col gap-2">
                {STATES.map((s) => (
                  <Field key={s} state={s} multiline />
                ))}
              </div>
            </Panel>
            <Panel className="w-full sm:w-[280px]">
              <div className="flex flex-col gap-2">
                {STATES.map((s) => (
                  <Select key={s} state={s} />
                ))}
              </div>
            </Panel>
          </div>
        </div>

        <div className="flex flex-col gap-6">
          <SectionTitle title="Selection Controls" desc="체크박스·라디오 선택 요소" />
          <div className="flex flex-wrap items-start gap-6">
            <Panel>
              <fieldset className="flex flex-col gap-3">
                <legend className="sr-only">Checkbox states</legend>
                <CheckboxRow initial="unchecked" />
                <CheckboxRow initial="checked" />
                <CheckboxRow initial="indeterminate" />
                <CheckboxRow initial="unchecked" disabled />
              </fieldset>
            </Panel>
            <Panel>
              <fieldset className="flex flex-col gap-3">
                <legend className="sr-only">Radio states</legend>
                {[
                  { id: "a", disabled: false },
                  { id: "b", disabled: false },
                  { id: "c", disabled: true },
                ].map((r) => {
                  const on = radio === r.id && !r.disabled;
                  return (
                    <label key={r.id} className={`flex items-center gap-2 ${r.disabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
                      <input type="radio" name="cs-radio" value={r.id} checked={radio === r.id} disabled={r.disabled} onChange={() => setRadio(r.id)} className="peer sr-only" />
                      <span
                        className={`flex size-4 items-center justify-center rounded-full peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#5b8def] ${
                          r.disabled ? "bg-[#e5e7eb]" : on ? "border-[5px] border-[#5b8def] bg-white" : "border-[1.5px] border-[#e5e7eb] bg-white"
                        }`}
                        aria-hidden="true"
                      />
                      <span className={`text-[14px] leading-normal ${r.disabled ? "text-[#d1d5db]" : "text-[#111827]"}`}>Radio label</span>
                    </label>
                  );
                })}
              </fieldset>
            </Panel>
          </div>
        </div>

        <div className="flex flex-col gap-6">
          <SectionTitle title="Data Display" desc="카드·토스트 정보 표시 컴포넌트" />
          <div className="flex flex-wrap items-start gap-4">
            <Panel className="w-full xl:w-auto">
              <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
                {[
                  { name: "Default", cls: "border border-[#e5e7eb]" },
                  { name: "Outlined", cls: "border border-[#d1d5db]" },
                  { name: "Elevated", cls: "shadow-[0px_4px_12px_0px_rgba(17,24,39,0.1)]" },
                ].map((c) => (
                  <article key={c.name} className={`h-[180px] w-full overflow-hidden rounded-xl bg-white sm:w-[240px] lg:w-[280px] ${c.cls}`}>
                    <div className="h-24 w-full bg-[#f3f4f6]" />
                    <div className="flex flex-col gap-1 px-4 pt-3">
                      <h4 className="text-[15px] font-semibold leading-normal text-[#111827]">Card title</h4>
                      <p className="text-[13px] leading-normal text-[#6b7280]">Short description text</p>
                    </div>
                  </article>
                ))}
              </div>
            </Panel>
            <Panel className="w-full sm:w-auto">
              <ul className="flex flex-col gap-4">
                {TOASTS.filter((t) => toasts.includes(t.type)).map((t) => (
                  <li
                    key={t.type}
                    role={t.type === "Error" ? "alert" : "status"}
                    className="relative flex h-14 w-full items-center gap-2.5 overflow-hidden rounded-lg border pl-5 pr-3 sm:w-[360px]"
                    style={{ background: t.bg, borderColor: t.color }}
                  >
                    <span className="absolute inset-y-0 left-0 w-1" style={{ background: t.color }} aria-hidden="true" />
                    <span className="size-2 shrink-0 rounded-full" style={{ background: t.color }} aria-hidden="true" />
                    <span className="flex-1 text-[13px] leading-normal text-[#111827]">{t.text}</span>
                    <button
                      type="button"
                      onClick={() => setToasts((s) => s.filter((x) => x !== t.type))}
                      aria-label={`${t.type} 알림 닫기`}
                      className={`rounded px-1.5 text-[16px] leading-none text-[#9ca3af] hover:text-[#111827] ${focusRing}`}
                    >
                      ×
                    </button>
                  </li>
                ))}
                {toasts.length < TOASTS.length && (
                  <li>
                    <button type="button" onClick={() => setToasts(TOASTS.map((t) => t.type))} className={`text-[12px] font-medium text-[#5b8def] hover:underline ${focusRing}`}>
                      알림 다시 보기
                    </button>
                  </li>
                )}
              </ul>
            </Panel>
          </div>
          <Panel className="self-start">
            <div role="tablist" aria-label="Tabs example" className="flex">
              {["Tab", "Tab", "Tab"].map((label, i) => {
                const disabled = i === 2;
                const active = tab === i;
                return (
                  <button
                    key={i}
                    type="button"
                    role="tab"
                    aria-selected={active}
                    disabled={disabled}
                    onClick={() => setTab(i)}
                    className={`flex flex-col items-center gap-2 px-4 pt-2.5 text-[14px] leading-normal transition-colors ${focusRing} ${
                      disabled ? "cursor-not-allowed text-[#d1d5db]" : active ? "font-semibold text-[#5b8def]" : "text-[#6b7280] hover:text-[#111827]"
                    }`}
                  >
                    {label}
                    <span className={`h-[2px] w-12 rounded-[2px] ${active ? "bg-[#5b8def]" : "bg-transparent"}`} />
                  </button>
                );
              })}
            </div>
          </Panel>
        </div>

        <div className="flex flex-col gap-6">
          <SectionTitle title="Overlay" desc="모달 오버레이 컴포넌트" />
          <Panel>
            <div className="flex flex-wrap items-start gap-4">
              {MODAL_SIZES.map((m) => (
                <div
                  key={m.size}
                  role="dialog"
                  aria-label={`Modal ${m.size}`}
                  className={`w-full ${m.width} overflow-hidden rounded-2xl border border-[#e5e7eb] bg-white shadow-[0px_8px_24px_0px_rgba(17,24,39,0.16)]`}
                >
                  <div className="flex h-16 items-center justify-between border-b border-[#e5e7eb] px-6">
                    <h4 className="text-[16px] font-semibold leading-normal text-[#111827]">Modal title</h4>
                    <button type="button" aria-label="닫기" className={`flex size-7 items-center justify-center rounded-md bg-[#f3f4f6] text-[16px] text-[#9ca3af] hover:text-[#111827] ${focusRing}`}>
                      ×
                    </button>
                  </div>
                  <div className={`px-6 pt-5 ${m.body}`}>
                    <p className="text-[14px] leading-normal text-[#6b7280]">Modal body content goes here.</p>
                  </div>
                  <div className="flex h-[63px] items-center justify-end gap-2 border-t border-[#e5e7eb] bg-[#f9fafb] px-6">
                    <button type="button" className={`h-9 w-20 rounded-lg border border-[#e5e7eb] bg-white text-[13px] font-medium text-[#6b7280] hover:bg-[#f3f4f6] ${focusRing}`}>
                      Cancel
                    </button>
                    <button type="button" className={`h-9 w-20 rounded-lg bg-[#5b8def] text-[13px] font-medium text-white hover:bg-[#3b82f6] ${focusRing}`}>
                      Confirm
                    </button>
                  </div>
                </div>
              ))}
            </div>
          </Panel>
        </div>

        <p className="whitespace-pre border-t border-[#e5e7eb] pt-4 text-[12px] leading-normal text-[#a5a9b2]">{"Design System  ·  Component System  ·  v1.0"}</p>
      </div>
    </section>
  );
}

적용 사이트 0

이 블록이 실제로 쓰인 사이트입니다.

로그인하고 내 사이트 추가

아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.

로그인하고 재현 결과 올리기

재현 후기

v1 · 0개

아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.