@justin_evo/evo-ui 1.2.0 → 1.2.1

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.
Files changed (77) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +70 -70
  3. package/dist/declarations.d.ts +6 -6
  4. package/package.json +52 -52
  5. package/src/Alert/Alert.tsx +49 -49
  6. package/src/AutoComplete/AutoComplete.tsx +810 -810
  7. package/src/Badge/Badge.tsx +53 -53
  8. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  9. package/src/Button/Button.tsx +125 -125
  10. package/src/Card/Card.tsx +257 -257
  11. package/src/Checkbox/Checkbox.tsx +59 -59
  12. package/src/CommandPalette/CommandPalette.tsx +185 -185
  13. package/src/Container/Container.tsx +31 -31
  14. package/src/Divider/Divider.tsx +31 -31
  15. package/src/Form/Form.tsx +185 -185
  16. package/src/Grid/Grid.tsx +66 -66
  17. package/src/ImageCropper/ImageCropper.tsx +911 -911
  18. package/src/Input/Input.tsx +74 -74
  19. package/src/Modal/Modal.tsx +77 -77
  20. package/src/Nav/Nav.tsx +708 -708
  21. package/src/Notification/Notification.tsx +1503 -1503
  22. package/src/Pagination/Pagination.tsx +76 -76
  23. package/src/Radio/Radio.tsx +69 -69
  24. package/src/RichTextArea/RichTextArea.tsx +886 -886
  25. package/src/Select/Select.tsx +515 -515
  26. package/src/Skeleton/Skeleton.tsx +70 -70
  27. package/src/Stack/Stack.tsx +52 -52
  28. package/src/Table/Table.tsx +335 -335
  29. package/src/Tabs/Tabs.tsx +90 -90
  30. package/src/Theme/ThemeProvider.tsx +253 -253
  31. package/src/Theme/ThemeToggle.tsx +79 -79
  32. package/src/Toggle/Toggle.tsx +48 -48
  33. package/src/Tooltip/Tooltip.tsx +38 -38
  34. package/src/TopNav/TopNav.tsx +1163 -1163
  35. package/src/TreeSelect/TreeSelect.tsx +825 -825
  36. package/src/css/alert.module.scss +93 -93
  37. package/src/css/autocomplete.module.scss +416 -416
  38. package/src/css/badge.module.scss +82 -82
  39. package/src/css/base/_color.scss +159 -159
  40. package/src/css/base/_theme.scss +237 -237
  41. package/src/css/base/_variables.scss +161 -161
  42. package/src/css/breadcrumb.module.scss +50 -50
  43. package/src/css/button.module.scss +385 -385
  44. package/src/css/card.module.scss +217 -217
  45. package/src/css/checkbox.module.scss +123 -123
  46. package/src/css/commandpalette.module.scss +211 -211
  47. package/src/css/container.module.scss +18 -18
  48. package/src/css/divider.module.scss +41 -41
  49. package/src/css/form.module.scss +245 -245
  50. package/src/css/imagecropper.module.scss +397 -397
  51. package/src/css/input.module.scss +89 -89
  52. package/src/css/modal.module.scss +105 -105
  53. package/src/css/nav.module.scss +494 -494
  54. package/src/css/notification.module.scss +691 -691
  55. package/src/css/pagination.module.scss +63 -63
  56. package/src/css/radio.module.scss +89 -89
  57. package/src/css/richtextarea.module.scss +307 -307
  58. package/src/css/select.module.scss +525 -525
  59. package/src/css/skeleton.module.scss +30 -30
  60. package/src/css/table.module.scss +386 -386
  61. package/src/css/tabs.module.scss +63 -63
  62. package/src/css/theme-toggle.module.scss +83 -83
  63. package/src/css/toggle.module.scss +54 -54
  64. package/src/css/tooltip.module.scss +97 -97
  65. package/src/css/topnav.module.scss +568 -568
  66. package/src/css/treeselect.module.scss +558 -558
  67. package/src/css/utilities/_borders.scss +111 -111
  68. package/src/css/utilities/_colors.scss +66 -66
  69. package/src/css/utilities/_effects.scss +216 -216
  70. package/src/css/utilities/_layout.scss +181 -181
  71. package/src/css/utilities/_position.scss +75 -75
  72. package/src/css/utilities/_sizing.scss +138 -138
  73. package/src/css/utilities/_spacing.scss +99 -99
  74. package/src/css/utilities/_typography.scss +121 -121
  75. package/src/css/utilities/index.scss +24 -24
  76. package/src/declarations.d.ts +6 -6
  77. package/src/index.ts +60 -60
@@ -1,335 +1,335 @@
1
- 'use client';
2
-
3
- import React, { useMemo, useState } from 'react';
4
- import styles from '../css/table.module.scss';
5
-
6
- export type TableSize = 'sm' | 'md' | 'lg';
7
- export type TableAlign = 'left' | 'center' | 'right';
8
- export type TableSortDirection = 'asc' | 'desc';
9
- export type TableResponsive = 'scroll' | 'stack';
10
-
11
- export interface TableSortState {
12
- key: string;
13
- direction: TableSortDirection;
14
- }
15
-
16
- export interface TableColumn<T> {
17
- /** Key in the data object. Supports dot notation for nested paths ('role.name'). */
18
- key: string;
19
- /** Display label or node for the column header. */
20
- header: React.ReactNode;
21
- /** Optional fixed width, e.g. '120px' or '10%'. */
22
- width?: string;
23
- /** Cell alignment. Defaults to 'left'. */
24
- align?: TableAlign;
25
- /** Enable click-to-sort on this column. */
26
- sortable?: boolean;
27
- /** Custom comparator. Falls back to a numeric-aware string compare. */
28
- sortFn?: (a: T, b: T, direction: TableSortDirection) => number;
29
- /** Custom cell renderer. Defaults to string coercion. */
30
- render?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
31
- /** Extra class for cells in this column. */
32
- cellClassName?: string;
33
- /** Extra class for the header cell. */
34
- headerClassName?: string;
35
- }
36
-
37
- export interface EvoTableProps<T extends Record<string, unknown>> {
38
- /** Column definitions. */
39
- columns: TableColumn<T>[];
40
- /** Row data array. */
41
- data: T[];
42
- /** Density variant — controls cell padding and font size. Default 'md'. */
43
- size?: TableSize;
44
- /** Alternating row backgrounds. */
45
- striped?: boolean;
46
- /** Highlight rows on hover. Default true. */
47
- hoverable?: boolean;
48
- /** Add vertical dividers between columns. Default false. */
49
- bordered?: boolean;
50
- /** Keep the header pinned while the body scrolls. */
51
- stickyHeader?: boolean;
52
- /** Show skeleton rows in place of data. */
53
- loading?: boolean;
54
- /** Skeleton row count when loading. Default 5. */
55
- loadingRows?: number;
56
- /** Stable key for each row. Pass a property name or a getter. */
57
- rowKey?: keyof T | ((row: T, index: number) => React.Key);
58
- /** Custom empty-state slot. Takes priority over `emptyText`. */
59
- emptyState?: React.ReactNode;
60
- /** Plain-text fallback when data is empty. */
61
- emptyText?: string;
62
- /** Fires on single row click. */
63
- onRowClick?: (row: T, index: number) => void;
64
- /** Fires on row double-click. */
65
- onRowDoubleClick?: (row: T, index: number) => void;
66
- /** Add custom classes to specific rows. */
67
- getRowClassName?: (row: T, index: number) => string | undefined;
68
- /**
69
- * Small-viewport behaviour.
70
- * - `scroll` (default): horizontal scroll with a thin scrollbar.
71
- * - `stack`: under ~640px each row becomes a labelled card.
72
- */
73
- responsive?: TableResponsive;
74
- /** Optional caption rendered above the table. */
75
- caption?: React.ReactNode;
76
- /** Controlled sort state. Pair with `onSortChange`. */
77
- sort?: TableSortState | null;
78
- /** Sort change callback. */
79
- onSortChange?: (next: TableSortState | null) => void;
80
- /** Initial sort for uncontrolled mode. */
81
- defaultSort?: TableSortState;
82
- className?: string;
83
- }
84
-
85
- function getNestedValue(obj: unknown, path: string): unknown {
86
- return path.split('.').reduce<unknown>((acc, key) => {
87
- if (acc !== null && acc !== undefined && typeof acc === 'object') {
88
- return (acc as Record<string, unknown>)[key];
89
- }
90
- return undefined;
91
- }, obj);
92
- }
93
-
94
- function defaultCompare(a: unknown, b: unknown): number {
95
- if (a == null && b == null) return 0;
96
- if (a == null) return -1;
97
- if (b == null) return 1;
98
- if (typeof a === 'number' && typeof b === 'number') return a - b;
99
- return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
100
- }
101
-
102
- function resolveRowKey<T>(
103
- row: T,
104
- index: number,
105
- rowKey?: keyof T | ((row: T, i: number) => React.Key),
106
- ): React.Key {
107
- if (typeof rowKey === 'function') return rowKey(row, index);
108
- if (rowKey && row && typeof row === 'object') {
109
- const value = (row as Record<string, unknown>)[rowKey as string];
110
- if (typeof value === 'string' || typeof value === 'number') return value;
111
- }
112
- return index;
113
- }
114
-
115
- const SortIcon = ({ direction }: { direction?: TableSortDirection }) => (
116
- <svg
117
- className={styles.sortIcon}
118
- width="8"
119
- height="12"
120
- viewBox="0 0 8 12"
121
- aria-hidden="true"
122
- focusable="false"
123
- >
124
- <path
125
- d="M4 0 L8 4 H0 Z"
126
- fill="currentColor"
127
- opacity={direction === 'asc' ? 1 : 0.3}
128
- />
129
- <path
130
- d="M4 12 L0 8 H8 Z"
131
- fill="currentColor"
132
- opacity={direction === 'desc' ? 1 : 0.3}
133
- />
134
- </svg>
135
- );
136
-
137
- export const EvoTable = <T extends Record<string, unknown>>({
138
- columns,
139
- data,
140
- size = 'md',
141
- striped = false,
142
- hoverable = true,
143
- bordered = false,
144
- stickyHeader = false,
145
- loading = false,
146
- loadingRows = 5,
147
- rowKey,
148
- emptyState,
149
- emptyText = 'No data available.',
150
- onRowClick,
151
- onRowDoubleClick,
152
- getRowClassName,
153
- responsive = 'scroll',
154
- caption,
155
- sort,
156
- onSortChange,
157
- defaultSort,
158
- className,
159
- }: EvoTableProps<T>) => {
160
- const isControlled = sort !== undefined;
161
- const [internalSort, setInternalSort] = useState<TableSortState | null>(defaultSort ?? null);
162
- const activeSort = isControlled ? sort : internalSort;
163
-
164
- const handleSort = (key: string) => {
165
- let next: TableSortState | null;
166
- if (!activeSort || activeSort.key !== key) {
167
- next = { key, direction: 'asc' };
168
- } else if (activeSort.direction === 'asc') {
169
- next = { key, direction: 'desc' };
170
- } else {
171
- next = null;
172
- }
173
- if (!isControlled) setInternalSort(next);
174
- onSortChange?.(next);
175
- };
176
-
177
- const sortedData = useMemo(() => {
178
- if (!activeSort) return data;
179
- const col = columns.find((c) => c.key === activeSort.key);
180
- if (!col) return data;
181
- const direction = activeSort.direction;
182
- const next = [...data];
183
- if (col.sortFn) {
184
- next.sort((a, b) => col.sortFn!(a, b, direction));
185
- } else {
186
- next.sort((a, b) => {
187
- const av = getNestedValue(a, col.key);
188
- const bv = getNestedValue(b, col.key);
189
- const cmp = defaultCompare(av, bv);
190
- return direction === 'asc' ? cmp : -cmp;
191
- });
192
- }
193
- return next;
194
- }, [data, columns, activeSort]);
195
-
196
- const isClickable = Boolean(onRowClick || onRowDoubleClick);
197
-
198
- const wrapperClass = [
199
- styles.wrapper,
200
- styles[`size-${size}`],
201
- striped && styles.striped,
202
- hoverable && styles.hoverable,
203
- bordered && styles.bordered,
204
- stickyHeader && styles.stickyHeader,
205
- responsive === 'stack' && styles.responsiveStack,
206
- isClickable && styles.clickable,
207
- className,
208
- ]
209
- .filter(Boolean)
210
- .join(' ');
211
-
212
- const renderHeader = (col: TableColumn<T>) => {
213
- const align = col.align ?? 'left';
214
- const isSorted = activeSort?.key === col.key;
215
- const direction = isSorted ? activeSort!.direction : undefined;
216
- const ariaSort: React.AriaAttributes['aria-sort'] = isSorted
217
- ? direction === 'asc'
218
- ? 'ascending'
219
- : 'descending'
220
- : col.sortable
221
- ? 'none'
222
- : undefined;
223
-
224
- const thClass = [styles.th, styles[`align-${align}`], col.headerClassName]
225
- .filter(Boolean)
226
- .join(' ');
227
-
228
- return (
229
- <th
230
- key={col.key}
231
- scope="col"
232
- className={thClass}
233
- style={col.width ? { width: col.width } : undefined}
234
- aria-sort={ariaSort}
235
- >
236
- {col.sortable ? (
237
- <button
238
- type="button"
239
- className={[styles.sortBtn, isSorted ? styles.sortActive : '']
240
- .filter(Boolean)
241
- .join(' ')}
242
- onClick={() => handleSort(col.key)}
243
- >
244
- <span>{col.header}</span>
245
- <SortIcon direction={direction} />
246
- </button>
247
- ) : (
248
- col.header
249
- )}
250
- </th>
251
- );
252
- };
253
-
254
- const renderBody = () => {
255
- if (loading) {
256
- return Array.from({ length: Math.max(1, loadingRows) }).map((_, i) => (
257
- <tr key={`evo-skeleton-${i}`} className={styles.tr}>
258
- {columns.map((col) => (
259
- <td
260
- key={col.key}
261
- className={[styles.td, styles[`align-${col.align ?? 'left'}`]]
262
- .filter(Boolean)
263
- .join(' ')}
264
- data-label={typeof col.header === 'string' ? col.header : col.key}
265
- >
266
- <span className={styles.skeleton} />
267
- </td>
268
- ))}
269
- </tr>
270
- ));
271
- }
272
-
273
- if (sortedData.length === 0) {
274
- return (
275
- <tr className={styles.emptyRow}>
276
- <td colSpan={columns.length} className={styles.empty}>
277
- {emptyState ?? <span className={styles.emptyText}>{emptyText}</span>}
278
- </td>
279
- </tr>
280
- );
281
- }
282
-
283
- return sortedData.map((row, rowIndex) => {
284
- const trClass = [styles.tr, getRowClassName?.(row, rowIndex)]
285
- .filter(Boolean)
286
- .join(' ');
287
-
288
- return (
289
- <tr
290
- key={resolveRowKey(row, rowIndex, rowKey)}
291
- className={trClass}
292
- onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
293
- onDoubleClick={
294
- onRowDoubleClick ? () => onRowDoubleClick(row, rowIndex) : undefined
295
- }
296
- >
297
- {columns.map((col) => {
298
- const value = getNestedValue(row, col.key);
299
- const align = col.align ?? 'left';
300
- const tdClass = [styles.td, styles[`align-${align}`], col.cellClassName]
301
- .filter(Boolean)
302
- .join(' ');
303
- return (
304
- <td
305
- key={col.key}
306
- className={tdClass}
307
- data-label={typeof col.header === 'string' ? col.header : col.key}
308
- >
309
- {col.render
310
- ? col.render(value, row, rowIndex)
311
- : value == null
312
- ? ''
313
- : String(value)}
314
- </td>
315
- );
316
- })}
317
- </tr>
318
- );
319
- });
320
- };
321
-
322
- return (
323
- <div className={wrapperClass}>
324
- <div className={styles.scroll}>
325
- <table className={styles.table}>
326
- {caption && <caption className={styles.caption}>{caption}</caption>}
327
- <thead className={styles.thead}>
328
- <tr>{columns.map(renderHeader)}</tr>
329
- </thead>
330
- <tbody className={styles.tbody}>{renderBody()}</tbody>
331
- </table>
332
- </div>
333
- </div>
334
- );
335
- };
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState } from 'react';
4
+ import styles from '../css/table.module.scss';
5
+
6
+ export type TableSize = 'sm' | 'md' | 'lg';
7
+ export type TableAlign = 'left' | 'center' | 'right';
8
+ export type TableSortDirection = 'asc' | 'desc';
9
+ export type TableResponsive = 'scroll' | 'stack';
10
+
11
+ export interface TableSortState {
12
+ key: string;
13
+ direction: TableSortDirection;
14
+ }
15
+
16
+ export interface TableColumn<T> {
17
+ /** Key in the data object. Supports dot notation for nested paths ('role.name'). */
18
+ key: string;
19
+ /** Display label or node for the column header. */
20
+ header: React.ReactNode;
21
+ /** Optional fixed width, e.g. '120px' or '10%'. */
22
+ width?: string;
23
+ /** Cell alignment. Defaults to 'left'. */
24
+ align?: TableAlign;
25
+ /** Enable click-to-sort on this column. */
26
+ sortable?: boolean;
27
+ /** Custom comparator. Falls back to a numeric-aware string compare. */
28
+ sortFn?: (a: T, b: T, direction: TableSortDirection) => number;
29
+ /** Custom cell renderer. Defaults to string coercion. */
30
+ render?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
31
+ /** Extra class for cells in this column. */
32
+ cellClassName?: string;
33
+ /** Extra class for the header cell. */
34
+ headerClassName?: string;
35
+ }
36
+
37
+ export interface EvoTableProps<T extends Record<string, unknown>> {
38
+ /** Column definitions. */
39
+ columns: TableColumn<T>[];
40
+ /** Row data array. */
41
+ data: T[];
42
+ /** Density variant — controls cell padding and font size. Default 'md'. */
43
+ size?: TableSize;
44
+ /** Alternating row backgrounds. */
45
+ striped?: boolean;
46
+ /** Highlight rows on hover. Default true. */
47
+ hoverable?: boolean;
48
+ /** Add vertical dividers between columns. Default false. */
49
+ bordered?: boolean;
50
+ /** Keep the header pinned while the body scrolls. */
51
+ stickyHeader?: boolean;
52
+ /** Show skeleton rows in place of data. */
53
+ loading?: boolean;
54
+ /** Skeleton row count when loading. Default 5. */
55
+ loadingRows?: number;
56
+ /** Stable key for each row. Pass a property name or a getter. */
57
+ rowKey?: keyof T | ((row: T, index: number) => React.Key);
58
+ /** Custom empty-state slot. Takes priority over `emptyText`. */
59
+ emptyState?: React.ReactNode;
60
+ /** Plain-text fallback when data is empty. */
61
+ emptyText?: string;
62
+ /** Fires on single row click. */
63
+ onRowClick?: (row: T, index: number) => void;
64
+ /** Fires on row double-click. */
65
+ onRowDoubleClick?: (row: T, index: number) => void;
66
+ /** Add custom classes to specific rows. */
67
+ getRowClassName?: (row: T, index: number) => string | undefined;
68
+ /**
69
+ * Small-viewport behaviour.
70
+ * - `scroll` (default): horizontal scroll with a thin scrollbar.
71
+ * - `stack`: under ~640px each row becomes a labelled card.
72
+ */
73
+ responsive?: TableResponsive;
74
+ /** Optional caption rendered above the table. */
75
+ caption?: React.ReactNode;
76
+ /** Controlled sort state. Pair with `onSortChange`. */
77
+ sort?: TableSortState | null;
78
+ /** Sort change callback. */
79
+ onSortChange?: (next: TableSortState | null) => void;
80
+ /** Initial sort for uncontrolled mode. */
81
+ defaultSort?: TableSortState;
82
+ className?: string;
83
+ }
84
+
85
+ function getNestedValue(obj: unknown, path: string): unknown {
86
+ return path.split('.').reduce<unknown>((acc, key) => {
87
+ if (acc !== null && acc !== undefined && typeof acc === 'object') {
88
+ return (acc as Record<string, unknown>)[key];
89
+ }
90
+ return undefined;
91
+ }, obj);
92
+ }
93
+
94
+ function defaultCompare(a: unknown, b: unknown): number {
95
+ if (a == null && b == null) return 0;
96
+ if (a == null) return -1;
97
+ if (b == null) return 1;
98
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
99
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
100
+ }
101
+
102
+ function resolveRowKey<T>(
103
+ row: T,
104
+ index: number,
105
+ rowKey?: keyof T | ((row: T, i: number) => React.Key),
106
+ ): React.Key {
107
+ if (typeof rowKey === 'function') return rowKey(row, index);
108
+ if (rowKey && row && typeof row === 'object') {
109
+ const value = (row as Record<string, unknown>)[rowKey as string];
110
+ if (typeof value === 'string' || typeof value === 'number') return value;
111
+ }
112
+ return index;
113
+ }
114
+
115
+ const SortIcon = ({ direction }: { direction?: TableSortDirection }) => (
116
+ <svg
117
+ className={styles.sortIcon}
118
+ width="8"
119
+ height="12"
120
+ viewBox="0 0 8 12"
121
+ aria-hidden="true"
122
+ focusable="false"
123
+ >
124
+ <path
125
+ d="M4 0 L8 4 H0 Z"
126
+ fill="currentColor"
127
+ opacity={direction === 'asc' ? 1 : 0.3}
128
+ />
129
+ <path
130
+ d="M4 12 L0 8 H8 Z"
131
+ fill="currentColor"
132
+ opacity={direction === 'desc' ? 1 : 0.3}
133
+ />
134
+ </svg>
135
+ );
136
+
137
+ export const EvoTable = <T extends Record<string, unknown>>({
138
+ columns,
139
+ data,
140
+ size = 'md',
141
+ striped = false,
142
+ hoverable = true,
143
+ bordered = false,
144
+ stickyHeader = false,
145
+ loading = false,
146
+ loadingRows = 5,
147
+ rowKey,
148
+ emptyState,
149
+ emptyText = 'No data available.',
150
+ onRowClick,
151
+ onRowDoubleClick,
152
+ getRowClassName,
153
+ responsive = 'scroll',
154
+ caption,
155
+ sort,
156
+ onSortChange,
157
+ defaultSort,
158
+ className,
159
+ }: EvoTableProps<T>) => {
160
+ const isControlled = sort !== undefined;
161
+ const [internalSort, setInternalSort] = useState<TableSortState | null>(defaultSort ?? null);
162
+ const activeSort = isControlled ? sort : internalSort;
163
+
164
+ const handleSort = (key: string) => {
165
+ let next: TableSortState | null;
166
+ if (!activeSort || activeSort.key !== key) {
167
+ next = { key, direction: 'asc' };
168
+ } else if (activeSort.direction === 'asc') {
169
+ next = { key, direction: 'desc' };
170
+ } else {
171
+ next = null;
172
+ }
173
+ if (!isControlled) setInternalSort(next);
174
+ onSortChange?.(next);
175
+ };
176
+
177
+ const sortedData = useMemo(() => {
178
+ if (!activeSort) return data;
179
+ const col = columns.find((c) => c.key === activeSort.key);
180
+ if (!col) return data;
181
+ const direction = activeSort.direction;
182
+ const next = [...data];
183
+ if (col.sortFn) {
184
+ next.sort((a, b) => col.sortFn!(a, b, direction));
185
+ } else {
186
+ next.sort((a, b) => {
187
+ const av = getNestedValue(a, col.key);
188
+ const bv = getNestedValue(b, col.key);
189
+ const cmp = defaultCompare(av, bv);
190
+ return direction === 'asc' ? cmp : -cmp;
191
+ });
192
+ }
193
+ return next;
194
+ }, [data, columns, activeSort]);
195
+
196
+ const isClickable = Boolean(onRowClick || onRowDoubleClick);
197
+
198
+ const wrapperClass = [
199
+ styles.wrapper,
200
+ styles[`size-${size}`],
201
+ striped && styles.striped,
202
+ hoverable && styles.hoverable,
203
+ bordered && styles.bordered,
204
+ stickyHeader && styles.stickyHeader,
205
+ responsive === 'stack' && styles.responsiveStack,
206
+ isClickable && styles.clickable,
207
+ className,
208
+ ]
209
+ .filter(Boolean)
210
+ .join(' ');
211
+
212
+ const renderHeader = (col: TableColumn<T>) => {
213
+ const align = col.align ?? 'left';
214
+ const isSorted = activeSort?.key === col.key;
215
+ const direction = isSorted ? activeSort!.direction : undefined;
216
+ const ariaSort: React.AriaAttributes['aria-sort'] = isSorted
217
+ ? direction === 'asc'
218
+ ? 'ascending'
219
+ : 'descending'
220
+ : col.sortable
221
+ ? 'none'
222
+ : undefined;
223
+
224
+ const thClass = [styles.th, styles[`align-${align}`], col.headerClassName]
225
+ .filter(Boolean)
226
+ .join(' ');
227
+
228
+ return (
229
+ <th
230
+ key={col.key}
231
+ scope="col"
232
+ className={thClass}
233
+ style={col.width ? { width: col.width } : undefined}
234
+ aria-sort={ariaSort}
235
+ >
236
+ {col.sortable ? (
237
+ <button
238
+ type="button"
239
+ className={[styles.sortBtn, isSorted ? styles.sortActive : '']
240
+ .filter(Boolean)
241
+ .join(' ')}
242
+ onClick={() => handleSort(col.key)}
243
+ >
244
+ <span>{col.header}</span>
245
+ <SortIcon direction={direction} />
246
+ </button>
247
+ ) : (
248
+ col.header
249
+ )}
250
+ </th>
251
+ );
252
+ };
253
+
254
+ const renderBody = () => {
255
+ if (loading) {
256
+ return Array.from({ length: Math.max(1, loadingRows) }).map((_, i) => (
257
+ <tr key={`evo-skeleton-${i}`} className={styles.tr}>
258
+ {columns.map((col) => (
259
+ <td
260
+ key={col.key}
261
+ className={[styles.td, styles[`align-${col.align ?? 'left'}`]]
262
+ .filter(Boolean)
263
+ .join(' ')}
264
+ data-label={typeof col.header === 'string' ? col.header : col.key}
265
+ >
266
+ <span className={styles.skeleton} />
267
+ </td>
268
+ ))}
269
+ </tr>
270
+ ));
271
+ }
272
+
273
+ if (sortedData.length === 0) {
274
+ return (
275
+ <tr className={styles.emptyRow}>
276
+ <td colSpan={columns.length} className={styles.empty}>
277
+ {emptyState ?? <span className={styles.emptyText}>{emptyText}</span>}
278
+ </td>
279
+ </tr>
280
+ );
281
+ }
282
+
283
+ return sortedData.map((row, rowIndex) => {
284
+ const trClass = [styles.tr, getRowClassName?.(row, rowIndex)]
285
+ .filter(Boolean)
286
+ .join(' ');
287
+
288
+ return (
289
+ <tr
290
+ key={resolveRowKey(row, rowIndex, rowKey)}
291
+ className={trClass}
292
+ onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
293
+ onDoubleClick={
294
+ onRowDoubleClick ? () => onRowDoubleClick(row, rowIndex) : undefined
295
+ }
296
+ >
297
+ {columns.map((col) => {
298
+ const value = getNestedValue(row, col.key);
299
+ const align = col.align ?? 'left';
300
+ const tdClass = [styles.td, styles[`align-${align}`], col.cellClassName]
301
+ .filter(Boolean)
302
+ .join(' ');
303
+ return (
304
+ <td
305
+ key={col.key}
306
+ className={tdClass}
307
+ data-label={typeof col.header === 'string' ? col.header : col.key}
308
+ >
309
+ {col.render
310
+ ? col.render(value, row, rowIndex)
311
+ : value == null
312
+ ? ''
313
+ : String(value)}
314
+ </td>
315
+ );
316
+ })}
317
+ </tr>
318
+ );
319
+ });
320
+ };
321
+
322
+ return (
323
+ <div className={wrapperClass}>
324
+ <div className={styles.scroll}>
325
+ <table className={styles.table}>
326
+ {caption && <caption className={styles.caption}>{caption}</caption>}
327
+ <thead className={styles.thead}>
328
+ <tr>{columns.map(renderHeader)}</tr>
329
+ </thead>
330
+ <tbody className={styles.tbody}>{renderBody()}</tbody>
331
+ </table>
332
+ </div>
333
+ </div>
334
+ );
335
+ };