Combobox
A searchable select with Uzbek-aware search, multiple selection, creatable options and server search.
Hali tanlanmagan
'use client';
import { Combobox, Field } from '@akhatmirzo/ui';
import { useState } from 'react';
const cities = [
'Toshkent',
'Samarqand',
'Buxoro',
'Xiva',
'Namangan',
'Andijon',
"Farg'ona",
"Qo'qon",
"Marg'ilon",
'Nukus',
'Qarshi',
'Termiz',
].map((label) => ({ value: label.toLowerCase().replace(/'/g, ''), label }));
export default function ComboboxBasic() {
const [city, setCity] = useState<string | null>(null);
return (
<Field
label="Shahar"
description={city ? `Qiymat: ${city}` : 'Hali tanlanmagan'}
className="w-full max-w-xs"
>
<Combobox
options={cities}
value={city}
onValueChange={setCity}
placeholder="Shahar tanlang"
clearable
/>
</Field>
);
}
Import
import { Combobox, type ComboboxOption } from '@akhatmirzo/ui';When to use
- Long lists — cities, districts, customers, products. Scanning more than 10–15 options by eye is slow; typing is faster.
- Several values —
multiple: tags, branches, assignees. - Values that may not exist yet —
creatable(a new tag, a new category). - Data that lives on the server —
onSearchandloadingsend every query to your API. - A short list with no need for search — a plain
Selectis enough.
Uzbek-aware search
Search is powered by searchMatch from @akhatmirzo/core, and it forgives the usual quirks of
written Uzbek:
- Every apostrophe is the same:
gʻ,g',g‘andg’all match. Leaving the apostrophe out works too:ulugbekfinds "Mirzo Ulug'bek". - Cyrillic ↔ Latin: typing
Чилонзорfinds "Chilonzor", andТошкентfinds "Toshkent". - Case-insensitive:
TOSHKENTandtoshkentare the same query. - Matches inside words:
chirchiqfinds "Oʻrta Chirchiq". - The matched part is bold — in the
descriptionline as well.
Yozib ko'ring: guzor, g‘allaorol, Чилонзор, ulugbek
'use client';
import { Combobox, Field } from '@akhatmirzo/ui';
const districts = [
'Gʻuzor',
"G'allaorol",
'Oʻrta Chirchiq',
"Qo'shrabot",
'Shahrisabz',
'Chilonzor',
'Yunusobod',
"Mirzo Ulug'bek",
'Toshkent tumani',
].map((label, i) => ({ value: String(i), label }));
export default function ComboboxUzbekSearch() {
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<Field label="Tuman">
<Combobox options={districts} placeholder="Tumanni tanlang" />
</Field>
<p className="text-sm text-text-muted">
Yozib ko'ring: <span className="font-medium text-text">guzor</span>,{' '}
<span className="font-medium text-text">g‘allaorol</span>,{' '}
<span className="font-medium text-text">Чилонзор</span>,{' '}
<span className="font-medium text-text">ulugbek</span>
</p>
</div>
);
}
Use it in your own search
The same helpers are exported on their own: searchMatch, normalizeUz and splitByRanges — see
core/text. Normalizing queries with normalizeUz helps on the server side
too.
Multiple selection
With multiple, value becomes an array (string[]). Selected options show up as Badge
chips inside the trigger, each with its own ✕. The panel stays open after each pick; Backspace
on an empty query removes the last selection.
2 ta tanlangan
'use client';
import { Combobox, Field } from '@akhatmirzo/ui';
import { useState } from 'react';
const branches = [
'Toshkent',
'Samarqand',
'Buxoro',
'Namangan',
'Andijon',
"Farg'ona",
'Nukus',
].map((label) => ({ value: label.toLowerCase().replace(/'/g, ''), label }));
export default function ComboboxMultiple() {
const [selected, setSelected] = useState<string[]>(['toshkent', 'samarqand']);
return (
<Field
label="Filiallar"
description={`${selected.length} ta tanlangan`}
className="w-full max-w-sm"
>
<Combobox
multiple
options={branches}
value={selected}
onValueChange={setSelected}
placeholder="Filiallarni tanlang"
/>
</Field>
);
}
Creating options
With creatable on, a "«text» yaratish" item appears at the end of the list whenever the query
doesn't exactly match an existing option. If onCreate returns an option, that option gets
selected; if it returns nothing, the text itself is used as both value and label. Adding the new
option to options is up to you.
Ro'yxatda yo'q tegni yozing va Enter bosing
'use client';
import { Combobox, Field, type ComboboxOption } from '@akhatmirzo/ui';
import { useState } from 'react';
const initialTags: ComboboxOption[] = ['dizayn', 'frontend', 'backend', 'mobil'].map((t) => ({
value: t,
label: t,
}));
export default function ComboboxCreatable() {
const [tags, setTags] = useState(initialTags);
const [chosen, setChosen] = useState<string[]>(['frontend']);
return (
<Field
label="Teglar"
description="Ro'yxatda yo'q tegni yozing va Enter bosing"
className="w-full max-w-sm"
>
<Combobox
multiple
creatable
options={tags}
value={chosen}
onValueChange={setChosen}
onCreate={(text) => {
const option = { value: text.toLowerCase(), label: text };
setTags((prev) => [...prev, option]);
return option;
}}
placeholder="Teg qo'shing"
leftIcon={null}
/>
</Field>
);
}
Server search
Passing onSearch turns off local filtering: you receive the query (debounced by 300ms) and
provide fresh options. While you wait, loading shows a spinner in place of the list. When the
panel closes, onSearch('') is called — a good moment to restore the initial list.
'use client';
import { searchMatch } from '@akhatmirzo/core';
import { Combobox, Field, type ComboboxOption } from '@akhatmirzo/ui';
import { useRef, useState } from 'react';
const all: ComboboxOption[] = ['Toshkent', 'Samarqand', 'Buxoro', 'Xiva', 'Nukus', 'Termiz'].map(
(label) => ({ value: label.toLowerCase(), label }),
);
export default function ComboboxServer() {
const [options, setOptions] = useState(all.slice(0, 3));
const [loading, setLoading] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Server so'rovini taqlid qiladi: javob 700ms dan keyin keladi
const search = (query: string) => {
clearTimeout(timer.current);
setLoading(true);
timer.current = setTimeout(() => {
setOptions(query ? all.filter((o) => searchMatch(o.label, query).match) : all.slice(0, 3));
setLoading(false);
}, 700);
};
return (
<Field label="Shahar (serverdan)" className="w-full max-w-xs">
<Combobox
options={options}
loading={loading}
onSearch={search}
placeholder="Qidirib tanlang"
searchPlaceholder="Shahar nomi..."
emptyText="Server hech narsa qaytarmadi"
/>
</Field>
);
}
The selection survives new results
Even if the selected option is missing from the latest server results, Combobox remembers its label and keeps showing it in the trigger.
Descriptions and icons
ComboboxOption supports description (a second line), icon and disabled. Search looks at
the description too. Remove the search icon in the trigger with leftIcon={null}, or pass your
own.
'use client';
import { Combobox, Field, type ComboboxOption } from '@akhatmirzo/ui';
function Dot({ className }: { className: string }) {
return <span className={`size-2.5 rounded-full ${className}`} />;
}
const statuses: ComboboxOption[] = [
{
value: 'new',
label: 'Yangi',
description: 'Buyurtma qabul qilindi',
icon: <Dot className="bg-info" />,
},
{
value: 'packing',
label: "Yig'ilmoqda",
description: 'Omborda tayyorlanmoqda',
icon: <Dot className="bg-warning" />,
},
{
value: 'shipping',
label: "Yo'lda",
description: 'Kuryerga topshirildi',
icon: <Dot className="bg-primary" />,
},
{
value: 'cancelled',
label: 'Bekor qilingan',
description: 'Arxivlangan holat',
icon: <Dot className="bg-danger" />,
disabled: true,
},
];
export default function ComboboxDetails() {
return (
<Field label="Buyurtma holati" className="w-full max-w-xs">
<Combobox options={statuses} defaultValue="packing" leftIcon={null} />
</Field>
);
}
interface ComboboxOption {
value: string;
label: string;
description?: string;
icon?: ReactNode;
disabled?: boolean;
}Sizes
The same as Input and Select: 32, 40 and 48px. With multiple, the height grows with the
number of chips.
'use client';
import { Combobox } from '@akhatmirzo/ui';
const cities = ['Toshkent', 'Samarqand', 'Buxoro'].map((label) => ({
value: label.toLowerCase(),
label,
}));
export default function ComboboxSizes() {
return (
<div className="flex w-full max-w-xs flex-col gap-4">
<Combobox size="sm" options={cities} aria-label="Shahar" placeholder="sm — 32px" />
<Combobox size="md" options={cities} aria-label="Shahar" placeholder="md — 40px" />
<Combobox size="lg" options={cities} aria-label="Shahar" placeholder="lg — 48px" />
</div>
);
}
States
Inside a Field, disabled, error and required are wired up for you. loading shows a
spinner instead of the list when the panel opens.
Shaharni tanlang
'use client';
import { Combobox, Field } from '@akhatmirzo/ui';
const cities = ['Toshkent', 'Samarqand', 'Buxoro'].map((label) => ({
value: label.toLowerCase(),
label,
}));
export default function ComboboxStates() {
return (
<div className="grid w-full gap-4 sm:grid-cols-3">
<Field label="O'chirilgan" disabled>
<Combobox options={cities} defaultValue="toshkent" />
</Field>
<Field label="Shahar" error="Shaharni tanlang" required>
<Combobox options={cities} placeholder="Shahar tanlang" />
</Field>
<Field label="Yuklanmoqda">
<Combobox options={[]} loading placeholder="Ochib ko'ring" />
</Field>
</div>
);
}
Inside a dialog
Inside a Dialog or a Drawer, the Combobox panel moves to the
modal layer and opens above the dialog — nothing to configure.
Props
Combobox
Any other <button> attributes (id, aria-label, aria-*, data-*, onBlur ...) and ref
are passed to the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | ComboboxOption[] | — | The options |
multiple | boolean | false | Multiple selection: value becomes string[] |
value | string | null · string[] | — | Selected value (an array with multiple) |
defaultValue | string | null · string[] | — | Initial value (uncontrolled) |
onValueChange | (value: string | null) => void · (value: string[]) => void | — | Called on change. Clearing gives null (or []) |
placeholder | string | 'Tanlang' | Shown while nothing is selected |
searchPlaceholder | string | 'Qidirish...' | Placeholder of the search input |
emptyText | ReactNode | 'Hech narsa topilmadi' | Shown when nothing matches |
onSearch | (query: string) => void | — | Server search: 300ms debounce, local filtering off |
loading | boolean | false | Spinner in place of the list |
creatable | boolean | false | Adds a "«text» yaratish" item |
onCreate | (text: string) => ComboboxOption | void | — | Called on create. A returned option gets selected |
clearable | boolean | false | Shows a ✕ (clear) button when there is a value |
leftIcon | ReactNode | search icon | Icon at the start of the trigger; null hides it |
size | 'sm' | 'md' | 'lg' | 'md' | Height: 32 / 40 / 48px |
invalid | boolean | — | Error state (automatic inside Field) |
disabled | boolean | — | Disables the Combobox |
required | boolean | — | Required field (aria-required) |
name | string | — | For forms: each selected value is sent as a hidden <input> |
open | boolean | — | Whether the panel is open (controlled) |
defaultOpen | boolean | false | Open on first render |
onOpenChange | (open: boolean) => void | — | Called when the panel opens or closes |
labels | ComboboxLabels | — | Screen reader texts (see below) |
contentClassName | string | — | Class for the panel |
ComboboxLabels
The built-in texts are in Uzbek. Override them for an English (or any other) interface.
| Prop | Type | Default | Description |
|---|---|---|---|
search | string | 'Qidirish' | Search input name |
list | string | 'Variantlar' | List name |
loading | string | 'Yuklanmoqda' | Spinner text |
clear | string | 'Tozalash' | Clear (✕) button name |
remove | (label: string) => string | `Olib tashlash: ${label}` | Name of the ✕ on each chip |
create | (text: string) => ReactNode | `«${text}» yaratish` | Text of the create item |
<Combobox
options={cities}
placeholder="Choose a city"
searchPlaceholder="Search..."
emptyText="Nothing found"
labels={{
search: 'Search',
list: 'Cities',
loading: 'Loading',
clear: 'Clear',
remove: (label) => `Remove ${label}`,
create: (text) => `Create "${text}"`,
}}
/>Data attributes
| Attribute | On | Value |
|---|---|---|
data-size | container | sm · md · lg |
data-state | container | open · closed |
data-invalid | container | present in the error state |
data-disabled | container | present when disabled |
data-placeholder | trigger | present while nothing is chosen |
data-checked | option | present on selected options |
Keyboard
| Key | Action |
|---|---|
Enter / Space / ↓ / ↑ | Opens the panel when the trigger is focused |
| A letter or digit | On a closed trigger — opens the panel and starts the search |
| Typing | In the open panel — searches |
↓ / ↑ | Moves between options (wraps around) |
Home / End | First / last option |
Enter | Selects. Single mode closes the panel; multiple keeps it open |
Esc | Clears the query first; a second press closes the panel |
Backspace | multiple: removes the last selection when the query is empty |
Tab | Closes the panel and moves focus to the next element |
Accessibility
- Roles. The trigger is
role="combobox"witharia-haspopup="listbox"; while open,aria-controlspoints to the list. The list is alistbox(aria-multiselectable="true"withmultiple), and every option hasaria-checked. - Name. Inside a
Field, the label and the selected value are read together ("Shahar, Toshkent"). Withmultiple, the selected labels are added as visually hidden text. - Field wiring.
descriptionanderrorgo toaria-describedby; errors setaria-invalid="true"andrequiredsetsaria-required="true". - The ✕ on each chip gets its own name ("Olib tashlash: Toshkent"), configurable through
labels.remove. - Focus. Opening moves focus to the search input; closing returns it to the trigger (or to
the next element when closed with
Tab). - Icons (
leftIcon, optionicon) arearia-hidden.