임동욱 임동욱 08-18
feat: 학생 회원 조회 팝업을 시안대로 구현 (+ FoxDescriptionList)
시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809.

FoxModal이 껍데기를 전부 덮는다 — padding 40 · radius 24 · 닫기 버튼 · tit/contents/foot 슬롯 ·
`` 시맨틱까지 시안 값 그대로다. 없던 것은 라벨+값 조회 목록 하나였다.

- FoxDescriptionList 신설(사용자 확정). `
`/`
`/`
`로 그려 이름·값 관계가 보조기술에 전달되게 하고, 이름 쪽은 `
` 안에 FoxFormLabel을 `span`으로 넣어 서식을 디자인시스템과 맞췄다(`
`가 이미 라벨이라 `
@640bf4583a30b04685e81b097bcb021f251048f6
 
@fox/core/components/fox-description-list/fox-description-list.tsx (added)
+++ @fox/core/components/fox-description-list/fox-description-list.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+import { cx } from "../../utils";
+import { FoxFormLabel } from "../fox-form-label";
+
+export interface FoxDescriptionItem {
+  /** 목록 안에서 고유해야 한다. */
+  key: string;
+  /** 항목 이름. */
+  term: ReactNode;
+  /** 값. 글자든 토글이든 무엇이든 온다. */
+  description: ReactNode;
+  /** 참이면 이 줄만 렌더하지 않는다. */
+  hidden?: boolean;
+}
+
+export interface FoxDescriptionListProps {
+  items: FoxDescriptionItem[];
+  hidden?: boolean;
+  className?: string;
+}
+
+/**
+ * @fox 조회 목록 — 이름과 값을 한 쌍씩 세로로 쌓는다. 조회 팝업·상세 화면이 쓰는 모양이다.
+ *
+ * 마크업 계약 (React 밖 소비자용):
+ *   <dl class="fox-description-list">
+ *     <div class="fox-description-list__item">
+ *       <dt class="fox-description-list__term"><span class="fox-form-label">이름</span></dt>
+ *       <dd class="fox-description-list__description">홍길동</dd>
+ *     </div>
+ *   </dl>
+ *
+ * `<dl>`로 그린다 — 이름·값 쌍이라는 관계가 보조기술에 그대로 전달된다. 이름 쪽은 `<dt>` 안에
+ * `FoxFormLabel`을 `span`으로 넣어 서식을 디자인시스템과 맞춘다(`<dt>` 자체가 라벨 요소라
+ * `<label>`을 쓰면 가리킬 대상이 없다).
+ *
+ * 짝을 `<div>`로 감싸는 것은 `<dl>`이 허용하는 유일한 묶음 방법이다 — 줄마다 여백과 구분선을
+ * 주려면 감싸는 상자가 필요하다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxDescriptionList({
+  items,
+  hidden = false,
+  className,
+}: FoxDescriptionListProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <dl className={cx("fox-description-list", className)}>
+      {items
+        .filter((item) => !item.hidden)
+        .map((item) => (
+          <div key={item.key} className="fox-description-list__item">
+            <dt className="fox-description-list__term">
+              <FoxFormLabel as="span">{item.term}</FoxFormLabel>
+            </dt>
+            <dd className="fox-description-list__description">
+              {item.description}
+            </dd>
+          </div>
+        ))}
+    </dl>
+  );
+}
 
@fox/core/components/fox-description-list/index.ts (added)
+++ @fox/core/components/fox-description-list/index.ts
@@ -0,0 +1,1 @@
+export * from "./fox-description-list";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -56,3 +56,4 @@
 export * from "./fox-list-search";
 export * from "./fox-list-toolbar";
 export * from "./fox-list-container";
+export * from "./fox-description-list";
 
@fox/styles/_fox-description-list.scss (added)
+++ @fox/styles/_fox-description-list.scss
@@ -0,0 +1,49 @@
+// FoxDescriptionList — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809의 form-list
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <dl class="fox-description-list">
+//     <div class="fox-description-list__item">
+//       <dt class="fox-description-list__term"><span class="fox-form-label">이름</span></dt>
+//       <dd class="fox-description-list__description">홍길동</dd>
+//     </div>
+//   </dl>
+//
+// 줄마다 아래에 구분선이 있다 — 시안이 마지막 줄에도 그어 두었으므로 걷어내지 않는다.
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일(body/md)의 값이나 Figma가 변수로
+//     export하지 않는다.
+
+@use "abstracts" as fox;
+
+.fox-description-list {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  margin: 0;
+
+  &__item {
+    display: flex;
+    inline-size: 100%;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    padding-block: fox.padding(6);
+    border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+  }
+
+  &__term {
+    margin: 0;
+  }
+
+  &__description {
+    inline-size: 100%;
+    margin: 0;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -59,3 +59,4 @@
 @use "fox-list-toolbar";
 @use "fox-list-container";
 @use "fox-disclosure";
+@use "fox-description-list";
app/(protected)/(basic)/students/_components/student-detail-modal.tsx
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
@@ -1,11 +1,13 @@
 'use client';
 
-import { useActionState, useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Modal } from '@/components/ui/modal';
-import { RadioGroup } from '@/components/ui/radio-group';
+import { useActionState, useEffect, useRef } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxDescriptionList,
+  type FoxDescriptionItem,
+} from '@fox/core/components/fox-description-list';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
 import {
   formatGradeClassNumber,
   formatOptionalValue,
@@ -21,43 +23,49 @@
   onClose: () => void;
 }
 
-const ACTIVE_STATUS_OPTIONS = [
-  { value: 'true', label: '활성' },
-  { value: 'false', label: '비활성' },
-];
-
 const INITIAL_STATE: UpdateStudentActiveStatusState = { status: 'idle' };
 const DETAIL_FORM_ID = 'student-active-status-form';
 
 /**
  * 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 —
  * 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로
- * 바꾸면 된다(라디오·저장 버튼의 비활성이 함께 풀린다).
+ * 바꾸면 된다(토글·저장 버튼의 비활성이 함께 풀린다).
  */
 const IS_ACTIVE_STATUS_EDITABLE = false;
 const ACTIVE_STATUS_UNSUPPORTED_NOTE =
   '사용여부 변경은 백엔드 API 준비 후 제공됩니다.';
 
+/** 시안은 두 값을 " / "로 묶는다("홍라애 / 010-2222-1234"). 둘 다 없으면 `-`다. */
+function joinPair(first: string | null, second: string | null) {
+  const parts = [first, second].filter(
+    (value): value is string => value !== null && value !== ''
+  );
+  return parts.length > 0 ? parts.join(' / ') : '-';
+}
+
 /**
- * 학생 회원 조회 팝업 — 모든 항목이 readOnly다. 데이터는 목록 행에서 이미 갖고 있는 member를
- * props로 그대로 받으므로 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다).
- * 백엔드가 주지 않는 항목은 `-`로 표시한다.
+ * 학생 회원 조회 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) modal 5227:809
  *
- * 사용여부는 원래 이 팝업에서 유일하게 수정 가능한 항목이었지만, 백엔드 목록 응답에 값이 없고
- * 변경 API도 없어 지금은 라디오·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝).
- * 폼·Server Action 배선은 그대로 남겨 두었으므로 변경 API가 생기면 `disabled`만 걷어내면 된다.
- * 저장(Server Action) 성공 시 자동으로 닫히는 동작도 그대로다.
+ * 모든 항목이 조회 전용이다. 데이터는 목록 행이 이미 갖고 있는 member를 그대로 받으므로
+ * 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다). 백엔드가 주지 않는 값은 `-`다.
  *
- * Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design
- * 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다.
- * 네이티브 HTML의 `form` 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서
- * id만 일치하면 그 form을 제출한다 — footer가 body와 다른 DOM 위치에 렌더돼도 안전하다.
+ * 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼
+ * 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다.
+ *
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이지만, 백엔드 목록 응답에 값이 없고 변경
+ * API도 없어 토글·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). 폼·Server
+ * Action 배선은 남겨 두었으므로 API가 생기면 `IS_ACTIVE_STATUS_EDITABLE`만 되돌리면 된다.
+ *
+ * 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
+ * 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그
+ * 폼을 제출한다.
  */
 export function StudentDetailModal({ member, onClose }: StudentDetailModalProps) {
   const [state, formAction, isPending] = useActionState(
     updateStudentActiveStatus,
     INITIAL_STATE
   );
+  const formRef = useRef<HTMLFormElement>(null);
 
   useEffect(() => {
     if (state.status === 'success') {
@@ -65,98 +73,91 @@
     }
   }, [state, onClose]);
 
-  const gradeClassNumber = formatGradeClassNumber(member);
+  const items: FoxDescriptionItem[] = [
+    { key: 'name', term: '이름', description: member.name },
+    { key: 'loginId', term: 'ID', description: member.loginId },
+    {
+      key: 'phoneNumber',
+      term: '휴대전화 번호',
+      description: formatOptionalValue(member.phoneNumber),
+    },
+    {
+      key: 'email',
+      term: '이메일',
+      description: formatOptionalValue(member.email),
+    },
+    {
+      key: 'birthDate',
+      term: '생년월일',
+      description: formatOptionalValue(member.birthDate),
+    },
+    {
+      key: 'guardian',
+      term: '보호자 이름 / 연락처',
+      description: joinPair(member.guardianName, member.guardianPhoneNumber),
+    },
+    {
+      key: 'school',
+      term: '학교 / 학년·반·번호',
+      description: joinPair(
+        member.schoolName,
+        formatGradeClassNumber(member) === '-'
+          ? null
+          : formatGradeClassNumber(member)
+      ),
+    },
+    {
+      key: 'isActive',
+      term: '사용여부',
+      description: (
+        <FoxToggleSwitch
+          name="isActive"
+          value="true"
+          label={member.isActive ? '활성' : '비활성'}
+          defaultChecked={member.isActive ?? false}
+          disabled={!IS_ACTIVE_STATUS_EDITABLE}
+        />
+      ),
+    },
+  ];
 
   return (
-    <Modal
+    <FoxModal
+      open
+      size="sm"
       title="학생 회원 조회"
       onClose={onClose}
-      footer={
+      actions={
         <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={DETAIL_FORM_ID}
-            variant="primary"
-            disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
+          <FoxButton
+            type="default"
+            size="md"
+            label="취소"
+            onAction={onClose}
+          />
+          {/* FoxButton에는 폼을 가리키는 prop이 없다(네이티브 `form` 속성 미노출). 버튼이
+              foot 슬롯에 그려져 폼의 자손이 아니므로 ref로 직접 제출한다.
+              비활성 사유는 감싼 span의 title로 남긴다 — 비활성 버튼은 hover를 받지 못한다. */}
+          <span
             title={
-              IS_ACTIVE_STATUS_EDITABLE
-                ? undefined
-                : ACTIVE_STATUS_UNSUPPORTED_NOTE
+              IS_ACTIVE_STATUS_EDITABLE ? undefined : ACTIVE_STATUS_UNSUPPORTED_NOTE
             }
           >
-            {isPending ? '저장 중...' : '저장'}
-          </Button>
+            <FoxButton
+              type="primary"
+              size="md"
+              label={isPending ? '저장 중...' : '저장'}
+              disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
+              onAction={() => formRef.current?.requestSubmit()}
+            />
+          </span>
         </>
       }
     >
-      <form id={DETAIL_FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form ref={formRef} id={DETAIL_FORM_ID} action={formAction}>
         <input type="hidden" name="id" value={member.id} />
-
-        <p className="text-body-sm text-foreground-muted">
-          본 화면의 모든 항목은 조회 전용입니다.
-        </p>
-
-        <Field label="이름">
-          <Input value={member.name} readOnly />
-        </Field>
-        <Field label="ID">
-          <Input value={member.loginId} readOnly />
-        </Field>
-        <Field label="휴대전화 번호">
-          <Input value={formatOptionalValue(member.phoneNumber)} readOnly />
-        </Field>
-        <Field label="이메일">
-          <Input value={formatOptionalValue(member.email)} readOnly />
-        </Field>
-        <Field label="생년월일">
-          <Input value={formatOptionalValue(member.birthDate)} readOnly />
-        </Field>
-        <Field label="보호자 이름">
-          <Input value={formatOptionalValue(member.guardianName)} readOnly />
-        </Field>
-        <Field label="보호자 연락처">
-          <Input
-            value={formatOptionalValue(member.guardianPhoneNumber)}
-            readOnly
-          />
-        </Field>
-        <Field label="학교">
-          <Input value={formatOptionalValue(member.schoolName)} readOnly />
-        </Field>
-        <Field label="학년/반/번호">
-          <Input value={gradeClassNumber} readOnly />
-        </Field>
-
-        <Field label="사용여부">
-          <RadioGroup
-            name="isActive"
-            options={ACTIVE_STATUS_OPTIONS}
-            defaultValue={
-              member.isActive === null
-                ? undefined
-                : String(member.isActive)
-            }
-            disabled={!IS_ACTIVE_STATUS_EDITABLE}
-          />
-        </Field>
-
-        {!IS_ACTIVE_STATUS_EDITABLE && (
-          <p className="text-body-sm text-foreground-muted">
-            {ACTIVE_STATUS_UNSUPPORTED_NOTE}
-          </p>
-        )}
-
-        {state.status === 'error' && (
-          <p className="text-body-sm text-danger">{state.error}</p>
-        )}
-
-        <p className="text-body-sm text-foreground-muted">
-          회원 가입일 : {formatOptionalValue(member.joinedAt)}
-        </p>
+        <FoxDescriptionList items={items} />
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
Add a comment
List