File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-18
08-18
08-14
08-18
08-18
08-18
08-18
08-18
File name
Commit message
Commit date
"use client";
import type { ReactNode } from "react";
import { cx } from "../../utils";
import { FoxPageHeader } from "../fox-page-header";
import type { FoxBreadcrumbItem } from "../fox-breadcrumb";
import { FoxTable } from "../fox-table";
import { FoxTableRow } from "../fox-table-row";
import { FoxTableCell } from "../fox-table-cell";
import type { FoxTableCellAlign } from "../fox-table-cell";
import { FoxPagination } from "../fox-pagination";
import { FoxListCount, FoxListToolbar } from "../fox-list-toolbar";
import { FoxListSearch } from "../fox-list-search";
import type { FoxSelectItem } from "../fox-select";
/** 열 하나의 정의. `render`가 있으면 그 결과가 셀 내용이 된다. */
export interface FoxListColumn<T> {
/** 열 식별자. `render`가 없으면 `row[key]`를 그대로 그린다. */
key: string;
header: ReactNode;
/** 셀을 직접 그린다 — 배지·버튼 무엇이든 넣을 수 있다. */
render?: (row: T, index: number) => ReactNode;
width?: string | number;
align?: FoxTableCellAlign;
/** 참이면 그 열의 본문 글자가 진해진다(시안의 강조 열). */
emphasis?: boolean;
}
/**
* 목록 상태의 변경분. 컨테이너는 **바뀐 것만** 담아 넘긴다 — 호출부가 기존 값과 합쳐
* 쓰라는 뜻이다. 화면마다 필요한 정렬·필터 키가 달라 열려 있는 모양이다.
*/
export interface FoxListQueryPatch {
page?: number;
keyword?: string;
searchField?: string;
[key: string]: unknown;
}
export interface FoxListContainerProps<T> {
// ── 머리말 ────────────────────────────────────────────────────────────────
title?: ReactNode;
breadcrumb?: FoxBreadcrumbItem[];
headerActions?: ReactNode;
// ── 표 ────────────────────────────────────────────────────────────────────
columns: FoxListColumn<T>[];
rows: T[];
/** 행의 안정적인 키. 인덱스를 쓰면 정렬·페이지 이동에서 어긋난다. */
rowKey: (row: T, index: number) => string;
caption?: string;
/** 행이 없을 때 표 안에 보여줄 말. */
emptyMessage?: ReactNode;
// ── 도구 줄 ───────────────────────────────────────────────────────────────
/** 주면 "총 N건"이 나온다. 없으면 그 자리를 비운다. */
totalCount?: number;
toolbarLeading?: ReactNode;
sorts?: ReactNode;
toolbarActions?: ReactNode;
filter?: ReactNode;
/** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */
searchFields?: FoxSelectItem[];
searchField?: string;
keyword?: string;
searchPlaceholder?: string;
/** 검색 상자를 아예 숨긴다. */
searchHidden?: boolean;
// ── 페이지 ────────────────────────────────────────────────────────────────
page?: number;
totalPages?: number;
// ── 이벤트 ────────────────────────────────────────────────────────────────
/**
* 주면 페이지 이동이 **링크**가 된다 — 목록이 Server Component로 남고 새로고침·뒤로가기·
* URL 공유가 그대로 동작한다. 이 저장소의 목록 화면들이 쓰는 방식이다.
*/
buildHref?: (patch: FoxListQueryPatch) => string;
/** `buildHref`가 없을 때(모달 안 목록 등) 쓰는 콜백. 둘 다 주면 링크가 이긴다. */
onQueryChange?: (patch: FoxListQueryPatch) => void;
hidden?: boolean;
className?: string;
}
/**
* @fox 목록 화면 뼈대 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608)
*
* 머리말 · 도구 줄 · 표 · 페이지네이션을 한 벌로 묶는다. **데이터를 가져오지도, 상태를 갖지도
* 않는다** — 받은 것을 그리고 바뀐 것을 알릴 뿐이라 어떤 목록에도 그대로 쓸 수 있다.
*
* 표는 `columns` 정의로 그린다. `render`가 ReactNode를 돌려주므로 셀 안에 배지든 버튼 묶음이든
* 들어간다(시안이 그렇다) — 값의 형식을 컨테이너가 알 필요가 없다.
*
* 이벤트는 두 갈래다. `buildHref`를 주면 링크로 그려 URL이 상태가 되고, `onQueryChange`만
* 주면 콜백으로 알린다. FoxPagination이 이미 같은 방식이라 그 관례를 따랐다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxListContainer<T>({
title,
breadcrumb,
headerActions,
columns,
rows,
rowKey,
caption,
emptyMessage = "조회된 내용이 없습니다.",
totalCount,
toolbarLeading,
sorts,
toolbarActions,
filter,
searchFields,
searchField,
keyword,
searchPlaceholder,
searchHidden = false,
page = 1,
totalPages = 1,
buildHref,
onQueryChange,
hidden = false,
className,
}: FoxListContainerProps<T>) {
if (hidden) {
return null;
}
const hasToolbar =
totalCount !== undefined ||
toolbarLeading !== undefined ||
sorts !== undefined ||
toolbarActions !== undefined ||
filter !== undefined ||
!searchHidden;
return (
<div className={cx("fox-list-container", className)}>
{(title !== undefined || breadcrumb !== undefined) && (
<FoxPageHeader
title={title}
breadcrumb={breadcrumb}
actions={headerActions}
/>
)}
<section className="fox-list-container__section">
{hasToolbar && (
<FoxListToolbar
count={
totalCount !== undefined ? (
<FoxListCount total={totalCount} />
) : undefined
}
leading={toolbarLeading}
sorts={sorts}
search={
searchHidden ? undefined : (
<FoxListSearch
fields={searchFields}
field={searchField}
keyword={keyword}
placeholder={searchPlaceholder}
onSearch={(nextKeyword, nextField) =>
onQueryChange?.({
keyword: nextKeyword,
searchField: nextField,
// 검색 결과는 항상 첫 장부터 본다 — 남아 있던 페이지 번호를 지운다.
page: 1,
})
}
/>
)
}
actions={toolbarActions}
filter={filter}
/>
)}
{/* 열이 많으면 표가 화면보다 넓어진다. 눌러 담지 않고 이 래퍼가 가로로 스크롤한다. */}
<div className="fox-list-container__table">
{/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고
제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */}
<FoxTable type="list" caption={caption}>
<FoxTableRow head>
{columns.map((column) => (
<FoxTableCell
key={column.key}
head
width={column.width}
align={column.align}
>
{column.header}
</FoxTableCell>
))}
</FoxTableRow>
{rows.length === 0 ? (
<FoxTableRow>
<FoxTableCell colSpan={columns.length} align="center">
{emptyMessage}
</FoxTableCell>
</FoxTableRow>
) : (
rows.map((row, index) => (
<FoxTableRow key={rowKey(row, index)}>
{columns.map((column) => (
<FoxTableCell
key={column.key}
align={column.align}
emphasis={column.emphasis}
label={
typeof column.header === "string"
? column.header
: undefined
}
>
{column.render
? column.render(row, index)
: String(
(row as Record<string, unknown>)[column.key] ?? ""
)}
</FoxTableCell>
))}
</FoxTableRow>
))
)}
</FoxTable>
</div>
{totalPages > 1 && (
<FoxPagination
currentPage={page}
totalPages={totalPages}
buildHref={buildHref ? (next) => buildHref({ page: next }) : undefined}
onPageChange={
buildHref ? undefined : (next) => onQueryChange?.({ page: next })
}
/>
)}
</section>
</div>
);
}