@umituz/web-design-system 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/components.smoke.test.tsx +227 -0
- package/src/presentation/atoms/Progress.tsx +11 -2
- package/src/presentation/organisms/DataTable.tsx +345 -127
- package/src/presentation/organisms/EmptyState.tsx +56 -26
- package/src/presentation/organisms/Tabs.tsx +61 -9
- package/src/presentation/organisms/index.ts +10 -2
- package/src/styles/compile.css +37 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DataTable Component (Organism)
|
|
3
|
-
* @description Enhanced table component for displaying data with sorting and pagination
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { useState, useMemo } from 'react';
|
|
7
|
-
import {
|
|
2
|
+
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
ArrowDown,
|
|
5
|
+
ArrowUp,
|
|
6
|
+
ChevronLeft,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
ChevronsLeft,
|
|
9
|
+
ChevronsRight,
|
|
10
|
+
ChevronsUpDown,
|
|
11
|
+
} from 'lucide-react';
|
|
8
12
|
import { cn } from '../../infrastructure/utils';
|
|
9
13
|
import {
|
|
10
14
|
Table,
|
|
@@ -17,6 +21,7 @@ import {
|
|
|
17
21
|
TableCaption,
|
|
18
22
|
} from './Table';
|
|
19
23
|
import { Button } from '../atoms/Button';
|
|
24
|
+
import { Skeleton } from '../atoms/Skeleton';
|
|
20
25
|
import {
|
|
21
26
|
computeTotalPages,
|
|
22
27
|
clampPageNumber,
|
|
@@ -25,21 +30,51 @@ import {
|
|
|
25
30
|
import type { BaseProps } from '../../domain/types';
|
|
26
31
|
import type { MediumSizes } from '../../infrastructure/constants';
|
|
27
32
|
|
|
33
|
+
export type ColumnAlign = 'left' | 'right' | 'center';
|
|
34
|
+
export type ColumnHideBelow = 'sm' | 'md' | 'lg' | 'xl';
|
|
35
|
+
export type SortDirection = 'asc' | 'desc';
|
|
36
|
+
|
|
37
|
+
export interface SortState {
|
|
38
|
+
id: string;
|
|
39
|
+
dir: SortDirection;
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
export interface Column<T> {
|
|
29
43
|
id: string;
|
|
30
|
-
header:
|
|
31
|
-
accessor: keyof T | ((row: T) =>
|
|
32
|
-
cell?: (row: T) =>
|
|
44
|
+
header: ReactNode;
|
|
45
|
+
accessor: keyof T | ((row: T) => ReactNode);
|
|
46
|
+
cell?: (row: T) => ReactNode;
|
|
47
|
+
sortValue?: (row: T) => string | number;
|
|
33
48
|
sortable?: boolean;
|
|
49
|
+
align?: ColumnAlign;
|
|
50
|
+
hideBelow?: ColumnHideBelow;
|
|
51
|
+
width?: string;
|
|
34
52
|
className?: string;
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
export interface TablePaginationProps extends BaseProps {
|
|
56
|
+
page: number;
|
|
57
|
+
totalPages: number;
|
|
58
|
+
onPage: (page: number) => void;
|
|
59
|
+
label?: ReactNode;
|
|
60
|
+
hideWhenSingle?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
export interface DataTableProps<T> extends BaseProps {
|
|
38
64
|
data: T[];
|
|
39
65
|
columns: Column<T>[];
|
|
40
66
|
caption?: string;
|
|
41
67
|
size?: MediumSizes;
|
|
42
68
|
sortable?: boolean;
|
|
69
|
+
sort?: SortState;
|
|
70
|
+
onSortChange?: (id: string) => void;
|
|
71
|
+
rowKey?: (row: T) => string;
|
|
72
|
+
selectedId?: string;
|
|
73
|
+
dense?: boolean;
|
|
74
|
+
loading?: boolean;
|
|
75
|
+
empty?: ReactNode;
|
|
76
|
+
sticky?: boolean;
|
|
77
|
+
stackable?: boolean;
|
|
43
78
|
paginated?: boolean;
|
|
44
79
|
pageSize?: number;
|
|
45
80
|
emptyState?: {
|
|
@@ -47,6 +82,143 @@ export interface DataTableProps<T> extends BaseProps {
|
|
|
47
82
|
description?: string;
|
|
48
83
|
};
|
|
49
84
|
onRowClick?: (row: T) => void;
|
|
85
|
+
rowLabel?: (row: T) => string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, label';
|
|
89
|
+
|
|
90
|
+
function rowClickAllowed(event: ReactMouseEvent<HTMLTableRowElement>): boolean {
|
|
91
|
+
if (event.target === event.currentTarget) return true;
|
|
92
|
+
return !(event.target as HTMLElement).closest(INTERACTIVE_SELECTOR);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stackLabel<T>(column: Column<T>): string {
|
|
96
|
+
return typeof column.header === 'string' ? column.header : column.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const alignStyles: Record<ColumnAlign, string> = {
|
|
100
|
+
left: 'text-left',
|
|
101
|
+
right: 'text-right',
|
|
102
|
+
center: 'text-center',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const hideBelowStyles: Record<ColumnHideBelow, string> = {
|
|
106
|
+
sm: 'hidden sm:table-cell',
|
|
107
|
+
md: 'hidden md:table-cell',
|
|
108
|
+
lg: 'hidden lg:table-cell',
|
|
109
|
+
xl: 'hidden xl:table-cell',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function displayValue<T>(column: Column<T>, row: T): ReactNode {
|
|
113
|
+
if (column.cell) return column.cell(row);
|
|
114
|
+
return typeof column.accessor === 'function'
|
|
115
|
+
? column.accessor(row)
|
|
116
|
+
: (row[column.accessor] as ReactNode);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sortValueOf<T>(column: Column<T>, row: T): string | number | null {
|
|
120
|
+
if (column.sortValue) return column.sortValue(row);
|
|
121
|
+
const value =
|
|
122
|
+
typeof column.accessor === 'function' ? column.accessor(row) : row[column.accessor];
|
|
123
|
+
if (typeof value === 'number') return value;
|
|
124
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
125
|
+
if (typeof value === 'string') return value;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function compareValues(a: string | number | null, b: string | number | null): number {
|
|
130
|
+
if (a === null && b === null) return 0;
|
|
131
|
+
if (a === null) return 1;
|
|
132
|
+
if (b === null) return -1;
|
|
133
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
134
|
+
return String(a).localeCompare(String(b));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function TablePagination({ className, page, totalPages, onPage, label, hideWhenSingle = false }: TablePaginationProps) {
|
|
138
|
+
const safeTotalPages = Number.isFinite(totalPages) ? Math.max(Math.floor(totalPages), 1) : 1;
|
|
139
|
+
const safePage = Number.isFinite(page) ? Math.min(Math.max(Math.floor(page), 1), safeTotalPages) : 1;
|
|
140
|
+
|
|
141
|
+
const slots = useMemo(() => {
|
|
142
|
+
type Slot = number | 'ellipsis-start' | 'ellipsis-end';
|
|
143
|
+
const out: Slot[] = [1];
|
|
144
|
+
const start = Math.max(safePage - 1, 2);
|
|
145
|
+
const end = Math.min(safePage + 1, safeTotalPages - 1);
|
|
146
|
+
if (start > 2) out.push('ellipsis-start');
|
|
147
|
+
for (let p = start; p <= end; p += 1) out.push(p);
|
|
148
|
+
if (end < safeTotalPages - 1) out.push('ellipsis-end');
|
|
149
|
+
if (safeTotalPages > 1) out.push(safeTotalPages);
|
|
150
|
+
return out;
|
|
151
|
+
}, [safePage, safeTotalPages]);
|
|
152
|
+
|
|
153
|
+
if (safeTotalPages <= 1 && hideWhenSingle) {
|
|
154
|
+
if (!label) return null;
|
|
155
|
+
return <div className={cn('text-sm text-muted-foreground', className)}>{label}</div>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<nav className={cn('flex items-center justify-between gap-2', className)} aria-label="Pagination">
|
|
160
|
+
{label && <span className="text-sm text-muted-foreground">{label}</span>}
|
|
161
|
+
<div className="flex items-center gap-1">
|
|
162
|
+
<Button
|
|
163
|
+
size="sm"
|
|
164
|
+
variant="outline"
|
|
165
|
+
onClick={() => onPage(1)}
|
|
166
|
+
disabled={safePage <= 1}
|
|
167
|
+
aria-label="First page"
|
|
168
|
+
>
|
|
169
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
size="sm"
|
|
173
|
+
variant="outline"
|
|
174
|
+
onClick={() => onPage(safePage - 1)}
|
|
175
|
+
disabled={safePage <= 1}
|
|
176
|
+
aria-label="Previous page"
|
|
177
|
+
>
|
|
178
|
+
<ChevronLeft className="h-4 w-4" />
|
|
179
|
+
</Button>
|
|
180
|
+
{slots.map((slot) =>
|
|
181
|
+
typeof slot === 'number' ? (
|
|
182
|
+
<Button
|
|
183
|
+
key={slot}
|
|
184
|
+
size="sm"
|
|
185
|
+
variant="outline"
|
|
186
|
+
className={cn(
|
|
187
|
+
'min-w-9',
|
|
188
|
+
slot === safePage && 'pointer-events-none bg-primary text-primary-foreground'
|
|
189
|
+
)}
|
|
190
|
+
onClick={() => onPage(slot)}
|
|
191
|
+
aria-current={slot === safePage ? 'page' : undefined}
|
|
192
|
+
>
|
|
193
|
+
{slot}
|
|
194
|
+
</Button>
|
|
195
|
+
) : (
|
|
196
|
+
<span key={slot} className="px-1 text-muted-foreground" aria-hidden="true">
|
|
197
|
+
…
|
|
198
|
+
</span>
|
|
199
|
+
)
|
|
200
|
+
)}
|
|
201
|
+
<Button
|
|
202
|
+
size="sm"
|
|
203
|
+
variant="outline"
|
|
204
|
+
onClick={() => onPage(safePage + 1)}
|
|
205
|
+
disabled={safePage >= safeTotalPages}
|
|
206
|
+
aria-label="Next page"
|
|
207
|
+
>
|
|
208
|
+
<ChevronRight className="h-4 w-4" />
|
|
209
|
+
</Button>
|
|
210
|
+
<Button
|
|
211
|
+
size="sm"
|
|
212
|
+
variant="outline"
|
|
213
|
+
onClick={() => onPage(safeTotalPages)}
|
|
214
|
+
disabled={safePage >= safeTotalPages}
|
|
215
|
+
aria-label="Last page"
|
|
216
|
+
>
|
|
217
|
+
<ChevronsRight className="h-4 w-4" />
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
</nav>
|
|
221
|
+
);
|
|
50
222
|
}
|
|
51
223
|
|
|
52
224
|
export function DataTable<T>({
|
|
@@ -56,48 +228,53 @@ export function DataTable<T>({
|
|
|
56
228
|
caption,
|
|
57
229
|
size = 'md',
|
|
58
230
|
sortable = false,
|
|
231
|
+
sort,
|
|
232
|
+
onSortChange,
|
|
233
|
+
rowKey,
|
|
234
|
+
selectedId,
|
|
235
|
+
dense = false,
|
|
236
|
+
loading = false,
|
|
237
|
+
empty,
|
|
238
|
+
sticky = false,
|
|
239
|
+
stackable = false,
|
|
59
240
|
paginated = false,
|
|
60
241
|
pageSize = 10,
|
|
61
242
|
emptyState,
|
|
62
243
|
onRowClick,
|
|
244
|
+
rowLabel,
|
|
63
245
|
...props
|
|
64
246
|
}: DataTableProps<T>) {
|
|
65
|
-
const [
|
|
66
|
-
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
247
|
+
const [internalSort, setInternalSort] = useState<SortState | null>(null);
|
|
67
248
|
const [currentPage, setCurrentPage] = useState(1);
|
|
68
249
|
|
|
69
|
-
const
|
|
70
|
-
|
|
250
|
+
const activeSort = sort ?? internalSort;
|
|
251
|
+
const controlled = sort !== undefined;
|
|
71
252
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
253
|
+
const handleSort = (id: string) => {
|
|
254
|
+
if (!sortable) return;
|
|
255
|
+
if (controlled) {
|
|
256
|
+
onSortChange?.(id);
|
|
257
|
+
return;
|
|
77
258
|
}
|
|
259
|
+
setInternalSort((prev) =>
|
|
260
|
+
prev && prev.id === id
|
|
261
|
+
? { id, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
|
262
|
+
: { id, dir: 'asc' }
|
|
263
|
+
);
|
|
78
264
|
};
|
|
79
265
|
|
|
80
266
|
const sortedData = useMemo(() => {
|
|
81
|
-
if (!
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (aValue === bValue) return 0;
|
|
91
|
-
if (aValue == null || bValue == null) return 0;
|
|
92
|
-
|
|
93
|
-
const comparison = (aValue as string | number) < (bValue as string | number) ? -1 : 1;
|
|
94
|
-
return sortDirection === 'asc' ? comparison : -comparison;
|
|
95
|
-
});
|
|
96
|
-
}, [data, sortColumn, sortDirection, columns, sortable]);
|
|
267
|
+
if (!activeSort || !sortable) return data;
|
|
268
|
+
const column = columns.find((col) => col.id === activeSort.id);
|
|
269
|
+
if (!column) return data;
|
|
270
|
+
const factor = activeSort.dir === 'asc' ? 1 : -1;
|
|
271
|
+
return [...data].sort(
|
|
272
|
+
(a, b) => factor * compareValues(sortValueOf(column, a), sortValueOf(column, b))
|
|
273
|
+
);
|
|
274
|
+
}, [data, activeSort, columns, sortable]);
|
|
97
275
|
|
|
98
276
|
const paginatedData = useMemo(() => {
|
|
99
277
|
if (!paginated) return sortedData;
|
|
100
|
-
|
|
101
278
|
const start = (currentPage - 1) * pageSize;
|
|
102
279
|
const end = start + pageSize;
|
|
103
280
|
return sortedData.slice(start, end);
|
|
@@ -107,27 +284,42 @@ export function DataTable<T>({
|
|
|
107
284
|
const safePage = clampPageNumber(currentPage, totalPages);
|
|
108
285
|
const pageBounds = computePageBounds(currentPage, pageSize, data.length);
|
|
109
286
|
|
|
110
|
-
const renderCell = (row: T, column: Column<T>) => {
|
|
111
|
-
if (column.cell) {
|
|
112
|
-
return column.cell(row);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const value = typeof column.accessor === 'function' ? column.accessor(row) : row[column.accessor];
|
|
116
|
-
return value as React.ReactNode;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
287
|
const sizeStyles: Record<MediumSizes, string> = {
|
|
120
288
|
sm: 'text-xs',
|
|
121
289
|
md: 'text-sm',
|
|
122
290
|
lg: 'text-base',
|
|
123
291
|
};
|
|
124
292
|
|
|
125
|
-
const
|
|
126
|
-
sm: 'px-2
|
|
127
|
-
md: 'px-4
|
|
128
|
-
lg: 'px-6
|
|
293
|
+
const pxStyles: Record<MediumSizes, string> = {
|
|
294
|
+
sm: 'px-2',
|
|
295
|
+
md: 'px-4',
|
|
296
|
+
lg: 'px-6',
|
|
129
297
|
};
|
|
130
298
|
|
|
299
|
+
const pyStyles: Record<MediumSizes, string> = dense
|
|
300
|
+
? { sm: 'py-1', md: 'py-1.5', lg: 'py-2' }
|
|
301
|
+
: { sm: 'py-2', md: 'py-3', lg: 'py-4' };
|
|
302
|
+
|
|
303
|
+
if (loading) {
|
|
304
|
+
const skeletonRows = dense ? 8 : 6;
|
|
305
|
+
const skeletonCols = Math.min(columns.length, 6);
|
|
306
|
+
return (
|
|
307
|
+
<div className={cn('space-y-3 p-2', className)} {...props}>
|
|
308
|
+
{Array.from({ length: skeletonRows }).map((_, row) => (
|
|
309
|
+
<div key={row} className="flex items-center gap-4" style={{ opacity: 1 - row * 0.08 }}>
|
|
310
|
+
{Array.from({ length: skeletonCols }).map((_, col) => (
|
|
311
|
+
<Skeleton key={col} variant="text" className="flex-1" />
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (data.length === 0 && empty !== undefined) {
|
|
320
|
+
return <>{empty}</>;
|
|
321
|
+
}
|
|
322
|
+
|
|
131
323
|
if (data.length === 0 && emptyState) {
|
|
132
324
|
return (
|
|
133
325
|
<div className={cn('p-8 text-center', className)}>
|
|
@@ -140,101 +332,127 @@ export function DataTable<T>({
|
|
|
140
332
|
}
|
|
141
333
|
|
|
142
334
|
return (
|
|
143
|
-
<div className={cn('space-y-4', className)} {...props}>
|
|
335
|
+
<div className={cn('space-y-4', stackable && 'ds-datatable-stack', className)} {...props}>
|
|
144
336
|
<Table>
|
|
145
337
|
{caption && <TableCaption>{caption}</TableCaption>}
|
|
146
338
|
<TableHeader>
|
|
147
339
|
<TableRow>
|
|
148
|
-
{columns.map((column) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{sortDirection === 'asc' ? (
|
|
164
|
-
<ArrowUp className="h-3 w-3" aria-hidden="true" />
|
|
165
|
-
) : (
|
|
166
|
-
<ArrowDown className="h-3 w-3" aria-hidden="true" />
|
|
167
|
-
)}
|
|
168
|
-
</span>
|
|
340
|
+
{columns.map((column) => {
|
|
341
|
+
const isActiveSort = activeSort?.id === column.id;
|
|
342
|
+
return (
|
|
343
|
+
<TableHead
|
|
344
|
+
key={column.id}
|
|
345
|
+
style={column.width ? { width: column.width } : undefined}
|
|
346
|
+
className={cn(
|
|
347
|
+
sizeStyles[size],
|
|
348
|
+
pxStyles[size],
|
|
349
|
+
pyStyles[size],
|
|
350
|
+
sticky && 'sticky top-0 z-[1] bg-background/95 backdrop-blur',
|
|
351
|
+
column.align && column.align !== 'left' && alignStyles[column.align],
|
|
352
|
+
column.hideBelow && hideBelowStyles[column.hideBelow],
|
|
353
|
+
column.sortable && sortable && 'cursor-pointer select-none hover:bg-muted/50',
|
|
354
|
+
column.className
|
|
169
355
|
)}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
356
|
+
onClick={() => {
|
|
357
|
+
if (column.sortable && sortable) handleSort(column.id);
|
|
358
|
+
}}
|
|
359
|
+
aria-sort={
|
|
360
|
+
isActiveSort
|
|
361
|
+
? activeSort?.dir === 'asc'
|
|
362
|
+
? 'ascending'
|
|
363
|
+
: 'descending'
|
|
364
|
+
: column.sortable && sortable
|
|
365
|
+
? 'none'
|
|
366
|
+
: undefined
|
|
367
|
+
}
|
|
368
|
+
>
|
|
369
|
+
<div
|
|
370
|
+
className={cn(
|
|
371
|
+
'flex items-center gap-2',
|
|
372
|
+
column.align === 'right' && 'flex-row-reverse'
|
|
373
|
+
)}
|
|
374
|
+
>
|
|
375
|
+
{column.header}
|
|
376
|
+
{column.sortable && sortable && (
|
|
377
|
+
<span className="text-xs">
|
|
378
|
+
{isActiveSort ? (
|
|
379
|
+
activeSort?.dir === 'asc' ? (
|
|
380
|
+
<ArrowUp className="h-3 w-3" aria-hidden="true" />
|
|
381
|
+
) : (
|
|
382
|
+
<ArrowDown className="h-3 w-3" aria-hidden="true" />
|
|
383
|
+
)
|
|
384
|
+
) : (
|
|
385
|
+
<ChevronsUpDown className="h-3 w-3 opacity-40" aria-hidden="true" />
|
|
386
|
+
)}
|
|
387
|
+
</span>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
</TableHead>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
173
393
|
</TableRow>
|
|
174
394
|
</TableHeader>
|
|
175
395
|
<TableBody>
|
|
176
|
-
{paginatedData.map((row,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
396
|
+
{paginatedData.map((row, index) => {
|
|
397
|
+
const key = rowKey ? rowKey(row) : index;
|
|
398
|
+
const selected = rowKey !== undefined && selectedId !== undefined && rowKey(row) === selectedId;
|
|
399
|
+
const handleRowKeyDown = (event: ReactKeyboardEvent<HTMLTableRowElement>) => {
|
|
400
|
+
if (event.target !== event.currentTarget) return;
|
|
401
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
onRowClick?.(row);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
return (
|
|
407
|
+
<TableRow
|
|
408
|
+
key={key}
|
|
409
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
410
|
+
aria-label={onRowClick && rowLabel ? rowLabel(row) : undefined}
|
|
411
|
+
className={cn(
|
|
412
|
+
onRowClick && 'cursor-pointer hover:bg-muted/50',
|
|
413
|
+
selected && 'bg-muted/60'
|
|
414
|
+
)}
|
|
415
|
+
onClick={
|
|
416
|
+
onRowClick
|
|
417
|
+
? (event) => {
|
|
418
|
+
if (rowClickAllowed(event)) onRowClick(row);
|
|
419
|
+
}
|
|
420
|
+
: undefined
|
|
421
|
+
}
|
|
422
|
+
onKeyDown={onRowClick ? handleRowKeyDown : undefined}
|
|
423
|
+
>
|
|
424
|
+
{columns.map((column) => (
|
|
425
|
+
<TableCell
|
|
426
|
+
key={column.id}
|
|
427
|
+
data-label={stackable ? stackLabel(column) : undefined}
|
|
428
|
+
className={cn(
|
|
429
|
+
pxStyles[size],
|
|
430
|
+
pyStyles[size],
|
|
431
|
+
column.align && column.align !== 'left' && alignStyles[column.align],
|
|
432
|
+
column.hideBelow && hideBelowStyles[column.hideBelow],
|
|
433
|
+
column.className
|
|
434
|
+
)}
|
|
435
|
+
>
|
|
436
|
+
{displayValue(column, row)}
|
|
437
|
+
</TableCell>
|
|
438
|
+
))}
|
|
439
|
+
</TableRow>
|
|
440
|
+
);
|
|
441
|
+
})}
|
|
192
442
|
</TableBody>
|
|
193
443
|
{paginated && (
|
|
194
444
|
<TableFooter>
|
|
195
445
|
<TableRow>
|
|
196
446
|
<TableCell colSpan={columns.length}>
|
|
197
|
-
<div className="flex items-center justify-between">
|
|
447
|
+
<div className="flex items-center justify-between gap-2">
|
|
198
448
|
<p className={cn('text-muted-foreground', sizeStyles[size])}>
|
|
199
449
|
Showing {pageBounds.startIndex} to {pageBounds.endIndex} of {data.length} results
|
|
200
450
|
</p>
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
disabled={safePage === 1}
|
|
207
|
-
>
|
|
208
|
-
<ChevronsLeft className="h-4 w-4" />
|
|
209
|
-
</Button>
|
|
210
|
-
<Button
|
|
211
|
-
size="sm"
|
|
212
|
-
variant="outline"
|
|
213
|
-
onClick={() => setCurrentPage((p) => clampPageNumber(p - 1, totalPages))}
|
|
214
|
-
disabled={safePage === 1}
|
|
215
|
-
>
|
|
216
|
-
<ChevronLeft className="h-4 w-4" />
|
|
217
|
-
</Button>
|
|
218
|
-
<span className={cn('text-sm font-medium', sizeStyles[size])}>
|
|
219
|
-
Page {safePage} of {totalPages}
|
|
220
|
-
</span>
|
|
221
|
-
<Button
|
|
222
|
-
size="sm"
|
|
223
|
-
variant="outline"
|
|
224
|
-
onClick={() => setCurrentPage((p) => clampPageNumber(p + 1, totalPages))}
|
|
225
|
-
disabled={safePage === totalPages}
|
|
226
|
-
>
|
|
227
|
-
<ChevronRight className="h-4 w-4" />
|
|
228
|
-
</Button>
|
|
229
|
-
<Button
|
|
230
|
-
size="sm"
|
|
231
|
-
variant="outline"
|
|
232
|
-
onClick={() => setCurrentPage(totalPages)}
|
|
233
|
-
disabled={safePage === totalPages}
|
|
234
|
-
>
|
|
235
|
-
<ChevronsRight className="h-4 w-4" />
|
|
236
|
-
</Button>
|
|
237
|
-
</div>
|
|
451
|
+
<TablePagination
|
|
452
|
+
page={safePage}
|
|
453
|
+
totalPages={totalPages}
|
|
454
|
+
onPage={(p) => setCurrentPage(clampPageNumber(p, totalPages))}
|
|
455
|
+
/>
|
|
238
456
|
</div>
|
|
239
457
|
</TableCell>
|
|
240
458
|
</TableRow>
|
|
@@ -3,18 +3,31 @@
|
|
|
3
3
|
* @description Component for displaying empty states with optional actions (Responsive)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { forwardRef } from 'react';
|
|
6
|
+
import { forwardRef, isValidElement } from 'react';
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
7
8
|
import { cn, getSpacing, getGap } from '../../infrastructure/utils';
|
|
8
9
|
import type { BaseProps, SizeVariant } from '../../domain/types';
|
|
9
10
|
|
|
11
|
+
export interface EmptyStateAction {
|
|
12
|
+
label: string;
|
|
13
|
+
onClick: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isActionConfig(action: EmptyStateAction | ReactNode): action is EmptyStateAction {
|
|
17
|
+
return (
|
|
18
|
+
typeof action === 'object' &&
|
|
19
|
+
action !== null &&
|
|
20
|
+
!isValidElement(action) &&
|
|
21
|
+
'label' in action &&
|
|
22
|
+
'onClick' in action
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
export interface EmptyStateProps extends BaseProps {
|
|
11
|
-
icon?: React.ComponentType<{ className?: string }
|
|
27
|
+
icon?: React.ComponentType<{ className?: string }> | ReactNode;
|
|
12
28
|
title: string;
|
|
13
29
|
description?: string;
|
|
14
|
-
action?:
|
|
15
|
-
label: string;
|
|
16
|
-
onClick: () => void;
|
|
17
|
-
};
|
|
30
|
+
action?: EmptyStateAction | ReactNode;
|
|
18
31
|
size?: Extract<SizeVariant, 'sm' | 'md' | 'lg' | 'xl'>;
|
|
19
32
|
centered?: boolean;
|
|
20
33
|
}
|
|
@@ -23,7 +36,7 @@ export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
23
36
|
(
|
|
24
37
|
{
|
|
25
38
|
className,
|
|
26
|
-
icon
|
|
39
|
+
icon,
|
|
27
40
|
title,
|
|
28
41
|
description,
|
|
29
42
|
action,
|
|
@@ -43,8 +56,22 @@ export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
43
56
|
)}
|
|
44
57
|
{...props}
|
|
45
58
|
>
|
|
46
|
-
{
|
|
47
|
-
|
|
59
|
+
{typeof icon === 'function' &&
|
|
60
|
+
(() => {
|
|
61
|
+
const Icon = icon;
|
|
62
|
+
return (
|
|
63
|
+
<Icon className={cn(size === 'sm' ? 'h-8 w-8 sm:h-10 sm:w-10' : size === 'md' ? 'h-12 w-12 sm:h-14 sm:w-14' : size === 'lg' ? 'h-16 w-16 sm:h-20 sm:w-20' : 'h-20 w-20 sm:h-24 sm:w-24', 'text-muted-foreground/50 mx-auto mb-2')} />
|
|
64
|
+
);
|
|
65
|
+
})()}
|
|
66
|
+
{typeof icon !== 'function' && icon && (
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
'inline-flex items-center justify-center text-muted-foreground/50 mx-auto mb-2',
|
|
70
|
+
size === 'sm' ? 'h-8 w-8 sm:h-10 sm:w-10' : size === 'md' ? 'h-12 w-12 sm:h-14 sm:w-14' : size === 'lg' ? 'h-16 w-16 sm:h-20 sm:w-20' : 'h-20 w-20 sm:h-24 sm:w-24'
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{icon}
|
|
74
|
+
</span>
|
|
48
75
|
)}
|
|
49
76
|
<h3 className={cn('font-semibold text-foreground', size === 'sm' ? 'text-sm sm:text-base' : size === 'md' ? 'text-base sm:text-lg' : size === 'lg' ? 'text-lg sm:text-xl' : 'text-xl sm:text-2xl')}>{title}</h3>
|
|
50
77
|
{description && (
|
|
@@ -52,23 +79,26 @@ export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
52
79
|
{description}
|
|
53
80
|
</p>
|
|
54
81
|
)}
|
|
55
|
-
{action &&
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
{action &&
|
|
83
|
+
(isActionConfig(action) ? (
|
|
84
|
+
<button
|
|
85
|
+
onClick={action.onClick}
|
|
86
|
+
className={cn(
|
|
87
|
+
'mt-4 inline-flex items-center justify-center',
|
|
88
|
+
'rounded-md font-medium',
|
|
89
|
+
'bg-primary text-primary-foreground',
|
|
90
|
+
'hover:bg-primary/90',
|
|
91
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
92
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
93
|
+
'h-9 px-4 text-sm',
|
|
94
|
+
'transition-colors'
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{action.label}
|
|
98
|
+
</button>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="mt-4">{action}</div>
|
|
101
|
+
))}
|
|
72
102
|
</div>
|
|
73
103
|
);
|
|
74
104
|
}
|