tekivex-ui 2.2.0 → 2.3.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/dist/index.cjs +75 -14
- package/dist/index.d.ts +32 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10957 -4936
- package/dist/src/components/TkxAffix.d.ts +10 -0
- package/dist/src/components/TkxAffix.d.ts.map +1 -0
- package/dist/src/components/TkxAnchor.d.ts +14 -0
- package/dist/src/components/TkxAnchor.d.ts.map +1 -0
- package/dist/src/components/TkxCascader.d.ts +16 -0
- package/dist/src/components/TkxCascader.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +5 -1
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
- package/dist/src/components/TkxList.d.ts +24 -0
- package/dist/src/components/TkxList.d.ts.map +1 -0
- package/dist/src/components/TkxMentions.d.ts +15 -0
- package/dist/src/components/TkxMentions.d.ts.map +1 -0
- package/dist/src/components/TkxQRCode.d.ts +11 -0
- package/dist/src/components/TkxQRCode.d.ts.map +1 -0
- package/dist/src/components/TkxResult.d.ts +11 -0
- package/dist/src/components/TkxResult.d.ts.map +1 -0
- package/dist/src/components/TkxSegmented.d.ts +16 -0
- package/dist/src/components/TkxSegmented.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts +5 -1
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxTour.d.ts +15 -0
- package/dist/src/components/TkxTour.d.ts.map +1 -0
- package/dist/src/components/TkxWatermark.d.ts +12 -0
- package/dist/src/components/TkxWatermark.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +10 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/engine/tkx.d.ts +38 -0
- package/dist/src/engine/tkx.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/TkxAffix.tsx +138 -0
- package/src/components/TkxAnchor.tsx +193 -0
- package/src/components/TkxCascader.tsx +276 -0
- package/src/components/TkxDataGrid.tsx +65 -2
- package/src/components/TkxList.tsx +242 -0
- package/src/components/TkxMentions.tsx +210 -0
- package/src/components/TkxQRCode.tsx +174 -0
- package/src/components/TkxResult.tsx +128 -0
- package/src/components/TkxSegmented.tsx +169 -0
- package/src/components/TkxSelect.tsx +209 -6
- package/src/components/TkxTour.tsx +239 -0
- package/src/components/TkxWatermark.tsx +143 -0
- package/src/components/index.ts +10 -0
- package/src/engine/tkx.ts +62 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { useTheme } from '../themes';
|
|
3
|
+
import { sanitizeString } from '../engine/security';
|
|
4
|
+
import { useReducedMotion } from '../hooks';
|
|
5
|
+
import { tkx } from '../engine/tkx';
|
|
6
|
+
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface AnchorLink {
|
|
10
|
+
key: string;
|
|
11
|
+
href: string;
|
|
12
|
+
title: string;
|
|
13
|
+
children?: AnchorLink[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TkxAnchorProps {
|
|
17
|
+
items: AnchorLink[];
|
|
18
|
+
offsetTop?: number;
|
|
19
|
+
getCurrentAnchor?: (activeLink: string) => string;
|
|
20
|
+
onChange?: (currentLink: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function flattenLinks(items: AnchorLink[]): AnchorLink[] {
|
|
26
|
+
const result: AnchorLink[] = [];
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
result.push(item);
|
|
29
|
+
if (item.children) result.push(...flattenLinks(item.children));
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getAnchorId(href: string): string {
|
|
35
|
+
return href.startsWith('#') ? href.slice(1) : href;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Link Item ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function AnchorItem({
|
|
41
|
+
item,
|
|
42
|
+
activeKey,
|
|
43
|
+
depth,
|
|
44
|
+
onClick,
|
|
45
|
+
theme,
|
|
46
|
+
reducedMotion,
|
|
47
|
+
}: {
|
|
48
|
+
item: AnchorLink;
|
|
49
|
+
activeKey: string;
|
|
50
|
+
depth: number;
|
|
51
|
+
onClick: (href: string) => void;
|
|
52
|
+
theme: ReturnType<typeof useTheme>;
|
|
53
|
+
reducedMotion: boolean;
|
|
54
|
+
}) {
|
|
55
|
+
const isActive = item.key === activeKey;
|
|
56
|
+
const safeTitle = sanitizeString(item.title);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<li role="none" style={{ listStyle: 'none' }}>
|
|
60
|
+
<a
|
|
61
|
+
role="treeitem"
|
|
62
|
+
aria-current={isActive ? 'location' : undefined}
|
|
63
|
+
href={item.href}
|
|
64
|
+
onClick={(e) => {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
onClick(item.href);
|
|
67
|
+
}}
|
|
68
|
+
className={tkx('block text-sm py-1 no-underline')}
|
|
69
|
+
style={{
|
|
70
|
+
paddingLeft: 12 + depth * 16,
|
|
71
|
+
color: isActive ? theme.primary : theme.textMuted,
|
|
72
|
+
fontWeight: isActive ? 600 : 400,
|
|
73
|
+
borderLeft: `2px solid ${isActive ? theme.primary : 'transparent'}`,
|
|
74
|
+
transition: reducedMotion ? 'none' : 'color 0.15s ease, border-color 0.15s ease',
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{safeTitle}
|
|
78
|
+
</a>
|
|
79
|
+
{item.children && item.children.length > 0 && (
|
|
80
|
+
<ul role="group" style={{ margin: 0, padding: 0 }}>
|
|
81
|
+
{item.children.map((child) => (
|
|
82
|
+
<AnchorItem
|
|
83
|
+
key={child.key}
|
|
84
|
+
item={child}
|
|
85
|
+
activeKey={activeKey}
|
|
86
|
+
depth={depth + 1}
|
|
87
|
+
onClick={onClick}
|
|
88
|
+
theme={theme}
|
|
89
|
+
reducedMotion={reducedMotion}
|
|
90
|
+
/>
|
|
91
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
)}
|
|
94
|
+
</li>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function TkxAnchor({
|
|
101
|
+
items,
|
|
102
|
+
offsetTop = 0,
|
|
103
|
+
getCurrentAnchor,
|
|
104
|
+
onChange,
|
|
105
|
+
}: TkxAnchorProps) {
|
|
106
|
+
const theme = useTheme();
|
|
107
|
+
const reducedMotion = useReducedMotion();
|
|
108
|
+
const [activeKey, setActiveKey] = useState(items[0]?.key ?? '');
|
|
109
|
+
const isScrolling = useRef(false);
|
|
110
|
+
|
|
111
|
+
const flat = flattenLinks(items);
|
|
112
|
+
|
|
113
|
+
const updateActive = useCallback(() => {
|
|
114
|
+
if (isScrolling.current) return;
|
|
115
|
+
|
|
116
|
+
let current = flat[0]?.key ?? '';
|
|
117
|
+
for (const link of flat) {
|
|
118
|
+
const id = getAnchorId(link.href);
|
|
119
|
+
const el = document.getElementById(id);
|
|
120
|
+
if (el) {
|
|
121
|
+
const top = el.getBoundingClientRect().top;
|
|
122
|
+
if (top <= offsetTop + 10) {
|
|
123
|
+
current = link.key;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (getCurrentAnchor) {
|
|
129
|
+
current = getCurrentAnchor(current);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (current !== activeKey) {
|
|
133
|
+
setActiveKey(current);
|
|
134
|
+
onChange?.(current);
|
|
135
|
+
}
|
|
136
|
+
}, [flat, offsetTop, getCurrentAnchor, activeKey, onChange]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
window.addEventListener('scroll', updateActive, { passive: true });
|
|
140
|
+
updateActive();
|
|
141
|
+
return () => window.removeEventListener('scroll', updateActive);
|
|
142
|
+
}, [updateActive]);
|
|
143
|
+
|
|
144
|
+
const handleClick = useCallback(
|
|
145
|
+
(href: string) => {
|
|
146
|
+
const id = getAnchorId(href);
|
|
147
|
+
const el = document.getElementById(id);
|
|
148
|
+
if (!el) return;
|
|
149
|
+
|
|
150
|
+
isScrolling.current = true;
|
|
151
|
+
const top = el.getBoundingClientRect().top + window.scrollY - offsetTop;
|
|
152
|
+
window.scrollTo({ top, behavior: reducedMotion ? 'auto' : 'smooth' });
|
|
153
|
+
|
|
154
|
+
// Find the link by href and set active
|
|
155
|
+
const link = flat.find((l) => l.href === href);
|
|
156
|
+
if (link) {
|
|
157
|
+
setActiveKey(link.key);
|
|
158
|
+
onChange?.(link.key);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
isScrolling.current = false;
|
|
163
|
+
}, 800);
|
|
164
|
+
},
|
|
165
|
+
[flat, offsetTop, reducedMotion, onChange],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<nav
|
|
170
|
+
role="tree"
|
|
171
|
+
aria-label="Anchor navigation"
|
|
172
|
+
className={tkx('font-sans')}
|
|
173
|
+
style={{
|
|
174
|
+
borderLeft: `1px solid ${theme.border}`,
|
|
175
|
+
padding: '4px 0',
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<ul role="group" style={{ margin: 0, padding: 0 }}>
|
|
179
|
+
{items.map((item) => (
|
|
180
|
+
<AnchorItem
|
|
181
|
+
key={item.key}
|
|
182
|
+
item={item}
|
|
183
|
+
activeKey={activeKey}
|
|
184
|
+
depth={0}
|
|
185
|
+
onClick={handleClick}
|
|
186
|
+
theme={theme}
|
|
187
|
+
reducedMotion={reducedMotion}
|
|
188
|
+
/>
|
|
189
|
+
))}
|
|
190
|
+
</ul>
|
|
191
|
+
</nav>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { useTheme } from '../themes';
|
|
4
|
+
import { sanitizeString } from '../engine/security';
|
|
5
|
+
import { useReducedMotion } from '../hooks';
|
|
6
|
+
import { tkx } from '../engine/tkx';
|
|
7
|
+
|
|
8
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface CascaderOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
children?: CascaderOption[];
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TkxCascaderProps {
|
|
18
|
+
options: CascaderOption[];
|
|
19
|
+
value?: string[];
|
|
20
|
+
onChange?: (value: string[], selectedOptions: CascaderOption[]) => void;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function getColumns(
|
|
29
|
+
options: CascaderOption[],
|
|
30
|
+
selected: string[],
|
|
31
|
+
): CascaderOption[][] {
|
|
32
|
+
const columns: CascaderOption[][] = [options];
|
|
33
|
+
let current = options;
|
|
34
|
+
for (const val of selected) {
|
|
35
|
+
const found = current.find((o) => o.value === val);
|
|
36
|
+
if (found?.children?.length) {
|
|
37
|
+
columns.push(found.children);
|
|
38
|
+
current = found.children;
|
|
39
|
+
} else {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return columns;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getSelectedOptions(
|
|
47
|
+
options: CascaderOption[],
|
|
48
|
+
values: string[],
|
|
49
|
+
): CascaderOption[] {
|
|
50
|
+
const result: CascaderOption[] = [];
|
|
51
|
+
let current = options;
|
|
52
|
+
for (const val of values) {
|
|
53
|
+
const found = current.find((o) => o.value === val);
|
|
54
|
+
if (found) {
|
|
55
|
+
result.push(found);
|
|
56
|
+
current = found.children ?? [];
|
|
57
|
+
} else break;
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getLabelPath(options: CascaderOption[], values: string[]): string {
|
|
63
|
+
return getSelectedOptions(options, values)
|
|
64
|
+
.map((o) => sanitizeString(o.label))
|
|
65
|
+
.join(' / ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export function TkxCascader({
|
|
71
|
+
options,
|
|
72
|
+
value = [],
|
|
73
|
+
onChange,
|
|
74
|
+
placeholder = 'Select...',
|
|
75
|
+
label,
|
|
76
|
+
multiple = false,
|
|
77
|
+
}: TkxCascaderProps) {
|
|
78
|
+
const theme = useTheme();
|
|
79
|
+
const reducedMotion = useReducedMotion();
|
|
80
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
81
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const [hoverPath, setHoverPath] = useState<string[]>(value);
|
|
84
|
+
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
|
|
85
|
+
|
|
86
|
+
const safeLabel = label ? sanitizeString(label) : undefined;
|
|
87
|
+
const safePlaceholder = sanitizeString(placeholder);
|
|
88
|
+
const displayText = value.length > 0 ? getLabelPath(options, value) : '';
|
|
89
|
+
|
|
90
|
+
// Position dropdown
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!open || !triggerRef.current) return;
|
|
93
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
94
|
+
setDropdownPos({
|
|
95
|
+
top: rect.bottom + 4 + window.scrollY,
|
|
96
|
+
left: rect.left + window.scrollX,
|
|
97
|
+
width: Math.max(rect.width, 200),
|
|
98
|
+
});
|
|
99
|
+
}, [open]);
|
|
100
|
+
|
|
101
|
+
// Close on outside click
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!open) return;
|
|
104
|
+
const handler = (e: MouseEvent) => {
|
|
105
|
+
if (
|
|
106
|
+
!triggerRef.current?.contains(e.target as Node) &&
|
|
107
|
+
!dropdownRef.current?.contains(e.target as Node)
|
|
108
|
+
) {
|
|
109
|
+
setOpen(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
document.addEventListener('mousedown', handler);
|
|
113
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
114
|
+
}, [open]);
|
|
115
|
+
|
|
116
|
+
const handleSelect = useCallback(
|
|
117
|
+
(colIdx: number, opt: CascaderOption) => {
|
|
118
|
+
if (opt.disabled) return;
|
|
119
|
+
const newPath = [...hoverPath.slice(0, colIdx), opt.value];
|
|
120
|
+
setHoverPath(newPath);
|
|
121
|
+
|
|
122
|
+
// If leaf node (no children), commit selection
|
|
123
|
+
if (!opt.children?.length) {
|
|
124
|
+
const selected = getSelectedOptions(options, newPath);
|
|
125
|
+
onChange?.(newPath, selected);
|
|
126
|
+
if (!multiple) setOpen(false);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[hoverPath, options, onChange, multiple],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const handleKeyDown = useCallback(
|
|
133
|
+
(e: React.KeyboardEvent) => {
|
|
134
|
+
if (e.key === 'Escape') {
|
|
135
|
+
setOpen(false);
|
|
136
|
+
triggerRef.current?.focus();
|
|
137
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
138
|
+
if (!open) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
setOpen(true);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
[open],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const columns = getColumns(options, hoverPath);
|
|
148
|
+
|
|
149
|
+
const dropdown = open
|
|
150
|
+
? createPortal(
|
|
151
|
+
<div
|
|
152
|
+
ref={dropdownRef}
|
|
153
|
+
role="tree"
|
|
154
|
+
aria-label={safeLabel ?? 'Cascader options'}
|
|
155
|
+
className={tkx('flex rounded-lg border overflow-hidden')}
|
|
156
|
+
style={{
|
|
157
|
+
position: 'absolute',
|
|
158
|
+
zIndex: 9999,
|
|
159
|
+
top: dropdownPos.top,
|
|
160
|
+
left: dropdownPos.left,
|
|
161
|
+
backgroundColor: theme.surface,
|
|
162
|
+
borderColor: theme.border,
|
|
163
|
+
boxShadow: `0 4px 16px ${theme.bg}80`,
|
|
164
|
+
animation: reducedMotion ? 'none' : 'tkxFadeIn 0.15s ease',
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{columns.map((col, colIdx) => (
|
|
168
|
+
<ul
|
|
169
|
+
key={colIdx}
|
|
170
|
+
role="group"
|
|
171
|
+
className={tkx('m-0 p-0 overflow-auto')}
|
|
172
|
+
style={{
|
|
173
|
+
listStyle: 'none',
|
|
174
|
+
minWidth: 160,
|
|
175
|
+
maxHeight: 260,
|
|
176
|
+
borderRight: colIdx < columns.length - 1 ? `1px solid ${theme.border}` : 'none',
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{col.map((opt) => {
|
|
180
|
+
const isSelected = hoverPath[colIdx] === opt.value;
|
|
181
|
+
const safeOptLabel = sanitizeString(opt.label);
|
|
182
|
+
return (
|
|
183
|
+
<li
|
|
184
|
+
key={opt.value}
|
|
185
|
+
role="treeitem"
|
|
186
|
+
aria-selected={isSelected}
|
|
187
|
+
aria-disabled={opt.disabled || undefined}
|
|
188
|
+
aria-expanded={opt.children?.length ? isSelected : undefined}
|
|
189
|
+
className={tkx('flex items-center justify-between px-3 py-2 cursor-pointer text-sm')}
|
|
190
|
+
style={{
|
|
191
|
+
backgroundColor: isSelected ? theme.surfaceAlt : 'transparent',
|
|
192
|
+
color: opt.disabled ? theme.textMuted : theme.text,
|
|
193
|
+
opacity: opt.disabled ? 0.5 : 1,
|
|
194
|
+
cursor: opt.disabled ? 'not-allowed' : 'pointer',
|
|
195
|
+
}}
|
|
196
|
+
onClick={() => handleSelect(colIdx, opt)}
|
|
197
|
+
onMouseEnter={() => {
|
|
198
|
+
if (!opt.disabled) {
|
|
199
|
+
setHoverPath((prev) => [...prev.slice(0, colIdx), opt.value]);
|
|
200
|
+
}
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<span>{safeOptLabel}</span>
|
|
204
|
+
{opt.children && opt.children.length > 0 && (
|
|
205
|
+
<svg
|
|
206
|
+
width="12"
|
|
207
|
+
height="12"
|
|
208
|
+
viewBox="0 0 24 24"
|
|
209
|
+
fill="currentColor"
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
style={{ color: theme.textMuted, flexShrink: 0 }}
|
|
212
|
+
>
|
|
213
|
+
<path d="M10 6l6 6-6 6V6z" />
|
|
214
|
+
</svg>
|
|
215
|
+
)}
|
|
216
|
+
</li>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
</ul>
|
|
220
|
+
))}
|
|
221
|
+
</div>,
|
|
222
|
+
document.body,
|
|
223
|
+
)
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div className={tkx('relative font-sans')} onKeyDown={handleKeyDown}>
|
|
228
|
+
{safeLabel && (
|
|
229
|
+
<label
|
|
230
|
+
className={tkx('block text-sm font-medium mb-1')}
|
|
231
|
+
style={{ color: theme.text }}
|
|
232
|
+
>
|
|
233
|
+
{safeLabel}
|
|
234
|
+
</label>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
<button
|
|
238
|
+
ref={triggerRef}
|
|
239
|
+
type="button"
|
|
240
|
+
role="combobox"
|
|
241
|
+
aria-expanded={open}
|
|
242
|
+
aria-haspopup="tree"
|
|
243
|
+
aria-label={safeLabel ?? 'Cascader'}
|
|
244
|
+
className={tkx('w-full flex items-center justify-between rounded-lg border px-3 py-2 text-sm cursor-pointer')}
|
|
245
|
+
style={{
|
|
246
|
+
backgroundColor: theme.surface,
|
|
247
|
+
borderColor: open ? theme.primary : theme.border,
|
|
248
|
+
color: displayText ? theme.text : theme.textMuted,
|
|
249
|
+
outline: 'none',
|
|
250
|
+
minHeight: 38,
|
|
251
|
+
textAlign: 'left',
|
|
252
|
+
}}
|
|
253
|
+
onClick={() => setOpen((v) => !v)}
|
|
254
|
+
>
|
|
255
|
+
<span className={tkx('truncate')}>{displayText || safePlaceholder}</span>
|
|
256
|
+
<svg
|
|
257
|
+
width="16"
|
|
258
|
+
height="16"
|
|
259
|
+
viewBox="0 0 24 24"
|
|
260
|
+
fill="currentColor"
|
|
261
|
+
aria-hidden="true"
|
|
262
|
+
style={{
|
|
263
|
+
color: theme.textMuted,
|
|
264
|
+
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
265
|
+
transition: reducedMotion ? 'none' : 'transform 0.2s ease',
|
|
266
|
+
flexShrink: 0,
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
<path d="M7 10l5 5 5-5H7z" />
|
|
270
|
+
</svg>
|
|
271
|
+
</button>
|
|
272
|
+
|
|
273
|
+
{dropdown}
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
useRef,
|
|
5
5
|
useMemo,
|
|
6
6
|
useId,
|
|
7
|
+
useEffect,
|
|
7
8
|
type ReactNode,
|
|
8
9
|
type CSSProperties,
|
|
9
10
|
type MouseEvent as ReactMouseEvent,
|
|
@@ -46,6 +47,10 @@ export interface TkxDataGridProps<T = any> {
|
|
|
46
47
|
compact?: boolean;
|
|
47
48
|
maxHeight?: number | string;
|
|
48
49
|
onRowClick?: (row: T) => void;
|
|
50
|
+
/** Enable virtual scrolling. Defaults to auto (enabled when data has 50+ rows and maxHeight is set). */
|
|
51
|
+
virtualScroll?: boolean;
|
|
52
|
+
/** Row height in pixels for virtual scrolling calculations. Default: 40 */
|
|
53
|
+
rowHeight?: number;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
// ── Sort icon ─────────────────────────────────────────────────────────────────
|
|
@@ -156,11 +161,51 @@ export function TkxDataGrid<T = any>({
|
|
|
156
161
|
compact = false,
|
|
157
162
|
maxHeight,
|
|
158
163
|
onRowClick,
|
|
164
|
+
virtualScroll,
|
|
165
|
+
rowHeight = 40,
|
|
159
166
|
}: TkxDataGridProps<T>) {
|
|
160
167
|
const theme = useTheme();
|
|
161
168
|
const reduced = useReducedMotion();
|
|
162
169
|
const gridId = useId();
|
|
163
170
|
|
|
171
|
+
// ── Virtual scroll ────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const OVERSCAN = 10;
|
|
174
|
+
const isVirtual =
|
|
175
|
+
virtualScroll !== undefined
|
|
176
|
+
? virtualScroll
|
|
177
|
+
: maxHeight !== undefined && data.length >= 50;
|
|
178
|
+
|
|
179
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
180
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
181
|
+
const [containerHeight, setContainerHeight] = useState(0);
|
|
182
|
+
|
|
183
|
+
const handleScroll = useCallback(() => {
|
|
184
|
+
if (!scrollContainerRef.current) return;
|
|
185
|
+
setScrollTop(scrollContainerRef.current.scrollTop);
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!isVirtual || !scrollContainerRef.current) return;
|
|
190
|
+
const el = scrollContainerRef.current;
|
|
191
|
+
setContainerHeight(el.clientHeight);
|
|
192
|
+
const ro = new ResizeObserver(() => {
|
|
193
|
+
setContainerHeight(el.clientHeight);
|
|
194
|
+
});
|
|
195
|
+
ro.observe(el);
|
|
196
|
+
return () => ro.disconnect();
|
|
197
|
+
}, [isVirtual]);
|
|
198
|
+
|
|
199
|
+
const totalHeight = data.length * rowHeight;
|
|
200
|
+
const startIndex = isVirtual
|
|
201
|
+
? Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
|
|
202
|
+
: 0;
|
|
203
|
+
const endIndex = isVirtual
|
|
204
|
+
? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + OVERSCAN)
|
|
205
|
+
: data.length;
|
|
206
|
+
const visibleData = isVirtual ? data.slice(startIndex, endIndex) : data;
|
|
207
|
+
const offsetY = startIndex * rowHeight;
|
|
208
|
+
|
|
164
209
|
// ── Sort state ──────────────────────────────────────────────────────────
|
|
165
210
|
|
|
166
211
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
|
@@ -281,6 +326,8 @@ export function TkxDataGrid<T = any>({
|
|
|
281
326
|
}}
|
|
282
327
|
>
|
|
283
328
|
<div
|
|
329
|
+
ref={scrollContainerRef}
|
|
330
|
+
onScroll={isVirtual ? handleScroll : undefined}
|
|
284
331
|
style={{
|
|
285
332
|
maxHeight: maxHeight ?? 'none',
|
|
286
333
|
overflowX: 'auto',
|
|
@@ -427,7 +474,15 @@ export function TkxDataGrid<T = any>({
|
|
|
427
474
|
</td>
|
|
428
475
|
</tr>
|
|
429
476
|
) : (
|
|
430
|
-
|
|
477
|
+
<>
|
|
478
|
+
{/* Top spacer for virtual scrolling */}
|
|
479
|
+
{isVirtual && offsetY > 0 && (
|
|
480
|
+
<tr aria-hidden="true">
|
|
481
|
+
<td colSpan={totalCols} style={{ height: offsetY, padding: 0, border: 'none' }} />
|
|
482
|
+
</tr>
|
|
483
|
+
)}
|
|
484
|
+
{visibleData.map((row, i) => {
|
|
485
|
+
const rowIndex = startIndex + i;
|
|
431
486
|
const id = getRowId(row, rowKey);
|
|
432
487
|
const isSelected = selectedSet.has(id);
|
|
433
488
|
const isStriped = striped && rowIndex % 2 === 1;
|
|
@@ -448,6 +503,7 @@ export function TkxDataGrid<T = any>({
|
|
|
448
503
|
transition: reduced
|
|
449
504
|
? 'none'
|
|
450
505
|
: 'background-color 120ms ease',
|
|
506
|
+
...(isVirtual ? { height: rowHeight, boxSizing: 'border-box' } : {}),
|
|
451
507
|
}}
|
|
452
508
|
onMouseEnter={
|
|
453
509
|
onRowClick
|
|
@@ -529,7 +585,14 @@ export function TkxDataGrid<T = any>({
|
|
|
529
585
|
})}
|
|
530
586
|
</tr>
|
|
531
587
|
);
|
|
532
|
-
})
|
|
588
|
+
})}
|
|
589
|
+
{/* Bottom spacer for virtual scrolling */}
|
|
590
|
+
{isVirtual && endIndex < data.length && (
|
|
591
|
+
<tr aria-hidden="true">
|
|
592
|
+
<td colSpan={totalCols} style={{ height: (data.length - endIndex) * rowHeight, padding: 0, border: 'none' }} />
|
|
593
|
+
</tr>
|
|
594
|
+
)}
|
|
595
|
+
</>
|
|
533
596
|
)}
|
|
534
597
|
</tbody>
|
|
535
598
|
)}
|