Combobox / autocomplete
In reviewLive · web
Single-select: type to filter, ↑↓ to move, Enter to pick
Leading status mark · selected row gets a cyan check.
Grouped options: leading + secondary text under a header
Cloud and local engines, bucketed.
Multi-select: removable tokens (Backspace pops the last)
TypeScript✕Jetpack Compose✕
Selected values live as chips inside the field.
Async + disabled
Inline spinner; the list shows a Searching… hint.
Inert, but still legible.
Committed 0 selections. The island is live.
// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,// not yet exported from @dizyx/nockerl-react; promotion is tracked.)// The combobox = a recessed field WELL + a lifted popover listbox you type to filter.
const PROJECTS: ComboOption[] = [ { value: 'api-server', label: 'api-server', secondary: 'API · service layer', status: 'success' }, { value: 'credential-store', label: 'credential-store', secondary: 'Credential store', status: 'warning' },];
export function ProjectSwitcher() { const [project, setProject] = useState('api-server'); const [tags, setTags] = useState<string[]>(['typescript']);
return ( <> {/* Single-select: type to filter, ↑↓ to move, Enter to pick, Esc to close. */} <Combobox label="Project" options={PROJECTS} value={project} onSelect={setProject} placeholder="Search projects…" />
{/* Multi-select: chosen values live as removable tokens; Backspace pops the last. */} <Combobox label="Stack tags" options={TAGS} value={tags} multiple onSelect={(v) => setTags((t) => [...t, v])} onRemove={(v) => setTags((t) => t.filter((x) => x !== v))} /> </> );}// Android: Jetpack Compose. The shipped pattern is a fixed-list SELECT:// a NockerlControlShape DropdownAnchor (label + trailing ▾ + inline spinner)// opening a Material3 DropdownMenu of DropdownMenuItem rows. Type-to-filter is// NOT yet shipped on Android (see drift). For autocomplete, wrap the anchor in// an ExposedDropdownMenuBox and feed it a query-filtered list.import androidx.compose.material3.DropdownMenuimport androidx.compose.material3.DropdownMenuItemimport com.nockerl.app.chat.ui.DropdownAnchor // core anchor (NockerlControlShape)
@Composablefun ProviderPicker( providers: List<ProviderInfo>, selected: ProviderInfo?, isLoading: Boolean, onSelect: (ProviderInfo) -> Unit,) { var expanded by remember { mutableStateOf(false) } Box { DropdownAnchor( label = selected?.name ?: "Select a provider", placeholder = selected == null, isLoading = isLoading, onClick = { if (!isLoading && providers.isNotEmpty()) expanded = true }, ) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { if (providers.isEmpty()) { DropdownMenuItem(text = { Text("No providers available") }, enabled = false, onClick = {}) } providers.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { onSelect(p); expanded = false }) } } }}// macOS: SwiftUI. The shipped filterable pattern is a plain TextField bound to// a case-insensitive .filter (HistorySection in NockerlVoice), with a leading// magnifyingglass + a clear ✕ and a ContentUnavailableView empty state. A fixed// SELECT uses Menu { Button … } with a chevron (AppSettingsSection). There is no// single combined combobox control yet (see drift).@State private var query = ""@State private var selection: String?
private var filtered: [ComboOption] { guard !query.isEmpty else { return options } return options.filter { $0.label.localizedCaseInsensitiveContains(query) }}
var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(NockerlTheme.onSurfaceMuted) TextField("Search projects", text: $query).textFieldStyle(.plain) if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.borderless) } } .padding(.horizontal, 12).padding(.vertical, 8) .background(NockerlTheme.canvasAlt, in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(NockerlTheme.hairline))
if filtered.isEmpty { ContentUnavailableView("No matches", systemImage: "magnifyingglass") } else { ForEach(filtered) { row in Button { selection = row.value } label: { OptionRow(row, selected: selection == row.value) } .buttonStyle(.plain) } } }}Parameters
Section titled “Parameters”| Prop | Type | Default | Description |
|---|---|---|---|
label * | string | Persistent label above the well, never a placeholder. Bound to the field via htmlFor. | |
options * | ComboOption[] | Full option set; filtered live by what is typed (case-insensitive substring of label / secondary). | |
value * | string | string[] | Single selected value, or the array of selected values in multi-select. | |
onSelect * | (value: string) => void | Commit a selection (the option value). In multi-select, toggles the value in/out. | |
multiple | boolean | false | Render selected values as removable tokens inside the well; Backspace on an empty input pops the last. |
onRemove | (value: string) => void | Remove a token (multi-select). Fired by its ✕ or by Backspace. | |
placeholder | string | Ghost prompt inside the well: supplementary, never the label. | |
helperText | string | Quiet helper line under the well. | |
disabled | boolean | false | Inert + clearly seen (never faded to invisible). |
loading | boolean | false | Inline spinner in the well + a Searching… hint in the list (async source). |
grouped | boolean | false | Bucket options under their group header. |
| ComboOption | Type | Default | Description |
|---|---|---|---|
value * | string | Stable identity + the selected-value carrier. | |
label * | string | Primary line: the option’s accessible name (label.large role). The matched run is highlighted with a <mark>. | |
secondary | string | Supporting line under the label (body.small role). | |
status | 'success' | 'warning' | 'error' | 'info' | 'idle' | Leading status mark color. Status colors only, never the brand cyan (cyan is reserved for the selected check). | |
group | string | Bucket name; options sharing a group render under one header when grouped. | |
disabled | boolean | false | Unselectable + skipped by the keyboard cursor, but still legible. |
| Parameter | Type | Default | Description |
|---|---|---|---|
expanded * | Boolean | Whether the DropdownMenu is open. Hoisted state, toggled by the anchor + onDismissRequest. | |
onDismissRequest * | () -> Unit | Close the menu (tap-outside / back). Sets expanded = false. | |
modifier | Modifier | Modifier | External modifier on the menu, e.g. Modifier.fillMaxWidth(0.85f) to match the anchor width. |
label * | String | DropdownAnchor: the current selection text (or a placeholder). | |
placeholder * | Boolean | DropdownAnchor: render the label muted as a placeholder when nothing is selected. | |
isLoading * | Boolean | DropdownAnchor: show an inline CircularProgressIndicator before the ▾. | |
enabled | Boolean | true | DropdownAnchor: when false, the anchor is dimmed + non-interactive (the filled surface). |
onClick * | () -> Unit | DropdownAnchor: open the menu (guarded by enabled / isLoading / non-empty list). | |
DropdownMenuItem.enabled | Boolean | true | Per-row enablement; the empty state renders a single disabled “No X available” item. |
SwiftUI has no single combobox control; the filterable pattern composes a TextField
bound to a .filter (HistorySection) and the fixed-select pattern is a Menu
(AppSettingsSection). These are the real parameters of those building blocks.
| Style | Type | Default | Description |
|---|---|---|---|
TextField(_:text:) | View | The query input. Bound to a @State String; style with .textFieldStyle(.plain) in a canvasAlt well + hairline border. | |
filter { … } | [T] | Live filter using localizedCaseInsensitiveContains(query) over each option’s label (the HistorySection idiom). | |
ContentUnavailableView | View | Empty / no-results state ("No matches", systemImage), never an error color. | |
Menu { Button … } label: | View | Fixed-select alternative: a borderless Menu of Button rows; label is the current selection + a chevron.up.chevron.down, accent-bordered. | |
.menuStyle(.borderlessButton) | MenuStyle | The Nockerl select menu style; pair with .menuIndicator(.hidden) to supply your own chevron. |