Combobox
Native combobox composition, package imports, and editable Swift source.
Preview


Use it
Import SwiftCN to use CNCombobox directly. This complete example also runs in the native component gallery.
import SwiftUI
import SwiftCN
struct ComboboxExample: View {
@State private var optionalSelection: String? = nil
private let options = [CNOption("design", title: "Design system"), CNOption("mobile", title: "Mobile app"), CNOption("archive", title: "Archived", isDisabled: true)]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
CNCombobox("Choose a workspace", options: options, selection: $optionalSelection)
}
}
}Native behavior
A searchable custom SwiftUI popover selects stable option IDs. Empty results and disabled options are handled. The selected value remains in the caller binding. Hosted popovers keep their intrinsic size until the host bounds limit them. A native scroll viewport makes oversized content reachable without replacing child controls during resizing.
Style and compose
Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.
Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.
Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.
View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.
Own the source
Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.
To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.
import SwiftUI
import SwiftCN
public enum CNOptionSearch {
public static func filter<ID>(_ options: [CNOption<ID>], query: String) -> [CNOption<ID>] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
return query.isEmpty ? options : options.filter {
$0.title.localizedStandardContains(query) || ($0.detail?.localizedStandardContains(query) ?? false)
}
}
}
/// Keyboard and pointer navigation share one enabled, stable option identity.
enum CNCommandNavigation {
static func reconcile<ID>(_ options: [CNOption<ID>], highlighted: ID?) -> ID? {
let enabled = options.filter { !$0.isDisabled }
return enabled.first { $0.id == highlighted }?.id ?? enabled.first?.id
}
static func move<ID>(_ options: [CNOption<ID>], highlighted: ID?, direction: Int) -> ID? {
let enabled = options.filter { !$0.isDisabled }
guard !enabled.isEmpty else { return nil }
let index = highlighted.flatMap { id in enabled.firstIndex { $0.id == id } }
guard let index else { return direction > 0 ? enabled.first?.id : enabled.last?.id }
return enabled[min(enabled.count - 1, max(0, index + (direction > 0 ? 1 : -1)))].id
}
}
/// Pointer bookkeeping does not invalidate the view on every mouse movement.
@MainActor final class CNCommandPointer {
private var location: CGPoint?
func moved(to next: CGPoint) -> Bool {
defer { location = next }
return location != nil && location != next
}
}
/// Search native buttons with one shared pointer/keyboard highlight and stable IDs.
public struct CNCommand<ID: Hashable & Sendable>: View {
private let options: [CNOption<ID>]
@Binding private var selection: ID?
private let classes: TWClasses
private let onSelect: (ID) -> Void
private let query: Binding<String>?
public init(_ options: [CNOption<ID>], selection: Binding<ID?>, query: Binding<String>? = nil,
classes: TWClasses = "", onSelect: @escaping (ID) -> Void = { _ in }) {
self.options = options; _selection = selection; self.query = query
self.classes = classes; self.onSelect = onSelect
}
public var body: some View {
itemContent { option, selected in CNCommandLabel(option: option, selected: selected) }
}
/// Customize native button labels; filtering, highlight, activation, and IDs stay shared.
public func itemContent<Item: View>(@ViewBuilder _ item: @escaping (CNOption<ID>, Bool) -> Item) -> some View {
itemContent(item, empty: { CNCommandEmpty() })
}
public func itemContent<Item: View, Empty: View>(
@ViewBuilder _ item: @escaping (CNOption<ID>, Bool) -> Item,
@ViewBuilder empty: () -> Empty
) -> some View {
CNCommandBody(options: options, selection: $selection, boundQuery: query,
classes: classes, onSelect: onSelect, item: item, empty: empty())
}
}
private struct CNCommandBody<ID: Hashable & Sendable, Item: View, Empty: View>: View {
let options: [CNOption<ID>]
@Binding var selection: ID?
let boundQuery: Binding<String>?
let classes: TWClasses
let onSelect: (ID) -> Void
let item: (CNOption<ID>, Bool) -> Item
let empty: Empty
@State private var localQuery = ""
@State private var highlighted: ID?
@State private var pointer = CNCommandPointer()
@Namespace private var pointerSpace
@FocusState private var searchFocused: Bool
private var query: Binding<String> { boundQuery ?? $localQuery }
private var results: [CNOption<ID>] { CNOptionSearch.filter(options, query: query.wrappedValue) }
public var body: some View {
let results = self.results
VStack(alignment: .leading, spacing: 0) {
CNInputGroup("command-search", focus: $searchFocused) {
Image(systemName: "magnifyingglass").tw("text-sm text-mutedForeground").accessibilityHidden(true)
CNInputGroupField("Search…", text: query).accessibilityLabel("Search commands")
.onSubmit(activateHighlighted)
}
CNSeparator()
ScrollViewReader { scroll in
ScrollView {
LazyVStack(spacing: 2) {
if results.isEmpty {
empty
}
ForEach(results) { option in
Button { activate(option) } label: {
item(option, selection == option.id)
}
.buttonStyle(.tw(cn("command-item w-full", highlighted == option.id ? "bg-accent" : "")))
.disabled(option.isDisabled).id(option.id)
.onContinuousHover(coordinateSpace: .named(pointerSpace)) { phase in
if case .active(let location) = phase {
// Opening or scrolling beneath a stationary pointer must not replace keyboard focus.
if pointer.moved(to: location) && !option.isDisabled { highlighted = option.id }
}
}
.accessibilityAddTraits(selection == option.id ? .isSelected : [])
}
}.tw("p-[6]")
}
.coordinateSpace(name: pointerSpace)
.onChange(of: results, initial: true) { _, options in
highlighted = CNCommandNavigation.reconcile(options, highlighted: highlighted)
if let highlighted { scroll.scrollTo(highlighted) }
}
.onChange(of: highlighted) { _, id in if let id { scroll.scrollTo(id) } }
}
}.tw(cn("command min-h-[180]", classes))
.onAppear { searchFocused = true }
.onKeyPress(.downArrow) { move(1); return .handled }
.onKeyPress(.upArrow) { move(-1); return .handled }
}
private func activate(_ option: CNOption<ID>) {
guard !option.isDisabled else { return }
selection = option.id; onSelect(option.id)
}
private func activateHighlighted() {
if let id = CNCommandNavigation.reconcile(results, highlighted: highlighted),
let option = results.first(where: { $0.id == id }) { activate(option) }
}
private func move(_ direction: Int) {
highlighted = CNCommandNavigation.move(results, highlighted: highlighted, direction: direction)
}
}
private struct CNCommandEmpty: View {
var body: some View { Text("No results found.").tw("w-full py-6 text-sm text-mutedForeground") }
}
private struct CNCommandLabel<ID: Hashable & Sendable>: View {
let option: CNOption<ID>
let selected: Bool
var body: some View {
HStack(spacing: 10) {
if let symbol = option.systemImage {
Image(systemName: symbol).tw("w-[20] text-mutedForeground").accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 3) {
Text(option.title).lineLimit(1)
if let detail = option.detail { Text(detail).tw("text-xs text-mutedForeground").lineLimit(1) }
}
Spacer(minLength: 8)
Image(systemName: "checkmark").tw("text-xs text-mutedForeground")
.opacity(selected ? 1 : 0).accessibilityHidden(true)
}
}
}
public struct CNCombobox<ID: Hashable & Sendable>: View {
private let title: String
private let options: [CNOption<ID>]
@Binding private var selection: ID?
private let classes: TWClasses
@State private var isPresented = false
public init(_ title: String, options: [CNOption<ID>], selection: Binding<ID?>, classes: TWClasses = "") {
self.title = title; self.options = options; _selection = selection; self.classes = classes
}
public var body: some View {
CNPopover(isPresented: $isPresented, classes: cn("combobox", classes)) {
CNCommand(options, selection: $selection, classes: "w-[300] h-[320]") { _ in isPresented = false }
} label: {
HStack {
Text(options.first { $0.id == selection }?.title ?? title)
Image(systemName: "chevron.up.chevron.down").accessibilityHidden(true)
}
}.accessibilityLabel(title).accessibilityValue(options.first { $0.id == selection }?.title ?? "No selection")
}
}