Skip to content

feat(dropdown): add single-choice dropdown bubble component#1002

Open
ShaySapozhnikov wants to merge 1 commit into
charmbracelet:mainfrom
ShaySapozhnikov:feat/dropdown-component
Open

feat(dropdown): add single-choice dropdown bubble component#1002
ShaySapozhnikov wants to merge 1 commit into
charmbracelet:mainfrom
ShaySapozhnikov:feat/dropdown-component

Conversation

@ShaySapozhnikov

Copy link
Copy Markdown

Summary

This PR adds a new dropdown bubble — a single-choice, keyboard-driven select control for Bubble Tea v2 applications. It follows the same Model–Update–View conventions as the other Bubbles components (textinput, table, list, etc.) and integrates with the existing key and help packages.

Dropdown demo


What it does

The dropdown renders a bordered header showing either:

  • the placeholder (default: Select…) when nothing is selected, or
  • the label of the currently committed selection

When focused and expanded, a vertical list of options appears below the header. The user navigates with arrow keys (or j/k), confirms with Enter, or dismisses with Esc without changing the selection.

Three visual states are supported for the header:

State When Style
Focused Focus() called, not disabled Pink border (212)
Blurred default / after Blur() Gray border and text (240)
Disabled Disabled = true Dimmed border and text (238)

Collapsed headers show a indicator; expanded headers show .


Architecture

Core types

Option

Each selectable item has a display Label and a semantic Value (they may differ, e.g. "English" / "en"):

type Option struct {
    Label string
    Value string
}

Model

The component model holds configuration, internal state, and styling:

Field Purpose
KeyMap Key bindings (implements help.KeyMap)
Styles Lip Gloss styles for header, options, indicators
Placeholder Header text when nothing is selected
MaxVisible Max options shown at once when expanded (default 6); longer lists scroll
Disabled When true, ignores input and uses DisabledHeader style
options Internal option slice
cursor Highlighted option index while expanded
selected Committed selection index (-1 = none)
open Whether the list is expanded
focus Whether the component has keyboard focus
scrollOffset First visible option index in the scrolling window
width Inner content width for label truncation/padding

Construction (New + option funcs)

Configure at creation time with functional options:

dd := dropdown.New(
    dropdown.WithOptions(
        {Label: "Go", Value: "go"},
        {Label: "Rust", Value: "rust"},
    ),
    dropdown.WithWidth(24),
    dropdown.WithPlaceholder("Pick a language"),
    dropdown.WithMaxVisible(8),
    dropdown.WithStyles(dropdown.DefaultStyles()),
    dropdown.WithKeyMap(dropdown.DefaultKeyMap()),
)

Defaults: width 20, placeholder Select…, MaxVisible 6, no selection.

Focus management

The parent model is responsible for routing keyboard messages to the focused dropdown and for focus switching between multiple dropdowns:

dd.Focus()   // grants keyboard focus; returns tea.Cmd (nil)
dd.Blur()    // removes focus and **closes** an open list
dd.Focused() // query focus state

Only focused, non-disabled models process key input in Update.

Update loop behavior

When collapsed (open == false):

  • Enter or Space opens the list (only if len(options) > 0)
  • On reopen, the cursor starts on the previously selected item (if any)

When expanded (open == true):

Key Action
/ k Move cursor up (no wrap at top)
/ j Move cursor down (no wrap at bottom)
Enter Commit cursor position as selection, collapse, emit SelectMsg
Esc Collapse without changing selection, emit CloseMsg

Scrolling: when len(options) > MaxVisible, scrollOffset adjusts automatically so the cursor stays within the visible window (clampScrollOffset).

When disabled or unfocused: all input is ignored.

When empty options: the placeholder is shown and the list never opens.

Messages emitted to the parent

Parent models should type-switch on these in their own Update:

type SelectMsg struct {
    Option Option
    Index  int
}

type CloseMsg struct{} // user pressed Esc while expanded

Example handling:

case dropdown.SelectMsg:
    m.status = fmt.Sprintf("picked %q (%q)", msg.Option.Label, msg.Option.Value)
case dropdown.CloseMsg:
    m.status = "dismissed without selecting"

Commands are returned as tea.Cmd closures that produce these messages, matching Bubble Tea conventions.

Runtime mutation

dd.SetOptions(newOpts) // replaces options; resets selection, cursor, scroll
dd.SetWidth(w)
dd.SetStyles(s)
opt, ok := dd.SelectedItem()   // committed selection
idx := dd.SelectedIndex()      // -1 if none
open := dd.IsOpen()
opts := dd.Options()             // copy of option slice

SetOptions should be called while collapsed (the example guards with !dd.IsOpen()).

Rendering (View)

Returns a multi-line string:

  1. Header row — truncated label + indicator, padded to fixed width
  2. Option rows (only when open) — highlighted row uses bold pink with left border; others are padded normally

Styles are fully customizable via Styles and DefaultStyles().

Key map & help integration

KeyMap implements help.KeyMap (ShortHelp / FullHelp) so it works with the help bubble out of the box:

Binding Default keys Help text
Open enter, space open
Confirm enter select
Close esc close
Up up, k ↑/k
Down down, j ↓/j

Customize with WithKeyMap or by mutating m.KeyMap after construction.


Example program

A full demo lives at examples/dropdown/main.go. It showcases three dropdowns side by side:

  1. Normal — interactive, reloadable options (r key swaps Charm projects → programming languages)
  2. Disabled — pre-selected "Lip Gloss", ignores input
  3. Empty — placeholder only, never opens

Run it:

go run ./examples/dropdown

Controls:

Key Action
tab / shift+tab Cycle focus between dropdowns
↑/k, ↓/j Navigate options (when expanded)
enter / space Open or confirm selection
esc Close without selecting
r Reload options in the first dropdown (when collapsed)
q / ctrl+c Quit

Full source:

examples/dropdown/main.go
// Example program demonstrating the dropdown component.
//
// Controls:
//
//   - tab / shift+tab  cycle focus between dropdowns
//   - ↑/k, ↓/j        navigate options (when expanded)
//   - enter / space    open or confirm selection
//   - esc              close without selecting
//   - r                reload options in the first dropdown at runtime
//   - q / ctrl+c       quit
package main

import (
	"fmt"
	"os"
	"strings"

	"charm.land/bubbles/v2/dropdown"
	tea "charm.land/bubbletea/v2"
)

const numDropdowns = 3

var charmOpts = []dropdown.Option{
	{Label: "Bubble Tea", Value: "bubbletea"},
	{Label: "Lip Gloss", Value: "lipgloss"},
	{Label: "Bubbles", Value: "bubbles"},
	{Label: "Huh", Value: "huh"},
	{Label: "Wish", Value: "wish"},
}

var langOpts = []dropdown.Option{
	{Label: "Go", Value: "go"},
	{Label: "Rust", Value: "rust"},
	{Label: "Zig", Value: "zig"},
	{Label: "C", Value: "c"},
}

type model struct {
	dropdowns    [numDropdowns]dropdown.Model
	focusIndex   int
	lastSelected string
}

func initialModel() model {
	// Dropdown 0: normal, interactive.
	dd0 := dropdown.New(
		dropdown.WithOptions(charmOpts...),
		dropdown.WithWidth(18),
	)
	dd0.Focus() //nolint:errcheck

	// Dropdown 1: disabled, with a pre-committed selection.
	dd1 := dropdown.New(
		dropdown.WithOptions(charmOpts...),
		dropdown.WithWidth(18),
	)
	// Pre-select "Lip Gloss" (index 1) by running Update with a temporary focus.
	dd1.Focus() //nolint:errcheck
	dd1, _ = dd1.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // open
	dd1, _ = dd1.Update(tea.KeyPressMsg{Code: tea.KeyDown})  // cursor → 1
	dd1, _ = dd1.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // confirm
	dd1.Blur()
	dd1.Disabled = true

	// Dropdown 2: empty options — shows placeholder only, never opens.
	dd2 := dropdown.New(dropdown.WithWidth(18))

	return model{
		dropdowns:  [numDropdowns]dropdown.Model{dd0, dd1, dd2},
		focusIndex: 0,
	}
}

func (m model) Init() tea.Cmd {
	return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		switch msg.String() {
		case "ctrl+c", "q":
			return m, tea.Quit

		case "tab", "shift+tab":
			step := 1
			if msg.String() == "shift+tab" {
				step = -1
			}
			m.dropdowns[m.focusIndex].Blur()
			m.focusIndex = (m.focusIndex + step + numDropdowns) % numDropdowns
			m.dropdowns[m.focusIndex].Focus() //nolint:errcheck
			return m, nil

		case "r":
			// Reload options at runtime (only when collapsed).
			if !m.dropdowns[0].IsOpen() {
				m.dropdowns[0].SetOptions(langOpts)
				m.lastSelected = "(options reloaded)"
			}
			return m, nil
		}

	case dropdown.SelectMsg:
		m.lastSelected = fmt.Sprintf("selected %q (value: %q, index: %d)",
			msg.Option.Label, msg.Option.Value, msg.Index)
		return m, nil

	case dropdown.CloseMsg:
		m.lastSelected = "(closed without selecting)"
		return m, nil
	}

	var cmd tea.Cmd
	m.dropdowns[m.focusIndex], cmd = m.dropdowns[m.focusIndex].Update(msg)
	return m, cmd
}

func (m model) View() tea.View {
	var sb strings.Builder

	sb.WriteString("Dropdown Component Demo\n")
	sb.WriteString("───────────────────────────────────────────────\n\n")

	labels := []string{"Normal (tab to focus)", "Disabled", "Empty"}
	for i, dd := range m.dropdowns {
		sb.WriteString(fmt.Sprintf("  %s\n", labels[i]))
		for _, line := range strings.Split(dd.View(), "\n") {
			sb.WriteString("  " + line + "\n")
		}
		sb.WriteRune('\n')
	}

	sb.WriteString("───────────────────────────────────────────────\n")
	if m.lastSelected != "" {
		sb.WriteString("  " + m.lastSelected + "\n")
	}
	sb.WriteString("\n  tab/shift+tab: focus  r: reload opts  q: quit\n")

	return tea.NewView(sb.String())
}

func main() {
	p := tea.NewProgram(initialModel())
	if _, err := p.Run(); err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
}

Demo recording

The GIF above was generated with VHS from dropdown/demo.tape. Reproduce it:

vhs dropdown/demo.tape

Test plan

  • go test ./dropdown/... — 20+ unit tests covering focus/blur, open/close, navigation, selection messages, scrolling, disabled/empty states, and view rendering
  • go run ./examples/dropdown — manual smoke test of multi-dropdown focus, selection, reload, disabled, and empty behaviors
  • VHS demo recording verified

Made with Cursor

Introduces a focusable dropdown with keyboard navigation, selection messages,
runtime option updates, and a demo example with VHS recording.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant