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

@@ -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) --------------------------