Initial import
This commit is contained in:
42
src/components/dataListTable/DataListTable.css
Executable file
42
src/components/dataListTable/DataListTable.css
Executable file
@@ -0,0 +1,42 @@
|
||||
.data-list-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-list-table__toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-list-table__search {
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.data-list-table__filter {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.data-list-table__mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-list-table__card {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.data-list-table__desktop--with-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-list-table__mobile {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-list-table__search,
|
||||
.data-list-table__filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
204
src/components/dataListTable/DataListTable.tsx
Executable file
204
src/components/dataListTable/DataListTable.tsx
Executable file
@@ -0,0 +1,204 @@
|
||||
import { Input, Pagination, Select, Space, Table, Typography } from 'antd';
|
||||
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { EmptyState, ErrorState, LoadingState } from '../stateKit';
|
||||
import './DataListTable.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export type DataListFilterOption<T> = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
predicate: (item: T) => boolean;
|
||||
};
|
||||
|
||||
export type DataListTableProps<T extends object> = {
|
||||
data: T[];
|
||||
columns: ColumnsType<T>;
|
||||
getRowKey: (item: T) => React.Key;
|
||||
searchPlaceholder?: string;
|
||||
searchFields?: ReadonlyArray<keyof T | ((item: T) => string)>;
|
||||
searchPredicate?: (item: T, keyword: string) => boolean;
|
||||
filters?: ReadonlyArray<DataListFilterOption<T>>;
|
||||
pageSize?: number;
|
||||
loading?: boolean;
|
||||
error?: ReactNode;
|
||||
emptyTitle?: ReactNode;
|
||||
emptyDescription?: ReactNode;
|
||||
retryLabel?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
mobileCardRender?: (item: T) => ReactNode;
|
||||
tableProps?: Omit<
|
||||
TableProps<T>,
|
||||
'columns' | 'dataSource' | 'rowKey' | 'pagination' | 'loading'
|
||||
>;
|
||||
};
|
||||
|
||||
function normalizeSearchText(text: string) {
|
||||
return text.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveFieldText<T extends object>(
|
||||
item: T,
|
||||
field: keyof T | ((item: T) => string),
|
||||
) {
|
||||
if (typeof field === 'function') {
|
||||
return field(item);
|
||||
}
|
||||
|
||||
const value = item[field];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function filterBySearch<T extends object>(
|
||||
items: T[],
|
||||
keyword: string,
|
||||
searchFields?: DataListTableProps<T>['searchFields'],
|
||||
searchPredicate?: DataListTableProps<T>['searchPredicate'],
|
||||
) {
|
||||
const normalizedKeyword = normalizeSearchText(keyword);
|
||||
|
||||
if (!normalizedKeyword) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (searchPredicate) {
|
||||
return items.filter((item) => searchPredicate(item, normalizedKeyword));
|
||||
}
|
||||
|
||||
if (!searchFields?.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) =>
|
||||
searchFields.some((field) =>
|
||||
normalizeSearchText(resolveFieldText(item, field)).includes(normalizedKeyword),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function DataListTable<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
getRowKey,
|
||||
searchPlaceholder = '검색어를 입력하세요',
|
||||
searchFields,
|
||||
searchPredicate,
|
||||
filters = [],
|
||||
pageSize = 10,
|
||||
loading,
|
||||
error,
|
||||
emptyTitle = '표시할 항목이 없습니다.',
|
||||
emptyDescription,
|
||||
retryLabel,
|
||||
onRetry,
|
||||
mobileCardRender,
|
||||
tableProps,
|
||||
}: DataListTableProps<T>) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<string | undefined>();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const searchedItems = filterBySearch(data, keyword, searchFields, searchPredicate);
|
||||
const filter = filters.find((item) => item.value === activeFilter);
|
||||
|
||||
return filter ? searchedItems.filter(filter.predicate) : searchedItems;
|
||||
}, [activeFilter, data, filters, keyword, searchFields, searchPredicate]);
|
||||
|
||||
const pagedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(startIndex, startIndex + pageSize);
|
||||
}, [currentPage, filteredData, pageSize]);
|
||||
const pageCount = Math.max(1, Math.ceil(filteredData.length / pageSize));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage((previousPage) => Math.min(previousPage, pageCount));
|
||||
}, [pageCount]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorState message={error} retryLabel={retryLabel} onRetry={onRetry} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState variant="list" rows={Math.min(pageSize, 5)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="data-list-table">
|
||||
<Space wrap className="data-list-table__toolbar">
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={keyword}
|
||||
placeholder={searchPlaceholder}
|
||||
className="data-list-table__search"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
{filters.length ? (
|
||||
<Select
|
||||
allowClear
|
||||
value={activeFilter}
|
||||
placeholder="필터"
|
||||
className="data-list-table__filter"
|
||||
options={filters.map((filter) => ({
|
||||
value: filter.value,
|
||||
label: filter.label,
|
||||
}))}
|
||||
onChange={(nextFilter) => {
|
||||
setActiveFilter(nextFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Text type="secondary">{filteredData.length}건</Text>
|
||||
</Space>
|
||||
|
||||
{filteredData.length ? (
|
||||
<>
|
||||
<Table<T>
|
||||
{...tableProps}
|
||||
className={[
|
||||
'data-list-table__desktop',
|
||||
mobileCardRender ? 'data-list-table__desktop--with-mobile' : '',
|
||||
tableProps?.className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
columns={columns}
|
||||
dataSource={pagedData}
|
||||
rowKey={getRowKey}
|
||||
pagination={false}
|
||||
/>
|
||||
{mobileCardRender ? (
|
||||
<div className="data-list-table__mobile">
|
||||
{pagedData.map((item) => (
|
||||
<article key={getRowKey(item)} className="data-list-table__card">
|
||||
{mobileCardRender(item)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredData.length > pageSize ? (
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredData.length}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} />
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
1
src/components/dataListTable/index.ts
Executable file
1
src/components/dataListTable/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export * from './DataListTable';
|
||||
97
src/components/dataListTable/samples/BaseSample.tsx
Executable file
97
src/components/dataListTable/samples/BaseSample.tsx
Executable file
@@ -0,0 +1,97 @@
|
||||
import { Space, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import { StatusBadgeUI } from '../../status-badge';
|
||||
import { DataListTable } from '../DataListTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type BoardItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
owner: string;
|
||||
status: 'ready' | 'working' | 'done';
|
||||
progress: number;
|
||||
};
|
||||
|
||||
const data: BoardItem[] = [
|
||||
{ id: 'board-1', title: '컴포넌트 구성 추가 제안', owner: 'Board', status: 'working', progress: 45 },
|
||||
{ id: 'plan-1', title: 'Plan 목록 정리', owner: 'Plan', status: 'ready', progress: 15 },
|
||||
{ id: 'docs-1', title: 'Docs preview 연결', owner: 'Docs', status: 'done', progress: 100 },
|
||||
];
|
||||
|
||||
const columns: ColumnsType<BoardItem> = [
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
sorter: (left, right) => left.title.localeCompare(right.title),
|
||||
},
|
||||
{
|
||||
title: '담당',
|
||||
dataIndex: 'owner',
|
||||
filters: Array.from(new Set(data.map((item) => item.owner))).map((owner) => ({
|
||||
text: owner,
|
||||
value: owner,
|
||||
})),
|
||||
onFilter: (value, record) => record.owner === value,
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
render: (status: BoardItem['status']) => (
|
||||
<StatusBadgeUI
|
||||
label={status}
|
||||
tone={status === 'done' ? 'success' : status === 'working' ? 'processing' : 'default'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '진행률',
|
||||
dataIndex: 'progress',
|
||||
sorter: (left, right) => left.progress - right.progress,
|
||||
render: (progress: number) => `${progress}%`,
|
||||
},
|
||||
];
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'data-list-table-base',
|
||||
componentId: 'data-list-table',
|
||||
title: 'Data List Table',
|
||||
description: '검색, 필터, 정렬, 페이지네이션과 모바일 카드 전환을 함께 제공하는 목록형 화면 컴포넌트입니다.',
|
||||
category: 'Common',
|
||||
kind: 'base',
|
||||
variantLabel: 'Base',
|
||||
order: 40,
|
||||
features: ['docs', 'component-sample'],
|
||||
};
|
||||
|
||||
export function Sample() {
|
||||
return (
|
||||
<DataListTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowKey={(item) => item.id}
|
||||
searchFields={['title', 'owner', 'status']}
|
||||
searchPlaceholder="제목, 담당, 상태 검색"
|
||||
pageSize={2}
|
||||
filters={[
|
||||
{
|
||||
value: 'active',
|
||||
label: '진행 중',
|
||||
predicate: (item) => item.status !== 'done',
|
||||
},
|
||||
]}
|
||||
mobileCardRender={(item) => (
|
||||
<Space direction="vertical" size={6}>
|
||||
<Text strong>{item.title}</Text>
|
||||
<Text type="secondary">{item.owner}</Text>
|
||||
<StatusBadgeUI
|
||||
label={item.status}
|
||||
tone={item.status === 'done' ? 'success' : item.status === 'working' ? 'processing' : 'default'}
|
||||
/>
|
||||
<Text>{item.progress}%</Text>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user