fix: 학생 회원 목록 시안 불일치 5건 수정
시안(관리자페이지 5199:9014)을 다시 대조해 고쳤다. 1·2·5는 내가 잘못 읽은 것이고, 3·4는 @fox에 기능이 없어 추가했다(사용자 확정). 1. 이름 열의 bold를 뺐다. 시안 셀 서체가 `Pretendard:Regular`인데 `emphasis`를 넣었다. 2. 역할을 배지로 바꿨다. 시안 셀에 `type=pastel, color=primary, size=md` 배지가 들어 있는데 평문으로 그렸다. 3. 관리 열을 없애고 행을 눌러 상세를 열게 했다. FoxTableRow에 `onSelect`, FoxListContainer에 `onRowSelect`를 추가했다. ``에 `role="button"`은 씌우지 않는다 — 보조기술에서 행이 표의 일부가 아니게 되어 열 이름 읽기가 끊긴다. 초점만 받게 하고 Enter·Space를 직접 듣는다. 모달 상태는 student-list가 갖고, 쓰임이 사라진 student-row-actions는 지웠다. 4. 정렬 셀렉트 아이콘을 ArrowsDownUp으로 바꿨다. 시안은 정렬과 페이지 크기가 같은 select-txt를 쓰면서 아이콘만 다른데(노드 293:138376 vs 293:139748), FoxSelectText가 캐럿을 하드코딩해 교체할 수 없었다. `icon` prop을 넣고 기본값은 캐럿 그대로 뒀다. 5. 엑셀 버튼을 secondary + DownloadSimple 아이콘으로 바꿨다. 시안 글자색이 `button/secondary-font`(#0b50d0)이고 20px 아이콘이 붙어 있다. 검증 — 이름 굵기 400, 역할 `fox-badge--primary --pastel --md`, 열 12개(마지막 가입일)에 행이 clickable·tabIndex 0이고 클릭 시 모달이 열린다, 정렬 아이콘이 페이지 크기 아이콘과 다르다, 엑셀 버튼이 `fox-button--secondary`에 아이콘을 갖는다. Co-Authored-By: Claude Opus 5
@83cd9bbfbb824948aba3c0ad7f64856c1eae1328
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
... | ... | @@ -51,6 +51,8 @@ |
| 51 | 51 |
caption?: string; |
| 52 | 52 |
/** 행이 없을 때 표 안에 보여줄 말. */ |
| 53 | 53 |
emptyMessage?: ReactNode; |
| 54 |
+ /** 주면 행 전체가 눌린다 — 관리 열 없이 행을 눌러 상세를 여는 시안에 쓴다. */ |
|
| 55 |
+ onRowSelect?: (row: T, index: number) => void; |
|
| 54 | 56 |
|
| 55 | 57 |
// ── 도구 줄 ─────────────────────────────────────────────────────────────── |
| 56 | 58 |
/** 주면 "총 N건"이 나온다. 없으면 그 자리를 비운다. */ |
... | ... | @@ -109,6 +111,7 @@ |
| 109 | 111 |
rowKey, |
| 110 | 112 |
caption, |
| 111 | 113 |
emptyMessage = "조회된 내용이 없습니다.", |
| 114 |
+ onRowSelect, |
|
| 112 | 115 |
totalCount, |
| 113 | 116 |
toolbarLeading, |
| 114 | 117 |
sorts, |
... | ... | @@ -207,7 +210,12 @@ |
| 207 | 210 |
</FoxTableRow> |
| 208 | 211 |
) : ( |
| 209 | 212 |
rows.map((row, index) => ( |
| 210 |
- <FoxTableRow key={rowKey(row, index)}>
|
|
| 213 |
+ <FoxTableRow |
|
| 214 |
+ key={rowKey(row, index)}
|
|
| 215 |
+ onSelect={
|
|
| 216 |
+ onRowSelect ? () => onRowSelect(row, index) : undefined |
|
| 217 |
+ } |
|
| 218 |
+ > |
|
| 211 | 219 |
{columns.map((column) => (
|
| 212 | 220 |
<FoxTableCell |
| 213 | 221 |
key={column.key}
|
--- @fox/core/components/fox-select-text/fox-select-text.tsx
+++ @fox/core/components/fox-select-text/fox-select-text.tsx
... | ... | @@ -36,6 +36,11 @@ |
| 36 | 36 |
* ("2026년 8월")이라 무엇을 고르는 자리인지는 소리로 전달되지 않는다 — `"조회 기간"`처럼
|
| 37 | 37 |
* 적어 준다. |
| 38 | 38 |
*/ |
| 39 |
+ /** |
|
| 40 |
+ * 오른쪽 표시를 바꾼다. 기본은 펼침을 뜻하는 캐럿이고, 시안이 다른 뜻을 담을 때만 준다 |
|
| 41 |
+ * (예: 정렬 셀렉트의 위아래 화살표). 장식이라 `aria-hidden`인 자리다. |
|
| 42 |
+ */ |
|
| 43 |
+ icon?: ReactNode; |
|
| 39 | 44 |
ariaLabel?: string; |
| 40 | 45 |
/** 이름 역할을 하는 요소의 id. `ariaLabel`보다 우선한다. */ |
| 41 | 46 |
labelledBy?: string; |
... | ... | @@ -76,6 +81,7 @@ |
| 76 | 81 |
onValueChange, |
| 77 | 82 |
placeholder = "선택해주세요", |
| 78 | 83 |
disabled = false, |
| 84 |
+ icon, |
|
| 79 | 85 |
ariaLabel, |
| 80 | 86 |
labelledBy, |
| 81 | 87 |
id, |
... | ... | @@ -219,7 +225,7 @@ |
| 219 | 225 |
> |
| 220 | 226 |
<span className="fox-select-text__value">{selectedOption?.label ?? placeholder}</span>
|
| 221 | 227 |
<span className="fox-select-text__icon" aria-hidden="true"> |
| 222 |
- <ChevronMark /> |
|
| 228 |
+ {icon ?? <ChevronMark />}
|
|
| 223 | 229 |
</span> |
| 224 | 230 |
</button> |
| 225 | 231 |
|
--- @fox/core/components/fox-table-row/fox-table-row.tsx
+++ @fox/core/components/fox-table-row/fox-table-row.tsx
... | ... | @@ -16,6 +16,14 @@ |
| 16 | 16 |
head?: boolean; |
| 17 | 17 |
/** 참이면 고른 줄이 된다 — 바탕과 글자가 파랗게 바뀐다(시안 list 표). */ |
| 18 | 18 |
selected?: boolean; |
| 19 |
+ /** |
|
| 20 |
+ * 주면 행 전체가 눌린다 — 시안이 관리 열 없이 행을 눌러 상세를 여는 경우다. |
|
| 21 |
+ * |
|
| 22 |
+ * `<tr>`에 `role="button"`을 씌우지 않는다 — 그러면 보조기술에서 행이 표의 일부가 아니게 |
|
| 23 |
+ * 되어 열 이름 읽기가 끊긴다. 대신 초점을 받게만 하고 Enter·Space를 직접 듣는다. 행의 |
|
| 24 |
+ * 이름은 셀 내용이 대신하므로 눌러야 할 행이 무엇인지 알 수 있다. |
|
| 25 |
+ */ |
|
| 26 |
+ onSelect?: () => void; |
|
| 19 | 27 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
| 20 | 28 |
hidden?: boolean; |
| 21 | 29 |
/** 배치 조정용. */ |
... | ... | @@ -52,6 +60,7 @@ |
| 52 | 60 |
children, |
| 53 | 61 |
head = false, |
| 54 | 62 |
selected = false, |
| 63 |
+ onSelect, |
|
| 55 | 64 |
hidden = false, |
| 56 | 65 |
className, |
| 57 | 66 |
}: FoxTableRowProps) {
|
... | ... | @@ -59,15 +68,31 @@ |
| 59 | 68 |
return null; |
| 60 | 69 |
} |
| 61 | 70 |
|
| 71 |
+ const clickable = Boolean(onSelect) && !head; |
|
| 72 |
+ |
|
| 62 | 73 |
return ( |
| 63 | 74 |
<tr |
| 64 | 75 |
className={cx(
|
| 65 | 76 |
"fox-table-row", |
| 66 | 77 |
head && "fox-table-row--head", |
| 67 | 78 |
selected && "fox-table-row--selected", |
| 79 |
+ clickable && "fox-table-row--clickable", |
|
| 68 | 80 |
className |
| 69 | 81 |
)} |
| 70 | 82 |
aria-selected={selected || undefined}
|
| 83 |
+ tabIndex={clickable ? 0 : undefined}
|
|
| 84 |
+ onClick={clickable ? onSelect : undefined}
|
|
| 85 |
+ onKeyDown={
|
|
| 86 |
+ clickable |
|
| 87 |
+ ? (event) => {
|
|
| 88 |
+ // Space는 기본 동작이 스크롤이라 막는다. |
|
| 89 |
+ if (event.key === "Enter" || event.key === " ") {
|
|
| 90 |
+ event.preventDefault(); |
|
| 91 |
+ onSelect?.(); |
|
| 92 |
+ } |
|
| 93 |
+ } |
|
| 94 |
+ : undefined |
|
| 95 |
+ } |
|
| 71 | 96 |
> |
| 72 | 97 |
{head ? markCells(children) : children}
|
| 73 | 98 |
</tr> |
--- @fox/styles/_fox-table-row.scss
+++ @fox/styles/_fox-table-row.scss
... | ... | @@ -18,4 +18,18 @@ |
| 18 | 18 |
background: fox.color(action-primary-hover); |
| 19 | 19 |
color: fox.color(font-theme-primary); |
| 20 | 20 |
} |
| 21 |
+ |
|
| 22 |
+ // 행 전체가 눌리는 경우 — 누를 수 있다는 것이 보이고, 키보드 초점도 드러나야 한다. |
|
| 23 |
+ &--clickable {
|
|
| 24 |
+ cursor: pointer; |
|
| 25 |
+ |
|
| 26 |
+ &:hover {
|
|
| 27 |
+ background: fox.color(action-default-hover); |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ &:focus-visible {
|
|
| 31 |
+ outline: fox.border(2) solid fox.color(border-theme-primary); |
|
| 32 |
+ outline-offset: calc(-1 * #{fox.border(2)});
|
|
| 33 |
+ } |
|
| 34 |
+ } |
|
| 21 | 35 |
} |
--- app/(protected)/(basic)/students/_components/student-list.tsx
+++ app/(protected)/(basic)/students/_components/student-list.tsx
... | ... | @@ -1,12 +1,18 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useRouter } from 'next/navigation';
|
| 4 |
+import { useState } from 'react';
|
|
| 5 |
+import { FoxBadge } from '@fox/core/components/fox-badge';
|
|
| 4 | 6 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 7 |
import {
|
| 6 | 8 |
FoxListContainer, |
| 7 | 9 |
type FoxListColumn, |
| 8 | 10 |
} from '@fox/core/components/fox-list-container'; |
| 9 | 11 |
import { FoxSelectText } from '@fox/core/components/fox-select-text';
|
| 12 |
+import {
|
|
| 13 |
+ FoxArrowsDownUpIcon, |
|
| 14 |
+ FoxDownloadSimpleIcon, |
|
| 15 |
+} from '@fox/core/icons'; |
|
| 10 | 16 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 11 | 17 |
import {
|
| 12 | 18 |
STUDENT_MEMBERS_EXCEL_PATH, |
... | ... | @@ -19,7 +25,7 @@ |
| 19 | 25 |
type StudentMemberSearchField, |
| 20 | 26 |
type StudentMemberSortOption, |
| 21 | 27 |
} from '@/lib/domain/student-member-query'; |
| 22 |
-import { StudentRowActions } from './student-row-actions';
|
|
| 28 |
+import { StudentDetailModal } from './student-detail-modal';
|
|
| 23 | 29 |
|
| 24 | 30 |
interface StudentListProps {
|
| 25 | 31 |
items: StudentMember[]; |
... | ... | @@ -66,6 +72,7 @@ |
| 66 | 72 |
totalCount, |
| 67 | 73 |
}: StudentListProps) {
|
| 68 | 74 |
const router = useRouter(); |
| 75 |
+ const [detailMember, setDetailMember] = useState<StudentMember | null>(null); |
|
| 69 | 76 |
|
| 70 | 77 |
/** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */ |
| 71 | 78 |
function go(patch: Partial<StudentMemberQuery>) {
|
... | ... | @@ -81,7 +88,7 @@ |
| 81 | 88 |
render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, |
| 82 | 89 |
}, |
| 83 | 90 |
{ key: 'memberCode', header: '회원코드', width: 120 },
|
| 84 |
- { key: 'name', header: '이름', width: 120, emphasis: true },
|
|
| 91 |
+ { key: 'name', header: '이름', width: 120 },
|
|
| 85 | 92 |
{ key: 'loginId', header: '아이디', width: 160 },
|
| 86 | 93 |
{
|
| 87 | 94 |
key: 'phoneNumber', |
... | ... | @@ -90,7 +97,16 @@ |
| 90 | 97 |
render: (row) => text(row.phoneNumber), |
| 91 | 98 |
}, |
| 92 | 99 |
{ key: 'email', header: '이메일', width: 200, render: (row) => text(row.email) },
|
| 93 |
- { key: 'role', header: '역할', width: 120 },
|
|
| 100 |
+ {
|
|
| 101 |
+ key: 'role', |
|
| 102 |
+ header: '역할', |
|
| 103 |
+ width: 120, |
|
| 104 |
+ render: (row) => ( |
|
| 105 |
+ <FoxBadge type="pastel" color="primary" size="md"> |
|
| 106 |
+ {row.role}
|
|
| 107 |
+ </FoxBadge> |
|
| 108 |
+ ), |
|
| 109 |
+ }, |
|
| 94 | 110 |
{
|
| 95 | 111 |
key: 'schoolName', |
| 96 | 112 |
header: '학교', |
... | ... | @@ -116,16 +132,11 @@ |
| 116 | 132 |
width: 160, |
| 117 | 133 |
render: (row) => text(row.joinedAt), |
| 118 | 134 |
}, |
| 119 |
- {
|
|
| 120 |
- key: 'actions', |
|
| 121 |
- header: '관리', |
|
| 122 |
- width: 160, |
|
| 123 |
- render: (row) => <StudentRowActions member={row} />,
|
|
| 124 |
- }, |
|
| 125 | 135 |
]; |
| 126 | 136 |
|
| 127 | 137 |
return ( |
| 128 |
- <FoxListContainer<StudentMember> |
|
| 138 |
+ <> |
|
| 139 |
+ <FoxListContainer<StudentMember> |
|
| 129 | 140 |
title="학생 회원 목록" |
| 130 | 141 |
breadcrumb={[
|
| 131 | 142 |
{ label: '홈', href: '/' },
|
... | ... | @@ -143,6 +154,7 @@ |
| 143 | 154 |
<FoxSelectText |
| 144 | 155 |
size="sm" |
| 145 | 156 |
ariaLabel="정렬" |
| 157 |
+ icon={<FoxArrowsDownUpIcon />}
|
|
| 146 | 158 |
options={STUDENT_MEMBER_SORT_OPTIONS.map((option) => ({
|
| 147 | 159 |
value: option.value, |
| 148 | 160 |
label: option.label, |
... | ... | @@ -176,13 +188,15 @@ |
| 176 | 188 |
// 검색·페이징 값을 싣지 않는 것이 사양이다(파일은 항상 전체 데이터). |
| 177 | 189 |
<form action={STUDENT_MEMBERS_EXCEL_PATH} method="get">
|
| 178 | 190 |
<FoxButton |
| 179 |
- type="primary" |
|
| 191 |
+ type="secondary" |
|
| 180 | 192 |
size="md" |
| 181 | 193 |
htmlType="submit" |
| 194 |
+ leadingIcon={<FoxDownloadSimpleIcon />}
|
|
| 182 | 195 |
label="엑셀 다운로드" |
| 183 | 196 |
/> |
| 184 | 197 |
</form> |
| 185 | 198 |
} |
| 199 |
+ onRowSelect={(row) => setDetailMember(row)}
|
|
| 186 | 200 |
page={currentPage}
|
| 187 | 201 |
totalPages={totalPages}
|
| 188 | 202 |
buildHref={(patch) =>
|
... | ... | @@ -197,6 +211,13 @@ |
| 197 | 211 |
}); |
| 198 | 212 |
} |
| 199 | 213 |
}} |
| 200 |
- /> |
|
| 214 |
+ /> |
|
| 215 |
+ {detailMember && (
|
|
| 216 |
+ <StudentDetailModal |
|
| 217 |
+ member={detailMember}
|
|
| 218 |
+ onClose={() => setDetailMember(null)}
|
|
| 219 |
+ /> |
|
| 220 |
+ )} |
|
| 221 |
+ </> |
|
| 201 | 222 |
); |
| 202 | 223 |
} |
--- app/(protected)/(basic)/students/_components/student-row-actions.tsx
... | ... | @@ -1,40 +0,0 @@ |
| 1 | -'use client'; | |
| 2 | - | |
| 3 | -import { useState } from 'react'; | |
| 4 | -import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | -import type { StudentMember } from '@/lib/domain/student-member'; | |
| 6 | -import { StudentDetailModal } from './student-detail-modal'; | |
| 7 | - | |
| 8 | -interface StudentRowActionsProps { | |
| 9 | - member: StudentMember; | |
| 10 | -} | |
| 11 | - | |
| 12 | -/** | |
| 13 | - * 목록 행의 "관리" 셀 — 조회 팝업의 열림 상태만 소유하는 최말단 상호작용 경계다(테이블 전체를 | |
| 14 | - * 클라이언트로 내리지 않기 위해 이 셀만 분리했다 — §2.4 "'use client'는 최말단에만"). 팝업에 | |
| 15 | - * 필요한 데이터는 이미 이 행이 갖고 있는 member를 그대로 전달한다(재조회 없음). | |
| 16 | - * | |
| 17 | - * 시안(관리자페이지 QsFVFFUKGH68xAPVkF3SJ9 5199:9014)의 관리 열은 "학생 회원 조회" 텍스트 | |
| 18 | - * 버튼이다 — 앞서 기획 시안을 근거로 돋보기 아이콘을 넣었으나, 이 Figma 시안이 더 최신이라 | |
| 19 | - * 그쪽으로 맞췄다(사용자 확정). 누르면 상세 팝업이 열리는 동작은 그대로다. | |
| 20 | - */ | |
| 21 | -export function StudentRowActions({ member }: StudentRowActionsProps) { | |
| 22 | - const [isDetailOpen, setIsDetailOpen] = useState(false); | |
| 23 | - | |
| 24 | - return ( | |
| 25 | - <> | |
| 26 | - <FoxButton | |
| 27 | - type="default" | |
| 28 | - size="sm" | |
| 29 | - label="학생 회원 조회" | |
| 30 | - onAction={() => setIsDetailOpen(true)} | |
| 31 | - /> | |
| 32 | - {isDetailOpen && ( | |
| 33 | - <StudentDetailModal | |
| 34 | - member={member} | |
| 35 | - onClose={() => setIsDetailOpen(false)} | |
| 36 | - /> | |
| 37 | - )} | |
| 38 | - </> | |
| 39 | - ); | |
| 40 | -} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?