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,128 @@
1
+ import { type ReactNode } 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 type ResultStatus = 'success' | 'error' | 'warning' | 'info' | '404' | '403' | '500';
10
+
11
+ export interface TkxResultProps {
12
+ status: ResultStatus;
13
+ title: string;
14
+ subTitle?: string;
15
+ icon?: ReactNode;
16
+ extra?: ReactNode;
17
+ }
18
+
19
+ // ── Status Icons (SVG) ──────────────────────────────────────────────────────
20
+
21
+ const STATUS_ICONS: Record<ResultStatus, (color: string) => ReactNode> = {
22
+ success: (c) => (
23
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
24
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
25
+ <path d="M22 37l9 9 19-19" stroke={c} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
26
+ </svg>
27
+ ),
28
+ error: (c) => (
29
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
30
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
31
+ <path d="M26 26l20 20M46 26L26 46" stroke={c} strokeWidth="4" strokeLinecap="round" />
32
+ </svg>
33
+ ),
34
+ warning: (c) => (
35
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
36
+ <path d="M36 6L4 64h64L36 6z" stroke={c} strokeWidth="3" fill={`${c}18`} />
37
+ <path d="M36 30v16" stroke={c} strokeWidth="4" strokeLinecap="round" />
38
+ <circle cx="36" cy="54" r="2.5" fill={c} />
39
+ </svg>
40
+ ),
41
+ info: (c) => (
42
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
43
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
44
+ <path d="M36 32v18" stroke={c} strokeWidth="4" strokeLinecap="round" />
45
+ <circle cx="36" cy="22" r="2.5" fill={c} />
46
+ </svg>
47
+ ),
48
+ '404': (c) => (
49
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
50
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
51
+ <text x="36" y="44" textAnchor="middle" fontSize="22" fontWeight="bold" fill={c}>404</text>
52
+ </svg>
53
+ ),
54
+ '403': (c) => (
55
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
56
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
57
+ <path d="M28 30h16v16H28z" stroke={c} strokeWidth="3" fill="none" />
58
+ <path d="M32 30v-4a4 4 0 018 0v4" stroke={c} strokeWidth="3" fill="none" />
59
+ </svg>
60
+ ),
61
+ '500': (c) => (
62
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
63
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
64
+ <text x="36" y="44" textAnchor="middle" fontSize="22" fontWeight="bold" fill={c}>500</text>
65
+ </svg>
66
+ ),
67
+ };
68
+
69
+ // ── Color Mapping ────────────────────────────────────────────────────────────
70
+
71
+ function getStatusColor(status: ResultStatus, theme: ReturnType<typeof useTheme>): string {
72
+ switch (status) {
73
+ case 'success': return theme.success;
74
+ case 'error':
75
+ case '500': return theme.danger;
76
+ case 'warning': return theme.warning;
77
+ case 'info': return theme.info;
78
+ case '404':
79
+ case '403': return theme.textMuted;
80
+ }
81
+ }
82
+
83
+ // ── Component ────────────────────────────────────────────────────────────────
84
+
85
+ export function TkxResult({ status, title, subTitle, icon, extra }: TkxResultProps) {
86
+ const theme = useTheme();
87
+ const reducedMotion = useReducedMotion();
88
+ const accentColor = getStatusColor(status, theme);
89
+ const safeTitle = sanitizeString(title);
90
+ const safeSubTitle = subTitle ? sanitizeString(subTitle) : undefined;
91
+
92
+ return (
93
+ <div
94
+ role="status"
95
+ aria-live="polite"
96
+ className={tkx('flex flex-col items-center text-center px-6 py-12 font-sans')}
97
+ style={{
98
+ animation: reducedMotion ? 'none' : 'tkxFadeIn 0.3s ease',
99
+ }}
100
+ >
101
+ <div className={tkx('mb-6')}>
102
+ {icon ?? STATUS_ICONS[status](accentColor)}
103
+ </div>
104
+
105
+ <h2
106
+ className={tkx('text-xl font-semibold m-0 mb-2')}
107
+ style={{ color: theme.text }}
108
+ >
109
+ {safeTitle}
110
+ </h2>
111
+
112
+ {safeSubTitle && (
113
+ <p
114
+ className={tkx('text-sm m-0 mb-6 max-w-md leading-relaxed')}
115
+ style={{ color: theme.textMuted }}
116
+ >
117
+ {safeSubTitle}
118
+ </p>
119
+ )}
120
+
121
+ {extra && (
122
+ <div className={tkx('flex items-center gap-3 mt-4')}>
123
+ {extra}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,169 @@
1
+ import { type ReactNode, useState, useRef, useEffect, useCallback } 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 SegmentedOption {
10
+ value: string;
11
+ label: string;
12
+ icon?: ReactNode;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export interface TkxSegmentedProps {
17
+ options: SegmentedOption[];
18
+ value?: string;
19
+ onChange?: (value: string) => void;
20
+ size?: 'sm' | 'md' | 'lg';
21
+ block?: boolean;
22
+ }
23
+
24
+ // ── Size Map ─────────────────────────────────────────────────────────────────
25
+
26
+ const SIZE_MAP = {
27
+ sm: { height: 28, fontSize: 12, px: 10 },
28
+ md: { height: 36, fontSize: 14, px: 16 },
29
+ lg: { height: 44, fontSize: 16, px: 20 },
30
+ } as const;
31
+
32
+ // ── Component ────────────────────────────────────────────────────────────────
33
+
34
+ export function TkxSegmented({
35
+ options,
36
+ value,
37
+ onChange,
38
+ size = 'md',
39
+ block = false,
40
+ }: TkxSegmentedProps) {
41
+ const theme = useTheme();
42
+ const reducedMotion = useReducedMotion();
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+ const [activeIndex, setActiveIndex] = useState(() =>
45
+ Math.max(0, options.findIndex((o) => o.value === value)),
46
+ );
47
+ const [indicatorStyle, setIndicatorStyle] = useState<React.CSSProperties>({});
48
+ const dims = SIZE_MAP[size];
49
+
50
+ // Sync external value
51
+ useEffect(() => {
52
+ const idx = options.findIndex((o) => o.value === value);
53
+ if (idx >= 0) setActiveIndex(idx);
54
+ }, [value, options]);
55
+
56
+ // Sliding indicator position
57
+ useEffect(() => {
58
+ const container = containerRef.current;
59
+ if (!container) return;
60
+ const buttons = container.querySelectorAll<HTMLButtonElement>('[data-seg-btn]');
61
+ const btn = buttons[activeIndex];
62
+ if (!btn) return;
63
+ setIndicatorStyle({
64
+ left: btn.offsetLeft,
65
+ width: btn.offsetWidth,
66
+ height: btn.offsetHeight,
67
+ transition: reducedMotion ? 'none' : 'left 0.2s ease, width 0.2s ease',
68
+ });
69
+ }, [activeIndex, options, reducedMotion]);
70
+
71
+ const handleSelect = useCallback(
72
+ (idx: number) => {
73
+ const opt = options[idx];
74
+ if (!opt || opt.disabled) return;
75
+ setActiveIndex(idx);
76
+ onChange?.(opt.value);
77
+ },
78
+ [options, onChange],
79
+ );
80
+
81
+ const handleKeyDown = useCallback(
82
+ (e: React.KeyboardEvent) => {
83
+ let next = activeIndex;
84
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
85
+ e.preventDefault();
86
+ next = (activeIndex + 1) % options.length;
87
+ while (options[next]?.disabled && next !== activeIndex) {
88
+ next = (next + 1) % options.length;
89
+ }
90
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
91
+ e.preventDefault();
92
+ next = (activeIndex - 1 + options.length) % options.length;
93
+ while (options[next]?.disabled && next !== activeIndex) {
94
+ next = (next - 1 + options.length) % options.length;
95
+ }
96
+ }
97
+ if (next !== activeIndex) {
98
+ handleSelect(next);
99
+ const container = containerRef.current;
100
+ const buttons = container?.querySelectorAll<HTMLButtonElement>('[data-seg-btn]');
101
+ buttons?.[next]?.focus();
102
+ }
103
+ },
104
+ [activeIndex, options, handleSelect],
105
+ );
106
+
107
+ return (
108
+ <div
109
+ ref={containerRef}
110
+ role="radiogroup"
111
+ aria-label="Segmented control"
112
+ className={tkx('relative inline-flex items-center rounded-lg p-1')}
113
+ style={{
114
+ backgroundColor: theme.surfaceAlt,
115
+ border: `1px solid ${theme.border}`,
116
+ width: block ? '100%' : undefined,
117
+ }}
118
+ onKeyDown={handleKeyDown}
119
+ >
120
+ {/* Sliding indicator */}
121
+ <div
122
+ aria-hidden="true"
123
+ className={tkx('absolute top-1 rounded-md')}
124
+ style={{
125
+ ...indicatorStyle,
126
+ backgroundColor: theme.surface,
127
+ boxShadow: `0 1px 3px ${theme.border}`,
128
+ zIndex: 0,
129
+ }}
130
+ />
131
+
132
+ {options.map((opt, idx) => {
133
+ const isActive = idx === activeIndex;
134
+ const safeLabel = sanitizeString(opt.label);
135
+ return (
136
+ <button
137
+ key={opt.value}
138
+ data-seg-btn
139
+ type="button"
140
+ role="radio"
141
+ aria-checked={isActive}
142
+ aria-disabled={opt.disabled || undefined}
143
+ disabled={opt.disabled}
144
+ tabIndex={isActive ? 0 : -1}
145
+ className={tkx(
146
+ 'relative z-10 flex items-center justify-center gap-1 rounded-md border-0 cursor-pointer',
147
+ 'font-sans whitespace-nowrap select-none',
148
+ )}
149
+ style={{
150
+ height: dims.height,
151
+ fontSize: dims.fontSize,
152
+ padding: `0 ${dims.px}px`,
153
+ flex: block ? 1 : undefined,
154
+ color: isActive ? theme.text : theme.textMuted,
155
+ backgroundColor: 'transparent',
156
+ opacity: opt.disabled ? 0.4 : 1,
157
+ fontWeight: isActive ? 600 : 400,
158
+ transition: reducedMotion ? 'none' : 'color 0.15s ease',
159
+ }}
160
+ onClick={() => handleSelect(idx)}
161
+ >
162
+ {opt.icon && <span aria-hidden="true">{opt.icon}</span>}
163
+ {safeLabel}
164
+ </button>
165
+ );
166
+ })}
167
+ </div>
168
+ );
169
+ }
@@ -44,6 +44,10 @@ export interface TkxSelectProps {
44
44
  onChange?: (value: string | string[]) => void;
45
45
  renderOption?: (option: SelectOption, isSelected: boolean) => ReactNode;
46
46
  maxMenuHeight?: number;
47
+ /** Enable virtual scrolling for the dropdown. Defaults to auto (enabled when 100+ options). */
48
+ virtualScroll?: boolean;
49
+ /** Fixed option height in pixels for virtual scroll calculations. Default: 36 */
50
+ optionHeight?: number;
47
51
  id?: string;
48
52
  className?: string;
49
53
  style?: CSSProperties;
@@ -139,6 +143,8 @@ export function TkxSelect({
139
143
  onChange,
140
144
  renderOption,
141
145
  maxMenuHeight = 280,
146
+ virtualScroll,
147
+ optionHeight = 36,
142
148
  id: idProp,
143
149
  className,
144
150
  style,
@@ -172,6 +178,32 @@ export function TkxSelect({
172
178
  const sz = SIZE_MAP[size];
173
179
  const hasError = isInvalid || !!errorMessage;
174
180
 
181
+ // ── Virtual scroll for options ─────────────────────────────────────────
182
+
183
+ const optionsListRef = useRef<HTMLDivElement>(null);
184
+ const [optScrollTop, setOptScrollTop] = useState(0);
185
+ const [optContainerHeight, setOptContainerHeight] = useState(0);
186
+
187
+ const handleOptScroll = useCallback(() => {
188
+ if (!optionsListRef.current) return;
189
+ setOptScrollTop(optionsListRef.current.scrollTop);
190
+ }, []);
191
+
192
+ useEffect(() => {
193
+ if (!isOpen) {
194
+ setOptScrollTop(0);
195
+ return;
196
+ }
197
+ const el = optionsListRef.current;
198
+ if (!el) return;
199
+ setOptContainerHeight(el.clientHeight);
200
+ const ro = new ResizeObserver(() => {
201
+ setOptContainerHeight(el.clientHeight);
202
+ });
203
+ ro.observe(el);
204
+ return () => ro.disconnect();
205
+ }, [isOpen]);
206
+
175
207
  // ── Filtered + grouped options ────────────────────────────────────────────
176
208
 
177
209
  const filteredOptions = searchable && search
@@ -182,6 +214,25 @@ export function TkxSelect({
182
214
 
183
215
  const flatEnabled = filteredOptions.filter((o) => !o.disabled);
184
216
 
217
+ const hasGroups = filteredOptions.some((o) => !!o.group);
218
+ const isVirtualSelect =
219
+ virtualScroll !== undefined
220
+ ? virtualScroll
221
+ : filteredOptions.length >= 100;
222
+ // Only virtualize when we have a flat list (no groups) for simplicity
223
+ const useVirtual = isVirtualSelect && !hasGroups;
224
+
225
+ const OPTION_OVERSCAN = 8;
226
+ const virtualTotalHeight = filteredOptions.length * optionHeight;
227
+ const vStartIndex = useVirtual
228
+ ? Math.max(0, Math.floor(optScrollTop / optionHeight) - OPTION_OVERSCAN)
229
+ : 0;
230
+ const vEndIndex = useVirtual
231
+ ? Math.min(filteredOptions.length, Math.ceil((optScrollTop + optContainerHeight) / optionHeight) + OPTION_OVERSCAN)
232
+ : filteredOptions.length;
233
+ const visibleOptions = useVirtual ? filteredOptions.slice(vStartIndex, vEndIndex) : filteredOptions;
234
+ const vOffsetY = vStartIndex * optionHeight;
235
+
185
236
  const groups = filteredOptions.reduce<Record<string, SelectOption[]>>(
186
237
  (acc, opt) => {
187
238
  const g = opt.group ?? '';
@@ -285,11 +336,23 @@ export function TkxSelect({
285
336
 
286
337
  useEffect(() => {
287
338
  if (!isOpen || activeIndex < 0) return;
288
- const el = listRef.current?.querySelector(
289
- `[data-idx="${activeIndex}"]`,
290
- ) as HTMLElement | null;
291
- el?.scrollIntoView({ block: 'nearest' });
292
- }, [activeIndex, isOpen]);
339
+ if (useVirtual && optionsListRef.current) {
340
+ // For virtual scroll, compute position directly
341
+ const itemTop = activeIndex * optionHeight;
342
+ const itemBottom = itemTop + optionHeight;
343
+ const el = optionsListRef.current;
344
+ if (itemTop < el.scrollTop) {
345
+ el.scrollTop = itemTop;
346
+ } else if (itemBottom > el.scrollTop + el.clientHeight) {
347
+ el.scrollTop = itemBottom - el.clientHeight;
348
+ }
349
+ } else {
350
+ const el = listRef.current?.querySelector(
351
+ `[data-idx="${activeIndex}"]`,
352
+ ) as HTMLElement | null;
353
+ el?.scrollIntoView({ block: 'nearest' });
354
+ }
355
+ }, [activeIndex, isOpen, useVirtual, optionHeight]);
293
356
 
294
357
  // ── Keyboard: trigger button ───────────────────────────────────────────────
295
358
 
@@ -494,13 +557,151 @@ export function TkxSelect({
494
557
 
495
558
  {/* Options list */}
496
559
  <div
497
- ref={listRef}
560
+ ref={(el) => {
561
+ (listRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
562
+ (optionsListRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
563
+ }}
564
+ onScroll={useVirtual ? handleOptScroll : undefined}
498
565
  style={{
499
566
  overflowY: 'auto',
500
567
  flexGrow: 1,
501
568
  maxHeight: maxMenuHeight - (searchable ? 48 : 0),
502
569
  }}
503
570
  >
571
+ {useVirtual ? (
572
+ /* Virtualized flat list (no groups) */
573
+ <div style={{ height: virtualTotalHeight, position: 'relative' }}>
574
+ <div style={{ position: 'absolute', top: vOffsetY, left: 0, right: 0 }}>
575
+ {visibleOptions.map((opt) => {
576
+ const flatIdx = flatEnabled.indexOf(opt);
577
+ const isActive = flatIdx === activeIndex;
578
+ const isSelected = selectedValues.includes(opt.value);
579
+
580
+ const optionContent = renderOption ? (
581
+ renderOption(opt, isSelected)
582
+ ) : (
583
+ <span
584
+ style={{
585
+ display: 'flex',
586
+ flexDirection: 'column',
587
+ flex: 1,
588
+ minWidth: 0,
589
+ }}
590
+ >
591
+ <span
592
+ style={{
593
+ display: 'flex',
594
+ alignItems: 'center',
595
+ gap: 8,
596
+ }}
597
+ >
598
+ {opt.icon && (
599
+ <span style={{ flexShrink: 0, display: 'flex' }}>
600
+ {opt.icon}
601
+ </span>
602
+ )}
603
+ <span
604
+ style={{
605
+ overflow: 'hidden',
606
+ textOverflow: 'ellipsis',
607
+ whiteSpace: 'nowrap',
608
+ }}
609
+ >
610
+ {sanitizeString(opt.label)}
611
+ </span>
612
+ </span>
613
+ {opt.description && (
614
+ <span
615
+ style={{
616
+ fontSize: '12px',
617
+ color: theme.textMuted,
618
+ marginTop: 1,
619
+ overflow: 'hidden',
620
+ textOverflow: 'ellipsis',
621
+ whiteSpace: 'nowrap',
622
+ }}
623
+ >
624
+ {sanitizeString(opt.description)}
625
+ </span>
626
+ )}
627
+ </span>
628
+ );
629
+
630
+ return (
631
+ <div
632
+ key={opt.value}
633
+ id={`${id}-opt-${opt.value}`}
634
+ data-idx={flatIdx >= 0 ? flatIdx : undefined}
635
+ role="option"
636
+ aria-selected={isSelected}
637
+ aria-disabled={opt.disabled || undefined}
638
+ onClick={() => !opt.disabled && commitValue(opt.value)}
639
+ onMouseEnter={() =>
640
+ !opt.disabled && flatIdx >= 0 && setActiveIndex(flatIdx)
641
+ }
642
+ style={{
643
+ height: optionHeight,
644
+ boxSizing: 'border-box',
645
+ padding: `0 ${sz.px}`,
646
+ fontSize: sz.fontSize,
647
+ fontFamily: 'inherit',
648
+ color: opt.disabled ? theme.textMuted : theme.text,
649
+ backgroundColor: isActive
650
+ ? `${theme.primary}22`
651
+ : isSelected
652
+ ? `${theme.primary}12`
653
+ : 'transparent',
654
+ cursor: opt.disabled ? 'not-allowed' : 'pointer',
655
+ display: 'flex',
656
+ alignItems: 'center',
657
+ justifyContent: 'space-between',
658
+ gap: 8,
659
+ transition: 'background-color 80ms',
660
+ opacity: opt.disabled ? 0.5 : 1,
661
+ userSelect: 'none',
662
+ touchAction: 'manipulation',
663
+ }}
664
+ >
665
+ {optionContent}
666
+ {isSelected && !renderOption && (
667
+ <svg
668
+ width="14"
669
+ height="14"
670
+ viewBox="0 0 24 24"
671
+ fill="none"
672
+ stroke={theme.primary}
673
+ strokeWidth="2.5"
674
+ aria-hidden="true"
675
+ style={{ flexShrink: 0 }}
676
+ >
677
+ <path d="M20 6L9 17l-5-5" />
678
+ </svg>
679
+ )}
680
+ </div>
681
+ );
682
+ })}
683
+ </div>
684
+ {filteredOptions.length === 0 && (
685
+ <div
686
+ style={{
687
+ padding: `${sz.py} ${sz.px}`,
688
+ fontSize: sz.fontSize,
689
+ fontFamily: 'inherit',
690
+ color: theme.textMuted,
691
+ textAlign: 'center',
692
+ position: 'absolute',
693
+ top: 0,
694
+ left: 0,
695
+ right: 0,
696
+ }}
697
+ >
698
+ {isLoading ? 'Loading\u2026' : 'No options found'}
699
+ </div>
700
+ )}
701
+ </div>
702
+ ) : (
703
+ /* Non-virtual: grouped rendering */
704
+ <>
504
705
  {Object.entries(groups).map(([group, groupOpts]) => (
505
706
  <div key={group}>
506
707
  {group && (
@@ -646,6 +847,8 @@ export function TkxSelect({
646
847
  {isLoading ? 'Loading…' : 'No options found'}
647
848
  </div>
648
849
  )}
850
+ </>
851
+ )}
649
852
  </div>
650
853
  </div>,
651
854
  document.body,