On this pageOverview
Combobox
A searchable select with input filtering, keyboard navigation, and anchor positioning. Unlike Listbox (which uses a button trigger), Combobox has a text input for searching. You control the filtering logic — read model.inputValue and pass the filtered items array.
For programmatic control in update functions, use Combobox.open(model), Combobox.close(model), and Combobox.selectItem(model, item, displayText). Each returns [Model, Commands] directly.
See it in an app
Check out how Combobox is wired up in a real Foldkit app.
Pass itemToValue and itemToDisplayText to control how items map to values and what text appears in the input on selection. Filter the items array yourself based on model.inputValue.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Array, Effect, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, Placeholder, div, span } from './html'
// Add a field to your Model for the Combobox Submodel, plus a field for
// the selected value your app actually cares about:
const Model = S.Struct({
maybeCity: S.OptionFromSelf(S.String),
combobox: Ui.Combobox.Model,
// ...your other fields
})
// In your init function, initialize the Combobox Submodel with a unique id:
const init = () => [
{
maybeCity: Option.none(),
combobox: Ui.Combobox.init({ id: 'city' }),
// ...your other fields
},
[],
]
// Embed the Combobox Message for keyboard/input events, plus your own
// Message for the actual selection:
const GotComboboxMessage = m('GotComboboxMessage', {
message: Ui.Combobox.Message,
})
const SelectedCity = m('SelectedCity', { value: S.String })
// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Combobox.update:
GotComboboxMessage: ({ message }) => {
const [nextCombobox, commands] = Ui.Combobox.update(model.combobox, message)
return [
// Merge the next state into your Model:
evo(model, { combobox: () => nextCombobox }),
// Forward the Submodel's Commands through your parent Message:
commands.map(
Command.mapEffect(Effect.map(message => GotComboboxMessage({ message }))),
),
]
}
// Still inside your update function's M.tagsExhaustive({...}), handle your
// own selection Message:
SelectedCity: ({ value }) => {
// Ui.Combobox.selectItem gives you the next combobox state with the
// selection reflected (input value updated, dropdown closed), plus
// the Commands that return focus to the input. Single-select combobox
// takes both the item value and the display text:
const [nextCombobox, commands] = Ui.Combobox.selectItem(
model.combobox,
value,
value,
)
return [
evo(model, {
maybeCity: () => Option.some(value),
combobox: () => nextCombobox,
}),
commands.map(
Command.mapEffect(Effect.map(message => GotComboboxMessage({ message }))),
),
]
}
type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'
const cities: ReadonlyArray<City> = [
'Johannesburg',
'Kyiv',
'Oxford',
'Wellington',
]
// Filter items based on the current input value:
const filteredCities =
model.combobox.inputValue === ''
? cities
: Array.filter(cities, city =>
city.toLowerCase().includes(model.combobox.inputValue.toLowerCase()),
)
// Inside your view function, pass onSelectedItem to fire your SelectedCity
// Message on selection:
Ui.Combobox.view({
model: model.combobox,
toParentMessage: message => GotComboboxMessage({ message }),
onSelectedItem: value => SelectedCity({ value }),
items: filteredCities,
itemToValue: city => city,
itemToDisplayText: city => city,
itemToConfig: (city, { isSelected }) => ({
className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
content: div(
[Class('flex items-center gap-2')],
[
isSelected ? span([], ['✓']) : span([Class('w-4')], []),
span([], [city]),
],
),
}),
inputAttributes: [
Class('w-full rounded-lg border px-3 py-2'),
Placeholder('Search cities...'),
],
itemsAttributes: [Class('rounded-lg border shadow-lg')],
backdropAttributes: [Class('fixed inset-0')],
anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
})Pass nullable: true at init to allow clearing the selection by clicking the selected item again.
Pass selectInputOnFocus: true at init to highlight the input text when the combobox receives focus. Typing immediately replaces the current value, making it easy to start a new search.
Pass selectInputOnFocus: true to highlight the input text when the combobox receives focus. Typing immediately replaces the current value, making it easy to start a new search without manually clearing the input.
Use Combobox.Multi for multi-selection. The dropdown stays open on selection and items toggle on/off. Selected items are stored in model.selectedItems.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Array, Effect } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, Placeholder, div, span } from './html'
// Add a field to your Model for the Combobox.Multi Submodel, plus a field
// for the selected values your app actually cares about:
const Model = S.Struct({
selectedCities: S.Array(S.String),
comboboxMulti: Ui.Combobox.Multi.Model,
// ...your other fields
})
// In your init function, initialize the Combobox Submodel with a unique id:
const init = () => [
{
selectedCities: [],
comboboxMulti: Ui.Combobox.Multi.init({ id: 'cities-multi' }),
// ...your other fields
},
[],
]
// Embed the Combobox Message for keyboard/input events, plus your own
// Message for the actual selection:
const GotComboboxMultiMessage = m('GotComboboxMultiMessage', {
message: Ui.Combobox.Message,
})
const ToggledCity = m('ToggledCity', { value: S.String })
// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Combobox.Multi.update:
GotComboboxMultiMessage: ({ message }) => {
const [nextCombobox, commands] = Ui.Combobox.Multi.update(
model.comboboxMulti,
message,
)
return [
// Merge the next state into your Model:
evo(model, { comboboxMulti: () => nextCombobox }),
// Forward the Submodel's Commands through your parent Message:
commands.map(
Command.mapEffect(
Effect.map(message => GotComboboxMultiMessage({ message })),
),
),
]
}
// Still inside your update function's M.tagsExhaustive({...}), handle your
// own toggle Message:
ToggledCity: ({ value }) => {
// Ui.Combobox.Multi.selectItem gives you the next combobox state with
// the value toggled in or out of the selection. Multi-select stays open
// on selection, so the returned Commands are empty:
const [nextCombobox] = Ui.Combobox.Multi.selectItem(
model.comboboxMulti,
value,
)
return [
evo(model, {
selectedCities: () =>
Array.contains(model.selectedCities, value)
? Array.filter(model.selectedCities, city => city !== value)
: Array.append(model.selectedCities, value),
comboboxMulti: () => nextCombobox,
}),
[],
]
}
type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'
const cities: ReadonlyArray<City> = [
'Johannesburg',
'Kyiv',
'Oxford',
'Wellington',
]
// Filter items based on the current input value:
const filteredCities =
model.comboboxMulti.inputValue === ''
? cities
: Array.filter(cities, city =>
city
.toLowerCase()
.includes(model.comboboxMulti.inputValue.toLowerCase()),
)
// Inside your view function, pass onSelectedItem to fire your ToggledCity
// Message on selection:
Ui.Combobox.Multi.view({
model: model.comboboxMulti,
toParentMessage: message => GotComboboxMultiMessage({ message }),
onSelectedItem: value => ToggledCity({ value }),
items: filteredCities,
itemToValue: city => city,
itemToDisplayText: city => city,
itemToConfig: (city, { isSelected }) => ({
className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
content: div(
[Class('flex items-center gap-2')],
[
isSelected ? span([], ['✓']) : span([Class('w-4')], []),
span([], [city]),
],
),
}),
inputAttributes: [
Class('w-full rounded-lg border px-3 py-2'),
Placeholder('Search cities...'),
],
itemsAttributes: [Class('rounded-lg border shadow-lg')],
backdropAttributes: [Class('fixed inset-0')],
anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
})Combobox is headless — the itemToConfig callback controls all item markup. Style the input, button, items container, and backdrop through their respective attribute props.
| Attribute | Condition |
|---|---|
data-active | Present on the item currently highlighted by keyboard or pointer. |
data-selected | Present on the selected item(s). |
data-disabled | Present on disabled items. |
data-closed | Present during close animation when isAnimated is true. |
Focus stays on the input while arrow keys navigate items via aria-activedescendant.
| Key | Description |
|---|---|
| Arrow Down | Opens the dropdown or moves to the next item. |
| Arrow Up | Moves to the previous item. |
| Enter | Selects the active item. |
| Escape | Closes the dropdown. |
| Type a character | Filters the items list. You control filtering in your view by passing filtered items. |
The input receives role="combobox" with aria-expanded and aria-activedescendant. The items container receives role="listbox" and each item receives role="option" with aria-selected.
Configuration object passed to Combobox.init() or Combobox.Multi.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the combobox instance. |
selectedItem | string | - | Initially selected item value (single-select only). |
selectedDisplayText | string | - | Initial display text in the input (single-select only). |
isAnimated | boolean | false | Enables CSS transition coordination. |
isModal | boolean | false | Locks page scroll and marks other elements inert when open. |
nullable | boolean | false | Allows clearing the selection by clicking the selected item again. |
selectInputOnFocus | boolean | false | Highlights the input text when the combobox receives focus, so typing replaces the current value. |
Configuration object passed to Combobox.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Combobox.Model | - | The combobox state from your parent Model. |
toParentMessage | (childMessage: Combobox.Message) => ParentMessage | - | Wraps Combobox Messages in your parent Message type for Submodel delegation. |
onSelectedItem | (value: string) => ParentMessage | - | Alternative to Submodel delegation — fires your own Message on selection. Use with Combobox.selectItem() in your update handler to reflect the selection in the combobox state. |
items | ReadonlyArray<Item> | - | The filtered list of items to display. You control the filtering logic based on model.inputValue. |
itemToConfig | (item, context) => ItemConfig | - | Maps each item to its className and content. The context provides isActive, isSelected, and isDisabled. |
itemToValue | (item: Item) => string | - | Extracts the string value from an item. |
itemToDisplayText | (item: Item) => string | - | Text shown in the input when an item is selected. |
inputAttributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the text input. |
itemsAttributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the dropdown items container. |
backdropAttributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the backdrop overlay. |
buttonContent | Html | - | Content for the dropdown toggle button (typically a chevron icon). |
buttonAttributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the toggle button. |
anchor | AnchorConfig | - | Floating positioning config: placement, gap, and padding. |