diff --git a/src/app/page.tsx b/src/app/page.tsx
index 876f282..352cb13 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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 },
];
@@ -1950,7 +1950,7 @@ function SimplifiedExample() {
/>
✅ Search enabled • ✅ Select all • ✅ Custom empty
- state
+ state • ✅ Item keywords are searchable
@@ -1996,11 +1996,14 @@ function SimplifiedExample() {
Our search implementation is optimized for performance
- with debounced input handling, efficient filtering
- algorithms, and smooth animations that don't
- block user interactions.
+ with debounced input handling and smooth animations that
+ don'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
+ "fullstack" to see how "Next.js" matches.
-
+
Best Practices:
{" "}
@@ -2008,6 +2011,13 @@ function SimplifiedExample() {
states, and consider disabling search for small option
sets (≤10 items) to simplify the interface.
+
+ Pro Tip:{" "}
+ Add a keywords field to your options to extend
+ and improve matching beyond visible text and values. You
+ can also provide a fully custom filter function
+ for complete control over search behavior.
+
diff --git a/src/components/multi-select.tsx b/src/components/multi-select.tsx
index 0770a93..70e5779 100644
--- a/src/components/multi-select.tsx
+++ b/src/components/multi-select.tsx
@@ -93,6 +93,8 @@ interface MultiSelectOption {
/** Gradient background for badge */
gradient?: string;
};
+ /** Optional keywords for improved searchability, passed on to `cmdk` `Command` */
+ keywords?: string[];
}
/**
@@ -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."
@@ -320,6 +328,7 @@ export const MultiSelect = React.forwardRef(
className,
hideSelectAll = false,
searchable = true,
+ filter,
emptyIndicator,
autoSize = false,
singleLine = false,
@@ -580,30 +589,6 @@ export const MultiSelect = React.forwardRef(
[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
) => {
@@ -739,26 +724,6 @@ export const MultiSelect = React.forwardRef(
}
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 (
@@ -1019,7 +984,7 @@ export const MultiSelect = React.forwardRef(
}}
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}>
-
+
{searchable && (
(
)}
- {isGroupedOptions(filteredOptions) ? (
- filteredOptions.map((group) => (
+ {isGroupedOptions(options) ? (
+ options.map((group) => (
{group.options.map((option) => {
const isSelected = selectedValues.includes(
@@ -1101,7 +1066,8 @@ export const MultiSelect = React.forwardRef(
"cursor-pointer",
option.disabled && "opacity-50 cursor-not-allowed"
)}
- disabled={option.disabled}>
+ disabled={option.disabled}
+ keywords={option.keywords}>
(
))
) : (
- {filteredOptions.map((option) => {
+ {options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
(
"cursor-pointer",
option.disabled && "opacity-50 cursor-not-allowed"
)}
- disabled={option.disabled}>
+ disabled={option.disabled}
+ keywords={option.keywords}>