feat: add searchable dropdown for Select field with long option lists (#7407)

When a Select field is rendered in dropdown format with more than 5 options,
a search input appears at the top of the dropdown. Users can type to filter
options in real time with case-insensitive matching.

- Search input with magnifying glass icon, threshold-gated (>5 options)
- useMemo-wrapped filtering for performance
- "None" option always visible regardless of search
- "Other" option filtered independently
- "No results found" empty state with i18n support
- Keyboard handling: Escape clears then closes, arrow keys for navigation
- Auto-focus on search input using double-defer pattern
- role="search" landmark for accessibility
- New searchPlaceholder/searchNoResultsText props with translations in 17 locales
- 3 new Storybook stories for searchable dropdown scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dhruwang
2026-03-18 12:30:37 +05:30
parent a51a006c26
commit c13f5cdb1a
20 changed files with 221 additions and 36 deletions
@@ -340,6 +340,75 @@ export const DropdownWithOtherOption: Story = {
},
};
export const DropdownWithSearch: Story = {
args: {
headline: "Select your country",
description: "Dropdown with search enabled (6+ options)",
options: [
{ id: "us", label: "United States" },
{ id: "uk", label: "United Kingdom" },
{ id: "de", label: "Germany" },
{ id: "fr", label: "France" },
{ id: "jp", label: "Japan" },
{ id: "br", label: "Brazil" },
{ id: "in", label: "India" },
],
variant: "dropdown",
placeholder: "Choose a country...",
},
};
export const DropdownWithSearchAndOther: Story = {
render: () => {
const [value, setValue] = React.useState<string | undefined>(undefined);
const [otherValue, setOtherValue] = React.useState<string>("");
return (
<div className="w-[600px]">
<SingleSelect
elementId="search-other"
inputId="search-other-input"
headline="Select your country"
description="Dropdown with search and other option"
options={[
{ id: "us", label: "United States" },
{ id: "uk", label: "United Kingdom" },
{ id: "de", label: "Germany" },
{ id: "fr", label: "France" },
{ id: "jp", label: "Japan" },
{ id: "br", label: "Brazil" },
]}
value={value}
onChange={setValue}
variant="dropdown"
placeholder="Choose a country..."
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Enter your country"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};
export const DropdownAtThreshold: Story = {
args: {
headline: "Pick a color",
description: "Exactly 5 options — search should NOT appear",
options: [
{ id: "red", label: "Red" },
{ id: "green", label: "Green" },
{ id: "blue", label: "Blue" },
{ id: "yellow", label: "Yellow" },
{ id: "purple", label: "Purple" },
],
variant: "dropdown",
placeholder: "Choose a color...",
},
};
export const WithContainerStyling: Story = {
args: {
headline: "Select your preferred option",
@@ -1,4 +1,4 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Search } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/general/button";
import {
@@ -14,6 +14,9 @@ import { Input } from "@/components/general/input";
import { RadioGroup, RadioGroupItem } from "@/components/general/radio-group";
import { cn } from "@/lib/utils";
/** Number of options above which the search input is shown inside the dropdown */
const SEARCH_THRESHOLD = 5;
/**
* Option for single-select element
*/
@@ -45,7 +48,7 @@ interface SingleSelectProps {
requiredLabel?: string;
/** Error message to display below the options */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-right), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the options are disabled */
disabled?: boolean;
@@ -67,6 +70,10 @@ interface SingleSelectProps {
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
/** Placeholder text for the search input in dropdown mode */
searchPlaceholder?: string;
/** Message shown when search yields no results */
searchNoResultsText?: string;
}
function SingleSelect({
@@ -91,6 +98,8 @@ function SingleSelect({
onOtherValueChange,
imageUrl,
videoUrl,
searchPlaceholder = "Search...",
searchNoResultsText = "No results found",
}: Readonly<SingleSelectProps>): React.JSX.Element {
// Ensure value is always a string or undefined
const selectedValue = value ?? undefined;
@@ -98,6 +107,46 @@ function SingleSelect({
const isOtherSelected = hasOtherOption && selectedValue === otherOptionId;
const otherInputRef = React.useRef<HTMLInputElement>(null);
// Search state for the dropdown variant
const [searchQuery, setSearchQuery] = React.useState("");
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Total option count including "other" to determine whether to show search
const allDropdownOptionCount = options.length + (hasOtherOption ? 1 : 0);
const showSearch = variant === "dropdown" && allDropdownOptionCount > SEARCH_THRESHOLD;
// Separate "none" option from regular options — "none" is always visible regardless of search
const noneOption = React.useMemo(() => options.find((opt) => opt.id === "none"), [options]);
const regularOptions = React.useMemo(() => options.filter((opt) => opt.id !== "none"), [options]);
// Filtered regular options based on the search query (only active when search is shown)
const filteredRegularOptions = React.useMemo(() => {
if (!showSearch || !searchQuery) return regularOptions;
const lowerQuery = searchQuery.toLowerCase();
return regularOptions.filter((opt) => opt.label.toLowerCase().includes(lowerQuery));
}, [showSearch, searchQuery, regularOptions]);
// Whether the "other" option matches the search
const otherMatchesSearch = React.useMemo(() => {
if (!hasOtherOption) return false;
if (!showSearch || !searchQuery) return true;
return otherOptionLabel.toLowerCase().includes(searchQuery.toLowerCase());
}, [showSearch, searchQuery, hasOtherOption, otherOptionLabel]);
const handleDropdownOpenChange = (open: boolean): void => {
if (!open) {
setSearchQuery("");
} else if (showSearch) {
// Focus the search input when dropdown opens, using the same double-defer pattern
// as the "other" input focus to win against Radix focus management.
globalThis.setTimeout(() => {
globalThis.requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
}, 0);
}
};
React.useEffect(() => {
if (!isOtherSelected || disabled) return;
@@ -155,7 +204,7 @@ function SingleSelect({
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu>
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -168,12 +217,41 @@ function SingleSelect({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)]"
align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options
.filter((option) => option.id !== "none")
.map((option) => {
{showSearch ? (
<div className="border-option-border border-b px-2 py-2" role="search">
<div className="relative flex items-center">
<Search className="text-input-placeholder pointer-events-none absolute left-2 h-4 w-4 shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
dir={dir}
onKeyDown={(e) => {
if (e.key === "Escape") {
if (searchQuery) {
e.stopPropagation();
setSearchQuery("");
}
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
// Let arrow keys propagate so Radix can move focus to options
} else {
e.stopPropagation();
}
}}
className="bg-input-bg text-input-text placeholder:text-input-placeholder font-input font-input-weight w-full rounded-sm py-1 pr-2 pl-7 text-sm outline-none"
aria-label={searchPlaceholder}
autoComplete="off"
/>
</div>
</div>
) : null}
<div className="max-h-[260px] overflow-y-auto">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{filteredRegularOptions.map((option) => {
const optionId = `${inputId}-${option.id}`;
return (
@@ -187,34 +265,36 @@ function SingleSelect({
</DropdownMenuRadioItem>
);
})}
{hasOtherOption && otherOptionId ? (
<DropdownMenuRadioItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem>
) : null}
{options
.filter((option) => option.id === "none")
.map((option) => {
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuRadioItem
key={option.id}
value={option.id}
id={optionId}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{option.label}</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
{otherMatchesSearch && otherOptionId ? (
<DropdownMenuRadioItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem>
) : null}
{noneOption ? (
<DropdownMenuRadioItem
key={noneOption.id}
value={noneOption.id}
id={`${inputId}-${noneOption.id}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">
{noneOption.label}
</span>
</DropdownMenuRadioItem>
) : null}
{showSearch && filteredRegularOptions.length === 0 && !otherMatchesSearch ? (
<div className="text-input-placeholder px-2 py-4 text-center text-sm">
{searchNoResultsText}
</div>
) : null}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
+2
View File
@@ -9,6 +9,7 @@
"finish": "إنهاء",
"language_switch": "تبديل اللغة",
"next": "التالي",
"no_results_found": "لم يتم العثور على نتائج",
"open_in_new_tab": "فتح في علامة تبويب جديدة",
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "لن يرى المستجيبون هذه البطاقة",
"retry": "إعادة المحاولة",
"retrying": "إعادة المحاولة...",
"search": "بحث...",
"select_option": "اختر خيارًا",
"select_options": "اختر الخيارات",
"sending_responses": "جارٍ إرسال الردود...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Afslut",
"language_switch": "Sprogskift",
"next": "Næste",
"no_results_found": "Ingen resultater fundet",
"open_in_new_tab": "Åbn i ny fane",
"people_responded": "{count, plural, one {1 person har svaret} other {{count} personer har svaret}}",
"please_retry_now_or_try_again_later": "Prøv igen nu eller prøv senere.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenter vil ikke se dette kort",
"retry": "Prøv igen",
"retrying": "Prøver igen…",
"search": "Søg...",
"select_option": "Vælg en mulighed",
"select_options": "Vælg muligheder",
"sending_responses": "Sender svar…",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Fertig",
"language_switch": "Sprachwechsel",
"next": "Weiter",
"no_results_found": "Keine Ergebnisse gefunden",
"open_in_new_tab": "In neuem Tab öffnen",
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
"retry": "Wiederholen",
"retrying": "Erneuter Versuch...",
"search": "Suchen...",
"select_option": "Wähle eine Option",
"select_options": "Wähle Optionen",
"sending_responses": "Antworten werden gesendet...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finish",
"language_switch": "Language switch",
"next": "Next",
"no_results_found": "No results found",
"open_in_new_tab": "Open in new tab",
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"retrying": "Retrying…",
"search": "Search...",
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses…",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finalizar",
"language_switch": "Cambio de idioma",
"next": "Siguiente",
"no_results_found": "No se encontraron resultados",
"open_in_new_tab": "Abrir en nueva pestaña",
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Los encuestados no verán esta tarjeta",
"retry": "Reintentar",
"retrying": "Reintentando...",
"search": "Buscar...",
"select_option": "Selecciona una opción",
"select_options": "Selecciona opciones",
"sending_responses": "Enviando respuestas...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Terminer",
"language_switch": "Changement de langue",
"next": "Suivant",
"no_results_found": "Aucun résultat trouvé",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Les répondants ne verront pas cette carte",
"retry": "Réessayer",
"retrying": "Nouvelle tentative...",
"search": "Rechercher...",
"select_option": "Sélectionner une option",
"select_options": "Sélectionner des options",
"sending_responses": "Envoi des réponses...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "समाप्त करें",
"language_switch": "भाषा बदलें",
"next": "अगला",
"no_results_found": "कोई परिणाम नहीं मिला",
"open_in_new_tab": "नए टैब में खोलें",
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "उत्तरदाता इस कार्ड को नहीं देखेंगे",
"retry": "पुनः प्रयास करें",
"retrying": "पुनः प्रयास कर रहे हैं...",
"search": "खोजें...",
"select_option": "एक विकल्प चुनें",
"select_options": "विकल्प चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Befejezés",
"language_switch": "Nyelvválasztó",
"next": "Következő",
"no_results_found": "Nincs találat",
"open_in_new_tab": "Megnyitás új lapon",
"people_responded": "{count, plural, one {1 személy válaszolt} other {{count} személy válaszolt}}",
"please_retry_now_or_try_again_later": "Próbálkozzon újra most, vagy próbálja meg később újra.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "A válaszadók nem fogják látni ezt a kártyát",
"retry": "Újrapróbálkozás",
"retrying": "Újrapróbálkozás…",
"search": "Keresés...",
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"sending_responses": "Válaszok küldése…",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Fine",
"language_switch": "Cambio lingua",
"next": "Avanti",
"no_results_found": "Nessun risultato trovato",
"open_in_new_tab": "Apri in una nuova scheda",
"people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}",
"please_retry_now_or_try_again_later": "Riprova ora o più tardi.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "I rispondenti non vedranno questa scheda",
"retry": "Riprova",
"retrying": "Riprovando...",
"search": "Cerca...",
"select_option": "Seleziona un'opzione",
"select_options": "Seleziona opzioni",
"sending_responses": "Invio risposte in corso...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "完了",
"language_switch": "言語切替",
"next": "次へ",
"no_results_found": "結果が見つかりません",
"open_in_new_tab": "新しいタブで開く",
"people_responded": "{count, plural, other {{count}人が回答しました}}",
"please_retry_now_or_try_again_later": "今すぐ再試行するか、後でもう一度お試しください。",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "回答者はこのカードを見ることができません",
"retry": "再試行",
"retrying": "再試行中...",
"search": "検索...",
"select_option": "オプションを選択",
"select_options": "オプションを選択",
"sending_responses": "回答を送信中...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Voltooien",
"language_switch": "Taalschakelaar",
"next": "Volgende",
"no_results_found": "Geen resultaten gevonden",
"open_in_new_tab": "Openen in nieuw tabblad",
"people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}",
"please_retry_now_or_try_again_later": "Probeer het nu opnieuw of probeer het later opnieuw.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenten zien deze kaart niet",
"retry": "Opnieuw proberen",
"retrying": "Opnieuw proberen...",
"search": "Zoeken...",
"select_option": "Selecteer een optie",
"select_options": "Selecteer opties",
"sending_responses": "Reacties verzenden...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finalizar",
"language_switch": "Alternar idioma",
"next": "Próximo",
"no_results_found": "Nenhum resultado encontrado",
"open_in_new_tab": "Abrir em nova aba",
"people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}",
"please_retry_now_or_try_again_later": "Por favor, tente novamente agora ou mais tarde.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Os respondentes não verão este cartão",
"retry": "Tentar novamente",
"retrying": "Tentando novamente...",
"search": "Pesquisar...",
"select_option": "Selecione uma opção",
"select_options": "Selecione opções",
"sending_responses": "Enviando respostas...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Finalizează",
"language_switch": "Schimbare limbă",
"next": "Următorul",
"no_results_found": "Nu s-au găsit rezultate",
"open_in_new_tab": "Deschide într-o filă nouă",
"people_responded": "{count, plural, one {1 persoană a răspuns} other {{count} persoane au răspuns}}",
"please_retry_now_or_try_again_later": "Te rugăm să încerci din nou acum sau mai târziu.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenții nu vor vedea acest card",
"retry": "Reîncearcă",
"retrying": "Se reîncearcă...",
"search": "Căutare...",
"select_option": "Selectează o opțiune",
"select_options": "Selectează opțiuni",
"sending_responses": "Trimiterea răspunsurilor...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Завершить",
"language_switch": "Переключение языка",
"next": "Далее",
"no_results_found": "Результатов не найдено",
"open_in_new_tab": "Открыть в новой вкладке",
"people_responded": "{count, plural, one {1 человек ответил} other {{count} человека ответили}}",
"please_retry_now_or_try_again_later": "Пожалуйста, повторите попытку сейчас или попробуйте позже.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Респонденты не увидят эту карточку",
"retry": "Повторить",
"retrying": "Повторная попытка...",
"search": "Поиск...",
"select_option": "Выбери вариант",
"select_options": "Выбери варианты",
"sending_responses": "Отправка ответов...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Slutför",
"language_switch": "Språkväxlare",
"next": "Nästa",
"no_results_found": "Inga resultat hittades",
"open_in_new_tab": "Öppna i ny flik",
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
"please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
"retry": "Försök igen",
"retrying": "Försöker igen...",
"search": "Sök...",
"select_option": "Välj ett alternativ",
"select_options": "Välj alternativ",
"sending_responses": "Skickar svar...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "Tugatish",
"language_switch": "Tilni almashtirish",
"next": "Keyingisi",
"no_results_found": "Natijalar topilmadi",
"open_in_new_tab": "Yangi oynada ochish",
"people_responded": "{count, plural, one {1 kishi javob berdi} other {{count} kishi javob berdi}}",
"please_retry_now_or_try_again_later": "Iltimos, hozir qayta urinib koring yoki keyinroq urinib koring.",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "Javob beruvchilar ushbu kartani ko'rmaydi",
"retry": "Qayta urinib ko'ring",
"retrying": "Qayta urinilmoqda...",
"search": "Qidirish...",
"select_option": "Variantni tanla",
"select_options": "Variantlarni tanla",
"sending_responses": "Javoblar yuborilmoqda...",
+2
View File
@@ -9,6 +9,7 @@
"finish": "完成",
"language_switch": "语言切换",
"next": "下一步",
"no_results_found": "未找到结果",
"open_in_new_tab": "在新标签页中打开",
"people_responded": "{count, plural, one {1 人已回应} other {{count} 人已回应}}",
"please_retry_now_or_try_again_later": "请立即重试或稍后再试。",
@@ -21,6 +22,7 @@
"respondents_will_not_see_this_card": "受访者将不会看到此卡片",
"retry": "重试",
"retrying": "重试中...",
"search": "搜索...",
"select_option": "请选择一个选项",
"select_options": "请选择多个选项",
"sending_responses": "正在发送响应...",
@@ -187,6 +187,8 @@ export function MultipleChoiceSingleElement({
onOtherValueChange={handleOtherValueChange}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
searchPlaceholder={t("common.search")}
searchNoResultsText={t("common.no_results_found")}
/>
</form>
);