feat: 新增 EnhancedSelect 组件,为 A2UI Dropdown 添加 Combobox 模式

- 创建 enhanced-select 组件(搜索、分组、描述、键盘导航、Glassmorphism 样式)
- 扩展 DropdownComponentSchema 支持 searchable/clearable/size/label/error 等字段
- A2UIDropdown 渲染器自动检测增强特性,按需切换标准 Select 与 Combobox 模式
- 所有新字段 optional,完全向后兼容
This commit is contained in:
catlog22
2026-02-08 15:21:22 +08:00
parent cafc6d06fe
commit f27e52a7a6
6 changed files with 536 additions and 16 deletions

View File

@@ -0,0 +1,369 @@
// ========================================
// Enhanced Select Component
// ========================================
// Glassmorphism-styled Combobox with search, groups, descriptions,
// form integration (label/required/error), and keyboard navigation.
// Built on native DOM + Radix-like patterns (no extra deps).
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { ChevronDown, Search, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { triggerVariants, optionItemVariants } from './enhanced-select-variants';
import type { EnhancedSelectProps, EnhancedSelectOption } from './types';
// ========== Grouped + Filtered Options ==========
interface GroupedOptions {
[group: string]: EnhancedSelectOption[];
}
function groupAndFilter(
options: EnhancedSelectOption[],
search: string,
): { grouped: GroupedOptions; total: number } {
const filtered = search
? options.filter(
(opt) =>
opt.label.toLowerCase().includes(search.toLowerCase()) ||
(opt.description && opt.description.toLowerCase().includes(search.toLowerCase()))
)
: options;
const grouped: GroupedOptions = {};
for (const opt of filtered) {
const key = opt.group || '';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(opt);
}
return { grouped, total: filtered.length };
}
function highlightMatch(text: string, search: string): React.ReactNode {
if (!search) return text;
const idx = text.toLowerCase().indexOf(search.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="bg-primary/20 text-foreground rounded-sm px-0.5">{text.slice(idx, idx + search.length)}</mark>
{text.slice(idx + search.length)}
</>
);
}
// ========== Main Component ==========
export function EnhancedSelect({
options,
value,
onChange,
placeholder = 'Select...',
searchable = false,
clearable = false,
size = 'default',
label,
required,
error,
disabled,
className,
}: EnhancedSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const triggerId = useRef(`enhanced-select-${Math.random().toString(36).slice(2, 8)}`).current;
// Resolve display label
const selectedOption = options.find((opt) => opt.value === value);
const displayValue = selectedOption?.label || '';
// Group and filter
const { grouped, total } = useMemo(() => groupAndFilter(options, search), [options, search]);
// Flat list of visible options for keyboard nav
const flatVisible = useMemo(() => {
const result: EnhancedSelectOption[] = [];
const sortedGroups = Object.keys(grouped).sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a.localeCompare(b);
});
for (const g of sortedGroups) {
for (const opt of grouped[g]) {
result.push(opt);
}
}
return result;
}, [grouped]);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch('');
setHighlightIndex(-1);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
// Focus search input on open
useEffect(() => {
if (open && searchable) {
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open, searchable]);
// Scroll highlighted option into view
useEffect(() => {
if (highlightIndex >= 0 && listRef.current) {
const el = listRef.current.querySelector(`[data-index="${highlightIndex}"]`);
el?.scrollIntoView({ block: 'nearest' });
}
}, [highlightIndex]);
const handleOpen = useCallback(() => {
if (disabled) return;
setOpen(true);
setHighlightIndex(-1);
}, [disabled]);
const handleSelect = useCallback(
(opt: EnhancedSelectOption) => {
if (opt.disabled) return;
onChange(opt.value);
setOpen(false);
setSearch('');
setHighlightIndex(-1);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
handleOpen();
}
if ((e.key === 'Backspace' || e.key === 'Delete') && clearable && value) {
e.preventDefault();
onChange('');
}
return;
}
switch (e.key) {
case 'Escape':
e.preventDefault();
setOpen(false);
setSearch('');
setHighlightIndex(-1);
break;
case 'ArrowDown':
e.preventDefault();
setHighlightIndex((prev) => {
let next = prev + 1;
while (next < flatVisible.length && flatVisible[next].disabled) next++;
return next < flatVisible.length ? next : prev;
});
break;
case 'ArrowUp':
e.preventDefault();
setHighlightIndex((prev) => {
let next = prev - 1;
while (next >= 0 && flatVisible[next].disabled) next--;
return next >= 0 ? next : prev;
});
break;
case 'Enter':
e.preventDefault();
if (highlightIndex >= 0 && highlightIndex < flatVisible.length) {
handleSelect(flatVisible[highlightIndex]);
}
break;
}
},
[open, handleOpen, flatVisible, highlightIndex, handleSelect, clearable, value, onChange]
);
// Derive state variant
const stateVariant = disabled ? 'disabled' as const : error ? 'error' as const : 'normal' as const;
return (
<div className={cn('space-y-1.5', className)}>
{/* Label */}
{label && (
<label htmlFor={triggerId} className="text-sm font-medium leading-none">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{/* Select Container */}
<div ref={containerRef} className="relative" onKeyDown={handleKeyDown}>
{/* Trigger */}
<button
id={triggerId}
type="button"
onClick={() => (open ? setOpen(false) : handleOpen())}
disabled={disabled}
aria-expanded={open}
aria-haspopup="listbox"
aria-invalid={!!error}
aria-required={required}
data-state={open ? 'open' : 'closed'}
className={cn(triggerVariants({ size, state: stateVariant }))}
>
<span className={cn('truncate', !displayValue && 'text-muted-foreground')}>
{displayValue || placeholder}
</span>
<div className="flex items-center shrink-0">
{clearable && value && !disabled && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
className="p-0.5 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X className="h-3.5 w-3.5" />
</span>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</button>
{/* Dropdown Panel */}
{open && (
<div
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"
role="listbox"
aria-label={label || placeholder}
>
{/* Search Input */}
{searchable && (
<div className="flex items-center px-3 py-2 border-b border-border/50">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50 text-muted-foreground" />
<input
ref={inputRef}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setHighlightIndex(-1);
}}
placeholder={placeholder}
className="flex-1 h-7 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
aria-label="Search options"
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('');
inputRef.current?.focus();
}}
className="p-0.5 rounded-full hover:bg-muted text-muted-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{/* Options List */}
<div ref={listRef} className="max-h-64 overflow-y-auto p-1">
{total === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{search ? 'No matching options' : 'No options available'}
</div>
) : (
(() => {
let globalIdx = 0;
const sortedGroups = Object.keys(grouped).sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a.localeCompare(b);
});
return sortedGroups.map((group) => (
<div key={group || '__ungrouped'}>
{group && (
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{group}
</div>
)}
{grouped[group].map((opt) => {
const idx = globalIdx++;
const isSelected = opt.value === value;
const isHighlighted = idx === highlightIndex;
return (
<div
key={opt.value}
data-index={idx}
role="option"
aria-selected={isSelected}
aria-disabled={opt.disabled}
onClick={() => handleSelect(opt)}
className={cn(
optionItemVariants({ isSelected, isDisabled: !!opt.disabled }),
isHighlighted && !isSelected && 'bg-accent/50',
'pl-3',
)}
>
<div className="flex items-center gap-2 w-full min-w-0">
{/* Icon */}
{opt.icon && (
<span className="shrink-0 text-muted-foreground">{opt.icon}</span>
)}
{/* Content */}
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate text-sm">
{highlightMatch(opt.label, search)}
</span>
{opt.description && (
<span className="truncate text-xs text-muted-foreground">
{highlightMatch(opt.description, search)}
</span>
)}
</div>
{/* Check mark */}
{isSelected && (
<Check className="h-4 w-4 shrink-0 text-primary" />
)}
</div>
</div>
);
})}
</div>
));
})()
)}
</div>
</div>
)}
</div>
{/* Error Message */}
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
// ========================================
// Enhanced Select - CVA Variant Definitions
// ========================================
import { cva } from 'class-variance-authority';
export const triggerVariants = cva(
'inline-flex w-full items-center justify-between rounded-lg border bg-background/80 backdrop-blur-sm text-sm ring-offset-background transition-all duration-200 [&>svg]:transition-transform [&>svg]:duration-200',
{
variants: {
size: {
sm: 'h-8 px-2 text-xs gap-1',
default: 'h-10 px-3 gap-2',
lg: 'h-12 px-4 text-base gap-2',
},
variant: {
default: 'border-input hover:bg-primary/10',
ghost: 'border-transparent hover:bg-primary/10',
outline: 'border-2 border-muted-foreground/30 hover:bg-primary/10',
},
state: {
normal: 'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
error: 'border-destructive focus-within:ring-2 focus-within:ring-destructive/50 focus-within:ring-offset-2',
disabled: 'opacity-50 cursor-not-allowed pointer-events-none',
},
},
defaultVariants: {
size: 'default',
variant: 'default',
state: 'normal',
},
}
);
export const optionItemVariants = cva(
'relative flex w-full cursor-pointer select-none items-start rounded-md px-2 py-1.5 text-sm outline-none transition-colors duration-150',
{
variants: {
isSelected: {
true: "bg-primary/20 before:absolute before:left-0 before:top-0 before:h-full before:w-1 before:bg-primary before:content-[''] before:rounded-l-md",
false: 'hover:bg-primary/15',
},
isDisabled: {
true: 'pointer-events-none opacity-50',
false: '',
},
},
defaultVariants: {
isSelected: false,
isDisabled: false,
},
}
);

View File

@@ -0,0 +1,7 @@
// ========================================
// Enhanced Select - Barrel Export
// ========================================
export { EnhancedSelect } from './EnhancedSelect';
export type { EnhancedSelectProps, EnhancedSelectOption } from './types';
export { triggerVariants, optionItemVariants } from './enhanced-select-variants';

View File

@@ -0,0 +1,27 @@
// ========================================
// Enhanced Select - Internal Types
// ========================================
export interface EnhancedSelectOption {
label: string;
value: string;
description?: string;
icon?: string;
disabled?: boolean;
group?: string;
}
export interface EnhancedSelectProps {
options: EnhancedSelectOption[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
searchable?: boolean;
clearable?: boolean;
size?: 'sm' | 'default' | 'lg';
label?: string;
required?: boolean;
error?: string;
disabled?: boolean;
className?: string;
}

View File

@@ -62,16 +62,31 @@ export const ButtonComponentSchema = z.object({
}),
});
/** Dropdown/Select component */
/** Dropdown option schema (enhanced with description, icon, disabled, group) */
export const DropdownOptionSchema = z.object({
label: TextContentSchema,
value: z.string(),
description: TextContentSchema.optional(),
icon: z.string().optional(),
disabled: BooleanContentSchema.optional(),
group: z.string().optional(),
});
/** Dropdown/Select component (enhanced with search, form integration, styling) */
export const DropdownComponentSchema = z.object({
Dropdown: z.object({
options: z.array(z.object({
label: TextContentSchema,
value: z.string(),
})),
options: z.array(DropdownOptionSchema),
selectedValue: TextContentSchema.optional(),
onChange: ActionSchema,
placeholder: z.string().optional(),
// Enhanced features
searchable: z.boolean().optional(),
clearable: z.boolean().optional(),
size: z.enum(['sm', 'default', 'lg']).optional(),
// Form integration
label: TextContentSchema.optional(),
required: z.boolean().optional(),
error: TextContentSchema.optional(),
}),
});

View File

@@ -1,9 +1,10 @@
// ========================================
// A2UI Dropdown Component Renderer
// ========================================
// Maps A2UI Dropdown component to shadcn/ui Select
// Renders as EnhancedSelect (Combobox) when searchable,
// or standard shadcn/ui Select otherwise.
import React, { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import {
Select,
SelectContent,
@@ -11,25 +12,31 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { EnhancedSelect } from '@/components/ui/enhanced-select';
import type { EnhancedSelectOption } from '@/components/ui/enhanced-select';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import { resolveTextContent, resolveLiteralOrBinding } from '../A2UIRenderer';
import type { DropdownComponent } from '../../core/A2UITypes';
interface A2UIDropdownProps {
component: DropdownComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Dropdown Component Renderer
* Using shadcn/ui Select with options array mapping to SelectItem
* Auto-selects rendering mode based on schema fields:
* - searchable/description/icon/group → EnhancedSelect (Combobox)
* - basic options only → shadcn/ui Select
*/
export const A2UIDropdown: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const dropdownComp = component as DropdownComponent;
const { Dropdown: dropdownConfig } = dropdownComp;
// Detect if enhanced features are requested
const hasEnhancedFeatures = dropdownConfig.searchable ||
dropdownConfig.clearable ||
dropdownConfig.label ||
dropdownConfig.error ||
dropdownConfig.size ||
dropdownConfig.options.some((opt: any) => opt.description || opt.icon || opt.group || opt.disabled);
// Resolve initial selected value from binding
const getInitialValue = (): string => {
if (!dropdownConfig.selectedValue) return '';
@@ -56,13 +63,55 @@ export const A2UIDropdown: ComponentRenderer = ({ component, state, onAction, re
});
}, [dropdownConfig.onChange, onAction]);
// Resolve enhanced options (always computed to satisfy hooks rules)
const resolvedOptions: EnhancedSelectOption[] = useMemo(() =>
dropdownConfig.options.map((opt: any) => ({
label: resolveTextContent(opt.label, resolveBinding),
value: opt.value,
description: opt.description ? resolveTextContent(opt.description, resolveBinding) : undefined,
icon: opt.icon || undefined,
disabled: opt.disabled
? Boolean(resolveLiteralOrBinding(opt.disabled, resolveBinding))
: false,
group: opt.group || undefined,
})),
[dropdownConfig.options, resolveBinding]
);
const resolvedLabel = dropdownConfig.label
? resolveTextContent(dropdownConfig.label, resolveBinding)
: undefined;
const resolvedError = dropdownConfig.error
? resolveTextContent(dropdownConfig.error, resolveBinding)
: undefined;
// ========== Enhanced Mode (Combobox) ==========
if (hasEnhancedFeatures) {
return (
<EnhancedSelect
options={resolvedOptions}
value={selectedValue}
onChange={handleChange}
placeholder={dropdownConfig.placeholder}
searchable={dropdownConfig.searchable || false}
clearable={dropdownConfig.clearable || false}
size={dropdownConfig.size}
label={resolvedLabel}
required={dropdownConfig.required || false}
error={resolvedError}
/>
);
}
// ========== Standard Mode (Radix Select) ==========
return (
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder={dropdownConfig.placeholder} />
</SelectTrigger>
<SelectContent>
{dropdownConfig.options.map((option) => {
{dropdownConfig.options.map((option: any) => {
const label = resolveTextContent(option.label, resolveBinding);
return (
<SelectItem key={option.value} value={option.value}>