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.
Embed Combobox via the create<Item>() factory at module scope: const CityCombobox = Ui.Combobox.create<City>(). The factory binds the view, update, and imperative helpers to the same Item type so the selected value flows through the OutMessage typed end-to-end. Combobox constrains Item extends string.
For programmatic control in update functions, use CityCombobox.open(model), CityCombobox.close(model), and CityCombobox.selectItem(model, item, displayText). Each returns [Model, Commands, Option<OutMessage>] directly. To mirror an externally-sourced selection without emitting (restoring a draft, a URL), use CityCombobox.reflectSelectedItem(model, maybeItem), which returns the model directly without an OutMessage.
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, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { childAttributes, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'
// Declare a typed Combobox once at module scope:
const CityCombobox = Ui.Combobox.create<City>()
// 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.Option(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
},
[],
]
// Wrap Combobox's Messages so they can flow through your update:
const GotComboboxMessage = m('GotComboboxMessage', {
message: Ui.Combobox.Message,
})
// Delegate keyboard navigation, typeahead, and open/close to
// CityCombobox.update. The OutMessage's `Selected` carries the chosen
// item; lift it into your domain state:
GotComboboxMessage: ({ message }) => {
const [nextCombobox, commands, maybeOutMessage] = CityCombobox.update(
model.combobox,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotComboboxMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [
evo(model, { combobox: () => nextCombobox }),
mappedCommands,
],
onSome: M.type<Ui.Combobox.OutMessage>().pipe(
M.tagsExhaustive({
Selected: ({ value }) => [
evo(model, {
combobox: () => nextCombobox,
maybeCity: () => Option.some(value),
}),
mappedCommands,
],
}),
),
})
}
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, embed the Combobox via h.submodel:
const view = () => {
const h = html<Message>()
return h.submodel({
slotId: 'city',
model: model.combobox,
view: CityCombobox.view,
viewInputs: {
items: filteredCities,
itemToValue: city => city,
itemToDisplayText: city => city,
itemToConfig: (city, { isSelected }) => ({
className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
content: h.div(
[h.Class('flex items-center gap-2')],
[
isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
h.span([], [city]),
],
),
}),
inputAttributes: childAttributes([
h.Class('w-full rounded-lg border px-3 py-2'),
h.Placeholder('Search cities...'),
]),
itemsAttributes: childAttributes([
h.Class('rounded-lg border shadow-lg'),
]),
backdropAttributes: childAttributes([h.Class('fixed inset-0')]),
anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
},
toParentMessage: message => GotComboboxMessage({ message }),
})
}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, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { childAttributes, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'
// Declare a typed multi-select Combobox once at module scope:
const CitiesCombobox = Ui.Combobox.Multi.create<City>()
// 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
},
[],
]
// Wrap Combobox's Messages so they can flow through your update:
const GotComboboxMultiMessage = m('GotComboboxMultiMessage', {
message: Ui.Combobox.Message,
})
// Delegate keyboard navigation, typeahead, and open/close to
// CitiesCombobox.update. On toggle, the OutMessage's `Selected` carries
// the item and `wasAdded`:
GotComboboxMultiMessage: ({ message }) => {
const [nextCombobox, commands, maybeOutMessage] = CitiesCombobox.update(
model.comboboxMulti,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotComboboxMultiMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [
evo(model, { comboboxMulti: () => nextCombobox }),
mappedCommands,
],
onSome: M.type<Ui.Combobox.OutMessage>().pipe(
M.tagsExhaustive({
Selected: ({ value, wasAdded }) => [
evo(model, {
comboboxMulti: () => nextCombobox,
selectedCities: () =>
wasAdded
? Array.append(model.selectedCities, value)
: Array.filter(model.selectedCities, city => city !== value),
}),
mappedCommands,
],
}),
),
})
}
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, embed the Combobox.Multi via h.submodel:
const view = () => {
const h = html<Message>()
return h.submodel({
slotId: 'cities-multi',
model: model.comboboxMulti,
view: CitiesCombobox.view,
viewInputs: {
items: filteredCities,
itemToValue: city => city,
itemToDisplayText: city => city,
itemToConfig: (city, { isSelected }) => ({
className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
content: h.div(
[h.Class('flex items-center gap-2')],
[
isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
h.span([], [city]),
],
),
}),
inputAttributes: childAttributes([
h.Class('w-full rounded-lg border px-3 py-2'),
h.Placeholder('Search cities...'),
]),
itemsAttributes: childAttributes([
h.Class('rounded-lg border shadow-lg'),
]),
backdropAttributes: childAttributes([h.Class('fixed inset-0')]),
anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
},
toParentMessage: message => GotComboboxMultiMessage({ message }),
})
}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 animation 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 CityCombobox.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. |
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. |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler. The same shape applies to Combobox.Multi.update.
| Name | Type | Default | Description |
|---|---|---|---|
Selected | { value: Item; wasAdded: boolean } | - | Emitted when an item is committed. Single-select comboboxes always emit `wasAdded: true`. Multi-select comboboxes emit `wasAdded: true` when adding to the selection and `wasAdded: false` when toggling off. Pattern-match the third tuple element of CityCombobox.update in your GotComboboxMessage handler to lift the value into domain state. |