임동욱 임동욱 08-10
merge: integration/student-member-list 동기화 (design 레인 공용 컴포넌트 반영)
design 레인이 병렬로 완료한 Button/Input/Select/Table/Pagination/Modal/Field/
RadioGroup을 integration에서 받아와 학생 회원 화면의 타입 에러를 실제로 해소한다.
@ab2fce81f11653d920fd41d292d60ecb2ae32591
 
components/ui/button.tsx (added)
+++ components/ui/button.tsx
@@ -0,0 +1,44 @@
+import type { ComponentProps } from "react";
+
+type ButtonVariant = "primary" | "secondary" | "success" | "ghost";
+type ButtonSize = "sm" | "md";
+
+const VARIANT_CLASSES: Record<ButtonVariant, string> = {
+  primary: "bg-primary text-on-primary hover:opacity-90",
+  secondary: "border border-border bg-surface text-foreground hover:bg-surface-muted",
+  // on-success 토큰이 아직 없어(§10.4 대상) primary 필렁 버튼과 동일하게 on-primary를
+  // 재사용한다 — 강조색(엑셀 다운로드 등) 액션용.
+  success: "bg-success text-on-primary hover:opacity-90",
+  ghost: "text-foreground-muted hover:bg-surface-muted hover:text-foreground",
+};
+
+const SIZE_CLASSES: Record<ButtonSize, string> = {
+  sm: "h-8 px-3 text-body-sm",
+  md: "h-10 px-4 text-body-md",
+};
+
+interface ButtonProps extends ComponentProps<"button"> {
+  variant?: ButtonVariant;
+  size?: ButtonSize;
+}
+
+/**
+ * 공용 버튼. 상태를 가지지 않는 프레젠테이션 컴포넌트 — 클릭 동작·로딩 처리는
+ * 호출부(feature) 책임이다. `type` 기본값은 `button`(폼 안에서 의도치 않은
+ * submit 방지) — 제출 버튼은 명시적으로 `type="submit"`을 전달한다.
+ */
+export function Button({
+  variant = "primary",
+  size = "md",
+  type = "button",
+  className,
+  ...props
+}: ButtonProps) {
+  return (
+    <button
+      type={type}
+      className={`inline-flex items-center justify-center gap-2 rounded-md transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className ?? ""}`}
+      {...props}
+    />
+  );
+}
 
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/input.tsx (added)
+++ components/ui/input.tsx
@@ -0,0 +1,16 @@
+import type { ComponentProps } from "react";
+
+type InputProps = ComponentProps<"input">;
+
+/** 기본 텍스트 입력. `readOnly`일 때 배경 톤 + 기본 커서로 편집 불가 상태를 시각 구분한다. */
+export function Input({ className, readOnly, ...props }: InputProps) {
+  return (
+    <input
+      readOnly={readOnly}
+      className={`h-10 w-full rounded-md border border-border px-3 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${
+        readOnly ? "cursor-default bg-surface-muted" : "bg-surface"
+      } ${className ?? ""}`}
+      {...props}
+    />
+  );
+}
 
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/pagination.tsx (added)
+++ components/ui/pagination.tsx
@@ -0,0 +1,109 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+interface PaginationProps {
+  /** 1-based. */
+  currentPage: number;
+  totalPages: number;
+  /** 각 페이지 번호로 이동할 href를 만들어 준다 — 라우팅은 소비처 책임. */
+  buildHref: (page: number) => string;
+  /** 한 번에 보여줄 번호 개수. 기본 5. */
+  windowSize?: number;
+}
+
+const ITEM_CLASSES =
+  "inline-flex size-9 items-center justify-center rounded-md text-body-sm transition-colors duration-200";
+const ENABLED_CLASSES = "text-foreground-muted hover:bg-surface-muted hover:text-foreground";
+const DISABLED_CLASSES = "text-foreground-subtle opacity-50";
+const CURRENT_CLASSES = "bg-primary text-on-primary";
+
+/** 현재 페이지를 중심으로 windowSize개 번호를 [1, totalPages] 범위 안에서 슬라이딩한다. */
+function getPageWindow(currentPage: number, totalPages: number, windowSize: number): number[] {
+  if (totalPages < 1) {
+    return [];
+  }
+  const half = Math.floor(windowSize / 2);
+  let start = Math.max(1, currentPage - half);
+  const end = Math.min(totalPages, start + windowSize - 1);
+  start = Math.max(1, end - windowSize + 1);
+
+  const pages: number[] = [];
+  for (let page = start; page <= end; page += 1) {
+    pages.push(page);
+  }
+  return pages;
+}
+
+interface PaginationItemProps {
+  disabled: boolean;
+  href: string;
+  label: string;
+  children: ReactNode;
+}
+
+/** 처음·이전·다음·끝 이동 항목 — 비활성일 때는 링크 대신 aria-disabled span으로 렌더한다. */
+function PaginationItem({ disabled, href, label, children }: PaginationItemProps) {
+  if (disabled) {
+    return (
+      <span aria-disabled="true" aria-label={label} className={`${ITEM_CLASSES} ${DISABLED_CLASSES}`}>
+        {children}
+      </span>
+    );
+  }
+  return (
+    <Link href={href} aria-label={label} className={`${ITEM_CLASSES} ${ENABLED_CLASSES}`}>
+      {children}
+    </Link>
+  );
+}
+
+/**
+ * 페이지네이션 — « ‹ [번호들] › ». 페이지 이동은 `next/link` href 기반이며 실제 라우팅(쿼리
+ * 구성 등)은 `buildHref`를 통해 호출부(feature)가 책임진다. 첫/마지막 페이지에서는 처음·이전·
+ * 다음·끝 이동이 비활성 처리된다.
+ */
+export function Pagination({
+  currentPage,
+  totalPages,
+  buildHref,
+  windowSize = 5,
+}: PaginationProps) {
+  const pages = getPageWindow(currentPage, totalPages, windowSize);
+  const isFirst = currentPage <= 1;
+  const isLast = currentPage >= totalPages;
+
+  return (
+    <nav aria-label="페이지네이션" className="flex items-center justify-center gap-1">
+      <PaginationItem disabled={isFirst} href={buildHref(1)} label="처음 페이지">
+        «
+      </PaginationItem>
+      <PaginationItem
+        disabled={isFirst}
+        href={buildHref(Math.max(1, currentPage - 1))}
+        label="이전 페이지"
+      >
+        ‹
+      </PaginationItem>
+      {pages.map((page) => (
+        <Link
+          key={page}
+          href={buildHref(page)}
+          aria-current={page === currentPage ? "page" : undefined}
+          className={`${ITEM_CLASSES} ${page === currentPage ? CURRENT_CLASSES : ENABLED_CLASSES}`}
+        >
+          {page}
+        </Link>
+      ))}
+      <PaginationItem
+        disabled={isLast}
+        href={buildHref(Math.min(totalPages, currentPage + 1))}
+        label="다음 페이지"
+      >
+        ›
+      </PaginationItem>
+      <PaginationItem disabled={isLast} href={buildHref(totalPages)} label="마지막 페이지">
+        »
+      </PaginationItem>
+    </nav>
+  );
+}
 
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>
+  );
+}
 
components/ui/select.tsx (added)
+++ components/ui/select.tsx
@@ -0,0 +1,13 @@
+import type { ComponentProps } from "react";
+
+type SelectProps = ComponentProps<"select">;
+
+/** 네이티브 `<select>` 래퍼(라이브러리 미사용) — 브라우저 기본 표시(화살표 등)를 그대로 쓴다. */
+export function Select({ className, ...props }: SelectProps) {
+  return (
+    <select
+      className={`h-10 rounded-md border border-border bg-surface px-3 text-body-md text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${className ?? ""}`}
+      {...props}
+    />
+  );
+}
components/ui/sidebar.tsx
--- components/ui/sidebar.tsx
+++ components/ui/sidebar.tsx
@@ -12,7 +12,12 @@
 export function Sidebar({ header, children, footer, className }: SidebarProps) {
   return (
     <aside
-      className={`flex h-full w-64 flex-col border-r border-border bg-surface ${className ?? ""}`}
+      // h-full(퍼센트 높이)을 쓰지 않는다 — 부모가 flex-grow로 산출한 높이는 CSS 퍼센트
+      // 해석의 기준(containing block)이 되지 못해 h-full이 auto로 무너질 수 있다. 대신
+      // 명시적 높이를 지정하지 않아(auto) 부모 flex 컨테이너의 기본 align-items:stretch가
+      // 그대로 적용되게 한다 — 이 방식은 부모 높이가 퍼센트든 flex-grow든 항상 안정적으로
+      // 동작한다.
+      className={`flex w-64 flex-col border-r border-border bg-surface ${className ?? ""}`}
     >
       {header && <div className="border-b border-border p-4">{header}</div>}
       <nav className="flex-1 overflow-y-auto p-2">{children}</nav>
 
components/ui/table.tsx (added)
+++ components/ui/table.tsx
@@ -0,0 +1,61 @@
+import type { ReactNode } from "react";
+
+interface TableProps {
+  children: ReactNode;
+  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
+  className?: string;
+}
+
+/** 테이블 컨테이너 — 가로 스크롤 래퍼(`overflow-x-auto`) + `<table>`. */
+export function Table({ children, className }: TableProps) {
+  return (
+    <div className={`w-full overflow-x-auto ${className ?? ""}`}>
+      <table className="w-full border-collapse text-body-sm text-foreground">{children}</table>
+    </div>
+  );
+}
+
+interface TableSectionProps {
+  children: ReactNode;
+}
+
+/** 테이블 헤더 영역(`<thead>`). */
+export function TableHead({ children }: TableSectionProps) {
+  return <thead className="bg-surface-muted">{children}</thead>;
+}
+
+/** 테이블 본문 영역(`<tbody>`). */
+export function TableBody({ children }: TableSectionProps) {
+  return <tbody>{children}</tbody>;
+}
+
+interface TableRowProps {
+  children: ReactNode;
+  className?: string;
+}
+
+/** 테이블 행(`<tr>`) — 아래쪽 구분선을 기본으로 갖는다. */
+export function TableRow({ children, className }: TableRowProps) {
+  return <tr className={`border-b border-border ${className ?? ""}`}>{children}</tr>;
+}
+
+interface TableCellProps {
+  children: ReactNode;
+  className?: string;
+}
+
+/** 헤더 셀(`<th>`). */
+export function TableHeaderCell({ children, className }: TableCellProps) {
+  return (
+    <th
+      className={`px-3 py-2 text-left text-label-md text-foreground-muted ${className ?? ""}`}
+    >
+      {children}
+    </th>
+  );
+}
+
+/** 본문 셀(`<td>`). */
+export function TableCell({ children, className }: TableCellProps) {
+  return <td className={`px-3 py-2 text-body-sm ${className ?? ""}`}>{children}</td>;
+}
Add a comment
List