@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.
@@ -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 { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
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: string;
31
- accessor: keyof T | ((row: T) => React.ReactNode);
32
- cell?: (row: T) => React.ReactNode;
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 [sortColumn, setSortColumn] = useState<string | null>(null);
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 handleSort = (columnId: string) => {
70
- if (!sortable) return;
250
+ const activeSort = sort ?? internalSort;
251
+ const controlled = sort !== undefined;
71
252
 
72
- if (sortColumn === columnId) {
73
- setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
74
- } else {
75
- setSortColumn(columnId);
76
- setSortDirection('asc');
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 (!sortColumn || !sortable) return data;
82
-
83
- return [...data].sort((a, b) => {
84
- const column = columns.find((col) => col.id === sortColumn);
85
- if (!column) return 0;
86
-
87
- const aValue = typeof column.accessor === 'function' ? column.accessor(a) : a[column.accessor];
88
- const bValue = typeof column.accessor === 'function' ? column.accessor(b) : b[column.accessor];
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 paddingStyles: Record<MediumSizes, string> = {
126
- sm: 'px-2 py-2',
127
- md: 'px-4 py-3',
128
- lg: 'px-6 py-4',
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
- <TableHead
150
- key={column.id}
151
- className={cn(
152
- sizeStyles[size],
153
- paddingStyles[size],
154
- column.sortable && sortable && 'cursor-pointer hover:bg-muted/50',
155
- column.className
156
- )}
157
- onClick={() => column.sortable && handleSort(column.id)}
158
- >
159
- <div className="flex items-center gap-2">
160
- {column.header}
161
- {column.sortable && sortable && sortColumn === column.id && (
162
- <span className="text-xs">
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
- </div>
171
- </TableHead>
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, rowIndex) => (
177
- <TableRow
178
- key={rowIndex}
179
- className={cn(onRowClick && 'cursor-pointer hover:bg-muted/50')}
180
- onClick={() => onRowClick?.(row)}
181
- >
182
- {columns.map((column) => (
183
- <TableCell
184
- key={column.id}
185
- className={cn(paddingStyles[size], column.className)}
186
- >
187
- {renderCell(row, column)}
188
- </TableCell>
189
- ))}
190
- </TableRow>
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
- <div className="flex items-center gap-2">
202
- <Button
203
- size="sm"
204
- variant="outline"
205
- onClick={() => setCurrentPage(1)}
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: 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
- {Icon && (
47
- <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')} />
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
- <button
57
- onClick={action.onClick}
58
- className={cn(
59
- 'mt-4 inline-flex items-center justify-center',
60
- 'rounded-md font-medium',
61
- 'bg-primary text-primary-foreground',
62
- 'hover:bg-primary/90',
63
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
64
- 'disabled:pointer-events-none disabled:opacity-50',
65
- 'h-9 px-4 text-sm',
66
- 'transition-colors'
67
- )}
68
- >
69
- {action.label}
70
- </button>
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
  }