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"
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<HTMLInputElement>) => {
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 (
<Combobox data={existingIcons} type="icon" value={value} onValueChange={handleValueChange}>
<ComboboxTrigger className="w-full justify-start">
{value ? (
<span className="flex items-center justify-between w-full">
<span className="font-mono">{value}</span>
</span>
) : (
<span className="text-muted-foreground">
{loading ? "Loading icons..." : "Select or create icon ID..."}
</span>
<div className="relative w-full">
<Input
type="text"
value={rawInput}
onChange={handleInputChange}
onFocus={() => 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"
)}
</ComboboxTrigger>
<ComboboxContent>
<ComboboxInput
placeholder="Search or type new icon ID..."
onValueChange={setPreviewValue}
/>
<ComboboxEmpty>
{loading ? (
"Loading..."
) : (
<ComboboxCreateNew onCreateNew={handleCreateNew}>
{(inputValue) => {
const sanitized = sanitizeIconName(inputValue)
return (
<div className="flex items-center gap-2 py-2">
<span className="text-muted-foreground">Create new icon:</span>
<span className="font-mono font-semibold text-foreground">
{sanitized}
</span>
</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>
aria-invalid={isInvalid}
aria-describedby={error ? "icon-name-error" : undefined}
/>
{/* Inline suggestions list */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-md border bg-popover shadow-md">
<Command className="rounded-md">
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty>No existing icons found</CommandEmpty>
<CommandGroup heading={`⚠️ Existing Icons (${filteredIcons.length} matches - Not Allowed)`}>
{filteredIcons.slice(0, 50).map((icon) => (
<CommandItem
key={icon.value}
value={icon.value}
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>
)
}