Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View 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%;
}
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export * from './DataListTable';

View 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>
)}
/>
);
}