Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ import {
import { MultiSelect, MultiSelectRef } from "@/components/multi-select";

const frameworksList = [
{ value: "next.js", label: "Next.js", icon: Icons.code },
{ value: "next.js", label: "Next.js", icon: Icons.code, keywords: ["fullstack"] },
{ value: "react", label: "React", icon: Icons.zap },
{ value: "vue", label: "Vue.js", icon: Icons.globe },
{ value: "angular", label: "Angular", icon: Icons.target },
{ value: "svelte", label: "Svelte", icon: Icons.star },
{ value: "nuxt.js", label: "Nuxt.js", icon: Icons.turtle },
{ value: "remix", label: "Remix", icon: Icons.rabbit },
{ value: "astro", label: "Astro", icon: Icons.fish },
{ value: "gatsby", label: "Gatsby", icon: Icons.dog },
{ value: "nuxt.js", label: "Nuxt.js", icon: Icons.turtle, keywords: ["fullstack"] },
{ value: "remix", label: "Remix", icon: Icons.rabbit, keywords: ["fullstack"] },
{ value: "astro", label: "Astro", icon: Icons.fish, keywords: ["fullstack"] },
{ value: "gatsby", label: "Gatsby", icon: Icons.dog, keywords: ["fullstack"] },
{ value: "solid", label: "SolidJS", icon: Icons.cpu },
];

Expand Down Expand Up @@ -1950,7 +1950,7 @@ function SimplifiedExample() {
/>
<div className="mt-2 text-xs text-muted-foreground">
✅ Search enabled • ✅ Select all • ✅ Custom empty
state
state • ✅ Item keywords are searchable
</div>
</div>
</div>
Expand Down Expand Up @@ -1996,18 +1996,28 @@ function SimplifiedExample() {
</h4>
<p className="text-base text-muted-foreground mb-4">
Our search implementation is optimized for performance
with debounced input handling, efficient filtering
algorithms, and smooth animations that don&apos;t
block user interactions.
with debounced input handling and smooth animations that
don&apos;t block user interactions. Uses the underlying
Command filter approach, allowing users to supply
"hidden keywords" for matches, and also an entirely
custom filter function if needed. Try searching for
&quot;fullstack&quot; to see how &quot;Next.js&quot; matches.
</p>
<div className="text-sm text-muted-foreground">
<div className="text-sm text-muted-foregroun mb-4">
<strong className="text-foreground">
Best Practices:
</strong>{" "}
Use descriptive placeholders, provide helpful empty
states, and consider disabling search for small option
sets (≤10 items) to simplify the interface.
</div>
<div className="text-sm text-muted-foreground">
<strong className="text-foreground">Pro Tip:</strong>{" "}
Add a <code>keywords</code> field to your options to extend
and improve matching beyond visible text and values. You
can also provide a fully custom <code>filter</code> function
for complete control over search behavior.
</div>
</div>
</div>
</div>
Expand Down
67 changes: 17 additions & 50 deletions src/components/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ interface MultiSelectOption {
/** Gradient background for badge */
gradient?: string;
};
/** Optional keywords for improved searchability, passed on to `cmdk` `Command` */
keywords?: string[];
}

/**
Expand Down Expand Up @@ -183,6 +185,12 @@ interface MultiSelectProps
*/
searchable?: boolean;

/**
* Optional filter function to customize how options are filtered based on search input.
* Takes the same signature as the underlying `cmdk` Command filter function.
*/
filter?: (value: string, search: string, keywords?: string[]) => number;

/**
* Custom empty state message when no options match search.
* Optional, defaults to "No results found."
Expand Down Expand Up @@ -320,6 +328,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
className,
hideSelectAll = false,
searchable = true,
filter,
emptyIndicator,
autoSize = false,
singleLine = false,
Expand Down Expand Up @@ -580,30 +589,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
[getAllOptions]
);

const filteredOptions = React.useMemo(() => {
if (!searchable || !searchValue) return options;
if (options.length === 0) return [];
if (isGroupedOptions(options)) {
return options
.map((group) => ({
...group,
options: group.options.filter(
(option) =>
option.label
.toLowerCase()
.includes(searchValue.toLowerCase()) ||
option.value.toLowerCase().includes(searchValue.toLowerCase())
),
}))
.filter((group) => group.options.length > 0);
}
return options.filter(
(option) =>
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
option.value.toLowerCase().includes(searchValue.toLowerCase())
);
}, [options, searchValue, searchable, isGroupedOptions]);

const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
Expand Down Expand Up @@ -739,26 +724,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
}
prevIsOpen.current = isPopoverOpen;
}

if (
searchValue !== prevSearchValue.current &&
searchValue !== undefined
) {
if (searchValue && isPopoverOpen) {
const filteredCount = allOptions.filter(
(opt) =>
opt.label.toLowerCase().includes(searchValue.toLowerCase()) ||
opt.value.toLowerCase().includes(searchValue.toLowerCase())
).length;

announce(
`${filteredCount} option${
filteredCount === 1 ? "" : "s"
} found for "${searchValue}"`
);
}
prevSearchValue.current = searchValue;
}
}, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions]);

return (
Expand Down Expand Up @@ -1019,7 +984,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
}}
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}>
<Command>
<Command filter={filter}>
{searchable && (
<CommandInput
placeholder="Search options..."
Expand Down Expand Up @@ -1080,8 +1045,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
</CommandItem>
</CommandGroup>
)}
{isGroupedOptions(filteredOptions) ? (
filteredOptions.map((group) => (
{isGroupedOptions(options) ? (
options.map((group) => (
<CommandGroup key={group.heading} heading={group.heading}>
{group.options.map((option) => {
const isSelected = selectedValues.includes(
Expand All @@ -1101,7 +1066,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
"cursor-pointer",
option.disabled && "opacity-50 cursor-not-allowed"
)}
disabled={option.disabled}>
disabled={option.disabled}
keywords={option.keywords}>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
Expand All @@ -1126,7 +1092,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
))
) : (
<CommandGroup>
{filteredOptions.map((option) => {
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
Expand All @@ -1142,7 +1108,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
"cursor-pointer",
option.disabled && "opacity-50 cursor-not-allowed"
)}
disabled={option.disabled}>
disabled={option.disabled}
keywords={option.keywords}>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
Expand Down