feat(web): Refactor icon filtering and sorting (#1288)

* feat(web): Refactor icon filtering and sorting logic using a new utility function

* feat(command-menu): Improve display and performance of cmd+k menu

* fix(utils): Adjust scoring logic in fuzzySearch and filter thresholds
This commit is contained in:
Bjorn Lammers
2025-04-26 23:07:05 +02:00
committed by GitHub
parent e7284241c9
commit 8afcb351ef
4 changed files with 231 additions and 145 deletions

View File

@@ -2,19 +2,15 @@
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { useMediaQuery } from "@/hooks/use-media-query"
import { formatIconName, fuzzySearch } from "@/lib/utils"
import { formatIconName, fuzzySearch, filterAndSortIcons } from "@/lib/utils"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useState, useMemo } from "react"
import type { IconWithName } from "@/types/icons"
import { Tag, Search as SearchIcon, Info } from "lucide-react"
import { Badge } from "@/components/ui/badge"
interface CommandMenuProps {
icons: {
name: string
data: {
categories: string[]
aliases: string[]
[key: string]: unknown
}
}[]
icons: IconWithName[]
triggerButtonId?: string
open?: boolean
onOpenChange?: (open: boolean) => void
@@ -41,7 +37,12 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
[externalOnOpenChange],
)
const filteredIcons = getFilteredIcons(icons, query)
const filteredIcons = useMemo(() =>
filterAndSortIcons({ icons, query, limit: 20 }),
[icons, query]
)
const totalIcons = icons.length
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -58,81 +59,100 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
return () => document.removeEventListener("keydown", handleKeyDown)
}, [isOpen, setIsOpen])
function getFilteredIcons(iconList: CommandMenuProps["icons"], query: string) {
if (!query) {
// Return a limited number of icons when no query is provided
return iconList.slice(0, 8)
}
// Calculate scores for each icon
const scoredIcons = iconList.map((icon) => {
// Calculate scores for different fields
const nameScore = fuzzySearch(icon.name, query) * 2.0 // Give more weight to name matches
// Get max score from aliases
const aliasScore =
icon.data.aliases && icon.data.aliases.length > 0
? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * 1.8 // Increased weight for aliases
: 0
// Get max score from categories
const categoryScore =
icon.data.categories && icon.data.categories.length > 0
? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query)))
: 0
// Use the highest score
const score = Math.max(nameScore, aliasScore, categoryScore)
return { icon, score, matchedField: score === nameScore ? "name" : score === aliasScore ? "alias" : "category" }
})
// Filter icons with a minimum score and sort by highest score
return scoredIcons
.filter((item) => item.score > 0.3) // Higher threshold for more accurate results
.sort((a, b) => b.score - a.score)
.slice(0, 20) // Limit the number of results
.map((item) => item.icon)
}
const handleSelect = (name: string) => {
setIsOpen(false)
router.push(`/icons/${name}`)
}
return (
<CommandDialog open={isOpen} onOpenChange={setIsOpen}>
<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty>
<CommandGroup heading="Icons">
{filteredIcons.map(({ name, data }) => {
// Find matched alias for display if available
const matchedAlias =
query && data.aliases && data.aliases.length > 0
? data.aliases.find((alias) => alias.toLowerCase().includes(query.toLowerCase()))
: null
const formatedIconName = formatIconName(name)
const handleBrowseAll = () => {
setIsOpen(false)
router.push("/icons")
}
return (
<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center">
<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span>
return (
<CommandDialog
open={isOpen}
onOpenChange={setIsOpen}
contentClassName="bg-background/90 backdrop-blur-sm border border-border/60"
>
<CommandInput
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-[300px]">
{/* Icon Results */}
<CommandGroup heading="Icons">
{filteredIcons.length > 0 && (
filteredIcons.map(({ name, data }) => {
const formatedIconName = formatIconName(name)
const hasCategories = data.categories && data.categories.length > 0
return (
<CommandItem
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="flex items-center gap-2 cursor-pointer py-1.5"
>
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-full w-full bg-primary/10 dark:bg-primary/20 rounded-md flex items-center justify-center">
<span className="text-[9px] font-medium text-primary dark:text-primary-foreground">{name.substring(0, 2).toUpperCase()}</span>
</div>
</div>
</div>
<span className="flex-grow capitalize">{formatedIconName}</span>
{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
{!matchedAlias && data.categories && data.categories.length > 0 && (
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
)}
</CommandItem>
)
})}
<span className="flex-grow capitalize font-medium text-sm">{formatedIconName}</span>
{hasCategories && (
<div className="flex gap-1 items-center flex-shrink-0 overflow-hidden max-w-[40%]">
{/* First category */}
<Badge
key={data.categories[0]}
variant="secondary"
className="text-xs font-normal inline-flex items-center gap-1 whitespace-nowrap max-w-[120px] overflow-hidden"
>
<Tag size={8} className="mr-1 flex-shrink-0" />
<span className="truncate">
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
</Badge>
{/* "+N" badge if more than one category */}
{data.categories.length > 1 && (
<Badge variant="outline" className="text-xs flex-shrink-0">
+{data.categories.length - 1}
</Badge>
)}
</div>
)}
</CommandItem>
)
})
)}
</CommandGroup>
<CommandEmpty>
{/* Minimal empty state */}
<div className="py-2 px-2 text-center text-xs text-muted-foreground flex items-center justify-center gap-1.5">
<Info className="h-3.5 w-3.5 text-destructive" /> {/* Smaller red icon */}
<span>No matching icons found.</span>
</div>
</CommandEmpty>
</CommandList>
{/* Separator and Browse section - Styled div outside CommandList */}
<div className="border-t border-border/40 pt-1 mt-1 px-1 pb-1">
<div
role="button"
tabIndex={0}
className="flex items-center gap-2 cursor-pointer rounded-sm px-2 py-1 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
onClick={handleBrowseAll}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleBrowseAll() }}
>
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-full w-full bg-primary/80 dark:bg-primary/40 rounded-md flex items-center justify-center">
<SearchIcon className="text-primary-foreground dark:text-primary-200 w-3.5 h-3.5" />
</div>
</div>
<span className="flex-grow text-sm">Browse all icons {totalIcons} available</span>
</div>
</div>
</CommandDialog>
)
}