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.
- package/dist/index.cjs +75 -14
- package/dist/index.d.ts +34 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11013 -4880
- 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/TkxConfigProvider.d.ts +100 -0
- package/dist/src/components/TkxConfigProvider.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/TkxEmpty.d.ts +870 -0
- package/dist/src/components/TkxEmpty.d.ts.map +1 -0
- package/dist/src/components/TkxForm.d.ts +54 -0
- package/dist/src/components/TkxForm.d.ts.map +1 -0
- package/dist/src/components/TkxLayout.d.ts +87 -0
- package/dist/src/components/TkxLayout.d.ts.map +1 -0
- 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/TkxSpin.d.ts +15 -0
- package/dist/src/components/TkxSpin.d.ts.map +1 -0
- package/dist/src/components/TkxStatistic.d.ts +1746 -0
- package/dist/src/components/TkxStatistic.d.ts.map +1 -0
- package/dist/src/components/TkxTour.d.ts +15 -0
- package/dist/src/components/TkxTour.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +2614 -0
- package/dist/src/components/TkxTypography.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 +17 -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/dist/src/themes/index.d.ts +108 -0
- package/dist/src/themes/index.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/TkxConfigProvider.tsx +458 -0
- package/src/components/TkxDataGrid.tsx +65 -2
- package/src/components/TkxEmpty.tsx +219 -0
- package/src/components/TkxForm.tsx +607 -0
- package/src/components/TkxLayout.tsx +647 -0
- 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/TkxSpin.tsx +261 -0
- package/src/components/TkxStatistic.tsx +261 -0
- package/src/components/TkxTour.tsx +239 -0
- package/src/components/TkxTypography.tsx +263 -0
- package/src/components/TkxWatermark.tsx +143 -0
- package/src/components/index.ts +17 -0
- package/src/engine/tkx.ts +62 -0
- 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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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={
|
|
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,
|