Skip to content

Commit f27e52a

Browse files
author
catlog22
committed
feat: 新增 EnhancedSelect 组件,为 A2UI Dropdown 添加 Combobox 模式
- 创建 enhanced-select 组件(搜索、分组、描述、键盘导航、Glassmorphism 样式) - 扩展 DropdownComponentSchema 支持 searchable/clearable/size/label/error 等字段 - A2UIDropdown 渲染器自动检测增强特性,按需切换标准 Select 与 Combobox 模式 - 所有新字段 optional,完全向后兼容
1 parent cafc6d0 commit f27e52a

6 files changed

Lines changed: 536 additions & 16 deletions

File tree

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
// ========================================
2+
// Enhanced Select Component
3+
// ========================================
4+
// Glassmorphism-styled Combobox with search, groups, descriptions,
5+
// form integration (label/required/error), and keyboard navigation.
6+
// Built on native DOM + Radix-like patterns (no extra deps).
7+
8+
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
9+
import { ChevronDown, Search, X, Check } from 'lucide-react';
10+
import { cn } from '@/lib/utils';
11+
import { triggerVariants, optionItemVariants } from './enhanced-select-variants';
12+
import type { EnhancedSelectProps, EnhancedSelectOption } from './types';
13+
14+
// ========== Grouped + Filtered Options ==========
15+
16+
interface GroupedOptions {
17+
[group: string]: EnhancedSelectOption[];
18+
}
19+
20+
function groupAndFilter(
21+
options: EnhancedSelectOption[],
22+
search: string,
23+
): { grouped: GroupedOptions; total: number } {
24+
const filtered = search
25+
? options.filter(
26+
(opt) =>
27+
opt.label.toLowerCase().includes(search.toLowerCase()) ||
28+
(opt.description && opt.description.toLowerCase().includes(search.toLowerCase()))
29+
)
30+
: options;
31+
32+
const grouped: GroupedOptions = {};
33+
for (const opt of filtered) {
34+
const key = opt.group || '';
35+
if (!grouped[key]) grouped[key] = [];
36+
grouped[key].push(opt);
37+
}
38+
39+
return { grouped, total: filtered.length };
40+
}
41+
42+
function highlightMatch(text: string, search: string): React.ReactNode {
43+
if (!search) return text;
44+
const idx = text.toLowerCase().indexOf(search.toLowerCase());
45+
if (idx === -1) return text;
46+
return (
47+
<>
48+
{text.slice(0, idx)}
49+
<mark className="bg-primary/20 text-foreground rounded-sm px-0.5">{text.slice(idx, idx + search.length)}</mark>
50+
{text.slice(idx + search.length)}
51+
</>
52+
);
53+
}
54+
55+
// ========== Main Component ==========
56+
57+
export function EnhancedSelect({
58+
options,
59+
value,
60+
onChange,
61+
placeholder = 'Select...',
62+
searchable = false,
63+
clearable = false,
64+
size = 'default',
65+
label,
66+
required,
67+
error,
68+
disabled,
69+
className,
70+
}: EnhancedSelectProps) {
71+
const [open, setOpen] = useState(false);
72+
const [search, setSearch] = useState('');
73+
const [highlightIndex, setHighlightIndex] = useState(-1);
74+
const containerRef = useRef<HTMLDivElement>(null);
75+
const inputRef = useRef<HTMLInputElement>(null);
76+
const listRef = useRef<HTMLDivElement>(null);
77+
const triggerId = useRef(`enhanced-select-${Math.random().toString(36).slice(2, 8)}`).current;
78+
79+
// Resolve display label
80+
const selectedOption = options.find((opt) => opt.value === value);
81+
const displayValue = selectedOption?.label || '';
82+
83+
// Group and filter
84+
const { grouped, total } = useMemo(() => groupAndFilter(options, search), [options, search]);
85+
86+
// Flat list of visible options for keyboard nav
87+
const flatVisible = useMemo(() => {
88+
const result: EnhancedSelectOption[] = [];
89+
const sortedGroups = Object.keys(grouped).sort((a, b) => {
90+
if (a === '') return -1;
91+
if (b === '') return 1;
92+
return a.localeCompare(b);
93+
});
94+
for (const g of sortedGroups) {
95+
for (const opt of grouped[g]) {
96+
result.push(opt);
97+
}
98+
}
99+
return result;
100+
}, [grouped]);
101+
102+
// Close on outside click
103+
useEffect(() => {
104+
if (!open) return;
105+
function handleClickOutside(e: MouseEvent) {
106+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
107+
setOpen(false);
108+
setSearch('');
109+
setHighlightIndex(-1);
110+
}
111+
}
112+
document.addEventListener('mousedown', handleClickOutside);
113+
return () => document.removeEventListener('mousedown', handleClickOutside);
114+
}, [open]);
115+
116+
// Focus search input on open
117+
useEffect(() => {
118+
if (open && searchable) {
119+
setTimeout(() => inputRef.current?.focus(), 0);
120+
}
121+
}, [open, searchable]);
122+
123+
// Scroll highlighted option into view
124+
useEffect(() => {
125+
if (highlightIndex >= 0 && listRef.current) {
126+
const el = listRef.current.querySelector(`[data-index="${highlightIndex}"]`);
127+
el?.scrollIntoView({ block: 'nearest' });
128+
}
129+
}, [highlightIndex]);
130+
131+
const handleOpen = useCallback(() => {
132+
if (disabled) return;
133+
setOpen(true);
134+
setHighlightIndex(-1);
135+
}, [disabled]);
136+
137+
const handleSelect = useCallback(
138+
(opt: EnhancedSelectOption) => {
139+
if (opt.disabled) return;
140+
onChange(opt.value);
141+
setOpen(false);
142+
setSearch('');
143+
setHighlightIndex(-1);
144+
},
145+
[onChange]
146+
);
147+
148+
const handleClear = useCallback(
149+
(e: React.MouseEvent) => {
150+
e.stopPropagation();
151+
onChange('');
152+
},
153+
[onChange]
154+
);
155+
156+
const handleKeyDown = useCallback(
157+
(e: React.KeyboardEvent) => {
158+
if (!open) {
159+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
160+
e.preventDefault();
161+
handleOpen();
162+
}
163+
if ((e.key === 'Backspace' || e.key === 'Delete') && clearable && value) {
164+
e.preventDefault();
165+
onChange('');
166+
}
167+
return;
168+
}
169+
170+
switch (e.key) {
171+
case 'Escape':
172+
e.preventDefault();
173+
setOpen(false);
174+
setSearch('');
175+
setHighlightIndex(-1);
176+
break;
177+
case 'ArrowDown':
178+
e.preventDefault();
179+
setHighlightIndex((prev) => {
180+
let next = prev + 1;
181+
while (next < flatVisible.length && flatVisible[next].disabled) next++;
182+
return next < flatVisible.length ? next : prev;
183+
});
184+
break;
185+
case 'ArrowUp':
186+
e.preventDefault();
187+
setHighlightIndex((prev) => {
188+
let next = prev - 1;
189+
while (next >= 0 && flatVisible[next].disabled) next--;
190+
return next >= 0 ? next : prev;
191+
});
192+
break;
193+
case 'Enter':
194+
e.preventDefault();
195+
if (highlightIndex >= 0 && highlightIndex < flatVisible.length) {
196+
handleSelect(flatVisible[highlightIndex]);
197+
}
198+
break;
199+
}
200+
},
201+
[open, handleOpen, flatVisible, highlightIndex, handleSelect, clearable, value, onChange]
202+
);
203+
204+
// Derive state variant
205+
const stateVariant = disabled ? 'disabled' as const : error ? 'error' as const : 'normal' as const;
206+
207+
return (
208+
<div className={cn('space-y-1.5', className)}>
209+
{/* Label */}
210+
{label && (
211+
<label htmlFor={triggerId} className="text-sm font-medium leading-none">
212+
{label}
213+
{required && <span className="text-destructive ml-0.5">*</span>}
214+
</label>
215+
)}
216+
217+
{/* Select Container */}
218+
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
219+
{/* Trigger */}
220+
<button
221+
id={triggerId}
222+
type="button"
223+
onClick={() => (open ? setOpen(false) : handleOpen())}
224+
disabled={disabled}
225+
aria-expanded={open}
226+
aria-haspopup="listbox"
227+
aria-invalid={!!error}
228+
aria-required={required}
229+
data-state={open ? 'open' : 'closed'}
230+
className={cn(triggerVariants({ size, state: stateVariant }))}
231+
>
232+
<span className={cn('truncate', !displayValue && 'text-muted-foreground')}>
233+
{displayValue || placeholder}
234+
</span>
235+
<div className="flex items-center shrink-0">
236+
{clearable && value && !disabled && (
237+
<span
238+
role="button"
239+
tabIndex={-1}
240+
onClick={handleClear}
241+
className="p-0.5 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
242+
>
243+
<X className="h-3.5 w-3.5" />
244+
</span>
245+
)}
246+
<ChevronDown className="h-4 w-4 opacity-50" />
247+
</div>
248+
</button>
249+
250+
{/* Dropdown Panel */}
251+
{open && (
252+
<div
253+
className="absolute z-50 mt-1 w-full rounded-lg shadow-lg bg-card/90 backdrop-blur-md border border-primary/20 animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-200"
254+
role="listbox"
255+
aria-label={label || placeholder}
256+
>
257+
{/* Search Input */}
258+
{searchable && (
259+
<div className="flex items-center px-3 py-2 border-b border-border/50">
260+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50 text-muted-foreground" />
261+
<input
262+
ref={inputRef}
263+
value={search}
264+
onChange={(e) => {
265+
setSearch(e.target.value);
266+
setHighlightIndex(-1);
267+
}}
268+
placeholder={placeholder}
269+
className="flex-1 h-7 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
270+
aria-label="Search options"
271+
/>
272+
{search && (
273+
<button
274+
type="button"
275+
onClick={() => {
276+
setSearch('');
277+
inputRef.current?.focus();
278+
}}
279+
className="p-0.5 rounded-full hover:bg-muted text-muted-foreground"
280+
>
281+
<X className="h-3.5 w-3.5" />
282+
</button>
283+
)}
284+
</div>
285+
)}
286+
287+
{/* Options List */}
288+
<div ref={listRef} className="max-h-64 overflow-y-auto p-1">
289+
{total === 0 ? (
290+
<div className="py-6 text-center text-sm text-muted-foreground">
291+
{search ? 'No matching options' : 'No options available'}
292+
</div>
293+
) : (
294+
(() => {
295+
let globalIdx = 0;
296+
const sortedGroups = Object.keys(grouped).sort((a, b) => {
297+
if (a === '') return -1;
298+
if (b === '') return 1;
299+
return a.localeCompare(b);
300+
});
301+
302+
return sortedGroups.map((group) => (
303+
<div key={group || '__ungrouped'}>
304+
{group && (
305+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
306+
{group}
307+
</div>
308+
)}
309+
{grouped[group].map((opt) => {
310+
const idx = globalIdx++;
311+
const isSelected = opt.value === value;
312+
const isHighlighted = idx === highlightIndex;
313+
314+
return (
315+
<div
316+
key={opt.value}
317+
data-index={idx}
318+
role="option"
319+
aria-selected={isSelected}
320+
aria-disabled={opt.disabled}
321+
onClick={() => handleSelect(opt)}
322+
className={cn(
323+
optionItemVariants({ isSelected, isDisabled: !!opt.disabled }),
324+
isHighlighted && !isSelected && 'bg-accent/50',
325+
'pl-3',
326+
)}
327+
>
328+
<div className="flex items-center gap-2 w-full min-w-0">
329+
{/* Icon */}
330+
{opt.icon && (
331+
<span className="shrink-0 text-muted-foreground">{opt.icon}</span>
332+
)}
333+
{/* Content */}
334+
<div className="flex flex-col min-w-0 flex-1">
335+
<span className="truncate text-sm">
336+
{highlightMatch(opt.label, search)}
337+
</span>
338+
{opt.description && (
339+
<span className="truncate text-xs text-muted-foreground">
340+
{highlightMatch(opt.description, search)}
341+
</span>
342+
)}
343+
</div>
344+
{/* Check mark */}
345+
{isSelected && (
346+
<Check className="h-4 w-4 shrink-0 text-primary" />
347+
)}
348+
</div>
349+
</div>
350+
);
351+
})}
352+
</div>
353+
));
354+
})()
355+
)}
356+
</div>
357+
</div>
358+
)}
359+
</div>
360+
361+
{/* Error Message */}
362+
{error && (
363+
<p className="text-sm text-destructive" role="alert">
364+
{error}
365+
</p>
366+
)}
367+
</div>
368+
);
369+
}

0 commit comments

Comments
 (0)