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

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>
This commit is contained in:
Shay Sapozhnikov 2026-06-10 16:27:28 -05:00
parent 40d145e1ea
commit 45af81d4c6
5 changed files with 1051 additions and 0 deletions

BIN
dropdown/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

47
dropdown/demo.tape Normal file
View File

@ -0,0 +1,47 @@
Output dropdown.gif
Set Shell "bash"
Set FontSize 14
Set Width 640
Set Height 420
Set Theme "Catppuccin Mocha"
Set Padding 20
Type "go run ./examples/dropdown"
Enter
Sleep 1s
# Open the first dropdown
Type " "
Sleep 500ms
# Navigate down twice and select "Bubbles"
Type "jj"
Sleep 300ms
Type " "
Sleep 500ms
# Reload options at runtime
Type "r"
Sleep 500ms
# Open again and pick "Rust"
Type " "
Sleep 300ms
Type "j"
Sleep 300ms
Enter
Sleep 500ms
# Tab to the disabled dropdown (no effect on input)
Type " "
Sleep 300ms
Tab
Sleep 500ms
# Tab to empty dropdown
Tab
Sleep 500ms
# Quit
Type "q"

460
dropdown/dropdown.go Normal file
View File

@ -0,0 +1,460 @@
// Package dropdown provides a single-choice dropdown component for Bubble Tea
// applications. It follows the same ModelUpdateView pattern and conventions
// as the other Bubbles components (table, paginator, spinner, etc.).
package dropdown
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
const (
defaultMaxVisible = 6
defaultWidth = 20
)
// KeyMap is the key bindings for different actions within the dropdown.
// It satisfies the help.KeyMap interface so it integrates with the help
// component out of the box.
type KeyMap struct {
// Open opens the dropdown when it is collapsed.
Open key.Binding
// Close closes the dropdown without changing the current selection.
Close key.Binding
// Confirm confirms the highlighted option and collapses the dropdown.
Confirm key.Binding
// Up moves the highlight cursor up.
Up key.Binding
// Down moves the highlight cursor down.
Down key.Binding
}
// ShortHelp implements help.KeyMap.
func (km KeyMap) ShortHelp() []key.Binding {
return []key.Binding{km.Up, km.Down, km.Confirm, km.Close}
}
// FullHelp implements help.KeyMap.
func (km KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{km.Up, km.Down},
{km.Open, km.Confirm, km.Close},
}
}
// DefaultKeyMap returns the default set of key bindings for the dropdown.
func DefaultKeyMap() KeyMap {
return KeyMap{
Open: key.NewBinding(
key.WithKeys("enter", "space"),
key.WithHelp("enter/space", "open"),
),
Close: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
Confirm: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
}
}
// Styles contains style definitions for this dropdown component. By default,
// these values are generated by DefaultStyles.
type Styles struct {
// FocusedHeader is the style for the collapsed header when the component
// has keyboard focus.
FocusedHeader lipgloss.Style
// BlurredHeader is the style for the collapsed header when the component
// does not have keyboard focus.
BlurredHeader lipgloss.Style
// DisabledHeader is the style for the header when the component is disabled.
DisabledHeader lipgloss.Style
// SelectedOption is the style for the currently highlighted option in the
// expanded list.
SelectedOption lipgloss.Style
// NormalOption is the style for options that are not highlighted.
NormalOption lipgloss.Style
// OpenIndicator is the decorator appended to the header when expanded.
OpenIndicator lipgloss.Style
// ClosedIndicator is the decorator appended to the header when collapsed.
ClosedIndicator lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this dropdown
// component.
func DefaultStyles() Styles {
return Styles{
FocusedHeader: lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("212")).
Padding(0, 1),
BlurredHeader: lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
Foreground(lipgloss.Color("240")).
Padding(0, 1),
DisabledHeader: lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")).
Foreground(lipgloss.Color("238")).
Padding(0, 1),
SelectedOption: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("212")).
PaddingLeft(1).
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("212")),
NormalOption: lipgloss.NewStyle().
PaddingLeft(2), //nolint:mnd
OpenIndicator: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).SetString("▼"),
ClosedIndicator: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).SetString("▶"),
}
}
// Option represents a single selectable item in the dropdown list.
type Option struct {
// Label is the text displayed in the header and in the expanded list.
Label string
// Value is the semantic value associated with this option. It may differ
// from Label (e.g. "en" vs "English").
Value string
}
// SelectMsg is emitted when the user confirms a selection. Parent models
// should type-switch on this in their own Update to react to a completed
// selection.
type SelectMsg struct {
Option Option
Index int
}
// CloseMsg is emitted when the user closes the dropdown without confirming a
// new selection (e.g. by pressing Escape).
type CloseMsg struct{}
// Model is the Bubble Tea model for the dropdown component.
type Model struct {
// KeyMap encodes the keybindings recognized by the dropdown.
KeyMap KeyMap
// Styles holds all visual style definitions for the dropdown.
Styles Styles
// Placeholder is the text shown in the header when no option is selected.
Placeholder string
// MaxVisible is the maximum number of options shown at once when expanded.
// Options beyond this count are accessed by scrolling. Defaults to 6.
MaxVisible int
// Disabled prevents the dropdown from receiving keyboard input and renders
// it with DisabledHeader style.
Disabled bool
options []Option
cursor int // index of the currently highlighted option
selected int // index of the committed selection; -1 means none
open bool
focus bool
scrollOffset int // index of the first option in the visible window
width int // inner content width for header label and option rows
}
// New creates a new model with default settings. Use the With* option
// functions to configure it, for example:
//
// dd := dropdown.New(
// dropdown.WithOptions(opts...),
// dropdown.WithWidth(24),
// )
func New(o ...OptionFunc) Model {
m := Model{
KeyMap: DefaultKeyMap(),
Styles: DefaultStyles(),
Placeholder: "Select…",
MaxVisible: defaultMaxVisible,
selected: -1,
width: defaultWidth,
}
for _, opt := range o {
opt(&m)
}
return m
}
// OptionFunc is used to set options in New. For example:
//
// dd := dropdown.New(dropdown.WithWidth(30))
type OptionFunc func(*Model)
// WithOptions sets the initial option list.
func WithOptions(opts ...Option) OptionFunc {
return func(m *Model) {
m.options = opts
}
}
// WithWidth sets the inner content width used for rendering.
func WithWidth(w int) OptionFunc {
return func(m *Model) {
m.width = w
}
}
// WithPlaceholder sets the placeholder text shown when nothing is selected.
func WithPlaceholder(s string) OptionFunc {
return func(m *Model) {
m.Placeholder = s
}
}
// WithMaxVisible sets the maximum number of options visible at once when
// the dropdown is expanded.
func WithMaxVisible(n int) OptionFunc {
return func(m *Model) {
m.MaxVisible = n
}
}
// WithStyles sets the styles.
func WithStyles(s Styles) OptionFunc {
return func(m *Model) {
m.Styles = s
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) OptionFunc {
return func(m *Model) {
m.KeyMap = km
}
}
// SetStyles sets the styles after construction.
func (m *Model) SetStyles(s Styles) {
m.Styles = s
}
// SetOptions replaces the current option list and resets the selection,
// cursor, and scroll offset.
func (m *Model) SetOptions(opts []Option) {
m.options = opts
m.selected = -1
m.cursor = 0
m.scrollOffset = 0
}
// Options returns a copy of the current option list.
func (m Model) Options() []Option {
out := make([]Option, len(m.options))
copy(out, m.options)
return out
}
// SelectedItem returns the currently selected option and true, or an empty
// Option and false when nothing has been selected.
func (m Model) SelectedItem() (Option, bool) {
if m.selected < 0 || m.selected >= len(m.options) {
return Option{}, false
}
return m.options[m.selected], true
}
// SelectedIndex returns the index of the selected option, or -1 if nothing
// has been selected.
func (m Model) SelectedIndex() int {
return m.selected
}
// IsOpen returns true when the dropdown is expanded.
func (m Model) IsOpen() bool {
return m.open
}
// Width returns the inner content width.
func (m Model) Width() int {
return m.width
}
// SetWidth sets the inner content width.
func (m *Model) SetWidth(w int) {
m.width = w
}
// Focus grants keyboard focus to the dropdown. It returns nil to match the
// tea.Cmd return type convention used by textinput and other components.
func (m *Model) Focus() tea.Cmd {
m.focus = true
return nil
}
// Blur removes keyboard focus. The dropdown is also closed so that an
// abandoned expanded list is not left on screen.
func (m *Model) Blur() {
m.focus = false
m.open = false
}
// Focused returns the current focus state.
func (m Model) Focused() bool {
return m.focus
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus || m.Disabled {
return m, nil
}
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return m, nil
}
if !m.open {
if key.Matches(keyMsg, m.KeyMap.Open) && len(m.options) > 0 {
m.open = true
// Start the cursor on the already-selected item when reopening.
if m.selected >= 0 {
m.cursor = m.selected
m.clampScrollOffset()
}
}
return m, nil
}
switch {
case key.Matches(keyMsg, m.KeyMap.Close):
m.open = false
return m, func() tea.Msg { return CloseMsg{} }
case key.Matches(keyMsg, m.KeyMap.Confirm):
m.selected = m.cursor
m.open = false
sel, idx := m.options[m.selected], m.selected
return m, func() tea.Msg { return SelectMsg{Option: sel, Index: idx} }
case key.Matches(keyMsg, m.KeyMap.Up):
if m.cursor > 0 {
m.cursor--
m.clampScrollOffset()
}
case key.Matches(keyMsg, m.KeyMap.Down):
if m.cursor < len(m.options)-1 {
m.cursor++
m.clampScrollOffset()
}
}
return m, nil
}
// clampScrollOffset keeps cursor visible within the MaxVisible window.
func (m *Model) clampScrollOffset() {
maxVis := m.MaxVisible
if maxVis <= 0 {
maxVis = defaultMaxVisible
}
if m.cursor < m.scrollOffset {
m.scrollOffset = m.cursor
}
if m.cursor >= m.scrollOffset+maxVis {
m.scrollOffset = m.cursor - maxVis + 1
}
}
// View renders the dropdown.
func (m Model) View() string {
var sb strings.Builder
// ── Header ────────────────────────────────────────────────────────────────
indicatorStr := m.indicatorString()
// Keep one space between the label and the indicator.
availForLabel := m.width - ansi.StringWidth(indicatorStr) - 1
if availForLabel < 0 {
availForLabel = 0
}
label := m.headerLabel()
label = ansi.Truncate(label, availForLabel, "…")
// Pad so the header width stays fixed regardless of label length.
label += strings.Repeat(" ", max(0, availForLabel-ansi.StringWidth(label)))
sb.WriteString(m.headerStyle().Render(label + " " + indicatorStr))
// ── Option list ───────────────────────────────────────────────────────────
if !m.open || len(m.options) == 0 {
return sb.String()
}
sb.WriteRune('\n')
maxVis := m.MaxVisible
if maxVis <= 0 {
maxVis = defaultMaxVisible
}
end := m.scrollOffset + maxVis
if end > len(m.options) {
end = len(m.options)
}
for i := m.scrollOffset; i < end; i++ {
optLabel := ansi.Truncate(m.options[i].Label, m.width, "…")
if i == m.cursor {
sb.WriteString(m.Styles.SelectedOption.Render(optLabel))
} else {
sb.WriteString(m.Styles.NormalOption.Render(optLabel))
}
if i < end-1 {
sb.WriteRune('\n')
}
}
return sb.String()
}
func (m Model) headerLabel() string {
if opt, ok := m.SelectedItem(); ok {
return opt.Label
}
return m.Placeholder
}
func (m Model) indicatorString() string {
if m.open {
return m.Styles.OpenIndicator.String()
}
return m.Styles.ClosedIndicator.String()
}
func (m Model) headerStyle() lipgloss.Style {
switch {
case m.Disabled:
return m.Styles.DisabledHeader
case m.focus:
return m.Styles.FocusedHeader
default:
return m.Styles.BlurredHeader
}
}

394
dropdown/dropdown_test.go Normal file
View File

@ -0,0 +1,394 @@
package dropdown
import (
"testing"
tea "charm.land/bubbletea/v2"
)
// helpers ──────────────────────────────────────────────────────────────────
func testOptions() []Option {
return []Option{
{Label: "Alpha", Value: "a"},
{Label: "Beta", Value: "b"},
{Label: "Gamma", Value: "c"},
{Label: "Delta", Value: "d"},
{Label: "Epsilon", Value: "e"},
}
}
// press sends a single key event and returns the updated model plus any
// emitted tea.Msg (by calling the returned Cmd immediately).
func press(m Model, code rune) (Model, tea.Msg) {
updated, cmd := m.Update(tea.KeyPressMsg{Code: code})
var emitted tea.Msg
if cmd != nil {
emitted = cmd()
}
return updated, emitted
}
func focused(opts []Option) Model {
m := New(WithOptions(opts...), WithWidth(20))
m.Focus() //nolint:errcheck
return m
}
// ── Focus / Blur ───────────────────────────────────────────────────────────
func TestFocused(t *testing.T) {
m := New(WithOptions(testOptions()...))
if m.Focused() {
t.Fatal("expected unfocused on creation")
}
m.Focus() //nolint:errcheck
if !m.Focused() {
t.Fatal("expected focused after Focus()")
}
m.Blur()
if m.Focused() {
t.Fatal("expected unfocused after Blur()")
}
}
func TestBlurClosesDropdown(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
if !m.open {
t.Fatal("expected open after Enter")
}
m.Blur()
if m.open {
t.Fatal("expected closed after Blur()")
}
}
// ── Disabled ───────────────────────────────────────────────────────────────
func TestDisabledIgnoresInput(t *testing.T) {
m := focused(testOptions())
m.Disabled = true
m, emitted := press(m, tea.KeyEnter)
if m.open {
t.Error("disabled dropdown should not open")
}
if emitted != nil {
t.Error("disabled dropdown should not emit messages")
}
}
// ── Empty options ─────────────────────────────────────────────────────────
func TestEmptyOptionsNoOpen(t *testing.T) {
m := New(WithWidth(20))
m.Focus() //nolint:errcheck
m, _ = press(m, tea.KeyEnter)
if m.open {
t.Fatal("empty dropdown should not open")
}
}
func TestEmptyOptionsNoPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("unexpected panic: %v", r)
}
}()
m := New(WithWidth(20))
m.Focus() //nolint:errcheck
for _, k := range []rune{tea.KeyEnter, tea.KeyDown, tea.KeyUp, tea.KeyEscape} {
m, _ = press(m, k)
}
_ = m.View()
}
// ── Opening / Closing ─────────────────────────────────────────────────────
func TestOpenWithEnter(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
if !m.IsOpen() {
t.Fatal("should be open after Enter")
}
}
func TestOpenWithSpace(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeySpace)
if !m.IsOpen() {
t.Fatal("should be open after Space")
}
}
func TestCloseWithEscape(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, emitted := press(m, tea.KeyEscape)
if m.IsOpen() {
t.Fatal("should be closed after Escape")
}
if _, ok := emitted.(CloseMsg); !ok {
t.Fatalf("expected CloseMsg, got %T", emitted)
}
}
func TestEscapeDoesNotChangeSelection(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyDown)
m, _ = press(m, tea.KeyEscape)
if m.SelectedIndex() != -1 {
t.Fatalf("Escape should not commit selection; got index %d", m.SelectedIndex())
}
}
// ── Navigation ────────────────────────────────────────────────────────────
func TestCursorDownUp(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyDown)
m, _ = press(m, tea.KeyDown)
if m.cursor != 2 {
t.Fatalf("expected cursor=2, got %d", m.cursor)
}
m, _ = press(m, tea.KeyUp)
if m.cursor != 1 {
t.Fatalf("expected cursor=1 after Up, got %d", m.cursor)
}
}
func TestCursorNoWrapAtTop(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyUp)
if m.cursor != 0 {
t.Fatalf("cursor should stay at 0, got %d", m.cursor)
}
}
func TestCursorNoWrapAtBottom(t *testing.T) {
opts := testOptions()
m := focused(opts)
m, _ = press(m, tea.KeyEnter)
for i := 0; i < len(opts)-1; i++ {
m, _ = press(m, tea.KeyDown)
}
last := m.cursor
m, _ = press(m, tea.KeyDown)
if m.cursor != last {
t.Fatalf("cursor should not go past last option; got %d", m.cursor)
}
}
func TestVimKeys(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = m.Update(tea.KeyPressMsg{Text: "j"})
if m.cursor != 1 {
t.Fatalf("expected cursor=1 after 'j', got %d", m.cursor)
}
m, _ = m.Update(tea.KeyPressMsg{Text: "k"})
if m.cursor != 0 {
t.Fatalf("expected cursor=0 after 'k', got %d", m.cursor)
}
}
// ── Selection (SelectMsg) ─────────────────────────────────────────────────
func TestSelectEmitsSelectMsg(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter) // open
m, _ = press(m, tea.KeyDown) // cursor → 1 "Beta"
m, emitted := press(m, tea.KeyEnter)
sel, ok := emitted.(SelectMsg)
if !ok {
t.Fatalf("expected SelectMsg, got %T", emitted)
}
if sel.Index != 1 {
t.Errorf("expected index 1, got %d", sel.Index)
}
if sel.Option.Value != "b" {
t.Errorf("expected value 'b', got %q", sel.Option.Value)
}
}
func TestSelectCommitsSelection(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyDown)
m, _ = press(m, tea.KeyDown)
m, _ = press(m, tea.KeyEnter)
if m.SelectedIndex() != 2 {
t.Fatalf("expected selected=2, got %d", m.SelectedIndex())
}
opt, ok := m.SelectedItem()
if !ok {
t.Fatal("SelectedItem should return true after selection")
}
if opt.Label != "Gamma" {
t.Errorf("expected 'Gamma', got %q", opt.Label)
}
}
func TestSelectClosesDropdown(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyEnter)
if m.IsOpen() {
t.Fatal("dropdown should close after selecting")
}
}
func TestSelectedItemNoneInitially(t *testing.T) {
m := New(WithOptions(testOptions()...))
_, ok := m.SelectedItem()
if ok {
t.Fatal("expected no selection initially")
}
if m.SelectedIndex() != -1 {
t.Fatalf("expected SelectedIndex=-1, got %d", m.SelectedIndex())
}
}
// ── Scroll offset ─────────────────────────────────────────────────────────
func TestScrollOffsetAdvancesWithCursor(t *testing.T) {
m := New(WithOptions(testOptions()...), WithWidth(20), WithMaxVisible(3))
m.Focus() //nolint:errcheck
m, _ = press(m, tea.KeyEnter)
for i := 0; i < 4; i++ {
m, _ = press(m, tea.KeyDown)
}
if m.scrollOffset < 2 {
t.Fatalf("expected scrollOffset>=2 at cursor=4 with MaxVisible=3, got %d", m.scrollOffset)
}
end := m.scrollOffset + m.MaxVisible
if m.cursor < m.scrollOffset || m.cursor >= end {
t.Fatalf("cursor %d outside visible window [%d, %d)", m.cursor, m.scrollOffset, end)
}
}
func TestScrollOffsetRetreatsWithCursor(t *testing.T) {
opts := testOptions()
m := New(WithOptions(opts...), WithWidth(20), WithMaxVisible(3))
m.Focus() //nolint:errcheck
m, _ = press(m, tea.KeyEnter)
for i := 0; i < len(opts)-1; i++ {
m, _ = press(m, tea.KeyDown)
}
prevOffset := m.scrollOffset
for i := 0; i < len(opts)-1; i++ {
m, _ = press(m, tea.KeyUp)
}
if m.scrollOffset >= prevOffset {
t.Fatalf("scrollOffset should have decreased (was %d, now %d)", prevOffset, m.scrollOffset)
}
if m.scrollOffset != 0 {
t.Fatalf("expected scrollOffset=0 at top, got %d", m.scrollOffset)
}
}
// ── SetOptions ────────────────────────────────────────────────────────────
func TestSetOptionsResetsState(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyDown)
m, _ = press(m, tea.KeyEnter)
if m.SelectedIndex() == -1 {
t.Fatal("expected a selection before SetOptions")
}
m.SetOptions([]Option{{Label: "X", Value: "x"}})
if m.SelectedIndex() != -1 {
t.Fatal("SetOptions should reset selection to -1")
}
if m.cursor != 0 {
t.Fatal("SetOptions should reset cursor to 0")
}
if m.scrollOffset != 0 {
t.Fatal("SetOptions should reset scrollOffset to 0")
}
}
// ── Option funcs ──────────────────────────────────────────────────────────
func TestWithKeyMap(t *testing.T) {
km := DefaultKeyMap()
m := New(WithKeyMap(km))
if m.KeyMap.Up.Keys()[0] != km.Up.Keys()[0] {
t.Fatal("WithKeyMap should set the KeyMap")
}
}
func TestWithStyles(t *testing.T) {
s := DefaultStyles()
m := New(WithStyles(s))
_ = m.View() // should not panic
}
// ── View ──────────────────────────────────────────────────────────────────
func TestViewDoesNotPanic(t *testing.T) {
cases := []struct {
name string
model Model
}{
{"default", New()},
{"focused", func() Model { m := New(WithOptions(testOptions()...)); m.Focus(); return m }()},
{"open", func() Model {
m := New(WithOptions(testOptions()...))
m.Focus()
m, _ = press(m, tea.KeyEnter)
return m
}()},
{"disabled", func() Model {
m := New(WithOptions(testOptions()...))
m.Disabled = true
return m
}()},
{"empty", New()},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("View() panicked: %v", r)
}
}()
_ = tc.model.View()
})
}
}
func TestViewContainsSelectedLabel(t *testing.T) {
m := focused(testOptions())
m, _ = press(m, tea.KeyEnter)
m, _ = press(m, tea.KeyDown) // cursor → 1 "Beta"
m, _ = press(m, tea.KeyEnter)
view := m.View()
if !containsSubstring(view, "Beta") {
t.Errorf("expected 'Beta' in view, got:\n%s", view)
}
}
func TestViewContainsPlaceholder(t *testing.T) {
m := New(WithOptions(testOptions()...), WithPlaceholder("Pick one"))
view := m.View()
if !containsSubstring(view, "Pick one") {
t.Errorf("expected placeholder in view, got:\n%s", view)
}
}
// containsSubstring is an inlined helper to avoid importing strings.
func containsSubstring(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

150
examples/dropdown/main.go Normal file
View File

@ -0,0 +1,150 @@
// 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)
}
}