Select
Unified single select with static, searchable, async, and preload modes built on Popover and Command.
Pick one value from a list. A button trigger opens a popover; the search box appears only when the list needs it — never for small static enums. One component covers four modes inferred from props: static (options), searchable static (more than 8 options or searchable), async (onSearch), and preload (onSearch + preload). Use Select for a label and helper text, SelectControl for the field only (forms use it through Formily's FormItem), or useSelect for fully custom layouts.
Built-in labels use useSelectI18n() under a tree with FuiI18nProvider (registry item fui-i18n).
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/select.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/select.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/select.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/select.jsonOr with a namespace: npx shadcn@latest add @f-ui/select.
The CLI installs cmdk and lucide-react, and pulls command, label, and popover from the default shadcn registry.
Usage
import { Select } from '@/components/f-ui/select/select';
import type { Option } from '@/components/f-ui/select/select-types';
const options: Option[] = [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
];
<Select options={options} value={value} onValueChange={setValue} />Clearing emits null (not an empty string). In async mode, seed the initial selected label with defaultOptions={[{ value, label }]} — the component never calls onSearch with a selected id.
Examples
Static
Small enum — the popover lists options with no search box.
Small static list — no search box
"use client";
import { useState } from "react";
import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";
const STATUSES: Option[] = [
{ value: "draft", label: "Draft" },
{ value: "review", label: "In review" },
{ value: "published", label: "Published" },
{ value: "archived", label: "Archived", disable: true },
];
export function SelectDemo() {
const [value, setValue] = useState<string | null>("review");
return (
<div className="max-w-xs">
<Select
label="Status"
description="Small static list — no search box"
options={STATUSES}
value={value}
onValueChange={setValue}
/>
</div>
);
}Searchable Static
Past SELECT_SEARCH_THRESHOLD (8) options, the search box appears automatically; cmdk filters locally. Force it on or off with searchable.
More than 8 options — search appears automatically
"use client";
import { useState } from "react";
import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";
const TIMEZONES: Option[] = [
"Africa/Cairo", "America/Chicago", "America/New_York", "America/Sao_Paulo",
"Asia/Dubai", "Asia/Shanghai", "Asia/Singapore", "Asia/Tokyo",
"Australia/Sydney", "Europe/Berlin", "Europe/London", "Europe/Paris",
"Pacific/Auckland", "UTC",
].map((tz) => ({ value: tz, label: tz.replace("_", " ") }));
export function SelectSearchableDemo() {
const [value, setValue] = useState<string | null>(null);
return (
<div className="max-w-xs">
<Select
label="Timezone"
description="More than 8 options — search appears automatically"
options={TIMEZONES}
value={value}
onValueChange={setValue}
placeholder="Pick a timezone"
/>
</div>
);
}Async Search
onSearch runs per debounced keystroke (delay, default 300 ms); triggerSearchOnFocus fetches an initial list when the popover opens. Rejections render an inline error row.
Each keystroke runs a debounced onSearch
"use client";
import { useCallback, useState } from "react";
import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";
/** Static pool "fetched" asynchronously — replace with your API in real apps. */
const USERS: Option[] = [
{ value: "u1", label: "Ada Lovelace" },
{ value: "u2", label: "Alan Turing" },
{ value: "u3", label: "Grace Hopper" },
{ value: "u4", label: "Katherine Johnson" },
{ value: "u5", label: "Margaret Hamilton" },
{ value: "u6", label: "Donald Knuth" },
{ value: "u7", label: "Barbara Liskov" },
{ value: "u8", label: "Edsger Dijkstra" },
];
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export function SelectAsyncDemo() {
const [value, setValue] = useState<string | null>(null);
const onSearch = useCallback(async (query: string) => {
await delay(400);
const q = query.trim().toLowerCase();
if (!q) return USERS.slice(0, 5);
return USERS.filter((u) => u.label.toLowerCase().includes(q));
}, []);
return (
<div className="max-w-xs">
<Select
label="Assignee"
description="Each keystroke runs a debounced onSearch"
onSearch={onSearch}
triggerSearchOnFocus
value={value}
onValueChange={setValue}
placeholder="Search users"
/>
</div>
);
}Preload
With preload, the full list is fetched once on first open, then filterFn filters locally — no further requests.
One fetch on first open, then local filtering
"use client";
import { useCallback, useState } from "react";
import { Select } from "@/components/f-ui/select/select";
import type { Option } from "@/components/f-ui/select/select-types";
const COUNTRIES: Option[] = [
{ value: "AR", label: "Argentina" }, { value: "AU", label: "Australia" },
{ value: "BR", label: "Brazil" }, { value: "CA", label: "Canada" },
{ value: "CN", label: "China" }, { value: "DE", label: "Germany" },
{ value: "FR", label: "France" }, { value: "GB", label: "United Kingdom" },
{ value: "IN", label: "India" }, { value: "JP", label: "Japan" },
{ value: "KR", label: "South Korea" }, { value: "MX", label: "Mexico" },
{ value: "SG", label: "Singapore" }, { value: "US", label: "United States" },
];
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export function SelectPreloadDemo() {
const [value, setValue] = useState<string | null>(null);
const fetchAll = useCallback(async () => {
await delay(600);
return COUNTRIES;
}, []);
return (
<div className="max-w-xs">
<Select
label="Country"
description="One fetch on first open, then local filtering"
onSearch={fetchAll}
preload
filterFn={(option, query) =>
option.label.toLowerCase().includes(query.toLowerCase())
}
value={value}
onValueChange={setValue}
placeholder="Pick a country"
/>
</div>
);
}Headless Usage
Call useSelect(fieldProps) and compose Popover, your own trigger, and SelectDropdown — the same building blocks SelectControl uses.
"use client";
import { useState } from "react";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { useSelect } from "@/components/f-ui/select/use-select";
import { SelectDropdown } from "@/components/f-ui/select/select-parts/select-dropdown";
import type { Option } from "@/components/f-ui/select/select-types";
const PRIORITIES: Option[] = [
{ value: "p0", label: "Urgent" },
{ value: "p1", label: "High" },
{ value: "p2", label: "Normal" },
{ value: "p3", label: "Low" },
];
export function SelectHeadlessDemo() {
const [value, setValue] = useState<string | null>("p2");
const s = useSelect({ value, onValueChange: setValue, options: PRIORITIES });
const selected = s.selectedOption;
return (
<Popover modal={false} open={s.open} onOpenChange={s.onOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={s.open}
aria-haspopup="listbox"
aria-label="Priority"
ref={s.triggerRef}
className="inline-flex h-8 items-center gap-2 rounded-full border border-input px-3 text-sm"
>
<span
aria-hidden
className="size-2 rounded-full bg-muted-foreground data-[p=p0]:bg-destructive"
data-p={value ?? undefined}
/>
{selected?.label ?? s.placeholder}
</button>
</PopoverTrigger>
<SelectDropdown
searchable={false}
searchTerm={s.searchTerm}
onSearchTermChange={s.setSearchTerm}
searchPlaceholder={s.searchPlaceholder}
shouldFilter={s.shouldFilter}
options={s.displayedOptions}
selectedValue={value}
onSelect={s.onSelect}
isLoading={s.isLoading}
loadingLabel={s.t("select.loading")}
errorMessage={s.errorMessage}
noResultsLabel={s.t("select.noResults")}
/>
</Popover>
);
}Composition
Select (optional label + description shell)
└── SelectControl
├── Popover
├── PopoverTrigger → SelectTrigger (button + clear + chevron)
└── SelectDropdown → PopoverContent
└── Command
├── CommandInput (searchable modes)
└── CommandList
├── CommandEmpty | SelectCommandEmpty (loading / error / empty)
└── CommandGroup → CommandItem (check on selected)API Reference
Props
| Prop | Type | Default |
|---|---|---|
value | string | null | — |
onValueChange | (value: string | null) => void | — |
options | Option[] | — |
defaultOptions | Option[] | — (seeds the label cache) |
onSearch | (query: string) => Promise<Option[]> | — |
preload | boolean | false |
filterFn | (option: Option, query: string) => boolean | label/value includes |
searchable | boolean | inferred (see modes) |
clearable | boolean | true |
delay | number | 300 |
triggerSearchOnFocus | boolean | false |
placeholder | string | select.placeholder |
renderOption / renderValue | (option: Option) => ReactNode | option.label |
loadingIndicator / emptyIndicator | ReactNode | built-in states |
surface | 'default' | 'compact' | 'default' |
label / description | ReactNode | — (Select shell only) |
locale / t | i18n overrides | provider context |
Slots
| Slot | Applied to |
|---|---|
root | Wrapper around label + control + description |
label | Label |
control / trigger | Trigger button |
content | PopoverContent |
list | CommandList |
item | Each CommandItem |
Hook
| Function | useSelect(props: UseSelectOptions) |
| Options | UseSelectOptions — same shape as SelectFieldProps. |
| Return | UseSelectReturn: mode, open / onOpenChange, triggerRef, searchTerm, displayedOptions, selectedOption, isLoading, errorMessage, onSelect, onClear, t, etc. |