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)
This commit is contained in:
Thomas Camlong
2025-10-13 15:37:41 +02:00
parent 758c4a5bbc
commit 7fe7d43c1a

View File

@@ -1,102 +1,124 @@
"use client" "use client"
import { useEffect, useState } from "react" import { AlertCircle } from "lucide-react"
import { import { useEffect, useMemo, useState } from "react"
Combobox, import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
ComboboxContent, import { Input } from "@/components/ui/input"
ComboboxCreateNew, import { cn } from "@/lib/utils"
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "@/components/ui/shadcn-io/combobox"
import { useExistingIconNames } from "@/hooks/use-submissions" import { useExistingIconNames } from "@/hooks/use-submissions"
interface IconNameComboboxProps { interface IconNameComboboxProps {
value: string value: string
onValueChange: (value: string) => void 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 { 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 const sanitizeIconName = (input: string): string => {
useEffect(() => { return input
const isExisting = existingIcons.some((icon) => icon.value === value)
onIsExisting(isExisting)
}, [value, existingIcons, onIsExisting])
const sanitizeIconName = (inputValue: string): string => {
return inputValue
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/\s+/g, "-") .replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "") .replace(/[^a-z0-9-]/g, "")
} }
const handleCreateNew = (inputValue: string) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = sanitizeIconName(inputValue) const raw = e.target.value
onValueChange(sanitizedValue) setRawInput(raw) // Track raw input for immediate filtering
const sanitized = sanitizeIconName(raw)
onValueChange(sanitized)
} }
const handleValueChange = (newValue: string) => { // Filter existing icons based on EITHER raw input OR sanitized value - show ALL matches
onValueChange(newValue) 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 ( return (
<Combobox data={existingIcons} type="icon" value={value} onValueChange={handleValueChange}> <div className="relative w-full">
<ComboboxTrigger className="w-full justify-start"> <Input
{value ? ( type="text"
<span className="flex items-center justify-between w-full"> value={rawInput}
<span className="font-mono">{value}</span> onChange={handleInputChange}
</span> onFocus={() => setIsFocused(true)}
) : ( onBlur={() => {
<span className="text-muted-foreground"> // Sync with sanitized value when leaving input
{loading ? "Loading icons..." : "Select or create icon ID..."} setRawInput(value)
</span> // 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"
)} )}
</ComboboxTrigger> aria-invalid={isInvalid}
<ComboboxContent> aria-describedby={error ? "icon-name-error" : undefined}
<ComboboxInput
placeholder="Search or type new icon ID..."
onValueChange={setPreviewValue}
/> />
<ComboboxEmpty>
{loading ? ( {/* Inline suggestions list */}
"Loading..." {showSuggestions && (
) : ( <div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-md border bg-popover shadow-md">
<ComboboxCreateNew onCreateNew={handleCreateNew}> <Command className="rounded-md">
{(inputValue) => { <CommandList className="max-h-[300px] overflow-y-auto">
const sanitized = sanitizeIconName(inputValue) <CommandEmpty>No existing icons found</CommandEmpty>
return ( <CommandGroup heading={`⚠️ Existing Icons (${filteredIcons.length} matches - Not Allowed)`}>
<div className="flex items-center gap-2 py-2"> {filteredIcons.slice(0, 50).map((icon) => (
<span className="text-muted-foreground">Create new icon:</span> <CommandItem
<span className="font-mono font-semibold text-foreground"> key={icon.value}
{sanitized} value={icon.value}
</span> onSelect={(selectedValue) => {
setRawInput(selectedValue)
onValueChange(selectedValue)
setIsFocused(false)
}}
className="cursor-pointer opacity-60"
>
<AlertCircle className="h-3.5 w-3.5 text-destructive mr-2 flex-shrink-0" />
<span className="font-mono text-sm">{icon.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
{/* Error message */}
{error && isInvalid && (
<p id="icon-name-error" className="text-sm text-destructive mt-1.5 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span>{error}</span>
</p>
)}
{/* Helper text when no error */}
{!error && value && (
<p className="text-sm text-muted-foreground mt-1.5">
{loading ? "Checking availability..." : "✓ Available icon ID"}
</p>
)}
</div> </div>
) )
}}
</ComboboxCreateNew>
)}
</ComboboxEmpty>
<ComboboxList>
{!loading && existingIcons.length > 0 && (
<ComboboxGroup heading="Existing Icons">
{existingIcons.map((icon) => (
<ComboboxItem key={icon.value} value={icon.value}>
<span className="font-mono text-sm">{icon.label}</span>
</ComboboxItem>
))}
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
} }