bubbledropdown

package module
v0.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: MIT Imports: 5 Imported by: 0

README

bubble-dropdown

A Bubble Tea dropdown component: a [ Label ▼ ] trigger that opens a scrollable selection panel as a bubble-overlay modal. Full keyboard and mouse support with bubblezone hit-testing.

v1 (Bubble Tea v1, View() string) at the repo root · v2 (Bubble Tea v2, View() tea.View) at github.com/madicen/bubble-dropdown/v2 (package dropdownv2).

Dropdown demo

Requirements

  • Go 1.25+ (this module includes charm.land/bubbletea/v2 alongside Bubble Tea v1.)

Installation

go get github.com/madicen/bubble-dropdown

The v2 API lives at import path github.com/madicen/bubble-dropdown/v2 (package dropdownv2); you still add the root module once.

Which API should I use?

You are on… Use
Bubble Tea v1, View() string Package bubbledropdown: github.com/madicen/bubble-dropdown
Bubble Tea v2 (charm.land/bubbletea/v2), View() tea.View Package dropdownv2: github.com/madicen/bubble-dropdown/v2

Both paths use the same string compositing for the open panel (bubble-overlay.OverlayView). The host wraps the final string in tea.NewView for v2.

Quick start

import bubbledropdown "github.com/madicen/bubble-dropdown"

// Create
d := bubbledropdown.New(
    bubbledropdown.WithOptions([]string{"Apple", "Banana", "Cherry"}),
    bubbledropdown.WithPlaceholder("Pick a fruit"),
)
d.SetZoneManager(zm) // optional; enable bubblezone hit-testing

// In View — call SetBounds every frame before building the zone mark
tw, th := d.TriggerSize()
d.SetBounds(row, col, tw, th)
mainView := zm.Mark("my-dropdown", d.TriggerView())
return d.ViewWithOverlay(mainView, width, height)
// (if using zm: wrap the final return in zm.Scan)

// In Update — forward all messages
d, cmd = d.Update(msg)

// Handle selection
case bubbledropdown.ItemChosenMsg:
    d.SetSelectedIndex(msg.Index)

See examples/basic for a full working example with three dropdowns and keyboard + mouse support, and examples/styled for fully customized appearance.

Bubble Tea v2 — quick start

import (
    tea "charm.land/bubbletea/v2"
    dropdownv2 "github.com/madicen/bubble-dropdown/v2"
)

// Create
d := dropdownv2.New(
    dropdownv2.WithOptions([]string{"Apple", "Banana", "Cherry"}),
    dropdownv2.WithPlaceholder("Pick a fruit"),
)
d.SetZoneManager(zm) // optional; enable bubblezone hit-testing

// In View — call SetBounds every frame before building the zone mark
tw, th := d.TriggerSize()
d.SetBounds(row, col, tw, th)
mainView := zm.Mark("my-dropdown", d.TriggerView())
content := d.ViewWithOverlay(mainView, width, height)
return tea.NewView(zm.Scan(content))

// In Update — forward all messages (tea.KeyPressMsg, tea.MouseClickMsg, etc.)
d, cmd = d.Update(msg)

// Handle selection
case dropdownv2.ItemChosenMsg:
    d.SetSelectedIndex(msg.Index)

Run go run ./examples/v2basic. The v2 package uses split mouse events (MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, MouseMotionMsg) and tea.KeyPressMsg per the Bubble Tea v2 upgrade guide. v1 (github.com/madicen/bubble-dropdown) remains supported for existing consumers.

Styling

By default the dropdown is neutral — it inherits your terminal's colors: a plain rounded border, a reverse-video highlight for the selected/hovered row, and a bold (uncolored) focused trigger arrow. Color is entirely opt-in.

Every visible part of the dropdown can be styled via options. WithAccentColor is the quickest path — it colors the panel border, the highlighted row, and the focused trigger arrow in one go. For finer control, override individual lipgloss styles. Custom styles take precedence over the accent color where they overlap.

When you set WithTriggerStyle, the trigger arrow () follows that style's color so the whole element stays consistent; focus is indicated by bolding the arrow rather than recoloring it with the accent. The accent-colored focused arrow only applies when an accent is set on the default, unstyled trigger.

For live theming (e.g. a settings screen where the user picks the primary color), use SetAccentColor to recolor an existing dropdown in place — including an already-open panel — instead of rebuilding it. Passing an empty string returns it to the neutral default. AccentColor returns the current value.

Styled demo

d := bubbledropdown.New(
    bubbledropdown.WithOptions([]string{"Apple", "Banana", "Cherry"}),

    // One accent color recolors border + highlight + focused arrow
    bubbledropdown.WithAccentColor("#7D56F4"),

    // …or override individual styles for full control
    bubbledropdown.WithTriggerStyle(lipgloss.NewStyle().Bold(true)),
    bubbledropdown.WithListStyle(
        lipgloss.NewStyle().Border(lipgloss.ThickBorder()),
    ),
    bubbledropdown.WithItemStyle(
        lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("245")),
    ),
    bubbledropdown.WithCursorStyle(
        lipgloss.NewStyle().Padding(0, 1).Bold(true).
            Background(lipgloss.Color("#2ECC71")).Foreground(lipgloss.Color("16")),
    ),
)

Note: WithItemStyle and WithCursorStyle should keep symmetric horizontal padding (e.g. Padding(0, 1)) so item rows align with the panel's computed width.

Run it: go run ./examples/styled

Keyboard controls

Context Key Action
Trigger focused Enter / Space Open the dropdown panel
Trigger focused / Cycle selection without opening
Trigger focused Tab / Shift+Tab Move focus (handled by your app)
Panel open / Move cursor through options
Panel open Enter Confirm selection → ItemChosenMsg
Panel open Esc Cancel → ItemCanceledMsg

Mouse controls

Action Result
Click trigger Open the panel
Click option row Confirm selection → ItemChosenMsg
Hover over option row Move cursor highlight
Scroll wheel inside panel Move cursor
Click outside open panel Cancel → ItemCanceledMsg

API

New(opts ...Option) *Dropdown

Creates a new Dropdown. All configuration is done through options.

Options
Option Description
WithOptions([]string) Selectable items
WithInitialIndex(int) Initially selected item (default 0)
WithPlaceholder(string) Text shown when nothing is selected (default "Select…")
WithMaxVisible(int) Max items shown before scrolling (default 8)
WithAccentColor(string) Opt-in color for the border, highlight, and focused arrow (e.g. "62" or "#7D56F4"); default is neutral
WithTriggerStyle(lipgloss.Style) Override the closed trigger style
WithListStyle(lipgloss.Style) Override the open panel border style
WithItemStyle(lipgloss.Style) Override non-highlighted item rows
WithCursorStyle(lipgloss.Style) Override the highlighted (hover/cursor) item row
Methods
// Positioning — call every frame before building view
d.SetBounds(row, col, w, h int)
d.TriggerSize() (width, height int)

// Rendering
d.TriggerView() string
d.ViewWithOverlay(mainView string, viewWidth, viewHeight int) string

// State
d.Open() bool
d.Focused() bool
d.SetFocused(bool)
d.Selected() string
d.SelectedIndex() int
d.SetSelectedIndex(i int)

// Theming — recolor in place at runtime (live theming)
d.AccentColor() string
d.SetAccentColor(color string)

// Zone support
d.SetZoneManager(*zone.Manager)

// Message loop
d.Update(tea.Msg) (*Dropdown, tea.Cmd)
Messages
// Emitted when the user picks an item
type ItemChosenMsg struct {
    Index int    // zero-based index into the options slice
    Value string // the selected option string
}

// Emitted when the user dismisses without selecting
type ItemCanceledMsg struct{}

Embedding pattern

The standard embed pattern (mirrors bubble-color-picker's SwatchPicker):

type appModel struct {
    width, height int
    zm            *zone.Manager
    dropdowns     []*bubbledropdown.Dropdown
    focused       int
}

func (a *appModel) View() string {
    mainView := a.buildMainView() // calls SetBounds + zm.Mark inside
    for _, d := range a.dropdowns {
        mainView = d.ViewWithOverlay(mainView, a.width, a.height)
    }
    return a.zm.Scan(mainView)
}

func (a *appModel) buildMainView() string {
    tw, th := a.dropdowns[0].TriggerSize()
    a.dropdowns[0].SetBounds(3, 8, tw, th)
    return a.zm.Mark("fruit", a.dropdowns[0].TriggerView())
}

func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    // Route to the open dropdown exclusively
    for i, d := range a.dropdowns {
        if d.Open() {
            a.dropdowns[i], cmd = d.Update(msg)
            return a, cmd
        }
    }
    // Route mouse clicks to the zone-matched dropdown
    if m, ok := msg.(tea.MouseMsg); ok && m.Action == tea.MouseActionPress {
        if a.zm.Get("fruit").InBounds(m) {
            a.dropdowns[0], cmd = a.dropdowns[0].Update(msg)
            return a, cmd
        }
    }
    // Broadcast to focused dropdown for keys
    a.dropdowns[a.focused], cmd = a.dropdowns[a.focused].Update(msg)
    return a, cmd
}

Panel positioning

The panel opens below the trigger by default. When there is not enough room below (the panel would exceed the terminal height), it automatically flips above the trigger. bubble-overlay's ClampedOrigin ensures the panel stays within the viewport at all times.

Dependencies

License

MIT

Documentation

Overview

Package bubbledropdown provides Dropdown: a trigger element that opens a scrollable selection panel as a bubble-overlay modal. Embed it in your BubbleTea model, call SetBounds and forward messages, then call ViewWithOverlay to composite the open panel over your main view.

Usage overview:

d := bubbledropdown.New(
    bubbledropdown.WithOptions([]string{"Apple", "Banana", "Cherry"}),
    bubbledropdown.WithPlaceholder("Pick a fruit"),
)
d.SetZoneManager(zm) // optional; enables bubblezone hit-testing

In your View:

tw, th := d.TriggerSize()
d.SetBounds(row, col, tw, th)
mainView := zm.Mark("my-dropdown", d.TriggerView())
return d.ViewWithOverlay(mainView, width, height)

In your Update, forward all messages:

d, cmd = d.Update(msg)

On ItemChosenMsg, call d.SetSelectedIndex(msg.Index).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	Options      []string
	InitialIndex int
	Placeholder  string
	MaxVisible   int

	// Styling. Each style has a corresponding Custom* flag so New can tell an
	// intentionally-empty style apart from "not set".
	TriggerStyle lipgloss.Style
	ListStyle    lipgloss.Style
	ItemStyle    lipgloss.Style
	CursorStyle  lipgloss.Style
	AccentColor  string

	CustomTriggerStyle bool
	CustomListStyle    bool
	CustomItemStyle    bool
	CustomCursorStyle  bool
}

Config holds static options for a Dropdown built with New.

type Dropdown struct {
	// contains filtered or unexported fields
}

Dropdown is a trigger + panel component. Use New to create one; the zero value is not valid. All methods are safe to call on a nil pointer (they are no-ops / return zero values) so partial initialisation is safe.

func New

func New(opts ...Option) *Dropdown

New creates a Dropdown configured with the given options.

func (d *Dropdown) AccentColor() string

AccentColor returns the current accent color used for the panel border, the highlighted item, and the focused trigger arrow. An empty string means the dropdown is neutral (uncolored). Lets consumers detect changes without shadow-tracking the value themselves.

func (d *Dropdown) Focused() bool

Focused returns true when the trigger is marked as focused (highlighted arrow).

func (d *Dropdown) Open() bool

Open returns true when the dropdown panel is currently displayed.

func (d *Dropdown) Selected() string

Selected returns the currently selected option string, or the placeholder if nothing is selected.

func (d *Dropdown) SelectedIndex() int

SelectedIndex returns the zero-based index of the selected option.

func (d *Dropdown) SetAccentColor(color string)

SetAccentColor changes the accent color at runtime. Pass any lipgloss color string (e.g. "62" or "#7D56F4"); an empty string clears the accent and returns the dropdown to its neutral, uncolored appearance. This is the live-theming counterpart to WithAccentColor: consumers with a user-editable theme can recolor the dropdown in place instead of rebuilding it.

The focused trigger arrow re-reads the accent on each render, so it updates automatically. If the panel is currently open it is recolored immediately so the change is visible without waiting for the next open. Custom styles set via WithListStyle / WithItemStyle / WithCursorStyle still take precedence.

func (d *Dropdown) SetBounds(row, col, w, h int)

SetBounds records where the trigger is drawn (0-based row, col) and its display size (w cells wide, h rows tall). Call this every frame before ViewWithOverlay so the panel is positioned correctly.

If w or h is zero, TriggerSize() is used for the missing dimension.

func (d *Dropdown) SetFocused(f bool)

SetFocused marks the trigger as focused or unfocused. When focused, the ▼ arrow is emphasized (bold, plus the accent color when one is set) so keyboard-only users have a visible indicator.

func (d *Dropdown) SetSelectedIndex(i int)

SetSelectedIndex updates which item appears selected. Out-of-range values are clamped. This is typically called after receiving ItemChosenMsg.

func (d *Dropdown) SetZoneManager(zm *zone.Manager)

SetZoneManager sets the bubblezone manager. When set, the host marks the trigger with zm.Mark and the dropdown skips its own coordinate hit-test (trusting the zone was already checked). The host must call zm.Scan on the final view string.

func (d *Dropdown) TriggerSize() (width, height int)

TriggerSize returns the stable display-cell width and height (always 1) of the trigger. Width is computed from the longest option so the trigger does not resize as the selection changes.

Use this when building your layout and when calling SetBounds.

func (d *Dropdown) TriggerView() string

TriggerView renders the closed-state trigger: "[ Label ▼ ]".

Focus is indicated on the ▼ arrow. For the default (unstyled) trigger the arrow is drawn in the accent color so keyboard-only users get a visible affordance. When a custom trigger style is set, the arrow instead keeps the trigger's own color (emphasized with bold) so the glyph always matches the caller's styling rather than clashing with the accent.

func (d *Dropdown) Update(msg tea.Msg) (*Dropdown, tea.Cmd)

Update handles all BubbleTea messages. Forward every tea.Msg here.

  • When the dropdown is open it routes keys and mouse events to the list panel.
  • On ItemChosenMsg / ItemCanceledMsg it closes the dropdown; the host should also handle ItemChosenMsg to call SetSelectedIndex(msg.Index).
  • When closed, a mouse press on the trigger (or zone hit) opens the panel.
func (d *Dropdown) ViewWithOverlay(mainView string, viewWidth, viewHeight int) string

ViewWithOverlay returns the view to display. When the dropdown is open it composites the panel over mainView using bubble-overlay. Call this at the end of your View method; it also caches overlay geometry for Update.

mainView := buildMain()
mainView = d.ViewWithOverlay(mainView, width, height)
return zm.Scan(mainView)

type ItemCanceledMsg

type ItemCanceledMsg struct{}

ItemCanceledMsg is sent when the user dismisses the dropdown without selecting an item (Esc key or click outside the panel).

type ItemChosenMsg

type ItemChosenMsg struct {
	Index int    // zero-based index into the options slice
	Value string // the option string at that index
}

ItemChosenMsg is sent when the user selects an item from the dropdown (Enter key or mouse click on an option). Handle it in your root model; call SetSelectedIndex(msg.Index) on the dropdown to update displayed value.

type Option

type Option func(*Config)

Option configures a Dropdown via New(opts...).

func WithAccentColor added in v0.0.2

func WithAccentColor(color string) Option

WithAccentColor sets the accent color (any lipgloss color string, e.g. "62" or "#7D56F4") used for the panel border, the highlighted item background, and the focused trigger arrow. It is opt-in: without it the dropdown renders neutrally. Styles set via WithListStyle / WithCursorStyle take precedence over the accent color where they overlap.

func WithCursorStyle added in v0.0.2

func WithCursorStyle(s lipgloss.Style) Option

WithCursorStyle overrides the lipgloss style applied to the highlighted (hovered / keyboard-cursor) item row. The default is reverse video, or an inverted accent color when an accent is set via WithAccentColor.

Note: the style should keep symmetric horizontal padding (e.g. Padding(0, 1)) so item rows align with the panel width computation.

func WithInitialIndex

func WithInitialIndex(i int) Option

WithInitialIndex sets which item is shown as selected when the dropdown is first rendered. Out-of-range values are clamped to [0, len(options)-1].

func WithItemStyle added in v0.0.2

func WithItemStyle(s lipgloss.Style) Option

WithItemStyle overrides the lipgloss style applied to non-highlighted item rows in the open panel. The default adds one cell of horizontal padding.

Note: the style should keep symmetric horizontal padding (e.g. Padding(0, 1)) so item rows align with the panel width computation.

func WithListStyle

func WithListStyle(s lipgloss.Style) Option

WithListStyle overrides the lipgloss style applied to the open dropdown panel border. The default is a neutral rounded border (the accent color is applied only when set via WithAccentColor).

func WithMaxVisible

func WithMaxVisible(n int) Option

WithMaxVisible sets the maximum number of items shown in the open dropdown panel before scrolling kicks in. Defaults to 8.

func WithOptions

func WithOptions(opts []string) Option

WithOptions sets the list of selectable items. An empty slice produces a disabled dropdown that shows only the placeholder.

func WithPlaceholder

func WithPlaceholder(p string) Option

WithPlaceholder sets the text shown when no item is selected or the options slice is empty. Defaults to "Select…".

func WithTriggerStyle

func WithTriggerStyle(s lipgloss.Style) Option

WithTriggerStyle overrides the lipgloss style applied to the trigger row (the closed-state "[ Label ▼ ]" element).

Directories

Path Synopsis
examples
basic command
Basic example: a small form with three dropdowns — fruit, color, and size.
Basic example: a small form with three dropdowns — fruit, color, and size.
styled command
Styled example: two dropdowns with fully customized appearance.
Styled example: two dropdowns with fully customized appearance.
v2basic command
Basic Bubble Tea v2 example: a small form with three dropdowns.
Basic Bubble Tea v2 example: a small form with three dropdowns.
Package dropdownv2 provides Dropdown for Bubble Tea v2 (charm.land/bubbletea/v2).
Package dropdownv2 provides Dropdown for Bubble Tea v2 (charm.land/bubbletea/v2).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL