@justin_evo/evo-ui 1.1.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 (80) hide show
  1. package/README.md +3 -3
  2. package/dist/TopNav/TopNav.d.ts +19 -0
  3. package/dist/declarations.d.ts +6 -6
  4. package/dist/evo-ui.css +1 -1
  5. package/dist/index.cjs.js +1 -1
  6. package/dist/index.es.js +3301 -3197
  7. package/package.json +52 -52
  8. package/src/Alert/Alert.tsx +49 -49
  9. package/src/AutoComplete/AutoComplete.tsx +810 -810
  10. package/src/Badge/Badge.tsx +53 -53
  11. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  12. package/src/Button/Button.tsx +125 -125
  13. package/src/Card/Card.tsx +257 -257
  14. package/src/Checkbox/Checkbox.tsx +59 -59
  15. package/src/CommandPalette/CommandPalette.tsx +185 -185
  16. package/src/Container/Container.tsx +31 -31
  17. package/src/Divider/Divider.tsx +31 -31
  18. package/src/Form/Form.tsx +185 -185
  19. package/src/Grid/Grid.tsx +66 -66
  20. package/src/ImageCropper/ImageCropper.tsx +911 -911
  21. package/src/Input/Input.tsx +74 -74
  22. package/src/Modal/Modal.tsx +77 -77
  23. package/src/Nav/Nav.tsx +708 -708
  24. package/src/Notification/Notification.tsx +1503 -1503
  25. package/src/Pagination/Pagination.tsx +76 -76
  26. package/src/Radio/Radio.tsx +69 -69
  27. package/src/RichTextArea/RichTextArea.tsx +886 -869
  28. package/src/Select/Select.tsx +515 -515
  29. package/src/Skeleton/Skeleton.tsx +70 -70
  30. package/src/Stack/Stack.tsx +52 -52
  31. package/src/Table/Table.tsx +335 -335
  32. package/src/Tabs/Tabs.tsx +90 -90
  33. package/src/Theme/ThemeProvider.tsx +253 -253
  34. package/src/Theme/ThemeToggle.tsx +79 -79
  35. package/src/Toggle/Toggle.tsx +48 -48
  36. package/src/Tooltip/Tooltip.tsx +38 -38
  37. package/src/TopNav/TopNav.tsx +1163 -994
  38. package/src/TreeSelect/TreeSelect.tsx +825 -825
  39. package/src/css/alert.module.scss +93 -93
  40. package/src/css/autocomplete.module.scss +416 -416
  41. package/src/css/badge.module.scss +82 -82
  42. package/src/css/base/_color.scss +159 -159
  43. package/src/css/base/_theme.scss +237 -237
  44. package/src/css/base/_variables.scss +161 -161
  45. package/src/css/breadcrumb.module.scss +50 -50
  46. package/src/css/button.module.scss +385 -385
  47. package/src/css/card.module.scss +217 -217
  48. package/src/css/checkbox.module.scss +123 -120
  49. package/src/css/commandpalette.module.scss +211 -211
  50. package/src/css/container.module.scss +18 -18
  51. package/src/css/divider.module.scss +41 -41
  52. package/src/css/form.module.scss +245 -245
  53. package/src/css/imagecropper.module.scss +397 -397
  54. package/src/css/input.module.scss +89 -89
  55. package/src/css/modal.module.scss +105 -105
  56. package/src/css/nav.module.scss +494 -494
  57. package/src/css/notification.module.scss +691 -691
  58. package/src/css/pagination.module.scss +63 -63
  59. package/src/css/radio.module.scss +89 -89
  60. package/src/css/richtextarea.module.scss +307 -307
  61. package/src/css/select.module.scss +525 -525
  62. package/src/css/skeleton.module.scss +30 -30
  63. package/src/css/table.module.scss +386 -386
  64. package/src/css/tabs.module.scss +63 -63
  65. package/src/css/theme-toggle.module.scss +83 -83
  66. package/src/css/toggle.module.scss +54 -54
  67. package/src/css/tooltip.module.scss +97 -97
  68. package/src/css/topnav.module.scss +568 -396
  69. package/src/css/treeselect.module.scss +558 -558
  70. package/src/css/utilities/_borders.scss +111 -111
  71. package/src/css/utilities/_colors.scss +66 -66
  72. package/src/css/utilities/_effects.scss +216 -216
  73. package/src/css/utilities/_layout.scss +181 -181
  74. package/src/css/utilities/_position.scss +75 -75
  75. package/src/css/utilities/_sizing.scss +138 -138
  76. package/src/css/utilities/_spacing.scss +99 -99
  77. package/src/css/utilities/_typography.scss +121 -121
  78. package/src/css/utilities/index.scss +24 -24
  79. package/src/declarations.d.ts +6 -6
  80. 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
+ };