rename packages, refactor out tailwind compiler,
This commit is contained in:
@@ -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) --------------------------
|
||||
|
||||
Reference in New Issue
Block a user