package webui import ( "strconv" "strings" "testing" "kjol/vdom" ) // fakeEvent is a vdom.Event with no DOM behind it — enough to drive Floating's // close handlers natively. Target() is nil, which the outside-click test reads (quite // correctly) as "this click was not inside the panel". type fakeEvent struct{ key string } func (fakeEvent) PreventDefault() {} func (fakeEvent) StopPropagation() {} func (fakeEvent) Value() string { return "" } func (fakeEvent) Checked() bool { return false } func (e fakeEvent) Key() string { return e.key } func (fakeEvent) ClientX() int { return 0 } func (fakeEvent) ClientY() int { return 0 } func (fakeEvent) Target() any { return nil } func (fakeEvent) SetData(_, _ string) {} func (fakeEvent) GetData(string) string { return "" } var _ vdom.Event = fakeEvent{} func dropdownOptions() []FormSelectOption { return []FormSelectOption{ {Value: "eng", Label: "Engineering"}, {Value: "res", Label: "Research"}, {Value: "net", Label: "Networking"}, } } // ---- closing ---- // The previous port took Open/OnToggle from the caller and had NO outside-click and // NO Escape: once open, a dropdown stayed open until you clicked its trigger again. // Both now come from Floating. func TestMultiSelectClosesOnOutsideClick(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) m.Open() if !m.IsOpen() { t.Fatal("did not open") } m.f.onOutside(fakeEvent{}) // a mousedown that landed outside the panel if m.IsOpen() { t.Error("a multi-select must close on an outside click") } } func TestMultiSelectClosesOnEscape(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) m.Open() m.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE}) if m.IsOpen() { t.Error("a multi-select must close on Escape") } } func TestComboboxClosesOnOutsideClickAndEscape(t *testing.T) { c := NewCombobox(DropdownOptions{}) c.Open() c.f.onOutside(fakeEvent{}) if c.IsOpen() { t.Error("a combobox must close on an outside click") } c.Open() c.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE}) if c.IsOpen() { t.Error("a combobox must close on Escape") } } // Another key must NOT close it — otherwise typing in the search box would dismiss // the thing you are searching. func TestDropdownIgnoresOtherKeys(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) m.Open() m.f.onKeydown(fakeEvent{key: "a"}) if !m.IsOpen() { t.Error("an ordinary keypress closed the dropdown") } } // ---- where they differ, deliberately ---- // A combobox picks ONE thing, so choosing is the end of the interaction: it closes. func TestComboboxClosesWhenAnOptionIsChosen(t *testing.T) { c := NewCombobox(DropdownOptions{}) got := "" c.Open() // Render, then invoke the option's click handler the way the DOM would. node := c.Render(FormComboboxProps{ Options: dropdownOptions(), OnChange: func(v string) { got = v }, }) clickOption(t, node, "Research") if got != "res" { t.Errorf("OnChange got %q, want \"res\"", got) } if c.IsOpen() { t.Error("a combobox should close once an option is chosen") } } // A multi-select picks SEVERAL, so choosing is NOT the end: closing the panel after // every tick would make it unusable. This is the difference the two have to keep. func TestMultiSelectStaysOpenWhenAnOptionIsChosen(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) var got []string m.Open() node := m.Render(FormMultiSelectProps{ Options: dropdownOptions(), Value: []string{}, OnChange: func(v []string) { got = v }, }) clickOption(t, node, "Research") if len(got) != 1 || got[0] != "res" { t.Errorf("OnChange got %v, want [res]", got) } if !m.IsOpen() { t.Error("a multi-select must STAY OPEN when an option is ticked — you are mid-selection") } } // ---- the search box actually filters ---- // // It used to be decorative: it rendered, and filtered nothing. func TestDropdownSearchFilters(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) m.Open() all := m.filter(dropdownOptions()) if len(all) != 3 { t.Fatalf("an empty query filtered to %d options, want all 3", len(all)) } m.search.Set("res") got := m.filter(dropdownOptions()) if len(got) != 1 || got[0].Value != "res" { t.Errorf("query \"res\" matched %v, want just Research", got) } // Case-insensitive, on the label the user can actually see. m.search.Set("NETWORK") if got := m.filter(dropdownOptions()); len(got) != 1 || got[0].Value != "net" { t.Errorf("query \"NETWORK\" matched %v, want Networking", got) } } // Closing abandons the query — reopening to a stale filter, showing one of three // options for no visible reason, is worse than retyping. func TestClosingClearsTheSearch(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) m.Open() m.search.Set("res") m.Close() if m.search.Get() != "" { t.Errorf("search survived the close: %q", m.search.Get()) } } // These open INSIDE other floatings (a filter popover, the calculated-column form). // Without Standalone the single-open manager reads the dropdown as a rival panel and // closes the very popover it lives in. func TestDropdownsAreStandalone(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) if !m.f.opts.Standalone { t.Error("a dropdown must be Standalone, or opening it closes its own parent popover") } } // ---- the panel rows ---- // A multi-select row shows a CHECKBOX. Its job is to show a state you toggle, not an // item you pick, and the tick is the only thing that says which is which. // // The unified dropdown dropped it: both kinds of row rendered as a bare label, so a // multi-select gave no indication of what was already selected. func TestMultiSelectOptionsHaveCheckboxes(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) m.Open() node := m.Render(FormMultiSelectProps{ Options: dropdownOptions(), Value: []string{"res"}, // Research is selected; the other two are not }) for _, want := range []struct { label string checked bool }{ {"Engineering", false}, {"Research", true}, {"Networking", false}, } { box := findCheckboxIn(optionRow(t, node, want.label)) if box == nil { t.Errorf("option %q has no checkbox", want.label) continue } if got := box.Props["checked"]; got != strconv.FormatBool(want.checked) { t.Errorf("option %q: checked=%q, want %v", want.label, got, want.checked) } } } // ...and a combobox row does NOT. It picks one thing; a checkbox would promise a // multiple selection it will not honour. func TestComboboxOptionsHaveNoCheckboxes(t *testing.T) { c := NewCombobox(DropdownOptions{}) c.Open() node := c.Render(FormComboboxProps{Options: dropdownOptions(), Value: "res"}) if findCheckboxIn(optionRow(t, node, "Research")) != nil { t.Error("a single-select option must not render a checkbox") } } // ---- the collapsed trigger ---- // Past maxTagsBeforeCollapse the field stops showing pills and says how many are // selected. Without this the trigger either grows without bound or silently clips the // selection, and the user cannot tell how much they picked. func TestMultiSelectCollapsesToACount(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) html := vdom.RenderHTML(m.Render(FormMultiSelectProps{ Options: manyOptions(6), Value: []string{"o1", "o2", "o3", "o4"}, // 4 > the default max of 3 MaxTagsBeforeCollapse: 3, })) if !strings.Contains(html, "4 items selected") { t.Errorf("collapsed trigger does not say how many are selected:\n%s", html) } // The count is the SERVER's choice too — this needs no measurement, so SSR must not // ship a row of clipped pills that the client then has to swap out. if !strings.Contains(html, `class="flex flex-nowrap items-center gap-1 min-w-0 overflow-hidden hidden"`) { t.Errorf("the pills were not hidden server-side:\n%s", html) } } // One is one ITEM, not one items. func TestCollapsedCountIsPluralised(t *testing.T) { if got := itemsSelected(1); got != "1 item selected" { t.Errorf("itemsSelected(1) = %q", got) } if got := itemsSelected(4); got != "4 items selected" { t.Errorf("itemsSelected(4) = %q", got) } } // Under the limit, the pills show and the summary is the hidden one. Both are always in // the DOM: which is visible is settled by a measurement after render (collapseFace), // and that measurement needs something to measure. func TestMultiSelectShowsPillsWhenTheyFit(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) html := vdom.RenderHTML(m.Render(FormMultiSelectProps{ Options: dropdownOptions(), Value: []string{"eng", "res"}, MaxTagsBeforeCollapse: 3, })) if !strings.Contains(html, "Engineering") || !strings.Contains(html, "Research") { t.Errorf("the selected pills are missing:\n%s", html) } if !strings.Contains(html, `class="truncate hidden"`) { t.Errorf("the count summary should be the hidden face here:\n%s", html) } // It must be RENDERED though, or there is nothing for collapseFace to reveal. if !strings.Contains(html, "2 items selected") { t.Errorf("the count summary was not rendered at all:\n%s", html) } } // The pills must lay out on ONE line and clip. With flex-wrap they wrap instead, so // scrollWidth never exceeds clientWidth, and the overflow collapse can never fire. func TestPillsAreNowrapSoOverflowIsMeasurable(t *testing.T) { m := NewMultiSelect(DropdownOptions{}) html := vdom.RenderHTML(m.Render(FormMultiSelectProps{ Options: dropdownOptions(), Value: []string{"eng"}, })) if strings.Contains(html, "flex-wrap") { t.Error("the pill row wraps; overflow would never be detected") } if !strings.Contains(html, "flex-nowrap") || !strings.Contains(html, "overflow-hidden") { t.Errorf("the pill row must be flex-nowrap + overflow-hidden:\n%s", html) } } func manyOptions(n int) []FormSelectOption { out := make([]FormSelectOption, n) for i := range out { id := strconv.Itoa(i + 1) out[i] = FormSelectOption{Value: "o" + id, Label: "Option " + id} } return out } // optionRow returns the panel row whose label is label. func optionRow(t *testing.T, n *vdom.VNode, label string) *vdom.VNode { t.Helper() if row := findRow(n, label); row != nil { return row } t.Fatalf("no option row labelled %q", label) return nil } func findRow(n *vdom.VNode, label string) *vdom.VNode { if n == nil { return nil } if n.Tag == "button" && n.Attrs["role"] == "option" && nodeText(n) == label { return n } for _, c := range n.Children { if row := findRow(c, label); row != nil { return row } } return nil } func findCheckboxIn(n *vdom.VNode) *vdom.VNode { if n == nil { return nil } if n.Tag == "input" && n.Attrs["type"] == "checkbox" { return n } for _, c := range n.Children { if box := findCheckboxIn(c); box != nil { return box } } return nil } // clickOption finds an option button by its label and invokes its click handler. func clickOption(t *testing.T, n *vdom.VNode, label string) { t.Helper() if !findAndClick(n, label) { t.Fatalf("no clickable option labelled %q in the rendered dropdown", label) } } func findAndClick(n *vdom.VNode, label string) bool { if n == nil { return false } if n.Tag == "button" && nodeText(n) == label { if h := n.Events[vdom.EVENT_CLICK]; h != nil { h(fakeEvent{}) return true } } for _, c := range n.Children { if findAndClick(c, label) { return true } } return false } func nodeText(n *vdom.VNode) string { if n.Tag == "" { return n.Text } var b strings.Builder for _, c := range n.Children { b.WriteString(nodeText(c)) } return b.String() }