mirror of
https://github.com/charmbracelet/bubbles.git
synced 2026-07-20 18:49:28 -06:00
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:
parent
40d145e1ea
commit
45af81d4c6
BIN
dropdown/demo.gif
Normal file
BIN
dropdown/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
47
dropdown/demo.tape
Normal file
47
dropdown/demo.tape
Normal 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
460
dropdown/dropdown.go
Normal file
@ -0,0 +1,460 @@
|
||||
// Package dropdown provides a single-choice dropdown component for Bubble Tea
|
||||
// applications. It follows the same Model–Update–View 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
394
dropdown/dropdown_test.go
Normal 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
150
examples/dropdown/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user