diff --git a/field_multiselect.go b/field_multiselect.go index 70ac298..10542c4 100644 --- a/field_multiselect.go +++ b/field_multiselect.go @@ -423,7 +423,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.keymap.Toggle) && !m.filtering: for i, option := range m.options.val { if option.Key == m.filteredOptions[m.cursor].Key { - if !m.options.val[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit { + if !m.options.val[i].selected && m.limit > 0 && m.numSelected() >= m.limit { break } selected := m.options.val[i].selected diff --git a/huh_test.go b/huh_test.go index 189622d..2909871 100644 --- a/huh_test.go +++ b/huh_test.go @@ -741,6 +741,47 @@ func TestMultiSelectFiltering(t *testing.T) { }) } +func TestMultiSelectLimitEnforcedWhileFiltered(t *testing.T) { + // Regression test: the toggle limit guard must inspect the option that is + // actually being toggled, not the option sharing the cursor index in the + // unfiltered list. With a filter applied the cursor indexes the filtered + // slice, so the two diverge and the limit could be exceeded. + field := NewMultiSelect[string](). + Options(NewOptions("Red", "Green", "Blue")...). + Title("Colors"). + Limit(1) + f := NewForm(NewGroup(field)) + f.Update(f.Init()) + + // Select "Red" (the first option), reaching the limit of one. + m := batchUpdate(f.Update(keypress('x'))) + + // Filter down to "Blue", which lives at index 2 in the unfiltered list but + // at index 0 in the filtered list. + m = batchUpdate(m.Update(keypress('/'))) + for _, r := range "Blue" { + m = batchUpdate(m.Update(keypress(r))) + } + + // Apply the filter: filtering stops but the filtered list remains in place. + m = batchUpdate(m.Update(codeKeypress(tea.KeyEscape))) + + // Attempt to toggle "Blue". The limit is already reached, so this must be a + // no-op rather than selecting a second option. + batchUpdate(m.Update(keypress('x'))) + + value, ok := field.GetValue().([]string) + if !ok { + t.Fatalf("expected []string value, got %T", field.GetValue()) + } + if len(value) != 1 { + t.Fatalf("limit of 1 exceeded while filtering: got %d selected options %v", len(value), value) + } + if value[0] != "Red" { + t.Errorf("expected the only selected option to be Red, got %q", value[0]) + } +} + func TestSelectFilteringShowsMatchesAboveTheCursor(t *testing.T) { // Bar and Baz sit at opposite ends of the list, so filtering for "ba" // while the cursor is at the bottom leaves Bar above the visible window.