임동욱 임동욱 08-10
feat: 모달·필드·라디오그룹 공용 컴포넌트 추가
Co-Authored-By: Claude Opus 5 
@f29287853d2ead4073a6a8f9908f2b914ae9cc5f
 
components/ui/field.tsx (added)
+++ components/ui/field.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+
+interface FieldProps {
+  label: string;
+  children: ReactNode;
+  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
+  className?: string;
+}
+
+/**
+ * 폼 라벨 + 값 배치. `<label>`이 children을 함께 감싸 암시적 라벨 연결(implicit label)을
+ * 제공한다 — 조회 팝업에서 readOnly `Input`을 감싸는 용도.
+ */
+export function Field({ label, children, className }: FieldProps) {
+  return (
+    <label className={`flex flex-col gap-1 ${className ?? ""}`}>
+      <span className="text-label-md text-foreground-muted">{label}</span>
+      {children}
+    </label>
+  );
+}
 
components/ui/modal.tsx (added)
+++ components/ui/modal.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useId, type ReactNode } from "react";
+import { IconButton } from "@/components/ui/icon-button";
+
+interface ModalProps {
+  title: string;
+  children: ReactNode;
+  footer?: ReactNode;
+  /** scrim 클릭·ESC·닫기 버튼에서 호출된다. 실제로 마운트 해제할지는 호출부(feature) 책임. */
+  onClose: () => void;
+}
+
+/**
+ * 조회/입력 팝업 셸(scrim + 패널). 열림 여부는 호출부가 마운트/언마운트로 제어한다.
+ * alert.tsx(확인 다이얼로그)와 달리 폼 필드를 담는 용도라 ESC 키·scrim 클릭·닫기 버튼에서
+ * onClose를 호출하는 상호작용을 자체적으로 가진다 — 그래서 이 컴포넌트만 'use client'.
+ * 포커스 트랩은 다루지 않는다(호출부 책임).
+ */
+export function Modal({ title, children, footer, onClose }: ModalProps) {
+  const titleId = useId();
+
+  useEffect(() => {
+    function handleKeyDown(event: KeyboardEvent) {
+      if (event.key === "Escape") {
+        onClose();
+      }
+    }
+    document.addEventListener("keydown", handleKeyDown);
+    return () => {
+      document.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [onClose]);
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-scrim p-4"
+      onClick={onClose}
+    >
+      <div
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby={titleId}
+        onClick={(event) => event.stopPropagation()}
+        className="w-full max-w-lg animate-dialog-in rounded-xl bg-surface p-6 shadow-dialog"
+      >
+        <div className="flex items-center justify-between gap-4">
+          <h2 id={titleId} className="text-title-lg text-foreground">
+            {title}
+          </h2>
+          <IconButton label="닫기" onClick={onClose}>
+            <span aria-hidden="true" className="text-title-md leading-none">
+              ×
+            </span>
+          </IconButton>
+        </div>
+        <div className="mt-4">{children}</div>
+        {footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>}
+      </div>
+    </div>
+  );
+}
 
components/ui/radio-group.tsx (added)
+++ components/ui/radio-group.tsx
@@ -0,0 +1,40 @@
+interface RadioGroupOption {
+  value: string;
+  label: string;
+}
+
+interface RadioGroupProps {
+  name: string;
+  options: RadioGroupOption[];
+  defaultValue?: string;
+  disabled?: boolean;
+}
+
+/**
+ * 네이티브 라디오 그룹(비제어) — 폼 제출 시 `name` 필드로 선택값이 함께 전달된다.
+ * React 상태를 가지지 않으므로 `'use client'`가 필요 없다.
+ */
+export function RadioGroup({ name, options, defaultValue, disabled = false }: RadioGroupProps) {
+  return (
+    <div role="radiogroup" className="flex items-center gap-4">
+      {options.map((option) => (
+        <label
+          key={option.value}
+          className={`flex items-center gap-2 text-body-md ${
+            disabled ? "text-foreground-subtle opacity-50" : "text-foreground"
+          }`}
+        >
+          <input
+            type="radio"
+            name={name}
+            value={option.value}
+            defaultChecked={option.value === defaultValue}
+            disabled={disabled}
+            className="size-4 accent-primary"
+          />
+          {option.label}
+        </label>
+      ))}
+    </div>
+  );
+}
Add a comment
List