mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-11-18 17:47:30 +01:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user