rename packages, refactor out tailwind compiler,

This commit is contained in:
2026-07-13 15:06:09 -04:00
parent c5b14d7c41
commit d52151cc1a
62 changed files with 1446 additions and 460 deletions

View File

@@ -1686,8 +1686,8 @@ type AutoTableState struct {
// thRefs memoizes header-cell refs, so a resize can measure them.
thRefs map[string]*vdom.Ref
// openSignals memoizes per-dropdown open state (see openSignal).
openSignals map[string]*vdom.Signal[bool]
// multiSelects memoizes the dropdown controllers (see multiSelect).
multiSelects map[string]*MultiSelect
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
// what the current filter actually selected.
@@ -2116,34 +2116,33 @@ func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, value
for _, v := range values {
options = append(options, FormSelectOption{Value: v, Label: v})
}
open := s.openSignal(identifier)
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
FormMultiSelect(FormMultiSelectProps{
s.multiSelect(identifier).Render(FormMultiSelectProps{
Options: options,
Value: s.SearchValues(identifier),
Placeholder: pick(placeholder, "Any"),
Searchable: true,
ShowSelectAll: true,
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
}),
)
}
// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to
// be memoized: a signal created fresh inside a render would reset to closed on
// every render, so the dropdown could never stay open.
func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] {
if s.openSignals == nil {
s.openSignals = map[string]*vdom.Signal[bool]{}
// multiSelect memoizes a dropdown controller, keyed by identifier.
//
// It MUST be memoized. A controller built inside a render would get fresh refs and a
// fresh closed state every frame — the dropdown could never stay open, and the
// outside-click listener would be watching an element that no longer exists.
func (s *AutoTableState) multiSelect(key string) *MultiSelect {
if s.multiSelects == nil {
s.multiSelects = map[string]*MultiSelect{}
}
sig, ok := s.openSignals[key]
ms, ok := s.multiSelects[key]
if !ok {
sig = vdom.NewSignal(false)
s.openSignals[key] = sig
ms = NewMultiSelect(DropdownOptions{})
s.multiSelects[key] = ms
}
return sig
return ms
}
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
@@ -2719,17 +2718,13 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
if len(options) == 0 {
return nil
}
open := s.openSignal("__columns__")
return FormMultiSelect(FormMultiSelectProps{
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
Options: options,
Value: selected,
Placeholder: "Columns",
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(visible []string) {
show := map[string]bool{}
for _, k := range visible {
@@ -3266,10 +3261,14 @@ type calcEditor struct {
position *vdom.Signal[int]
errorMsg *vdom.Signal[string]
opOpen *vdom.Signal[bool]
colMenu *Menu
fnMenu *Menu
constMenu *Menu
opSelect *MultiSelect // the Basic tab's operand picker
// The function menu's own search box.
fnSearch *vdom.Signal[string]
fnSearchRef *vdom.Ref
colMenu *Menu
fnMenu *Menu
constMenu *Menu
// The textarea and the highlight overlay behind it. Both are needed: the caret
// insert writes into the textarea, and the overlay's scroll has to follow it.
@@ -3282,27 +3281,40 @@ func (s *AutoTableState) calcEditorState() *calcEditor {
return s.editor
}
e := &calcEditor{
view: vdom.NewSignal("menu"),
editingID: vdom.NewSignal(""),
advanced: vdom.NewSignal(false),
name: vdom.NewSignal(""),
fn: vdom.NewSignal(string(CALC_FN_SUM)),
operands: vdom.NewSignal([]string{}),
formula: vdom.NewSignal(""),
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
precision: vdom.NewSignal(""),
position: vdom.NewSignal(int(COL_POS_RIGHT)),
errorMsg: vdom.NewSignal(""),
opOpen: vdom.NewSignal(false),
formulaRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
view: vdom.NewSignal("menu"),
editingID: vdom.NewSignal(""),
advanced: vdom.NewSignal(false),
name: vdom.NewSignal(""),
fn: vdom.NewSignal(string(CALC_FN_SUM)),
operands: vdom.NewSignal([]string{}),
formula: vdom.NewSignal(""),
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
precision: vdom.NewSignal(""),
position: vdom.NewSignal(int(COL_POS_RIGHT)),
errorMsg: vdom.NewSignal(""),
opSelect: NewMultiSelect(DropdownOptions{}),
fnSearch: vdom.NewSignal(""),
fnSearchRef: vdom.NewRef(),
formulaRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
// Standalone: these menus live INSIDE the editor's popover, and without it the
// single-open manager would read them as a rival panel and close their own
// parent as they opened.
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
}
// Built after e exists, because it closes over it: closing the menu abandons the
// query. Reopening to a stale filter — four of thirty-five functions showing, for
// no visible reason — is worse than retyping.
e.fnMenu = NewMenu(MenuOptions{
Placement: PlacementBottomStart,
Standalone: true,
OnOpenChange: func(open bool) {
if !open {
e.fnSearch.Set("")
}
},
})
e.pop = NewPopover(PopoverProps{
Placement: PlacementBottomEnd,
// Closing the popover abandons the draft — reopening should start clean rather
@@ -3382,12 +3394,124 @@ var calcDataTypes = []struct {
{CALC_TYPE_PLAIN, "Plain"},
}
// FormulaFunctionNames are the functions a formula may call, for the insert menu.
var FormulaFunctionNames = []string{
"SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT",
"ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG",
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH",
"RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW",
// FormulaFunction is one entry in the insert menu: what to type, what it takes, and
// what it does. The signature and the description are not decoration — they are what
// makes a list of thirty-five names usable by someone who does not already know them,
// and they are what the menu's search matches against.
type FormulaFunction struct {
Name string
Sig string
Desc string
}
// FormulaFunctionGroup is a category of functions.
type FormulaFunctionGroup struct {
Label string
Fns []FormulaFunction
}
// FormulaFunctionGroups are the functions a formula may call, grouped the way someone
// looking for one would think about them — by what they are FOR, not alphabetically.
// Someone who wants a total looks under Aggregate; nobody scans an A-to-Z list from
// ABS to TANH hoping to recognise something.
var FormulaFunctionGroups = []FormulaFunctionGroup{
{Label: "Aggregate", Fns: []FormulaFunction{
{"SUM", "SUM(range)", "Total of the values"},
{"AVERAGE", "AVERAGE(range)", "Mean of the values"},
{"MEDIAN", "MEDIAN(range)", "Middle value"},
{"MODE", "MODE(range)", "Most frequent value"},
{"MIN", "MIN(range)", "Smallest value"},
{"MAX", "MAX(range)", "Largest value"},
{"COUNT", "COUNT(range)", "How many numbers"},
}},
{Label: "Math", Fns: []FormulaFunction{
{"ABS", "ABS(n)", "Absolute value"},
{"ROUND", "ROUND(n, digits)", "Round to digits"},
{"FLOOR", "FLOOR(n)", "Round down"},
{"CEILING", "CEILING(n)", "Round up"},
{"SQRT", "SQRT(n)", "Square root"},
{"POWER", "POWER(n, p)", "n to the power p"},
{"MOD", "MOD(n, d)", "Remainder of n ÷ d"},
{"EXP", "EXP(n)", "e to the power n"},
{"LN", "LN(n)", "Natural log (base e)"},
{"LOG", "LOG(n, base)", "Log, base 10 by default"},
}},
{Label: "Trigonometry", Fns: []FormulaFunction{
{"SIN", "SIN(angle)", "Sine (radians)"},
{"COS", "COS(angle)", "Cosine (radians)"},
{"TAN", "TAN(angle)", "Tangent (radians)"},
{"ASIN", "ASIN(n)", "Inverse sine"},
{"ACOS", "ACOS(n)", "Inverse cosine"},
{"ATAN", "ATAN(n)", "Inverse tangent"},
{"ATAN2", "ATAN2(x, y)", "Angle of point (x, y)"},
{"SINH", "SINH(n)", "Hyperbolic sine"},
{"COSH", "COSH(n)", "Hyperbolic cosine"},
{"TANH", "TANH(n)", "Hyperbolic tangent"},
{"PI", "PI()", "π constant"},
{"RADIANS", "RADIANS(deg)", "Degrees → radians"},
{"DEGREES", "DEGREES(rad)", "Radians → degrees"},
}},
{Label: "Logic", Fns: []FormulaFunction{
{"IF", "IF(test, then, else)", "Choose by condition"},
{"AND", "AND(a, b, …)", "True if all are true"},
{"OR", "OR(a, b, …)", "True if any are true"},
{"NOT", "NOT(a)", "Negate"},
}},
{Label: "Row", Fns: []FormulaFunction{
{"ROW", "ROW()", "Current row number"},
}},
}
// FormulaConstant is a named constant, with what it is worth.
type FormulaConstant struct {
Name string
Desc string
}
// FormulaConstantOptions are the constants a formula may write bare, for the insert
// menu. The approximate value is shown because "PHI" tells you nothing and "≈ 1.618"
// tells you everything.
var FormulaConstantOptions = []FormulaConstant{
{"PI", "π ≈ 3.14159"},
{"E", "Euler's number ≈ 2.71828"},
{"TAU", "2π ≈ 6.28319"},
{"PHI", "Golden ratio ≈ 1.61803"},
{"SQRT2", "√2 ≈ 1.41421"},
}
// FormulaFunctionNames flattens the groups — for anything that just needs the names.
func FormulaFunctionNames() []string {
var out []string
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
out = append(out, f.Name)
}
}
return out
}
// filterFormulaGroups narrows the menu by a query, matching name, signature AND
// description — so "total" finds SUM, which is the whole point of carrying the prose
// around. Empty groups drop out rather than leaving a bare heading behind.
func filterFormulaGroups(query string) []FormulaFunctionGroup {
q := strings.TrimSpace(strings.ToLower(query))
if q == "" {
return FormulaFunctionGroups
}
var out []FormulaFunctionGroup
for _, g := range FormulaFunctionGroups {
var fns []FormulaFunction
for _, f := range g.Fns {
hay := strings.ToLower(f.Name + " " + f.Sig + " " + f.Desc)
if strings.Contains(hay, q) {
fns = append(fns, f)
}
}
if len(fns) > 0 {
out = append(out, FormulaFunctionGroup{Label: g.Label, Fns: fns})
}
}
return out
}
// operandOption is one entry in the operand pickers and the column insert menu: the
@@ -3743,14 +3867,12 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
}
children = append(children, calcField("Columns (combined per row)",
FormMultiSelect(FormMultiSelectProps{
e.opSelect.Render(FormMultiSelectProps{
Options: msOpts,
Value: e.operands.Get(),
Placeholder: "Select columns…",
Searchable: true,
Small: true,
Open: e.opOpen.Get(),
OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) },
OnChange: func(v []string) { e.operands.Set(v) },
})))
}
@@ -3796,25 +3918,45 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
)
}
fnItems := make([]*vdom.VNode, 0, len(FormulaFunctionNames))
for _, name := range FormulaFunctionNames {
fname := name
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(fname + "()") },
}, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname))))
// The function menu, grouped by category and searchable. Thirty-five names in one
// flat list is a wall; grouped by what they are FOR, with a signature and a
// sentence, it is something you can actually find SUM in.
fnItems := []*vdom.VNode{e.fnSearchBox()}
groups := filterFormulaGroups(e.fnSearch.Get())
if len(groups) == 0 {
fnItems = append(fnItems, vdom.Div(
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
vdom.Text("No functions match"),
))
}
for _, g := range groups {
fnItems = append(fnItems, MenuSection("", vdom.Text(g.Label)))
for _, fn := range g.Fns {
f := fn
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
OnClick: func() { e.insertFunction(f) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5 min-w-0"),
// The signature is syntax-highlighted with the same highlighter the
// formula box uses, so the menu and the editor speak one language.
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
),
))
}
}
constNames := make([]string, 0, len(FormulaConstants))
for name := range FormulaConstants {
constNames = append(constNames, name)
}
sort.Strings(constNames)
constItems := make([]*vdom.VNode, 0, len(constNames))
for _, name := range constNames {
cname := name
constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions))
for _, c := range FormulaConstantOptions {
cc := c
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(cname) },
}, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname))))
OnClick: func() { e.insertAtCaret(cc.Name) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
),
))
}
menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"),
@@ -3876,6 +4018,44 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
return calcField("Formula", children...)
}
// fnSearchBox filters the function menu. It is sticky, so it stays reachable as the
// list scrolls, and it is focused when the menu opens — you can type "total", see SUM,
// and never touch the mouse.
func (e *calcEditor) fnSearchBox() *vdom.VNode {
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
vdom.Input(
vdom.WithRef(e.fnSearchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
vdom.Attr("placeholder", "Search functions…"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", e.fnSearch.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) { e.fnSearch.Set(ev.Value()) }),
),
)
}
// insertFunction drops a call in and puts the caret INSIDE the parentheses, ready for
// the first argument — SUM() with the cursor after the "(" rather than after the ")",
// which would make you move it back every single time.
//
// A no-argument function (ROW(), PI()) has nothing to type, so the caret goes after it.
func (e *calcEditor) insertFunction(f FormulaFunction) {
noArgs := strings.HasSuffix(f.Sig, "()")
e.insertAtCaretOffset(f.Name+"()", boolToInt(!noArgs))
e.fnSearch.Set("")
e.fnMenu.Close()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this
// fires on every scroll frame, and a signal write would re-render the whole table.
func (e *calcEditor) syncOverlayScroll() {
@@ -3916,7 +4096,11 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
// insertAtCaret drops text where the cursor is, rather than at the end — which is
// the whole point of an insert menu. It needs the caret position and focus back
// afterwards, both of which come from the host API.
func (e *calcEditor) insertAtCaret(text string) {
func (e *calcEditor) insertAtCaret(text string) { e.insertAtCaretOffset(text, 0) }
// insertAtCaretOffset inserts text and leaves the caret `back` characters from its
// end — so a function can land with the cursor between its parentheses.
func (e *calcEditor) insertAtCaretOffset(text string, back int) {
src := e.formula.Get()
start, end := wasmruntime.SelectionRange(e.formulaRef)
if start < 0 || start > len(src) || end < start || end > len(src) {
@@ -3927,7 +4111,7 @@ func (e *calcEditor) insertAtCaret(text string) {
// The textarea's value is written by the render this signal just scheduled, so the
// caret can only be placed once that render has landed.
caret := start + len(text)
caret := start + len(text) - back
wasmruntime.AfterRender(func() {
wasmruntime.Focus(e.formulaRef)
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)

View File

@@ -490,3 +490,115 @@ func TestEditingJumpsToTheRightForm(t *testing.T) {
t.Error("reset did not return the editor to a clean chooser")
}
}
// ---- the function insert menu ----
// Thirty-five function names in one flat list is a wall. Grouped by what they are FOR,
// with a signature and a sentence, it is something you can find SUM in.
func TestFormulaFunctionGroups(t *testing.T) {
want := []string{"Aggregate", "Math", "Trigonometry", "Logic", "Row"}
if len(FormulaFunctionGroups) != len(want) {
t.Fatalf("got %d groups, want %d", len(FormulaFunctionGroups), len(want))
}
for i, label := range want {
if FormulaFunctionGroups[i].Label != label {
t.Errorf("group %d is %q, want %q", i, FormulaFunctionGroups[i].Label, label)
}
}
// Every function carries a signature and a description — they are what the menu
// shows and what its search matches, not decoration.
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
if f.Name == "" || f.Sig == "" || f.Desc == "" {
t.Errorf("%s/%s is missing its signature or description: %+v", g.Label, f.Name, f)
}
if !strings.HasPrefix(f.Sig, f.Name+"(") {
t.Errorf("%s's signature %q does not start with its own name", f.Name, f.Sig)
}
}
}
}
// Every function the menu offers must actually EXIST in the engine — an insert menu
// that offers a function the evaluator rejects is worse than no menu.
func TestEveryOfferedFunctionEvaluates(t *testing.T) {
// A formula per function, using its own arity.
args := map[string]string{
"IF": "IF(1, 2, 3)", "ATAN2": "ATAN2(1, 1)", "POWER": "POWER(2, 3)",
"MOD": "MOD(5, 2)", "LOG": "LOG(100, 10)", "ROUND": "ROUND(1.5, 0)",
"ROW": "ROW()", "PI": "PI()", "AND": "AND(1, 1)", "OR": "OR(1, 0)", "NOT": "NOT(0)",
}
for _, g := range FormulaFunctionGroups {
for _, f := range g.Fns {
src, ok := args[f.Name]
if !ok {
src = f.Name + "(1)"
}
if _, err := CompileFormula(src); err != nil {
t.Errorf("the menu offers %s, but %q does not compile: %v", f.Name, src, err)
}
}
}
}
// And every constant it offers must be one the evaluator knows.
func TestEveryOfferedConstantExists(t *testing.T) {
if len(FormulaConstantOptions) != len(FormulaConstants) {
t.Errorf("the menu offers %d constants, the engine knows %d",
len(FormulaConstantOptions), len(FormulaConstants))
}
for _, c := range FormulaConstantOptions {
if _, ok := FormulaConstants[c.Name]; !ok {
t.Errorf("the menu offers %q, which the engine does not know", c.Name)
}
if c.Desc == "" {
t.Errorf("%q has no description — the name alone says nothing", c.Name)
}
}
}
// The menu's search matches the DESCRIPTION too, which is the whole point of carrying
// the prose around: someone who wants a total types "total", not "SUM".
func TestFormulaMenuSearch(t *testing.T) {
got := filterFormulaGroups("total")
found := false
for _, g := range got {
for _, f := range g.Fns {
if f.Name == "SUM" {
found = true
}
}
}
if !found {
t.Error(`searching "total" did not find SUM — the description is not being matched`)
}
// Empty groups drop out rather than leaving a bare heading behind.
for _, g := range filterFormulaGroups("sine") {
if len(g.Fns) == 0 {
t.Errorf("group %q survived the filter with no functions in it", g.Label)
}
}
// An empty query is everything.
if len(filterFormulaGroups("")) != len(FormulaFunctionGroups) {
t.Error("an empty query should show every group")
}
// A query matching nothing yields nothing (the menu shows its own empty state).
if got := filterFormulaGroups("zzzz"); len(got) != 0 {
t.Errorf("a query matching nothing returned %d groups", len(got))
}
}
func TestFormulaFunctionNamesFlattensTheGroups(t *testing.T) {
names := FormulaFunctionNames()
if len(names) < 30 {
t.Errorf("flattened to %d names, want the full set", len(names))
}
for _, want := range []string{"SUM", "ROUND", "ATAN2", "IF", "ROW"} {
if !contains2(names, want) {
t.Errorf("%s missing from the flattened names", want)
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/kit/Forms.tsx. Reactive accessors collapse to plain values, and
@@ -790,22 +791,234 @@ func FormTimezoneSelector(p FormSelectProps) *vdom.VNode {
return FormSelect(p, opts...)
}
// -- combobox / multi-select (approximated) ------------------------------------
// -- combobox / multi-select ---------------------------------------------------
//
// NOTE: FormCombobox, FormSearchableSelect and FormMultiSelect in the TSX open a
// solid-js Portal, measure the trigger with getBoundingClientRect + a
// requestAnimationFrame position tracker (floating-ui style), focus-trap the
// search box, filter options against the query, track a keyboard-highlighted
// index, and close on document mousedown. None of that has a neutral-runtime
// equivalent, so these ports:
// - take `Open bool` + `OnToggle func()` instead of an internal open signal,
// - render the dropdown inline, positioned with `absolute top-full` utilities
// rather than fixed computed coordinates (no Portal),
// - render ALL options (the search box is decorative — client-side filtering,
// keyboard nav, and highlight tracking are dropped),
// - keep selection fully functional through Value + OnChange.
// Both are built on the Floating controller, which is where their close behaviour
// comes from: an outside mousedown, and Escape closing the topmost panel only. The
// previous port took `Open bool` + `OnToggle func()` from the caller and had NEITHER
// — a dropdown, once open, stayed open until you clicked its trigger again. It also
// rendered its panel in-flow, so it was clipped by any scrolling ancestor, and its
// search box was decorative: it filtered nothing.
//
// # Where a multi-select differs from a combobox, deliberately
//
// A combobox picks ONE thing, so choosing closes it. A multi-select picks SEVERAL, so
// choosing does NOT — you are mid-selection, and closing the panel under you after
// each tick would make it unusable. That is why both are controllers over the same
// Floating but only one of them hides on select.
//
// Everything else they share: the panel is portaled to document.body (so it escapes
// overflow), positioned by measurement, flipped when it will not fit, and closed by an
// outside click or Escape.
// FormComboboxProps configures FormCombobox / FormSearchableSelect.
// dropdown is the shared half of Combobox and MultiSelect.
type dropdown struct {
f *Floating
search *vdom.Signal[string]
searchRef *vdom.Ref
// active is the keyboard-highlighted option, as an index into the FILTERED list.
// -1 means nothing is highlighted.
active *vdom.Signal[int]
}
// DropdownOptions configures NewCombobox / NewMultiSelect.
type DropdownOptions struct {
// Placement defaults to bottom-start. The panel flips above the field when there
// is not room below.
Placement string
OnOpenChange func(bool)
}
func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown {
d := &dropdown{
search: vdom.NewSignal(""),
searchRef: vdom.NewRef(),
active: vdom.NewSignal(-1),
}
d.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: 4,
// A long option list near the bottom of the page scrolls inside its own box
// rather than running off the screen.
ConstrainToViewport: true,
// Standalone: these open INSIDE other floatings (a filter popover, the calc
// editor's form). Without it the single-open manager would read the dropdown as
// a rival panel and close the very popover it belongs to.
Standalone: true,
OnOpenChange: func(open bool) {
// Closing abandons the query. Reopening to a stale filter — showing three of
// twenty options for no visible reason — is worse than retyping.
if !open {
d.search.Set("")
d.active.Set(-1)
}
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
},
})
_ = closeOnSelect
return d
}
// IsOpen / Open / Close / Toggle drive the panel.
func (d *dropdown) IsOpen() bool { return d.f.IsOpen() }
func (d *dropdown) Open() { d.f.Show() }
func (d *dropdown) Close() { d.f.Hide() }
func (d *dropdown) Toggle() { d.f.Toggle() }
// Dispose removes the panel's listeners. Call it if the component owning this
// dropdown goes away while the panel might still be open.
func (d *dropdown) Dispose() { d.f.Dispose() }
// filter narrows the options by the search query — the box actually filters now.
// Matching is case-insensitive on the label, which is what the user can see.
func (d *dropdown) filter(opts []FormSelectOption) []FormSelectOption {
q := strings.TrimSpace(strings.ToLower(d.search.Get()))
if q == "" {
return opts
}
out := make([]FormSelectOption, 0, len(opts))
for _, o := range opts {
if strings.Contains(strings.ToLower(o.Label), q) {
out = append(out, o)
}
}
return out
}
// searchBox is the filter field at the top of the panel. It is focused when the panel
// opens, so a searchable dropdown can be driven entirely from the keyboard.
func (d *dropdown) searchBox(placeholder string, onDark bool, count int) *vdom.VNode {
wrapCls, inputCls := formDropdownSearchWrap, formDropdownSearchInput
if onDark {
wrapCls, inputCls = formDropdownSearchWrapDark, formDropdownSearchInputDark
}
// Focus it once the panel is in the DOM. Scheduled rather than called: the input
// does not exist yet at the moment the signal that opens the panel is written.
wasmruntime.AfterRender(func() { wasmruntime.Focus(d.searchRef) })
return vdom.Div(vdom.Attr("class", wrapCls),
vdom.Input(
vdom.WithRef(d.searchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", inputCls),
vdom.Attr("placeholder", pick(placeholder, "Search...")),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", d.search.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) {
d.search.Set(e.Value())
d.active.Set(-1) // the old highlight indexed a different list
}),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { d.onSearchKey(e, count) }),
),
)
}
// onSearchKey is arrow-key navigation over the filtered list. Escape is NOT handled
// here: Floating already closes the topmost panel on Escape, and handling it twice
// would close a dropdown and the popover around it with one press.
func (d *dropdown) onSearchKey(e vdom.Event, count int) {
switch e.Key() {
case vdom.KEY_ARROW_DOWN:
e.PreventDefault()
if count > 0 {
d.active.Set((d.active.Get() + 1) % count)
}
case vdom.KEY_ARROW_UP:
e.PreventDefault()
if count > 0 {
next := d.active.Get() - 1
if next < 0 {
next = count - 1
}
d.active.Set(next)
}
}
}
// optionButton is one row of the panel.
func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark, small bool, onClick func()) *vdom.VNode {
cls := formDropdownOption
if onDark {
cls = formDropdownOptionDark
}
if small {
cls = cx(cls, "py-1.5 px-2")
}
if idx == d.active.Get() {
cls = cx(cls, "bg-neutral-100")
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cls),
vdom.Attr("role", "option"),
vdom.Attr("aria-selected", strconv.FormatBool(selected)),
}
if opt.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
} else if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
}
return vdom.Button(append(mods, vdom.Text(opt.Label))...)
}
func (d *dropdown) noResults(onDark bool) *vdom.VNode {
cls := formDropdownNoResults
if onDark {
cls = formDropdownNoResultsDark
}
return vdom.Div(vdom.Attr("class", cls), vdom.Text("No options found"))
}
// panel wraps the dropdown's rows in the floating panel. Width is matched to the
// field so the options line up under it.
func (d *dropdown) panel(onDark bool, children ...*vdom.VNode) *vdom.VNode {
cls := formDropdown
if onDark {
cls = formDropdownDark
}
// The exact width is written by matchFieldWidth once the field has been measured;
// min-w-48 is only a floor, so a very narrow field still gets a readable list.
return d.f.Panel(FloatingPanelProps{
Role: "listbox",
Class: cx("min-w-48", cls),
}, children...)
}
// matchFieldWidth sizes the panel to the field it hangs off. Done imperatively, from
// a measurement, because the two are no longer DOM relatives: the panel is portaled to
// document.body, so it cannot simply be `w-full`.
func (d *dropdown) matchFieldWidth() {
wasmruntime.AfterRender(func() {
w := wasmruntime.Measure(d.f.triggerRef).Width
if w > 0 {
wasmruntime.SetStyle(d.f.panelRef, "width", px(w))
}
})
}
// ---- Combobox: pick one ----
// Combobox is a single-select dropdown. Choosing an option closes it.
//
// Create it once, alongside your signals — never inside a render function, which
// would rebuild its refs and open state every frame:
//
// team := webui.NewCombobox(webui.DropdownOptions{})
// …
// team.Render(webui.FormComboboxProps{Options: opts, Value: v.Get(), OnChange: v.Set})
type Combobox struct{ *dropdown }
// NewCombobox creates a single-select dropdown.
func NewCombobox(o DropdownOptions) *Combobox {
return &Combobox{dropdown: newDropdown(o, true)}
}
// FormComboboxProps configures Combobox.Render.
type FormComboboxProps struct {
Options []FormSelectOption
Value string
@@ -819,13 +1032,10 @@ type FormComboboxProps struct {
OnDark bool
MaxDisplayLength int
Disabled bool
// Open/OnToggle replace the internal open signal (see NOTE above).
Open bool
OnToggle func()
}
// FormCombobox is a single-select dropdown styled like a listbox trigger.
func FormCombobox(p FormComboboxProps) *vdom.VNode {
// Render draws the field and its panel.
func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
var selected *FormSelectOption
for i := range p.Options {
if p.Options[i].Value == p.Value {
@@ -840,13 +1050,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
placeholderCls = "text-text-on-dark-muted"
}
if selected != nil {
display = selected.Label
if p.MaxDisplayLength > 0 {
r := []rune(display)
if len(r) > p.MaxDisplayLength {
display = strings.TrimRight(string(r[:p.MaxDisplayLength]), " ") + "…"
}
}
display = truncateRunes(selected.Label, p.MaxDisplayLength)
placeholderCls = ""
}
@@ -855,89 +1059,72 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
chevronCls = "text-text-on-dark-muted"
}
chevron := "chevron-down"
if p.Open {
if c.IsOpen() {
chevron = "chevron-up"
}
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formTriggerCls(p.Small, p.OnDark)),
trigger := c.f.Trigger(FloatingTriggerProps{
Class: formTriggerCls(p.Small, p.OnDark),
AriaHasPopup: "listbox",
},
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
IconInline(chevron, 16, chevronCls),
}
)
if p.Disabled {
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
trigger.Attrs["disabled"] = "disabled"
}
rootMods := []vdom.Mod{
filtered := c.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+1)
if p.Searchable && c.IsOpen() {
rows = append(rows, c.searchBox(p.SearchPlaceholder, p.OnDark, len(filtered)))
}
if len(filtered) == 0 {
rows = append(rows, c.noResults(p.OnDark))
}
for i := range filtered {
opt := filtered[i]
rows = append(rows, c.optionButton(opt, i, opt.Value == p.Value, p.OnDark, p.Small, func() {
if p.OnChange != nil {
p.OnChange(opt.Value)
}
c.Close() // one choice, so we are done
}))
}
if c.IsOpen() {
c.matchFieldWidth()
}
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.Button(triggerMods...),
}
if p.Open {
dropdownCls := formDropdown
if p.OnDark {
dropdownCls = formDropdownDark
}
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", dropdownCls))}
if p.Searchable {
searchWrapCls := formDropdownSearchWrap
searchInputCls := formDropdownSearchInput
if p.OnDark {
searchWrapCls = formDropdownSearchWrapDark
searchInputCls = formDropdownSearchInputDark
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", searchWrapCls),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", searchInputCls),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
))
}
optionCls := formDropdownOption
if p.OnDark {
optionCls = formDropdownOptionDark
}
if p.Small {
optionCls = cx(optionCls, "py-1.5 px-2")
}
if len(p.Options) == 0 {
noResultsCls := formDropdownNoResults
if p.OnDark {
noResultsCls = formDropdownNoResultsDark
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", noResultsCls), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
optMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", optionCls)}
if opt.Disabled {
optMods = append(optMods, vdom.Attr("disabled", "disabled"))
} else if p.OnChange != nil {
oc := p.OnChange
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(ov) }))
}
optMods = append(optMods, vdom.Text(opt.Label))
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.Div(ddMods...))
}
return vdom.Div(rootMods...)
trigger,
c.panel(p.OnDark, rows...),
)
}
// FormSearchableSelect is a FormCombobox with the search box shown.
func FormSearchableSelect(p FormComboboxProps) *vdom.VNode {
p.Searchable = true
return FormCombobox(p)
// NewSearchableSelect is a Combobox with the search box on — the old
// FormSearchableSelect.
func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) }
// ---- MultiSelect: pick several ----
// MultiSelect is a multi-select dropdown with removable tags and an optional
// select-all row.
//
// Unlike a Combobox it does NOT close when an option is chosen: you are mid-selection,
// and closing the panel after each tick would make it useless. It closes on an outside
// click, on Escape, or when its trigger is clicked again.
//
// Create it once, alongside your signals — never inside a render function.
type MultiSelect struct{ *dropdown }
// NewMultiSelect creates a multi-select dropdown.
func NewMultiSelect(o DropdownOptions) *MultiSelect {
return &MultiSelect{dropdown: newDropdown(o, false)}
}
// FormMultiSelectProps configures FormMultiSelect.
// FormMultiSelectProps configures MultiSelect.Render.
type FormMultiSelectProps struct {
Options []FormSelectOption
Value []string
@@ -951,14 +1138,10 @@ type FormMultiSelectProps struct {
Small bool
Class string
FieldWidth string
// Open/OnToggle replace the internal open signal (see NOTE above).
Open bool
OnToggle func()
}
// FormMultiSelect is a multi-select dropdown with removable tags and an optional
// select-all row. Selection is functional through Value + OnChange.
func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
// Render draws the field and its panel.
func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
maxTags := p.MaxTagsBeforeCollapse
if maxTags == 0 {
maxTags = 3
@@ -971,138 +1154,133 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
}
}
// Trigger content: placeholder, tag list, or "N items selected".
var triggerContent *vdom.VNode
// The trigger's face: a placeholder, tags, or "N selected" once there are too many
// to show without the field growing unboundedly.
var face *vdom.VNode
switch {
case len(selected) == 0:
triggerContent = vdom.Span(vdom.Attr("class", "text-neutral-500"), vdom.Text(pick(p.Placeholder, "Select options")))
face = vdom.Span(vdom.Attr("class", "text-neutral-500 truncate"),
vdom.Text(pick(p.Placeholder, "Select options")))
case len(selected) > maxTags:
n := len(selected)
suffix := "s"
if n == 1 {
suffix = ""
}
triggerContent = vdom.Span(vdom.Text(strconv.Itoa(n) + " item" + suffix + " selected"))
face = vdom.Span(vdom.Attr("class", "truncate"),
vdom.Text(strconv.Itoa(len(selected))+" selected"))
default:
tagSize := "py-0.5 px-2 text-sm"
if p.Small {
tagSize = "py-0.5 px-1.5 text-xs"
}
tagCls := cx("inline-flex items-center gap-0.5 bg-neutral-200 rounded-default whitespace-nowrap leading-none", tagSize)
tagsMods := []vdom.Mod{vdom.Attr("class", "flex flex-nowrap gap-1 overflow-hidden items-center")}
tags := []vdom.Mod{vdom.Attr("class", "flex flex-wrap items-center gap-1 min-w-0")}
for _, opt := range selected {
ov := opt.Value
// NOTE: the remove button nests inside the trigger button (as in the TSX);
// the neutral Event has no stopPropagation, so a remove click also toggles
// the dropdown. Harmless given selection is idempotent through OnChange.
rmMods := []vdom.Mod{
remove := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
vdom.Attr("aria-label", "Remove "+opt.Label),
IconInline("xmark", 10, ""),
}
if p.OnChange != nil {
oc := p.OnChange
cur := p.Value
rmMods = append(rmMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formRemoveStr(cur, ov)) }))
oc, cur := p.OnChange, p.Value
remove = append(remove, vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
// The tag sits INSIDE the trigger, so without this the click would
// bubble up and toggle the panel open as it removed the tag.
e.StopPropagation()
oc(formRemoveStr(cur, ov))
}))
}
tag := vdom.Span(vdom.Attr("class", tagCls),
tags = append(tags, vdom.Span(vdom.Attr("class", formMultiSelectTag),
vdom.Text(opt.Label),
vdom.Button(rmMods...),
)
tagsMods = append(tagsMods, tag)
vdom.Button(remove...),
))
}
triggerContent = vdom.Div(tagsMods...)
face = vdom.Div(tags...)
}
pad := "p-2"
if p.Small {
pad = "p-1"
}
triggerCls := cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad)
chevron := "chevron-down"
if p.Open {
if m.IsOpen() {
chevron = "chevron-up"
}
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", triggerCls),
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent),
trigger := m.f.Trigger(FloatingTriggerProps{
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
AriaHasPopup: "listbox",
},
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), face),
IconInline(chevron, 16, "text-neutral-400"),
}
)
if p.Disabled {
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
trigger.Attrs["disabled"] = "disabled"
}
rootMods := []vdom.Mod{
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.Button(triggerMods...),
filtered := m.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+2)
if p.Searchable && m.IsOpen() {
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
}
if p.Open {
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", formDropdown))}
if p.Searchable {
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownSearchWrap),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", formDropdownSearchInput),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
))
// Select-all acts on what is VISIBLE. Selecting all of a filtered list is what the
// user is looking at and asked for; quietly selecting the hidden ones too would be
// a nasty surprise.
if p.ShowSelectAll && len(filtered) > 0 {
all := formAllSelected(filtered, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
if p.ShowSelectAll && len(p.Options) > 0 {
all := formAllSelected(p.Options, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
saMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc := p.OnChange
opts := p.Options
cur := p.Value
allNow := all
saMods = append(saMods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
oc(formDeselectAll(opts, cur))
} else {
oc(formSelectAllValues(opts, cur))
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
next := cur
for _, o := range opts {
next = formRemoveStr(next, o.Value)
}
}))
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formSelectAllWrap), vdom.Button(saMods...)))
oc(next)
return
}
oc(formSelectAllValues(opts, cur))
}))
}
if len(p.Options) == 0 {
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
cbMods := []vdom.Mod{vdom.Attr("type", "checkbox"), vdom.Attr("style", "pointer-events:none")}
if formContainsStr(p.Value, opt.Value) {
cbMods = append(cbMods, vdom.Attr("checked", "checked"))
}
optMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formDropdownOption),
vdom.Input(cbMods...),
vdom.Text(opt.Label),
}
if opt.Disabled {
optMods = append(optMods, vdom.Attr("disabled", "disabled"))
} else if p.OnChange != nil {
oc := p.OnChange
cur := p.Value
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formToggleStr(cur, ov)) }))
}
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.Div(ddMods...))
rows = append(rows, vdom.Button(mods...))
}
return vdom.Div(rootMods...)
if len(filtered) == 0 {
rows = append(rows, m.noResults(false))
}
for i := range filtered {
opt := filtered[i]
on := formContainsStr(p.Value, opt.Value)
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
if p.OnChange != nil {
p.OnChange(formToggleStr(p.Value, opt.Value))
}
// Deliberately NOT closing: this is a MULTI-select, and the user is very
// likely about to tick another one.
}))
}
if m.IsOpen() {
m.matchFieldWidth()
}
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
trigger,
m.panel(false, rows...),
)
}
const formMultiSelectTag = "inline-flex items-center gap-1 rounded-default bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-700 max-w-full"
func truncateRunes(s string, max int) string {
if max <= 0 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return strings.TrimRight(string(r[:max]), " ") + "…"
}
// -- []string selection helpers (for FormMultiSelect) --------------------------

View File

@@ -0,0 +1,221 @@
package webui
import (
"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")
}
}
// 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()
}