From 7fe7d43c1af5b1473a986f9549c478f1c2d099b5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 13 Oct 2025 15:37:41 +0200 Subject: [PATCH] refactor: redesign icon name input with inline suggestions - Replace popover-based combobox with direct input field - Add inline dropdown showing existing icons (max 50 for performance) - Implement real-time search filtering on both value and label - Track raw input separately for instant search feedback - Display existing icons as warnings with AlertCircle icons - Add proper validation error display with TanStack Form integration - Show validation errors in red below input - Add aria-invalid and aria-describedby for accessibility - Sync raw input with sanitized value on blur - Prevent selecting existing icons (shows as not allowed) --- web/src/components/icon-name-combobox.tsx | 178 ++++++++++++---------- 1 file changed, 100 insertions(+), 78 deletions(-) diff --git a/web/src/components/icon-name-combobox.tsx b/web/src/components/icon-name-combobox.tsx index c02379b2..7ba58c47 100644 --- a/web/src/components/icon-name-combobox.tsx +++ b/web/src/components/icon-name-combobox.tsx @@ -1,102 +1,124 @@ "use client" -import { useEffect, useState } from "react" -import { - Combobox, - ComboboxContent, - ComboboxCreateNew, - ComboboxEmpty, - ComboboxGroup, - ComboboxInput, - ComboboxItem, - ComboboxList, - ComboboxTrigger, -} from "@/components/ui/shadcn-io/combobox" +import { AlertCircle } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" import { useExistingIconNames } from "@/hooks/use-submissions" interface IconNameComboboxProps { value: string onValueChange: (value: string) => void - onIsExisting: (isExisting: boolean) => void + error?: string + isInvalid?: boolean } -export function IconNameCombobox({ value, onValueChange, onIsExisting }: IconNameComboboxProps) { +export function IconNameCombobox({ value, onValueChange, error, isInvalid }: IconNameComboboxProps) { const { data: existingIcons = [], isLoading: loading } = useExistingIconNames() - const [previewValue, setPreviewValue] = useState("") + const [isFocused, setIsFocused] = useState(false) + const [rawInput, setRawInput] = useState(value) - // Check if current value is existing - useEffect(() => { - const isExisting = existingIcons.some((icon) => icon.value === value) - onIsExisting(isExisting) - }, [value, existingIcons, onIsExisting]) - - const sanitizeIconName = (inputValue: string): string => { - return inputValue + const sanitizeIconName = (input: string): string => { + return input .toLowerCase() .trim() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, "") } - const handleCreateNew = (inputValue: string) => { - const sanitizedValue = sanitizeIconName(inputValue) - onValueChange(sanitizedValue) + const handleInputChange = (e: React.ChangeEvent) => { + const raw = e.target.value + setRawInput(raw) // Track raw input for immediate filtering + const sanitized = sanitizeIconName(raw) + onValueChange(sanitized) } - const handleValueChange = (newValue: string) => { - onValueChange(newValue) - } + // Filter existing icons based on EITHER raw input OR sanitized value - show ALL matches + const filteredIcons = useMemo(() => { + const searchTerm = rawInput || value + if (!searchTerm || !existingIcons.length) return [] + + const lowerSearch = searchTerm.toLowerCase() + return existingIcons.filter((icon) => + icon.value.toLowerCase().includes(lowerSearch) || + icon.label.toLowerCase().includes(lowerSearch) + ) + }, [rawInput, value, existingIcons]) + + const showSuggestions = isFocused && (rawInput || value) && filteredIcons.length > 0 + + // Sync rawInput with external value changes (form reset, etc.) + useEffect(() => { + if (!isFocused) { + setRawInput(value) + } + }, [value, isFocused]) return ( - - - {value ? ( - - {value} - - ) : ( - - {loading ? "Loading icons..." : "Select or create icon ID..."} - +
+ setIsFocused(true)} + onBlur={() => { + // Sync with sanitized value when leaving input + setRawInput(value) + // Delay to allow clicking on suggestions + setTimeout(() => setIsFocused(false), 200) + }} + placeholder="Type new icon ID (e.g., my-app)..." + className={cn( + "font-mono", + isInvalid && "border-destructive focus-visible:ring-destructive/50" )} - - - - - {loading ? ( - "Loading..." - ) : ( - - {(inputValue) => { - const sanitized = sanitizeIconName(inputValue) - return ( -
- Create new icon: - - {sanitized} - -
- ) - }} -
- )} -
- - {!loading && existingIcons.length > 0 && ( - - {existingIcons.map((icon) => ( - - {icon.label} - - ))} - - )} - -
- + aria-invalid={isInvalid} + aria-describedby={error ? "icon-name-error" : undefined} + /> + + {/* Inline suggestions list */} + {showSuggestions && ( +
+ + + No existing icons found + + {filteredIcons.slice(0, 50).map((icon) => ( + { + setRawInput(selectedValue) + onValueChange(selectedValue) + setIsFocused(false) + }} + className="cursor-pointer opacity-60" + > + + {icon.label} + + ))} + + + +
+ )} + + {/* Error message */} + {error && isInvalid && ( +

+ + {error} +

+ )} + + {/* Helper text when no error */} + {!error && value && ( +

+ {loading ? "Checking availability..." : "✓ Available icon ID"} +

+ )} +
) } -