tekivex-ui 2.0.2 → 2.1.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.
Files changed (78) hide show
  1. package/dist/index.cjs +12 -12
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2207 -2018
  5. package/dist/src/components/TkxAppBar.d.ts +12 -0
  6. package/dist/src/components/TkxAppBar.d.ts.map +1 -0
  7. package/dist/src/components/TkxAutocomplete.d.ts +25 -0
  8. package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
  9. package/dist/src/components/TkxBottomNav.d.ts +15 -0
  10. package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
  11. package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
  12. package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
  13. package/dist/src/components/TkxCard.d.ts.map +1 -1
  14. package/dist/src/components/TkxDataGrid.d.ts +33 -0
  15. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  16. package/dist/src/components/TkxMasonry.d.ts +12 -0
  17. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  18. package/dist/src/components/TkxModal.d.ts.map +1 -1
  19. package/dist/src/components/TkxPopover.d.ts +15 -0
  20. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  21. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  22. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  23. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  24. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  25. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  26. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  27. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  28. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  29. package/dist/src/components/TkxToolbar.d.ts +22 -0
  30. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  31. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  32. package/dist/src/components/TkxTransferList.d.ts +20 -0
  33. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  34. package/dist/src/components/TkxTreeView.d.ts +23 -0
  35. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  36. package/dist/src/components/index.d.ts +13 -0
  37. package/dist/src/components/index.d.ts.map +1 -1
  38. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  39. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  40. package/dist/src/i18n/index.d.ts +50 -0
  41. package/dist/src/i18n/index.d.ts.map +1 -0
  42. package/package.json +2 -2
  43. package/src/components/TkxAppBar.tsx +260 -0
  44. package/src/components/TkxAutocomplete.tsx +453 -0
  45. package/src/components/TkxBottomNav.tsx +239 -0
  46. package/src/components/TkxBreadcrumb.tsx +322 -0
  47. package/src/components/TkxCard.tsx +3 -1
  48. package/src/components/TkxDataGrid.tsx +540 -0
  49. package/src/components/TkxMasonry.tsx +176 -0
  50. package/src/components/TkxModal.tsx +4 -3
  51. package/src/components/TkxPopover.tsx +295 -0
  52. package/src/components/TkxRichTextDisplay.tsx +394 -0
  53. package/src/components/TkxSelect.tsx +2 -0
  54. package/src/components/TkxSlider.tsx +1 -0
  55. package/src/components/TkxSnackbar.tsx +314 -0
  56. package/src/components/TkxSpeedDial.tsx +395 -0
  57. package/src/components/TkxToolbar.tsx +260 -0
  58. package/src/components/TkxTooltip.tsx +3 -2
  59. package/src/components/TkxTransferList.tsx +487 -0
  60. package/src/components/TkxTreeView.tsx +421 -0
  61. package/src/components/index.ts +13 -0
  62. package/src/hooks/index.ts +1 -1
  63. package/src/i18n/I18nProvider.tsx +36 -0
  64. package/src/i18n/index.ts +237 -0
  65. package/dist/tests/TkxButton.test.d.ts +0 -2
  66. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  67. package/dist/tests/a11y/components.test.d.ts +0 -2
  68. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  69. package/dist/tests/quantum.test.d.ts +0 -2
  70. package/dist/tests/quantum.test.d.ts.map +0 -1
  71. package/dist/tests/security.test.d.ts +0 -2
  72. package/dist/tests/security.test.d.ts.map +0 -1
  73. package/dist/tests/setup.d.ts +0 -2
  74. package/dist/tests/setup.d.ts.map +0 -1
  75. package/dist/tests/tkx.test.d.ts +0 -2
  76. package/dist/tests/tkx.test.d.ts.map +0 -1
  77. package/dist/tests/wcag.test.d.ts +0 -2
  78. package/dist/tests/wcag.test.d.ts.map +0 -1
@@ -0,0 +1,540 @@
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useRef,
5
+ useMemo,
6
+ useId,
7
+ type ReactNode,
8
+ type CSSProperties,
9
+ type MouseEvent as ReactMouseEvent,
10
+ } from 'react';
11
+ import { useTheme } from '../themes';
12
+ import { sanitizeString } from '../engine/security';
13
+ import { useReducedMotion } from '../hooks';
14
+ import { TkxSkeleton } from './TkxSkeleton';
15
+ import { tkx } from '../engine/tkx';
16
+
17
+ // ── Types ─────────────────────────────────────────────────────────────────────
18
+
19
+ export interface DataGridColumn<T = any> {
20
+ key: string;
21
+ header: string;
22
+ width?: number | string;
23
+ sortable?: boolean;
24
+ filterable?: boolean;
25
+ resizable?: boolean;
26
+ renderCell?: (value: any, row: T) => ReactNode;
27
+ renderHeader?: (col: DataGridColumn<T>) => ReactNode;
28
+ align?: 'left' | 'center' | 'right';
29
+ pinned?: 'left' | 'right';
30
+ }
31
+
32
+ export interface TkxDataGridProps<T = any> {
33
+ columns: DataGridColumn<T>[];
34
+ data: T[];
35
+ rowKey: string | ((row: T) => string);
36
+ selectable?: boolean;
37
+ selectedRows?: string[];
38
+ onSelectionChange?: (ids: string[]) => void;
39
+ sortable?: boolean;
40
+ onSort?: (key: string, direction: 'asc' | 'desc') => void;
41
+ loading?: boolean;
42
+ emptyMessage?: string;
43
+ stickyHeader?: boolean;
44
+ striped?: boolean;
45
+ bordered?: boolean;
46
+ compact?: boolean;
47
+ maxHeight?: number | string;
48
+ onRowClick?: (row: T) => void;
49
+ }
50
+
51
+ // ── Sort icon ─────────────────────────────────────────────────────────────────
52
+
53
+ function SortIcon({ direction }: { direction: 'asc' | 'desc' | null }) {
54
+ return (
55
+ <svg
56
+ width="12"
57
+ height="12"
58
+ viewBox="0 0 24 24"
59
+ fill="currentColor"
60
+ aria-hidden="true"
61
+ className={tkx('shrink-0 ml-1')}
62
+ >
63
+ {direction === 'asc' ? (
64
+ <path d="M7 14l5-5 5 5H7z" />
65
+ ) : direction === 'desc' ? (
66
+ <path d="M7 10l5 5 5-5H7z" />
67
+ ) : (
68
+ <path d="M7 10l5 5 5-5H7zM7 14l5-5 5 5H7z" opacity="0.35" />
69
+ )}
70
+ </svg>
71
+ );
72
+ }
73
+
74
+ // ── Checkbox icon ─────────────────────────────────────────────────────────────
75
+
76
+ function CheckboxIcon({ checked, indeterminate }: { checked: boolean; indeterminate?: boolean }) {
77
+ const theme = useTheme();
78
+ return (
79
+ <svg
80
+ width="16"
81
+ height="16"
82
+ viewBox="0 0 16 16"
83
+ aria-hidden="true"
84
+ >
85
+ <rect
86
+ x="1"
87
+ y="1"
88
+ width="14"
89
+ height="14"
90
+ rx="2"
91
+ fill={checked || indeterminate ? theme.primary : 'transparent'}
92
+ stroke={checked || indeterminate ? theme.primary : theme.border}
93
+ strokeWidth="1.5"
94
+ />
95
+ {indeterminate && (
96
+ <line x1="4" y1="8" x2="12" y2="8" stroke={theme.bg} strokeWidth="2" />
97
+ )}
98
+ {checked && !indeterminate && (
99
+ <path d="M4.5 8L7 10.5L11.5 5.5" fill="none" stroke={theme.bg} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
100
+ )}
101
+ </svg>
102
+ );
103
+ }
104
+
105
+ // ── Helpers ──────────────────────────────────────────────────────────────────
106
+
107
+ function getRowId<T>(row: T, rowKey: string | ((row: T) => string)): string {
108
+ if (typeof rowKey === 'function') return rowKey(row);
109
+ return String((row as Record<string, unknown>)[rowKey]);
110
+ }
111
+
112
+ function getCellValue<T>(row: T, key: string): unknown {
113
+ return (row as Record<string, unknown>)[key];
114
+ }
115
+
116
+ // ── Loading skeleton ────────────────────────────────────────────────────────
117
+
118
+ function LoadingOverlay({ colCount }: { colCount: number }) {
119
+ const theme = useTheme();
120
+ const rows = Array.from({ length: 5 }, (_, i) => i);
121
+ return (
122
+ <tbody>
123
+ {rows.map((r) => (
124
+ <tr key={r}>
125
+ {Array.from({ length: colCount }, (_, c) => (
126
+ <td
127
+ key={c}
128
+ className={tkx('px-3 py-2')}
129
+ style={{ borderBottom: `1px solid ${theme.border}` }}
130
+ >
131
+ <TkxSkeleton height={16} />
132
+ </td>
133
+ ))}
134
+ </tr>
135
+ ))}
136
+ </tbody>
137
+ );
138
+ }
139
+
140
+ // ── Component ─────────────────────────────────────────────────────────────────
141
+
142
+ export function TkxDataGrid<T = any>({
143
+ columns,
144
+ data,
145
+ rowKey,
146
+ selectable = false,
147
+ selectedRows = [],
148
+ onSelectionChange,
149
+ sortable = false,
150
+ onSort,
151
+ loading = false,
152
+ emptyMessage = 'No data to display',
153
+ stickyHeader = false,
154
+ striped = false,
155
+ bordered = false,
156
+ compact = false,
157
+ maxHeight,
158
+ onRowClick,
159
+ }: TkxDataGridProps<T>) {
160
+ const theme = useTheme();
161
+ const reduced = useReducedMotion();
162
+ const gridId = useId();
163
+
164
+ // ── Sort state ──────────────────────────────────────────────────────────
165
+
166
+ const [sortKey, setSortKey] = useState<string | null>(null);
167
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
168
+
169
+ const handleSort = useCallback(
170
+ (key: string) => {
171
+ let nextDir: 'asc' | 'desc' = 'asc';
172
+ if (sortKey === key) {
173
+ nextDir = sortDir === 'asc' ? 'desc' : 'asc';
174
+ }
175
+ setSortKey(key);
176
+ setSortDir(nextDir);
177
+ onSort?.(key, nextDir);
178
+ },
179
+ [sortKey, sortDir, onSort],
180
+ );
181
+
182
+ // ── Selection logic ─────────────────────────────────────────────────────
183
+
184
+ const selectedSet = useMemo(() => new Set(selectedRows), [selectedRows]);
185
+ const allIds = useMemo(
186
+ () => data.map((row) => getRowId(row, rowKey)),
187
+ [data, rowKey],
188
+ );
189
+ const allSelected = allIds.length > 0 && allIds.every((id) => selectedSet.has(id));
190
+ const someSelected = allIds.some((id) => selectedSet.has(id));
191
+
192
+ const toggleAll = useCallback(() => {
193
+ if (allSelected) {
194
+ onSelectionChange?.([]);
195
+ } else {
196
+ onSelectionChange?.(allIds);
197
+ }
198
+ }, [allSelected, allIds, onSelectionChange]);
199
+
200
+ const toggleRow = useCallback(
201
+ (id: string) => {
202
+ const next = selectedSet.has(id)
203
+ ? selectedRows.filter((r) => r !== id)
204
+ : [...selectedRows, id];
205
+ onSelectionChange?.(next);
206
+ },
207
+ [selectedSet, selectedRows, onSelectionChange],
208
+ );
209
+
210
+ // ── Column resize ─────────────────────────────────────────────────────
211
+
212
+ const [colWidths, setColWidths] = useState<Record<string, number>>({});
213
+ const resizeRef = useRef<{
214
+ key: string;
215
+ startX: number;
216
+ startWidth: number;
217
+ } | null>(null);
218
+
219
+ const handleResizeStart = useCallback(
220
+ (key: string, e: ReactMouseEvent) => {
221
+ e.preventDefault();
222
+ e.stopPropagation();
223
+ const th = (e.target as HTMLElement).closest('th');
224
+ if (!th) return;
225
+ const startWidth = colWidths[key] || th.getBoundingClientRect().width;
226
+ resizeRef.current = { key, startX: e.clientX, startWidth };
227
+
228
+ const handleMouseMove = (ev: globalThis.MouseEvent) => {
229
+ if (!resizeRef.current) return;
230
+ const diff = ev.clientX - resizeRef.current.startX;
231
+ const newWidth = Math.max(50, resizeRef.current.startWidth + diff);
232
+ setColWidths((prev) => ({ ...prev, [resizeRef.current!.key]: newWidth }));
233
+ };
234
+
235
+ const handleMouseUp = () => {
236
+ resizeRef.current = null;
237
+ document.removeEventListener('mousemove', handleMouseMove);
238
+ document.removeEventListener('mouseup', handleMouseUp);
239
+ };
240
+
241
+ document.addEventListener('mousemove', handleMouseMove);
242
+ document.addEventListener('mouseup', handleMouseUp);
243
+ },
244
+ [colWidths],
245
+ );
246
+
247
+ // ── Cell sizing ─────────────────────────────────────────────────────────
248
+
249
+ const py = compact ? '4px' : '8px';
250
+ const px = compact ? '8px' : '12px';
251
+
252
+ const getColStyle = (col: DataGridColumn<T>): CSSProperties => {
253
+ const w = colWidths[col.key] || col.width;
254
+ return {
255
+ width: w ? (typeof w === 'number' ? w : w) : undefined,
256
+ minWidth: w ? (typeof w === 'number' ? w : w) : 80,
257
+ textAlign: col.align ?? 'left',
258
+ padding: `${py} ${px}`,
259
+ };
260
+ };
261
+
262
+ // ── Render helpers ────────────────────────────────────────────────────
263
+
264
+ const borderStyle = bordered
265
+ ? `1px solid ${theme.border}`
266
+ : 'none';
267
+
268
+ const safeEmpty = sanitizeString(emptyMessage);
269
+
270
+ const totalCols = columns.length + (selectable ? 1 : 0);
271
+
272
+ return (
273
+ <div
274
+ role="grid"
275
+ aria-label="Data grid"
276
+ id={gridId}
277
+ className={tkx('font-sans rounded-lg overflow-hidden')}
278
+ style={{
279
+ border: `1px solid ${theme.border}`,
280
+ backgroundColor: theme.bg,
281
+ }}
282
+ >
283
+ <div
284
+ style={{
285
+ maxHeight: maxHeight ?? 'none',
286
+ overflowX: 'auto',
287
+ overflowY: maxHeight ? 'auto' : 'visible',
288
+ }}
289
+ >
290
+ <table
291
+ className={tkx('w-full')}
292
+ style={{
293
+ borderCollapse: 'separate',
294
+ borderSpacing: 0,
295
+ tableLayout: 'auto',
296
+ }}
297
+ >
298
+ {/* ── Header ─────────────────────────────────────────────── */}
299
+ <thead>
300
+ <tr>
301
+ {/* Selection header */}
302
+ {selectable && (
303
+ <th
304
+ scope="col"
305
+ className={tkx('shrink-0')}
306
+ style={{
307
+ position: stickyHeader ? 'sticky' : 'static',
308
+ top: 0,
309
+ zIndex: 2,
310
+ backgroundColor: theme.surface,
311
+ borderBottom: `2px solid ${theme.border}`,
312
+ borderRight: bordered ? borderStyle : 'none',
313
+ padding: `${py} ${px}`,
314
+ width: 40,
315
+ textAlign: 'center',
316
+ }}
317
+ >
318
+ <button
319
+ onClick={toggleAll}
320
+ aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
321
+ className={tkx(
322
+ 'bg-transparent border-none cursor-pointer p-0',
323
+ 'flex items-center justify-center',
324
+ 'focus-visible:focus-ring',
325
+ )}
326
+ style={{ margin: '0 auto' }}
327
+ >
328
+ <CheckboxIcon
329
+ checked={allSelected}
330
+ indeterminate={someSelected && !allSelected}
331
+ />
332
+ </button>
333
+ </th>
334
+ )}
335
+
336
+ {/* Column headers */}
337
+ {columns.map((col) => {
338
+ const isSortable = (col.sortable ?? sortable) && col.sortable !== false;
339
+ const isSorted = sortKey === col.key;
340
+ const safeHeader = sanitizeString(col.header);
341
+
342
+ return (
343
+ <th
344
+ key={col.key}
345
+ scope="col"
346
+ aria-sort={
347
+ isSorted
348
+ ? sortDir === 'asc'
349
+ ? 'ascending'
350
+ : 'descending'
351
+ : undefined
352
+ }
353
+ className={tkx('text-xs font-semibold uppercase tracking-wider')}
354
+ style={{
355
+ ...getColStyle(col),
356
+ position: stickyHeader ? 'sticky' : 'static',
357
+ top: 0,
358
+ zIndex: 2,
359
+ backgroundColor: theme.surface,
360
+ color: theme.textMuted,
361
+ borderBottom: `2px solid ${theme.border}`,
362
+ borderRight: bordered ? borderStyle : 'none',
363
+ cursor: isSortable ? 'pointer' : 'default',
364
+ userSelect: 'none',
365
+ whiteSpace: 'nowrap',
366
+ transition: reduced ? 'none' : 'background-color 150ms ease',
367
+ }}
368
+ onClick={isSortable ? () => handleSort(col.key) : undefined}
369
+ onKeyDown={
370
+ isSortable
371
+ ? (e) => {
372
+ if (e.key === 'Enter' || e.key === ' ') {
373
+ e.preventDefault();
374
+ handleSort(col.key);
375
+ }
376
+ }
377
+ : undefined
378
+ }
379
+ tabIndex={isSortable ? 0 : undefined}
380
+ role={isSortable ? 'columnheader button' : 'columnheader'}
381
+ >
382
+ <div className={tkx('flex items-center gap-1')}>
383
+ {col.renderHeader ? col.renderHeader(col) : safeHeader}
384
+ {isSortable && (
385
+ <SortIcon direction={isSorted ? sortDir : null} />
386
+ )}
387
+
388
+ {/* Resize handle */}
389
+ {col.resizable && (
390
+ <div
391
+ role="separator"
392
+ aria-orientation="vertical"
393
+ aria-label={`Resize column ${safeHeader}`}
394
+ className={tkx('cursor-col-resize shrink-0')}
395
+ style={{
396
+ position: 'absolute',
397
+ right: 0,
398
+ top: 0,
399
+ bottom: 0,
400
+ width: 4,
401
+ backgroundColor: 'transparent',
402
+ }}
403
+ onMouseDown={(e) => handleResizeStart(col.key, e)}
404
+ />
405
+ )}
406
+ </div>
407
+ </th>
408
+ );
409
+ })}
410
+ </tr>
411
+ </thead>
412
+
413
+ {/* ── Loading ───────────────────────────────────────────── */}
414
+ {loading && <LoadingOverlay colCount={totalCols} />}
415
+
416
+ {/* ── Body ──────────────────────────────────────────────── */}
417
+ {!loading && (
418
+ <tbody>
419
+ {data.length === 0 ? (
420
+ <tr>
421
+ <td
422
+ colSpan={totalCols}
423
+ className={tkx('text-center py-10')}
424
+ style={{ color: theme.textMuted }}
425
+ >
426
+ {safeEmpty}
427
+ </td>
428
+ </tr>
429
+ ) : (
430
+ data.map((row, rowIndex) => {
431
+ const id = getRowId(row, rowKey);
432
+ const isSelected = selectedSet.has(id);
433
+ const isStriped = striped && rowIndex % 2 === 1;
434
+
435
+ let rowBg = 'transparent';
436
+ if (isSelected) rowBg = `${theme.primary}15`;
437
+ else if (isStriped) rowBg = theme.surfaceAlt;
438
+
439
+ return (
440
+ <tr
441
+ key={id}
442
+ role="row"
443
+ aria-selected={selectable ? isSelected : undefined}
444
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
445
+ className={tkx(onRowClick ? 'cursor-pointer' : '')}
446
+ style={{
447
+ backgroundColor: rowBg,
448
+ transition: reduced
449
+ ? 'none'
450
+ : 'background-color 120ms ease',
451
+ }}
452
+ onMouseEnter={
453
+ onRowClick
454
+ ? (e) => {
455
+ (e.currentTarget as HTMLElement).style.backgroundColor =
456
+ isSelected
457
+ ? `${theme.primary}22`
458
+ : `${theme.surfaceAlt}`;
459
+ }
460
+ : undefined
461
+ }
462
+ onMouseLeave={
463
+ onRowClick
464
+ ? (e) => {
465
+ (e.currentTarget as HTMLElement).style.backgroundColor = rowBg;
466
+ }
467
+ : undefined
468
+ }
469
+ >
470
+ {/* Selection checkbox */}
471
+ {selectable && (
472
+ <td
473
+ style={{
474
+ borderBottom: `1px solid ${theme.border}`,
475
+ borderRight: bordered ? borderStyle : 'none',
476
+ padding: `${py} ${px}`,
477
+ textAlign: 'center',
478
+ width: 40,
479
+ }}
480
+ >
481
+ <button
482
+ onClick={(e) => {
483
+ e.stopPropagation();
484
+ toggleRow(id);
485
+ }}
486
+ aria-label={
487
+ isSelected
488
+ ? `Deselect row ${id}`
489
+ : `Select row ${id}`
490
+ }
491
+ className={tkx(
492
+ 'bg-transparent border-none cursor-pointer p-0',
493
+ 'flex items-center justify-center',
494
+ 'focus-visible:focus-ring',
495
+ )}
496
+ style={{ margin: '0 auto' }}
497
+ >
498
+ <CheckboxIcon checked={isSelected} />
499
+ </button>
500
+ </td>
501
+ )}
502
+
503
+ {/* Data cells */}
504
+ {columns.map((col) => {
505
+ const value = getCellValue(row, col.key);
506
+ const cellContent = col.renderCell
507
+ ? col.renderCell(value, row)
508
+ : typeof value === 'string'
509
+ ? sanitizeString(value)
510
+ : String(value ?? '');
511
+
512
+ return (
513
+ <td
514
+ key={col.key}
515
+ className={tkx('text-sm')}
516
+ style={{
517
+ ...getColStyle(col),
518
+ borderBottom: `1px solid ${theme.border}`,
519
+ borderRight: bordered ? borderStyle : 'none',
520
+ color: theme.text,
521
+ overflow: 'hidden',
522
+ textOverflow: 'ellipsis',
523
+ whiteSpace: 'nowrap',
524
+ }}
525
+ >
526
+ {cellContent}
527
+ </td>
528
+ );
529
+ })}
530
+ </tr>
531
+ );
532
+ })
533
+ )}
534
+ </tbody>
535
+ )}
536
+ </table>
537
+ </div>
538
+ </div>
539
+ );
540
+ }