fix(multiselect): enforce selection limit while filtering

The toggle handler's limit guard indexed the unfiltered options slice
with the cursor, but the cursor tracks the filtered slice. When a filter
was applied the two indices diverged, so the guard inspected the wrong
option's selected state and allowed toggling past the configured Limit.

Index the option that is actually being toggled instead, and add a
regression test that selects up to the limit, filters, then verifies a
further toggle is a no-op.
This commit is contained in:
dualfroz committed 2026-09-02 10:59:11 +02:00
1 parent bb7e934525
commit ba0822d4e9
2 files changed
+42 -1

No files matched your search

+1 -1
View File
@@ -423,7 +423,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
case key.Matches(msg, m.keymap.Toggle) && !m.filtering: case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
for i, option := range m.options.val { for i, option := range m.options.val {
if option.Key == m.filteredOptions[m.cursor].Key { 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 break
} }
selected := m.options.val[i].selected selected := m.options.val[i].selected
+41
View File
@@ -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) { func TestSelectFilteringShowsMatchesAboveTheCursor(t *testing.T) {
// Bar and Baz sit at opposite ends of the list, so filtering for "ba" // 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. // while the cursor is at the bottom leaves Bar above the visible window.