@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.
- package/LICENSE +21 -21
- package/README.md +70 -70
- package/dist/declarations.d.ts +6 -6
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -886
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -1163
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -123
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -568
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- package/src/index.ts +60 -60
package/src/Table/Table.tsx
CHANGED
|
@@ -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
|
+
};
|