diff --git a/ccw/frontend/src/components/ui/enhanced-select/EnhancedSelect.tsx b/ccw/frontend/src/components/ui/enhanced-select/EnhancedSelect.tsx new file mode 100644 index 00000000..20ad4351 --- /dev/null +++ b/ccw/frontend/src/components/ui/enhanced-select/EnhancedSelect.tsx @@ -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)} + {text.slice(idx, idx + search.length)} + {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(null); + const inputRef = useRef(null); + const listRef = useRef(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 ( +
+ {/* Label */} + {label && ( + + )} + + {/* Select Container */} +
+ {/* Trigger */} + + + {/* Dropdown Panel */} + {open && ( +
+ {/* Search Input */} + {searchable && ( +
+ + { + 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 && ( + + )} +
+ )} + + {/* Options List */} +
+ {total === 0 ? ( +
+ {search ? 'No matching options' : 'No options available'} +
+ ) : ( + (() => { + 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) => ( +
+ {group && ( +
+ {group} +
+ )} + {grouped[group].map((opt) => { + const idx = globalIdx++; + const isSelected = opt.value === value; + const isHighlighted = idx === highlightIndex; + + return ( +
handleSelect(opt)} + className={cn( + optionItemVariants({ isSelected, isDisabled: !!opt.disabled }), + isHighlighted && !isSelected && 'bg-accent/50', + 'pl-3', + )} + > +
+ {/* Icon */} + {opt.icon && ( + {opt.icon} + )} + {/* Content */} +
+ + {highlightMatch(opt.label, search)} + + {opt.description && ( + + {highlightMatch(opt.description, search)} + + )} +
+ {/* Check mark */} + {isSelected && ( + + )} +
+
+ ); + })} +
+ )); + })() + )} +
+
+ )} +
+ + {/* Error Message */} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/ccw/frontend/src/components/ui/enhanced-select/enhanced-select-variants.ts b/ccw/frontend/src/components/ui/enhanced-select/enhanced-select-variants.ts new file mode 100644 index 00000000..c2a33a88 --- /dev/null +++ b/ccw/frontend/src/components/ui/enhanced-select/enhanced-select-variants.ts @@ -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, + }, + } +); diff --git a/ccw/frontend/src/components/ui/enhanced-select/index.ts b/ccw/frontend/src/components/ui/enhanced-select/index.ts new file mode 100644 index 00000000..a39207fe --- /dev/null +++ b/ccw/frontend/src/components/ui/enhanced-select/index.ts @@ -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'; diff --git a/ccw/frontend/src/components/ui/enhanced-select/types.ts b/ccw/frontend/src/components/ui/enhanced-select/types.ts new file mode 100644 index 00000000..ca231199 --- /dev/null +++ b/ccw/frontend/src/components/ui/enhanced-select/types.ts @@ -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; +} diff --git a/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts b/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts index 2358578d..671e662f 100644 --- a/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts +++ b/ccw/frontend/src/packages/a2ui-runtime/core/A2UITypes.ts @@ -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(), }), }); diff --git a/ccw/frontend/src/packages/a2ui-runtime/renderer/components/A2UIDropdown.tsx b/ccw/frontend/src/packages/a2ui-runtime/renderer/components/A2UIDropdown.tsx index 7aa09233..a4e26d31 100644 --- a/ccw/frontend/src/packages/a2ui-runtime/renderer/components/A2UIDropdown.tsx +++ b/ccw/frontend/src/packages/a2ui-runtime/renderer/components/A2UIDropdown.tsx @@ -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; - onAction: (actionId: string, params: Record) => void | Promise; - 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 ( + + ); + } + + // ========== Standard Mode (Radix Select) ========== return (