@thangnqdev/ui 0.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.
- package/README.md +196 -0
- package/dist/Label-DWOlgSNW.d.cts +14 -0
- package/dist/Label-DWOlgSNW.d.ts +14 -0
- package/dist/bento/index.cjs +140 -0
- package/dist/bento/index.cjs.map +1 -0
- package/dist/bento/index.d.cts +4 -0
- package/dist/bento/index.d.ts +4 -0
- package/dist/bento/index.js +4 -0
- package/dist/bento/index.js.map +1 -0
- package/dist/bento/styles.css +46 -0
- package/dist/buttons/index.cjs +365 -0
- package/dist/buttons/index.cjs.map +1 -0
- package/dist/buttons/index.d.cts +4 -0
- package/dist/buttons/index.d.ts +4 -0
- package/dist/buttons/index.js +5 -0
- package/dist/buttons/index.js.map +1 -0
- package/dist/buttons/styles.css +6 -0
- package/dist/chunk-2OXZK6IP.js +514 -0
- package/dist/chunk-2OXZK6IP.js.map +1 -0
- package/dist/chunk-4S6L5V7X.js +15 -0
- package/dist/chunk-4S6L5V7X.js.map +1 -0
- package/dist/chunk-5FZ6ZFII.js +728 -0
- package/dist/chunk-5FZ6ZFII.js.map +1 -0
- package/dist/chunk-66VWUJEI.js +86 -0
- package/dist/chunk-66VWUJEI.js.map +1 -0
- package/dist/chunk-A7P3O5Z5.js +163 -0
- package/dist/chunk-A7P3O5Z5.js.map +1 -0
- package/dist/chunk-ATYGDTMK.js +257 -0
- package/dist/chunk-ATYGDTMK.js.map +1 -0
- package/dist/chunk-C4SUXMI3.js +138 -0
- package/dist/chunk-C4SUXMI3.js.map +1 -0
- package/dist/chunk-EU2LPUYP.js +25 -0
- package/dist/chunk-EU2LPUYP.js.map +1 -0
- package/dist/chunk-F5Q7BTMH.js +1991 -0
- package/dist/chunk-F5Q7BTMH.js.map +1 -0
- package/dist/chunk-GXI2H5XA.js +46 -0
- package/dist/chunk-GXI2H5XA.js.map +1 -0
- package/dist/chunk-H3LCNPMC.js +101 -0
- package/dist/chunk-H3LCNPMC.js.map +1 -0
- package/dist/chunk-IABPUCAQ.js +19 -0
- package/dist/chunk-IABPUCAQ.js.map +1 -0
- package/dist/chunk-KJRBRTQE.js +101 -0
- package/dist/chunk-KJRBRTQE.js.map +1 -0
- package/dist/chunk-LBUBXUYQ.js +40 -0
- package/dist/chunk-LBUBXUYQ.js.map +1 -0
- package/dist/chunk-LWFRUJRU.js +537 -0
- package/dist/chunk-LWFRUJRU.js.map +1 -0
- package/dist/chunk-NFVONJUK.js +882 -0
- package/dist/chunk-NFVONJUK.js.map +1 -0
- package/dist/chunk-PF4ZHGVW.js +284 -0
- package/dist/chunk-PF4ZHGVW.js.map +1 -0
- package/dist/chunk-WAFHC4DQ.js +366 -0
- package/dist/chunk-WAFHC4DQ.js.map +1 -0
- package/dist/chunk-X76KBH4Y.js +71 -0
- package/dist/chunk-X76KBH4Y.js.map +1 -0
- package/dist/core/index.cjs +103 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +3 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +4 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/styles.css +128 -0
- package/dist/data-display/index.cjs +1023 -0
- package/dist/data-display/index.cjs.map +1 -0
- package/dist/data-display/index.d.cts +4 -0
- package/dist/data-display/index.d.ts +4 -0
- package/dist/data-display/index.js +6 -0
- package/dist/data-display/index.js.map +1 -0
- package/dist/data-display/styles.css +4 -0
- package/dist/feedback/index.cjs +368 -0
- package/dist/feedback/index.cjs.map +1 -0
- package/dist/feedback/index.d.cts +4 -0
- package/dist/feedback/index.d.ts +4 -0
- package/dist/feedback/index.js +4 -0
- package/dist/feedback/index.js.map +1 -0
- package/dist/feedback/styles.css +4 -0
- package/dist/forms/index.cjs +2287 -0
- package/dist/forms/index.cjs.map +1 -0
- package/dist/forms/index.d.cts +5 -0
- package/dist/forms/index.d.ts +5 -0
- package/dist/forms/index.js +10 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/forms/styles.css +6 -0
- package/dist/glass/index.cjs +165 -0
- package/dist/glass/index.cjs.map +1 -0
- package/dist/glass/index.d.cts +4 -0
- package/dist/glass/index.d.ts +4 -0
- package/dist/glass/index.js +4 -0
- package/dist/glass/index.js.map +1 -0
- package/dist/glass/styles.css +81 -0
- package/dist/index-BTvkbCIj.d.cts +146 -0
- package/dist/index-BTvkbCIj.d.ts +146 -0
- package/dist/index-BkRqGNG1.d.cts +201 -0
- package/dist/index-BkRqGNG1.d.ts +201 -0
- package/dist/index-BlPcGeW-.d.cts +45 -0
- package/dist/index-BlPcGeW-.d.ts +45 -0
- package/dist/index-BzOCqvC6.d.cts +32 -0
- package/dist/index-BzOCqvC6.d.ts +32 -0
- package/dist/index-CCeWYAvn.d.cts +83 -0
- package/dist/index-COQ7uwt9.d.cts +44 -0
- package/dist/index-COQ7uwt9.d.ts +44 -0
- package/dist/index-CenuF_P2.d.cts +221 -0
- package/dist/index-CenuF_P2.d.ts +221 -0
- package/dist/index-Cle6QzQl.d.ts +371 -0
- package/dist/index-D6fE7w-b.d.cts +90 -0
- package/dist/index-D6fE7w-b.d.ts +90 -0
- package/dist/index-DR8cfJUG.d.ts +83 -0
- package/dist/index-DzcWh1Hs.d.cts +131 -0
- package/dist/index-DzcWh1Hs.d.ts +131 -0
- package/dist/index-TyK-Ldpy.d.cts +371 -0
- package/dist/index-ugj5s6a3.d.cts +95 -0
- package/dist/index-ugj5s6a3.d.ts +95 -0
- package/dist/index.cjs +6400 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/index.cjs +516 -0
- package/dist/layout/index.cjs.map +1 -0
- package/dist/layout/index.d.cts +4 -0
- package/dist/layout/index.d.ts +4 -0
- package/dist/layout/index.js +4 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/layout/styles.css +8 -0
- package/dist/navigation/index.cjs +673 -0
- package/dist/navigation/index.cjs.map +1 -0
- package/dist/navigation/index.d.cts +2 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +8 -0
- package/dist/navigation/index.js.map +1 -0
- package/dist/navigation/styles.css +4 -0
- package/dist/overlays/index.cjs +954 -0
- package/dist/overlays/index.cjs.map +1 -0
- package/dist/overlays/index.d.cts +4 -0
- package/dist/overlays/index.d.ts +4 -0
- package/dist/overlays/index.js +9 -0
- package/dist/overlays/index.js.map +1 -0
- package/dist/overlays/styles.css +8 -0
- package/dist/styles.css +333 -0
- package/dist/typography/index.cjs +311 -0
- package/dist/typography/index.cjs.map +1 -0
- package/dist/typography/index.d.cts +5 -0
- package/dist/typography/index.d.ts +5 -0
- package/dist/typography/index.js +6 -0
- package/dist/typography/index.js.map +1 -0
- package/dist/typography/styles.css +5 -0
- package/llms.txt +113 -0
- package/package.json +116 -0
- package/src/bento/BentoCard.tsx +140 -0
- package/src/bento/BentoGrid.tsx +50 -0
- package/src/bento/index.ts +2 -0
- package/src/bento/tokens.css +46 -0
- package/src/buttons/Button.tsx +104 -0
- package/src/buttons/ButtonGroup.tsx +43 -0
- package/src/buttons/IconButton.tsx +79 -0
- package/src/buttons/SplitButton.tsx +86 -0
- package/src/buttons/ToggleButton.tsx +157 -0
- package/src/buttons/index.ts +5 -0
- package/src/buttons/tokens.css +6 -0
- package/src/core/ThemeProvider.tsx +123 -0
- package/src/core/index.ts +2 -0
- package/src/core/tokens.css +128 -0
- package/src/data-display/Accordion.tsx +147 -0
- package/src/data-display/Avatar.tsx +134 -0
- package/src/data-display/Card.tsx +112 -0
- package/src/data-display/Carousel.tsx +130 -0
- package/src/data-display/Chip.tsx +74 -0
- package/src/data-display/DataTablePro.tsx +356 -0
- package/src/data-display/StatCard.tsx +60 -0
- package/src/data-display/Table.tsx +74 -0
- package/src/data-display/Timeline.tsx +62 -0
- package/src/data-display/TreeView.tsx +140 -0
- package/src/data-display/index.ts +10 -0
- package/src/data-display/tokens.css +4 -0
- package/src/feedback/Alert.tsx +80 -0
- package/src/feedback/EmptyState.tsx +45 -0
- package/src/feedback/ProgressBar.tsx +174 -0
- package/src/feedback/Skeleton.tsx +66 -0
- package/src/feedback/Spinner.tsx +65 -0
- package/src/feedback/index.ts +5 -0
- package/src/feedback/tokens.css +4 -0
- package/src/forms/Checkbox.tsx +69 -0
- package/src/forms/CheckboxGroup.tsx +29 -0
- package/src/forms/Combobox.tsx +368 -0
- package/src/forms/DatePicker.tsx +480 -0
- package/src/forms/FileUpload.tsx +222 -0
- package/src/forms/FormField.tsx +115 -0
- package/src/forms/Input.tsx +151 -0
- package/src/forms/MaskedInput.tsx +97 -0
- package/src/forms/NumberInput.tsx +248 -0
- package/src/forms/PasswordInput.tsx +35 -0
- package/src/forms/PinInput.tsx +125 -0
- package/src/forms/Radio.tsx +142 -0
- package/src/forms/RangeSlider.tsx +111 -0
- package/src/forms/SearchInput.tsx +34 -0
- package/src/forms/Select.tsx +103 -0
- package/src/forms/Slider.tsx +85 -0
- package/src/forms/Switch.tsx +95 -0
- package/src/forms/Textarea.tsx +114 -0
- package/src/forms/index.ts +18 -0
- package/src/forms/tokens.css +6 -0
- package/src/glass/GlassBadge.tsx +61 -0
- package/src/glass/GlassButton.tsx +59 -0
- package/src/glass/GlassCard.tsx +71 -0
- package/src/glass/index.ts +3 -0
- package/src/glass/tokens.css +81 -0
- package/src/index.ts +26 -0
- package/src/layout/AspectRatio.tsx +39 -0
- package/src/layout/Container.tsx +53 -0
- package/src/layout/Divider.tsx +61 -0
- package/src/layout/Flex.tsx +79 -0
- package/src/layout/Grid.tsx +124 -0
- package/src/layout/PageLayout.tsx +59 -0
- package/src/layout/ScrollArea.tsx +43 -0
- package/src/layout/Section.tsx +47 -0
- package/src/layout/Spacer.tsx +30 -0
- package/src/layout/Stack.tsx +88 -0
- package/src/layout/index.ts +10 -0
- package/src/layout/tokens.css +8 -0
- package/src/navigation/Breadcrumb.tsx +73 -0
- package/src/navigation/CommandPalette.tsx +142 -0
- package/src/navigation/DropdownMenu.tsx +160 -0
- package/src/navigation/Pagination.tsx +114 -0
- package/src/navigation/Stepper.tsx +93 -0
- package/src/navigation/Tabs.tsx +133 -0
- package/src/navigation/index.ts +6 -0
- package/src/navigation/tokens.css +4 -0
- package/src/overlays/AlertDialog.tsx +54 -0
- package/src/overlays/BottomSheet.tsx +123 -0
- package/src/overlays/ContextMenu.tsx +119 -0
- package/src/overlays/Drawer.tsx +145 -0
- package/src/overlays/FocusTrap.tsx +49 -0
- package/src/overlays/HoverCard.tsx +72 -0
- package/src/overlays/Modal.tsx +219 -0
- package/src/overlays/Popover.tsx +126 -0
- package/src/overlays/Portal.tsx +23 -0
- package/src/overlays/Toast.tsx +137 -0
- package/src/overlays/Tooltip.tsx +100 -0
- package/src/overlays/index.ts +11 -0
- package/src/overlays/tokens.css +8 -0
- package/src/styles/tokens.css +2 -0
- package/src/typography/Blockquote.tsx +32 -0
- package/src/typography/Code.tsx +89 -0
- package/src/typography/Heading.tsx +56 -0
- package/src/typography/Kbd.tsx +25 -0
- package/src/typography/Label.tsx +47 -0
- package/src/typography/Link.tsx +56 -0
- package/src/typography/Text.tsx +62 -0
- package/src/typography/index.ts +7 -0
- package/src/typography/tokens.css +5 -0
- package/src/utils/cn.ts +6 -0
- package/ui-contract.json +62 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import React, { useState, useMemo, forwardRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ChevronUp,
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronsUpDown,
|
|
6
|
+
Search,
|
|
7
|
+
Download,
|
|
8
|
+
Trash2,
|
|
9
|
+
ChevronLeft,
|
|
10
|
+
ChevronRight,
|
|
11
|
+
Filter,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
import { cn } from '../utils/cn';
|
|
14
|
+
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
|
|
15
|
+
import { Button } from '../buttons/Button';
|
|
16
|
+
import { Input } from '../forms/Input';
|
|
17
|
+
import { Checkbox } from '../forms/Checkbox';
|
|
18
|
+
import { Chip } from './Chip';
|
|
19
|
+
|
|
20
|
+
export interface ColumnDef<T> {
|
|
21
|
+
key: string;
|
|
22
|
+
header: string | React.ReactNode;
|
|
23
|
+
accessor?: (row: T) => any;
|
|
24
|
+
render?: (row: T, index: number) => React.ReactNode;
|
|
25
|
+
sortable?: boolean;
|
|
26
|
+
width?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DataTableProProps<T> {
|
|
30
|
+
data: T[];
|
|
31
|
+
columns: ColumnDef<T>[];
|
|
32
|
+
keyExtractor: (row: T) => string;
|
|
33
|
+
searchable?: boolean;
|
|
34
|
+
searchPlaceholder?: string;
|
|
35
|
+
selectable?: boolean;
|
|
36
|
+
onSelectionChange?: (selectedKeys: string[]) => void;
|
|
37
|
+
pageSize?: number;
|
|
38
|
+
pageSizeOptions?: number[];
|
|
39
|
+
title?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
actions?: React.ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function DataTablePro<T extends Record<string, any>>({
|
|
46
|
+
data,
|
|
47
|
+
columns,
|
|
48
|
+
keyExtractor,
|
|
49
|
+
searchable = true,
|
|
50
|
+
searchPlaceholder = 'Search all records...',
|
|
51
|
+
selectable = true,
|
|
52
|
+
onSelectionChange,
|
|
53
|
+
pageSize: initialPageSize = 10,
|
|
54
|
+
pageSizeOptions = [5, 10, 25, 50],
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
actions,
|
|
58
|
+
className,
|
|
59
|
+
}: DataTableProProps<T>) {
|
|
60
|
+
const [search, setSearch] = useState('');
|
|
61
|
+
const [sortKey, setSortKey] = useState<string | null>(null);
|
|
62
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
63
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
64
|
+
const [pageSize, setPageSize] = useState(initialPageSize);
|
|
65
|
+
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
|
66
|
+
|
|
67
|
+
// 1. Search filter
|
|
68
|
+
const filteredData = useMemo(() => {
|
|
69
|
+
if (!search.trim()) return data;
|
|
70
|
+
const q = search.toLowerCase();
|
|
71
|
+
return data.filter((row) => {
|
|
72
|
+
return Object.values(row).some((val) => {
|
|
73
|
+
if (val === null || val === undefined) return false;
|
|
74
|
+
return String(val).toLowerCase().includes(q);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}, [data, search]);
|
|
78
|
+
|
|
79
|
+
// 2. Sorting
|
|
80
|
+
const sortedData = useMemo(() => {
|
|
81
|
+
if (!sortKey) return filteredData;
|
|
82
|
+
const col = columns.find((c) => c.key === sortKey);
|
|
83
|
+
|
|
84
|
+
return [...filteredData].sort((a, b) => {
|
|
85
|
+
const valA = col?.accessor ? col.accessor(a) : a[sortKey];
|
|
86
|
+
const valB = col?.accessor ? col.accessor(b) : b[sortKey];
|
|
87
|
+
|
|
88
|
+
if (valA === valB) return 0;
|
|
89
|
+
if (valA === null || valA === undefined) return 1;
|
|
90
|
+
if (valB === null || valB === undefined) return -1;
|
|
91
|
+
|
|
92
|
+
const comp = valA < valB ? -1 : 1;
|
|
93
|
+
return sortDirection === 'asc' ? comp : -comp;
|
|
94
|
+
});
|
|
95
|
+
}, [filteredData, sortKey, sortDirection, columns]);
|
|
96
|
+
|
|
97
|
+
// 3. Pagination
|
|
98
|
+
const totalPages = Math.max(1, Math.ceil(sortedData.length / pageSize));
|
|
99
|
+
const paginatedData = useMemo(() => {
|
|
100
|
+
const start = (currentPage - 1) * pageSize;
|
|
101
|
+
return sortedData.slice(start, start + pageSize);
|
|
102
|
+
}, [sortedData, currentPage, pageSize]);
|
|
103
|
+
|
|
104
|
+
const handleSort = (key: string) => {
|
|
105
|
+
if (sortKey === key) {
|
|
106
|
+
if (sortDirection === 'asc') {
|
|
107
|
+
setSortDirection('desc');
|
|
108
|
+
} else {
|
|
109
|
+
setSortKey(null);
|
|
110
|
+
setSortDirection('asc');
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
setSortKey(key);
|
|
114
|
+
setSortDirection('asc');
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const toggleSelectAll = () => {
|
|
119
|
+
if (selectedKeys.length === paginatedData.length) {
|
|
120
|
+
setSelectedKeys([]);
|
|
121
|
+
onSelectionChange?.([]);
|
|
122
|
+
} else {
|
|
123
|
+
const allKeys = paginatedData.map(keyExtractor);
|
|
124
|
+
setSelectedKeys(allKeys);
|
|
125
|
+
onSelectionChange?.(allKeys);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const toggleSelectRow = (key: string) => {
|
|
130
|
+
const next = selectedKeys.includes(key)
|
|
131
|
+
? selectedKeys.filter((k) => k !== key)
|
|
132
|
+
: [...selectedKeys, key];
|
|
133
|
+
setSelectedKeys(next);
|
|
134
|
+
onSelectionChange?.(next);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const exportCSV = () => {
|
|
138
|
+
const headers = columns.map((c) => (typeof c.header === 'string' ? c.header : c.key));
|
|
139
|
+
const rows = sortedData.map((row) =>
|
|
140
|
+
columns.map((c) => {
|
|
141
|
+
const val = c.accessor ? c.accessor(row) : row[c.key];
|
|
142
|
+
return `"${String(val ?? '').replace(/"/g, '""')}"`;
|
|
143
|
+
}).join(',')
|
|
144
|
+
);
|
|
145
|
+
const csvContent = 'data:text/csv;charset=utf-8,' + [headers.join(','), ...rows].join('\n');
|
|
146
|
+
const encodedUri = encodeURI(csvContent);
|
|
147
|
+
const link = document.createElement('a');
|
|
148
|
+
link.setAttribute('href', encodedUri);
|
|
149
|
+
link.setAttribute('download', `export_${Date.now()}.csv`);
|
|
150
|
+
document.body.appendChild(link);
|
|
151
|
+
link.click();
|
|
152
|
+
document.body.removeChild(link);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const isAllSelected =
|
|
156
|
+
paginatedData.length > 0 && selectedKeys.length === paginatedData.length;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className={cn('w-full space-y-4', className)}>
|
|
160
|
+
{/* Header Toolbar */}
|
|
161
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
162
|
+
{(title || description) && (
|
|
163
|
+
<div>
|
|
164
|
+
{title && <h3 className="text-base font-bold tracking-tight text-[var(--ui-text-primary)]">{title}</h3>}
|
|
165
|
+
{description && <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
<div className="flex flex-wrap items-center gap-2.5">
|
|
170
|
+
{searchable && (
|
|
171
|
+
<div className="relative w-full sm:w-64">
|
|
172
|
+
<Search className="absolute left-3 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
|
|
173
|
+
<input
|
|
174
|
+
type="text"
|
|
175
|
+
placeholder={searchPlaceholder}
|
|
176
|
+
value={search}
|
|
177
|
+
onChange={(e) => {
|
|
178
|
+
setSearch(e.target.value);
|
|
179
|
+
setCurrentPage(1);
|
|
180
|
+
}}
|
|
181
|
+
className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--ui-primary)]/40 text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] transition-all shadow-inner"
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<Button size="xs" variant="outline" leftIcon={<Download className="w-3 h-3" />} onClick={exportCSV}>
|
|
187
|
+
Export CSV
|
|
188
|
+
</Button>
|
|
189
|
+
|
|
190
|
+
{actions}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Bulk selection pill */}
|
|
195
|
+
{selectedKeys.length > 0 && (
|
|
196
|
+
<div className="px-4 py-2 rounded-xl bg-[var(--ui-primary)]/15 border border-[var(--ui-primary)]/30 flex items-center justify-between animate-in fade-in duration-200">
|
|
197
|
+
<div className="flex items-center gap-2 text-xs font-semibold text-[var(--ui-primary)]">
|
|
198
|
+
<span>Selected {selectedKeys.length} of {sortedData.length} records</span>
|
|
199
|
+
</div>
|
|
200
|
+
<Button
|
|
201
|
+
size="xs"
|
|
202
|
+
variant="ghost"
|
|
203
|
+
className="text-xs text-rose-500 hover:text-rose-600 hover:bg-rose-500/10"
|
|
204
|
+
onClick={() => {
|
|
205
|
+
setSelectedKeys([]);
|
|
206
|
+
onSelectionChange?.([]);
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
Clear Selection
|
|
210
|
+
</Button>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Table Container */}
|
|
215
|
+
<div className="relative w-full overflow-x-auto rounded-2xl border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] shadow-sm">
|
|
216
|
+
<Table>
|
|
217
|
+
<TableHeader>
|
|
218
|
+
<TableRow>
|
|
219
|
+
{selectable && (
|
|
220
|
+
<TableHead className="w-10 px-4">
|
|
221
|
+
<input
|
|
222
|
+
type="checkbox"
|
|
223
|
+
checked={isAllSelected}
|
|
224
|
+
onChange={toggleSelectAll}
|
|
225
|
+
className="rounded border-[var(--ui-border-default)] text-[var(--ui-primary)] focus:ring-[var(--ui-primary)] cursor-pointer"
|
|
226
|
+
/>
|
|
227
|
+
</TableHead>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{columns.map((col) => (
|
|
231
|
+
<TableHead
|
|
232
|
+
key={col.key}
|
|
233
|
+
style={{ width: col.width }}
|
|
234
|
+
onClick={() => col.sortable !== false && handleSort(col.key)}
|
|
235
|
+
className={cn(
|
|
236
|
+
col.sortable !== false && 'cursor-pointer select-none hover:text-[var(--ui-text-primary)] transition-colors'
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
<div className="flex items-center gap-1.5">
|
|
240
|
+
<span>{col.header}</span>
|
|
241
|
+
{col.sortable !== false && (
|
|
242
|
+
<span className="text-[var(--ui-text-muted)]">
|
|
243
|
+
{sortKey === col.key ? (
|
|
244
|
+
sortDirection === 'asc' ? (
|
|
245
|
+
<ChevronUp className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
|
|
246
|
+
) : (
|
|
247
|
+
<ChevronDown className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
|
|
248
|
+
)
|
|
249
|
+
) : (
|
|
250
|
+
<ChevronsUpDown className="w-3 h-3 opacity-40" />
|
|
251
|
+
)}
|
|
252
|
+
</span>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</TableHead>
|
|
256
|
+
))}
|
|
257
|
+
</TableRow>
|
|
258
|
+
</TableHeader>
|
|
259
|
+
|
|
260
|
+
<TableBody>
|
|
261
|
+
{paginatedData.length === 0 ? (
|
|
262
|
+
<TableRow>
|
|
263
|
+
<TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="h-32 text-center text-xs text-[var(--ui-text-muted)]">
|
|
264
|
+
No matching records found.
|
|
265
|
+
</TableCell>
|
|
266
|
+
</TableRow>
|
|
267
|
+
) : (
|
|
268
|
+
paginatedData.map((row, rowIdx) => {
|
|
269
|
+
const key = keyExtractor(row);
|
|
270
|
+
const isSelected = selectedKeys.includes(key);
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<TableRow
|
|
274
|
+
key={key}
|
|
275
|
+
data-state={isSelected ? 'selected' : undefined}
|
|
276
|
+
className={cn(isSelected && 'bg-[var(--ui-primary)]/10')}
|
|
277
|
+
>
|
|
278
|
+
{selectable && (
|
|
279
|
+
<TableCell className="w-10 px-4">
|
|
280
|
+
<input
|
|
281
|
+
type="checkbox"
|
|
282
|
+
checked={isSelected}
|
|
283
|
+
onChange={() => toggleSelectRow(key)}
|
|
284
|
+
className="rounded border-[var(--ui-border-default)] text-[var(--ui-primary)] focus:ring-[var(--ui-primary)] cursor-pointer"
|
|
285
|
+
/>
|
|
286
|
+
</TableCell>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{columns.map((col) => {
|
|
290
|
+
const cellContent = col.render
|
|
291
|
+
? col.render(row, rowIdx)
|
|
292
|
+
: col.accessor
|
|
293
|
+
? col.accessor(row)
|
|
294
|
+
: row[col.key];
|
|
295
|
+
|
|
296
|
+
return <TableCell key={col.key}>{cellContent}</TableCell>;
|
|
297
|
+
})}
|
|
298
|
+
</TableRow>
|
|
299
|
+
);
|
|
300
|
+
})
|
|
301
|
+
)}
|
|
302
|
+
</TableBody>
|
|
303
|
+
</Table>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* Pagination Footer */}
|
|
307
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-1 text-xs text-[var(--ui-text-muted)] font-mono">
|
|
308
|
+
<div className="flex items-center gap-2">
|
|
309
|
+
<span>Rows per page:</span>
|
|
310
|
+
<select
|
|
311
|
+
value={pageSize}
|
|
312
|
+
onChange={(e) => {
|
|
313
|
+
setPageSize(Number(e.target.value));
|
|
314
|
+
setCurrentPage(1);
|
|
315
|
+
}}
|
|
316
|
+
className="px-2 py-1 rounded-lg bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] text-[var(--ui-text-primary)] font-mono text-xs focus:outline-none"
|
|
317
|
+
>
|
|
318
|
+
{pageSizeOptions.map((opt) => (
|
|
319
|
+
<option key={opt} value={opt}>
|
|
320
|
+
{opt}
|
|
321
|
+
</option>
|
|
322
|
+
))}
|
|
323
|
+
</select>
|
|
324
|
+
<span className="text-[var(--ui-text-muted)]">
|
|
325
|
+
Showing {Math.min((currentPage - 1) * pageSize + 1, sortedData.length)}–
|
|
326
|
+
{Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length}
|
|
327
|
+
</span>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div className="flex items-center gap-1.5">
|
|
331
|
+
<Button
|
|
332
|
+
size="xs"
|
|
333
|
+
variant="outline"
|
|
334
|
+
disabled={currentPage === 1}
|
|
335
|
+
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
336
|
+
>
|
|
337
|
+
<ChevronLeft className="w-3.5 h-3.5" />
|
|
338
|
+
</Button>
|
|
339
|
+
|
|
340
|
+
<span className="px-2 font-bold text-[var(--ui-text-primary)]">
|
|
341
|
+
{currentPage} / {totalPages}
|
|
342
|
+
</span>
|
|
343
|
+
|
|
344
|
+
<Button
|
|
345
|
+
size="xs"
|
|
346
|
+
variant="outline"
|
|
347
|
+
disabled={currentPage === totalPages}
|
|
348
|
+
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
349
|
+
>
|
|
350
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
351
|
+
</Button>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
|
+
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
|
|
3
|
+
import { Card } from './Card';
|
|
4
|
+
import { cn } from '../utils/cn';
|
|
5
|
+
|
|
6
|
+
export interface StatCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string | number;
|
|
9
|
+
change?: string | number;
|
|
10
|
+
trend?: 'up' | 'down' | 'neutral';
|
|
11
|
+
trendLabel?: string;
|
|
12
|
+
icon?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
|
|
16
|
+
({ className, label, value, change, trend, trendLabel, icon, ...props }, ref) => {
|
|
17
|
+
return (
|
|
18
|
+
<Card ref={ref} className={cn('p-6', className)} {...props}>
|
|
19
|
+
<div className="flex items-center justify-between">
|
|
20
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--ui-text-muted)]">
|
|
21
|
+
{label}
|
|
22
|
+
</span>
|
|
23
|
+
{icon && (
|
|
24
|
+
<div className="p-2 rounded-[var(--ui-radius-md)] bg-[var(--ui-bg-secondary)] text-[var(--ui-text-secondary)]">
|
|
25
|
+
{icon}
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div className="mt-3 flex items-baseline gap-2">
|
|
31
|
+
<span className="text-2xl sm:text-3xl font-extrabold tracking-tight text-[var(--ui-text-primary)]">
|
|
32
|
+
{value}
|
|
33
|
+
</span>
|
|
34
|
+
|
|
35
|
+
{change !== undefined && (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
'inline-flex items-center gap-0.5 text-xs font-bold px-1.5 py-0.5 rounded-full',
|
|
39
|
+
trend === 'up' && 'text-[var(--ui-success)] bg-[var(--ui-success-subtle)]',
|
|
40
|
+
trend === 'down' && 'text-[var(--ui-error)] bg-[var(--ui-error-subtle)]',
|
|
41
|
+
trend === 'neutral' && 'text-[var(--ui-text-muted)] bg-[var(--ui-bg-tertiary)]'
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{trend === 'up' && <ArrowUpRight className="w-3.5 h-3.5" />}
|
|
45
|
+
{trend === 'down' && <ArrowDownRight className="w-3.5 h-3.5" />}
|
|
46
|
+
{trend === 'neutral' && <Minus className="w-3.5 h-3.5" />}
|
|
47
|
+
<span>{change}</span>
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{trendLabel && (
|
|
53
|
+
<p className="mt-2 text-xs text-[var(--ui-text-muted)]">{trendLabel}</p>
|
|
54
|
+
)}
|
|
55
|
+
</Card>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
StatCard.displayName = 'StatCard';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
|
|
4
|
+
export const Table = forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div className="relative w-full overflow-auto rounded-[var(--ui-radius-lg)] border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)]">
|
|
7
|
+
<table ref={ref} className={cn('w-full caption-bottom text-sm text-left', className)} {...props} />
|
|
8
|
+
</div>
|
|
9
|
+
)
|
|
10
|
+
);
|
|
11
|
+
Table.displayName = 'Table';
|
|
12
|
+
|
|
13
|
+
export const TableHeader = forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
|
14
|
+
({ className, ...props }, ref) => (
|
|
15
|
+
<thead ref={ref} className={cn('bg-[var(--ui-bg-secondary)] border-b border-[var(--ui-border-default)]', className)} {...props} />
|
|
16
|
+
)
|
|
17
|
+
);
|
|
18
|
+
TableHeader.displayName = 'TableHeader';
|
|
19
|
+
|
|
20
|
+
export const TableBody = forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
|
21
|
+
({ className, ...props }, ref) => (
|
|
22
|
+
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
TableBody.displayName = 'TableBody';
|
|
26
|
+
|
|
27
|
+
export const TableFooter = forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
|
28
|
+
({ className, ...props }, ref) => (
|
|
29
|
+
<tfoot ref={ref} className={cn('bg-[var(--ui-bg-secondary)] font-medium border-t border-[var(--ui-border-default)]', className)} {...props} />
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
TableFooter.displayName = 'TableFooter';
|
|
33
|
+
|
|
34
|
+
export const TableRow = forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
|
35
|
+
({ className, ...props }, ref) => (
|
|
36
|
+
<tr
|
|
37
|
+
ref={ref}
|
|
38
|
+
className={cn(
|
|
39
|
+
'border-b border-[var(--ui-border-subtle)] transition-colors hover:bg-[var(--ui-bg-tertiary)]/50 data-[state=selected]:bg-[var(--ui-bg-tertiary)]',
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
TableRow.displayName = 'TableRow';
|
|
47
|
+
|
|
48
|
+
export const TableHead = forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
|
49
|
+
({ className, ...props }, ref) => (
|
|
50
|
+
<th
|
|
51
|
+
ref={ref}
|
|
52
|
+
className={cn(
|
|
53
|
+
'h-11 px-4 text-left align-middle font-semibold text-xs text-[var(--ui-text-muted)] uppercase tracking-wider',
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
TableHead.displayName = 'TableHead';
|
|
61
|
+
|
|
62
|
+
export const TableCell = forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
|
63
|
+
({ className, ...props }, ref) => (
|
|
64
|
+
<td ref={ref} className={cn('p-4 align-middle text-sm text-[var(--ui-text-primary)]', className)} {...props} />
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
TableCell.displayName = 'TableCell';
|
|
68
|
+
|
|
69
|
+
export const TableCaption = forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
|
70
|
+
({ className, ...props }, ref) => (
|
|
71
|
+
<caption ref={ref} className={cn('mt-4 text-xs text-[var(--ui-text-muted)]', className)} {...props} />
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
TableCaption.displayName = 'TableCaption';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
|
|
4
|
+
export interface TimelineItem {
|
|
5
|
+
id: string | number;
|
|
6
|
+
title: React.ReactNode;
|
|
7
|
+
description?: React.ReactNode;
|
|
8
|
+
timestamp?: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
status?: 'completed' | 'active' | 'pending';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
items: TimelineItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Timeline = forwardRef<HTMLDivElement, TimelineProps>(
|
|
18
|
+
({ className, items, ...props }, ref) => {
|
|
19
|
+
return (
|
|
20
|
+
<div ref={ref} className={cn('relative pl-6 space-y-6', className)} {...props}>
|
|
21
|
+
{/* Vertical track line */}
|
|
22
|
+
<div className="absolute left-[11px] top-2 bottom-2 w-0.5 bg-[var(--ui-border-subtle)]" />
|
|
23
|
+
|
|
24
|
+
{items.map((item) => {
|
|
25
|
+
const isCompleted = item.status === 'completed';
|
|
26
|
+
const isActive = item.status === 'active';
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div key={item.id} className="relative flex items-start gap-4">
|
|
30
|
+
{/* Dot */}
|
|
31
|
+
<div
|
|
32
|
+
className={cn(
|
|
33
|
+
'absolute -left-6 flex items-center justify-center w-6 h-6 rounded-full ring-4 ring-[var(--ui-bg-surface)] text-xs font-bold transition-all',
|
|
34
|
+
isCompleted && 'bg-[var(--ui-primary)] text-[var(--ui-primary-fg)]',
|
|
35
|
+
isActive && 'bg-[var(--ui-bg-surface)] border-2 border-[var(--ui-primary)] text-[var(--ui-primary)]',
|
|
36
|
+
(!item.status || item.status === 'pending') && 'bg-[var(--ui-bg-tertiary)] border border-[var(--ui-border-default)] text-[var(--ui-text-muted)]'
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{item.icon || <span className="w-1.5 h-1.5 rounded-full bg-current" />}
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{/* Content */}
|
|
43
|
+
<div className="flex-1 space-y-1 pt-0.5">
|
|
44
|
+
<div className="flex items-center justify-between gap-2">
|
|
45
|
+
<h4 className="text-sm font-semibold text-[var(--ui-text-primary)]">{item.title}</h4>
|
|
46
|
+
{item.timestamp && (
|
|
47
|
+
<span className="text-xs text-[var(--ui-text-muted)] font-mono">{item.timestamp}</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
{item.description && (
|
|
51
|
+
<p className="text-xs text-[var(--ui-text-secondary)] leading-relaxed">{item.description}</p>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
Timeline.displayName = 'Timeline';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React, { useState, forwardRef } from 'react';
|
|
2
|
+
import { ChevronRight, Folder, FolderOpen, File, FileCode, FileText, Image as ImageIcon } from 'lucide-react';
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
|
|
5
|
+
export interface TreeItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
badge?: string | number;
|
|
10
|
+
children?: TreeItem[];
|
|
11
|
+
defaultExpanded?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TreeViewProps {
|
|
15
|
+
data: TreeItem[];
|
|
16
|
+
selectedId?: string;
|
|
17
|
+
onSelect?: (item: TreeItem) => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TreeNodeProps {
|
|
22
|
+
item: TreeItem;
|
|
23
|
+
level: number;
|
|
24
|
+
selectedId?: string;
|
|
25
|
+
onSelect?: (item: TreeItem) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function TreeNode({ item, level, selectedId, onSelect }: TreeNodeProps) {
|
|
29
|
+
const [isExpanded, setIsExpanded] = useState(Boolean(item.defaultExpanded));
|
|
30
|
+
const hasChildren = Boolean(item.children && item.children.length > 0);
|
|
31
|
+
const isSelected = selectedId === item.id;
|
|
32
|
+
|
|
33
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
if (hasChildren) {
|
|
36
|
+
setIsExpanded(!isExpanded);
|
|
37
|
+
}
|
|
38
|
+
onSelect?.(item);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getIcon = () => {
|
|
42
|
+
if (item.icon) return item.icon;
|
|
43
|
+
if (hasChildren) {
|
|
44
|
+
return isExpanded ? (
|
|
45
|
+
<FolderOpen className="w-4 h-4 text-amber-500 shrink-0" />
|
|
46
|
+
) : (
|
|
47
|
+
<Folder className="w-4 h-4 text-amber-500 shrink-0" />
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (item.label.endsWith('.tsx') || item.label.endsWith('.ts') || item.label.endsWith('.js')) {
|
|
51
|
+
return <FileCode className="w-4 h-4 text-blue-500 shrink-0" />;
|
|
52
|
+
}
|
|
53
|
+
if (item.label.endsWith('.png') || item.label.endsWith('.jpg') || item.label.endsWith('.svg')) {
|
|
54
|
+
return <ImageIcon className="w-4 h-4 text-purple-500 shrink-0" />;
|
|
55
|
+
}
|
|
56
|
+
return <FileText className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="select-none text-xs">
|
|
61
|
+
<div
|
|
62
|
+
onClick={handleClick}
|
|
63
|
+
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
|
64
|
+
className={cn(
|
|
65
|
+
'flex items-center justify-between py-1.5 pr-2.5 rounded-xl cursor-pointer transition-colors group',
|
|
66
|
+
isSelected
|
|
67
|
+
? 'bg-[var(--ui-primary)] text-white font-semibold shadow-xs'
|
|
68
|
+
: 'text-[var(--ui-text-primary)] hover:bg-[var(--ui-bg-tertiary)]'
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
<div className="flex items-center gap-2 truncate">
|
|
72
|
+
{hasChildren ? (
|
|
73
|
+
<span className={cn('p-0.5 rounded transition-transform duration-200', isExpanded && 'rotate-90')}>
|
|
74
|
+
<ChevronRight className={cn('w-3 h-3', isSelected ? 'text-white' : 'text-[var(--ui-text-muted)]')} />
|
|
75
|
+
</span>
|
|
76
|
+
) : (
|
|
77
|
+
<span className="w-4" />
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{getIcon()}
|
|
81
|
+
<span className="truncate">{item.label}</span>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{item.badge !== undefined && (
|
|
85
|
+
<span
|
|
86
|
+
className={cn(
|
|
87
|
+
'px-1.5 py-0.5 rounded-md text-[10px] font-mono',
|
|
88
|
+
isSelected ? 'bg-white/20 text-white' : 'bg-[var(--ui-bg-secondary)] text-[var(--ui-text-muted)]'
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{item.badge}
|
|
92
|
+
</span>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{hasChildren && isExpanded && (
|
|
97
|
+
<div className="relative space-y-0.5 animate-in fade-in duration-150">
|
|
98
|
+
<div
|
|
99
|
+
className="absolute left-[15px] top-0 bottom-0 w-[1px] bg-[var(--ui-border-subtle)]"
|
|
100
|
+
style={{ left: `${level * 16 + 15}px` }}
|
|
101
|
+
/>
|
|
102
|
+
{item.children!.map((child) => (
|
|
103
|
+
<TreeNode
|
|
104
|
+
key={child.id}
|
|
105
|
+
item={child}
|
|
106
|
+
level={level + 1}
|
|
107
|
+
selectedId={selectedId}
|
|
108
|
+
onSelect={onSelect}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const TreeView = forwardRef<HTMLDivElement, TreeViewProps>(
|
|
118
|
+
({ data, selectedId, onSelect, className }, ref) => {
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={ref}
|
|
122
|
+
className={cn(
|
|
123
|
+
'w-full p-2 rounded-2xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] space-y-0.5 shadow-sm overflow-hidden',
|
|
124
|
+
className
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
{data.map((item) => (
|
|
128
|
+
<TreeNode
|
|
129
|
+
key={item.id}
|
|
130
|
+
item={item}
|
|
131
|
+
level={0}
|
|
132
|
+
selectedId={selectedId}
|
|
133
|
+
onSelect={onSelect}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
TreeView.displayName = 'TreeView';
|