tekivex-ui 2.1.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.
Files changed (71) hide show
  1. package/dist/index.cjs +75 -14
  2. package/dist/index.d.ts +34 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11013 -4880
  5. package/dist/src/components/TkxAffix.d.ts +10 -0
  6. package/dist/src/components/TkxAffix.d.ts.map +1 -0
  7. package/dist/src/components/TkxAnchor.d.ts +14 -0
  8. package/dist/src/components/TkxAnchor.d.ts.map +1 -0
  9. package/dist/src/components/TkxCascader.d.ts +16 -0
  10. package/dist/src/components/TkxCascader.d.ts.map +1 -0
  11. package/dist/src/components/TkxConfigProvider.d.ts +100 -0
  12. package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
  13. package/dist/src/components/TkxDataGrid.d.ts +5 -1
  14. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  15. package/dist/src/components/TkxEmpty.d.ts +870 -0
  16. package/dist/src/components/TkxEmpty.d.ts.map +1 -0
  17. package/dist/src/components/TkxForm.d.ts +54 -0
  18. package/dist/src/components/TkxForm.d.ts.map +1 -0
  19. package/dist/src/components/TkxLayout.d.ts +87 -0
  20. package/dist/src/components/TkxLayout.d.ts.map +1 -0
  21. package/dist/src/components/TkxList.d.ts +24 -0
  22. package/dist/src/components/TkxList.d.ts.map +1 -0
  23. package/dist/src/components/TkxMentions.d.ts +15 -0
  24. package/dist/src/components/TkxMentions.d.ts.map +1 -0
  25. package/dist/src/components/TkxQRCode.d.ts +11 -0
  26. package/dist/src/components/TkxQRCode.d.ts.map +1 -0
  27. package/dist/src/components/TkxResult.d.ts +11 -0
  28. package/dist/src/components/TkxResult.d.ts.map +1 -0
  29. package/dist/src/components/TkxSegmented.d.ts +16 -0
  30. package/dist/src/components/TkxSegmented.d.ts.map +1 -0
  31. package/dist/src/components/TkxSelect.d.ts +5 -1
  32. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  33. package/dist/src/components/TkxSpin.d.ts +15 -0
  34. package/dist/src/components/TkxSpin.d.ts.map +1 -0
  35. package/dist/src/components/TkxStatistic.d.ts +1746 -0
  36. package/dist/src/components/TkxStatistic.d.ts.map +1 -0
  37. package/dist/src/components/TkxTour.d.ts +15 -0
  38. package/dist/src/components/TkxTour.d.ts.map +1 -0
  39. package/dist/src/components/TkxTypography.d.ts +2614 -0
  40. package/dist/src/components/TkxTypography.d.ts.map +1 -0
  41. package/dist/src/components/TkxWatermark.d.ts +12 -0
  42. package/dist/src/components/TkxWatermark.d.ts.map +1 -0
  43. package/dist/src/components/index.d.ts +17 -0
  44. package/dist/src/components/index.d.ts.map +1 -1
  45. package/dist/src/engine/tkx.d.ts +38 -0
  46. package/dist/src/engine/tkx.d.ts.map +1 -1
  47. package/dist/src/themes/index.d.ts +108 -0
  48. package/dist/src/themes/index.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/TkxAffix.tsx +138 -0
  51. package/src/components/TkxAnchor.tsx +193 -0
  52. package/src/components/TkxCascader.tsx +276 -0
  53. package/src/components/TkxConfigProvider.tsx +458 -0
  54. package/src/components/TkxDataGrid.tsx +65 -2
  55. package/src/components/TkxEmpty.tsx +219 -0
  56. package/src/components/TkxForm.tsx +607 -0
  57. package/src/components/TkxLayout.tsx +647 -0
  58. package/src/components/TkxList.tsx +242 -0
  59. package/src/components/TkxMentions.tsx +210 -0
  60. package/src/components/TkxQRCode.tsx +174 -0
  61. package/src/components/TkxResult.tsx +128 -0
  62. package/src/components/TkxSegmented.tsx +169 -0
  63. package/src/components/TkxSelect.tsx +209 -6
  64. package/src/components/TkxSpin.tsx +261 -0
  65. package/src/components/TkxStatistic.tsx +261 -0
  66. package/src/components/TkxTour.tsx +239 -0
  67. package/src/components/TkxTypography.tsx +263 -0
  68. package/src/components/TkxWatermark.tsx +143 -0
  69. package/src/components/index.ts +17 -0
  70. package/src/engine/tkx.ts +62 -0
  71. package/src/themes/index.ts +149 -0
@@ -0,0 +1,458 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ useCallback,
6
+ type ReactNode,
7
+ type CSSProperties,
8
+ } from 'react';
9
+
10
+ // ══════════════════════════════════════════════════════════════════════════════
11
+ // TkxConfigProvider — Global configuration provider for TekiVex UI.
12
+ //
13
+ // Provides locale, direction, component defaults, and a CSS class prefix to
14
+ // all descendant components via React context. Supports nesting: a child
15
+ // ConfigProvider merges its values on top of its parent.
16
+ // ══════════════════════════════════════════════════════════════════════════════
17
+
18
+ // ── Component Defaults ───────────────────────────────────────────────────────
19
+
20
+ export interface ComponentDefaults {
21
+ /** Default size for all components that support sizing. */
22
+ size?: 'sm' | 'md' | 'lg';
23
+ /** Default variant name. Interpretation depends on the component. */
24
+ variant?: string;
25
+ /** Enable or disable animations globally. */
26
+ animation?: boolean;
27
+ /** Show borders by default on components that support it. */
28
+ bordered?: boolean;
29
+ }
30
+
31
+ // ── Per-component overrides ──────────────────────────────────────────────────
32
+
33
+ export interface ButtonConfig {
34
+ size?: 'sm' | 'md' | 'lg';
35
+ variant?: string;
36
+ }
37
+
38
+ export interface InputConfig {
39
+ size?: 'sm' | 'md' | 'lg';
40
+ bordered?: boolean;
41
+ }
42
+
43
+ export interface TableConfig {
44
+ size?: 'sm' | 'md' | 'lg';
45
+ bordered?: boolean;
46
+ striped?: boolean;
47
+ }
48
+
49
+ export interface ModalConfig {
50
+ centered?: boolean;
51
+ maskClosable?: boolean;
52
+ animation?: boolean;
53
+ }
54
+
55
+ export interface ComponentOverrides {
56
+ button?: ButtonConfig;
57
+ input?: InputConfig;
58
+ table?: TableConfig;
59
+ modal?: ModalConfig;
60
+ }
61
+
62
+ // ── Config Context Value ─────────────────────────────────────────────────────
63
+
64
+ export interface ConfigContextValue {
65
+ /** BCP 47 locale code (e.g. "en-US", "ja-JP"). */
66
+ locale: string;
67
+ /** Text direction. */
68
+ direction: 'ltr' | 'rtl';
69
+ /** Global defaults applied to every component. */
70
+ componentDefaults: Required<ComponentDefaults>;
71
+ /** Per-component configuration overrides. */
72
+ componentOverrides: ComponentOverrides;
73
+ /** CSS class prefix used by all TekiVex components. */
74
+ prefixCls: string;
75
+ /** Whether we are inside at least one ConfigProvider. */
76
+ configured: boolean;
77
+ /** Helper: build a prefixed class name. */
78
+ cls: (name: string) => string;
79
+ /** Helper: resolve size for a component (component override > global default). */
80
+ getSize: (component?: keyof ComponentOverrides, local?: 'sm' | 'md' | 'lg') => 'sm' | 'md' | 'lg';
81
+ /** Helper: resolve whether animations should run. */
82
+ getAnimation: (local?: boolean) => boolean;
83
+ /** Helper: resolve bordered default. */
84
+ getBordered: (component?: keyof ComponentOverrides, local?: boolean) => boolean;
85
+ }
86
+
87
+ // ── Sensible Defaults ────────────────────────────────────────────────────────
88
+
89
+ const DEFAULT_COMPONENT_DEFAULTS: Required<ComponentDefaults> = {
90
+ size: 'md',
91
+ variant: 'default',
92
+ animation: true,
93
+ bordered: true,
94
+ };
95
+
96
+ const DEFAULT_CONFIG: ConfigContextValue = {
97
+ locale: 'en-US',
98
+ direction: 'ltr',
99
+ componentDefaults: DEFAULT_COMPONENT_DEFAULTS,
100
+ componentOverrides: {},
101
+ prefixCls: 'tkx',
102
+ configured: false,
103
+ cls: (name: string) => `tkx-${name}`,
104
+ getSize: () => 'md',
105
+ getAnimation: () => true,
106
+ getBordered: () => true,
107
+ };
108
+
109
+ // ── Context ──────────────────────────────────────────────────────────────────
110
+
111
+ const ConfigContext = createContext<ConfigContextValue>(DEFAULT_CONFIG);
112
+
113
+ // ── Props ────────────────────────────────────────────────────────────────────
114
+
115
+ export interface TkxConfigProviderProps {
116
+ children: ReactNode;
117
+ /** BCP 47 locale code. Default "en-US". */
118
+ locale?: string;
119
+ /** Text direction. Default "ltr". */
120
+ direction?: 'ltr' | 'rtl';
121
+ /** Global component defaults. Merged with parent provider values. */
122
+ componentDefaults?: ComponentDefaults;
123
+ /** Per-component overrides. Merged shallowly per component key. */
124
+ componentOverrides?: ComponentOverrides;
125
+ /** CSS class prefix. Default "tkx". */
126
+ prefixCls?: string;
127
+ }
128
+
129
+ // ══════════════════════════════════════════════════════════════════════════════
130
+ // TkxConfigProvider Component
131
+ // ══════════════════════════════════════════════════════════════════════════════
132
+
133
+ export function TkxConfigProvider({
134
+ children,
135
+ locale,
136
+ direction,
137
+ componentDefaults,
138
+ componentOverrides,
139
+ prefixCls,
140
+ }: TkxConfigProviderProps) {
141
+ // Read parent config (supports nesting).
142
+ const parent = useContext(ConfigContext);
143
+
144
+ // Merge defaults: child overrides parent.
145
+ const mergedDefaults: Required<ComponentDefaults> = useMemo(
146
+ () => ({
147
+ size: componentDefaults?.size ?? parent.componentDefaults.size,
148
+ variant: componentDefaults?.variant ?? parent.componentDefaults.variant,
149
+ animation: componentDefaults?.animation ?? parent.componentDefaults.animation,
150
+ bordered: componentDefaults?.bordered ?? parent.componentDefaults.bordered,
151
+ }),
152
+ [componentDefaults, parent.componentDefaults],
153
+ );
154
+
155
+ // Merge per-component overrides: shallow merge per key.
156
+ const mergedOverrides: ComponentOverrides = useMemo(() => {
157
+ const parentOv = parent.componentOverrides;
158
+ const childOv = componentOverrides ?? {};
159
+ return {
160
+ button: { ...parentOv.button, ...childOv.button },
161
+ input: { ...parentOv.input, ...childOv.input },
162
+ table: { ...parentOv.table, ...childOv.table },
163
+ modal: { ...parentOv.modal, ...childOv.modal },
164
+ };
165
+ }, [componentOverrides, parent.componentOverrides]);
166
+
167
+ const resolvedLocale = locale ?? parent.locale;
168
+ const resolvedDirection = direction ?? parent.direction;
169
+ const resolvedPrefix = prefixCls ?? parent.prefixCls;
170
+
171
+ // Helper: build prefixed class name.
172
+ const cls = useCallback(
173
+ (name: string) => `${resolvedPrefix}-${name}`,
174
+ [resolvedPrefix],
175
+ );
176
+
177
+ // Helper: resolve component size.
178
+ const getSize = useCallback(
179
+ (component?: keyof ComponentOverrides, local?: 'sm' | 'md' | 'lg'): 'sm' | 'md' | 'lg' => {
180
+ // Local prop wins.
181
+ if (local !== undefined) return local;
182
+ // Then per-component override.
183
+ if (component) {
184
+ const ov = mergedOverrides[component] as { size?: 'sm' | 'md' | 'lg' } | undefined;
185
+ if (ov?.size) return ov.size;
186
+ }
187
+ // Then global default.
188
+ return mergedDefaults.size;
189
+ },
190
+ [mergedDefaults.size, mergedOverrides],
191
+ );
192
+
193
+ // Helper: resolve animation preference.
194
+ const getAnimation = useCallback(
195
+ (local?: boolean): boolean => {
196
+ if (local !== undefined) return local;
197
+ return mergedDefaults.animation;
198
+ },
199
+ [mergedDefaults.animation],
200
+ );
201
+
202
+ // Helper: resolve bordered preference.
203
+ const getBordered = useCallback(
204
+ (component?: keyof ComponentOverrides, local?: boolean): boolean => {
205
+ if (local !== undefined) return local;
206
+ if (component) {
207
+ const ov = mergedOverrides[component] as { bordered?: boolean } | undefined;
208
+ if (ov?.bordered !== undefined) return ov.bordered;
209
+ }
210
+ return mergedDefaults.bordered;
211
+ },
212
+ [mergedDefaults.bordered, mergedOverrides],
213
+ );
214
+
215
+ // Assemble context value.
216
+ const value: ConfigContextValue = useMemo(
217
+ () => ({
218
+ locale: resolvedLocale,
219
+ direction: resolvedDirection,
220
+ componentDefaults: mergedDefaults,
221
+ componentOverrides: mergedOverrides,
222
+ prefixCls: resolvedPrefix,
223
+ configured: true,
224
+ cls,
225
+ getSize,
226
+ getAnimation,
227
+ getBordered,
228
+ }),
229
+ [
230
+ resolvedLocale,
231
+ resolvedDirection,
232
+ mergedDefaults,
233
+ mergedOverrides,
234
+ resolvedPrefix,
235
+ cls,
236
+ getSize,
237
+ getAnimation,
238
+ getBordered,
239
+ ],
240
+ );
241
+
242
+ // Apply dir attribute to the wrapper so CSS logical properties work.
243
+ // Also set lang attribute for accessibility.
244
+ return (
245
+ <ConfigContext.Provider value={value}>
246
+ <div
247
+ dir={resolvedDirection}
248
+ lang={resolvedLocale}
249
+ className={`${resolvedPrefix}-config-root`}
250
+ style={{ direction: resolvedDirection }}
251
+ >
252
+ {children}
253
+ </div>
254
+ </ConfigContext.Provider>
255
+ );
256
+ }
257
+
258
+ TkxConfigProvider.displayName = 'TkxConfigProvider';
259
+
260
+ // ══════════════════════════════════════════════════════════════════════════════
261
+ // useConfig — Hook for components to read the global configuration.
262
+ // ══════════════════════════════════════════════════════════════════════════════
263
+
264
+ export function useConfig(): ConfigContextValue {
265
+ return useContext(ConfigContext);
266
+ }
267
+
268
+ // ══════════════════════════════════════════════════════════════════════════════
269
+ // useDirection — Convenience hook for reading text direction.
270
+ // ══════════════════════════════════════════════════════════════════════════════
271
+
272
+ export function useDirection(): 'ltr' | 'rtl' {
273
+ const { direction } = useConfig();
274
+ return direction;
275
+ }
276
+
277
+ // ══════════════════════════════════════════════════════════════════════════════
278
+ // useLocale — Convenience hook for reading the active locale.
279
+ // ══════════════════════════════════════════════════════════════════════════════
280
+
281
+ export function useLocale(): string {
282
+ const { locale } = useConfig();
283
+ return locale;
284
+ }
285
+
286
+ // ══════════════════════════════════════════════════════════════════════════════
287
+ // usePrefixCls — Convenience hook for building prefixed class names.
288
+ // ══════════════════════════════════════════════════════════════════════════════
289
+
290
+ export function usePrefixCls(componentName: string, customPrefix?: string): string {
291
+ const { prefixCls } = useConfig();
292
+ const prefix = customPrefix ?? prefixCls;
293
+ return `${prefix}-${componentName}`;
294
+ }
295
+
296
+ // ══════════════════════════════════════════════════════════════════════════════
297
+ // useComponentSize — Resolves the effective size for a component.
298
+ // ══════════════════════════════════════════════════════════════════════════════
299
+
300
+ export function useComponentSize(
301
+ component: keyof ComponentOverrides,
302
+ localSize?: 'sm' | 'md' | 'lg',
303
+ ): 'sm' | 'md' | 'lg' {
304
+ const { getSize } = useConfig();
305
+ return getSize(component, localSize);
306
+ }
307
+
308
+ // ══════════════════════════════════════════════════════════════════════════════
309
+ // SIZE & SPACING TOKENS — Maps config sizes to concrete pixel/rem values.
310
+ // Components can use these to avoid duplicating size-to-dimension logic.
311
+ // ══════════════════════════════════════════════════════════════════════════════
312
+
313
+ export const SIZE_HEIGHT: Record<'sm' | 'md' | 'lg', number> = {
314
+ sm: 32,
315
+ md: 40,
316
+ lg: 48,
317
+ };
318
+
319
+ export const SIZE_FONT: Record<'sm' | 'md' | 'lg', string> = {
320
+ sm: '0.75rem',
321
+ md: '0.875rem',
322
+ lg: '1rem',
323
+ };
324
+
325
+ export const SIZE_PADDING_X: Record<'sm' | 'md' | 'lg', number> = {
326
+ sm: 8,
327
+ md: 12,
328
+ lg: 16,
329
+ };
330
+
331
+ export const SIZE_PADDING_Y: Record<'sm' | 'md' | 'lg', number> = {
332
+ sm: 4,
333
+ md: 8,
334
+ lg: 12,
335
+ };
336
+
337
+ export const SIZE_BORDER_RADIUS: Record<'sm' | 'md' | 'lg', number> = {
338
+ sm: 4,
339
+ md: 6,
340
+ lg: 8,
341
+ };
342
+
343
+ export const SIZE_ICON: Record<'sm' | 'md' | 'lg', number> = {
344
+ sm: 14,
345
+ md: 16,
346
+ lg: 20,
347
+ };
348
+
349
+ // ══════════════════════════════════════════════════════════════════════════════
350
+ // RTL Utilities
351
+ // ══════════════════════════════════════════════════════════════════════════════
352
+
353
+ /** Returns a transform that flips an element horizontally for RTL layouts. */
354
+ export function useRtlFlip(): CSSProperties | undefined {
355
+ const { direction } = useConfig();
356
+ if (direction === 'rtl') {
357
+ return { transform: 'scaleX(-1)' };
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ /** Returns logical margin/padding properties that respect direction. */
363
+ export function useLogicalSpacing(
364
+ inlineStart: number | string,
365
+ inlineEnd: number | string = 0,
366
+ ): CSSProperties {
367
+ const { direction } = useConfig();
368
+ const start = typeof inlineStart === 'number' ? `${inlineStart}px` : inlineStart;
369
+ const end = typeof inlineEnd === 'number' ? `${inlineEnd}px` : inlineEnd;
370
+
371
+ if (direction === 'rtl') {
372
+ return { marginRight: start, marginLeft: end };
373
+ }
374
+ return { marginLeft: start, marginRight: end };
375
+ }
376
+
377
+ // ══════════════════════════════════════════════════════════════════════════════
378
+ // Locale Utilities
379
+ // ══════════════════════════════════════════════════════════════════════════════
380
+
381
+ /** Built-in locale display names for the most common locales. */
382
+ const LOCALE_DISPLAY_NAMES: Record<string, string> = {
383
+ 'en-US': 'English (US)',
384
+ 'en-GB': 'English (UK)',
385
+ 'es-ES': 'Spanish',
386
+ 'fr-FR': 'French',
387
+ 'de-DE': 'German',
388
+ 'ja-JP': 'Japanese',
389
+ 'ko-KR': 'Korean',
390
+ 'zh-CN': 'Chinese (Simplified)',
391
+ 'zh-TW': 'Chinese (Traditional)',
392
+ 'pt-BR': 'Portuguese (Brazil)',
393
+ 'ar-SA': 'Arabic',
394
+ 'hi-IN': 'Hindi',
395
+ 'ru-RU': 'Russian',
396
+ 'it-IT': 'Italian',
397
+ 'nl-NL': 'Dutch',
398
+ 'sv-SE': 'Swedish',
399
+ 'pl-PL': 'Polish',
400
+ 'tr-TR': 'Turkish',
401
+ 'th-TH': 'Thai',
402
+ 'vi-VN': 'Vietnamese',
403
+ };
404
+
405
+ /** RTL locales — used to auto-detect direction when not explicitly set. */
406
+ const RTL_LOCALES = new Set([
407
+ 'ar', 'ar-SA', 'ar-EG', 'ar-AE',
408
+ 'he', 'he-IL',
409
+ 'fa', 'fa-IR',
410
+ 'ur', 'ur-PK',
411
+ 'ps', 'ps-AF',
412
+ ]);
413
+
414
+ /**
415
+ * Returns true if the given locale code is typically written right-to-left.
416
+ * Useful for auto-setting direction based on locale.
417
+ */
418
+ export function isRtlLocale(locale: string): boolean {
419
+ return RTL_LOCALES.has(locale) || RTL_LOCALES.has(locale.split('-')[0]);
420
+ }
421
+
422
+ /**
423
+ * Returns the human-readable name for a locale code, falling back to
424
+ * the Intl.DisplayNames API or the raw code itself.
425
+ */
426
+ export function getLocaleDisplayName(locale: string): string {
427
+ if (LOCALE_DISPLAY_NAMES[locale]) return LOCALE_DISPLAY_NAMES[locale];
428
+
429
+ if (typeof Intl !== 'undefined' && Intl.DisplayNames) {
430
+ try {
431
+ const dn = new Intl.DisplayNames(['en'], { type: 'language' });
432
+ return dn.of(locale) ?? locale;
433
+ } catch {
434
+ return locale;
435
+ }
436
+ }
437
+
438
+ return locale;
439
+ }
440
+
441
+ // ══════════════════════════════════════════════════════════════════════════════
442
+ // Usage example:
443
+ //
444
+ // <TkxConfigProvider
445
+ // locale="ja-JP"
446
+ // direction="ltr"
447
+ // componentDefaults={{ size: 'sm', animation: true, bordered: false }}
448
+ // componentOverrides={{ table: { striped: true } }}
449
+ // prefixCls="myapp"
450
+ // >
451
+ // <App />
452
+ // </TkxConfigProvider>
453
+ //
454
+ // Inside any component:
455
+ // const { locale, direction, getSize, cls } = useConfig();
456
+ // const size = getSize('button', props.size);
457
+ // const className = cls('button');
458
+ // ══════════════════════════════════════════════════════════════════════════════
@@ -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
- data.map((row, rowIndex) => {
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
  )}