Andrew P
Posted on May 2, 2024
Is it possible to create a reusable and customizable multi-select form control with only a tiny JS library and without any additional JS code?
The answer is 'yes' :)
There are some cool posts about how to create an autocomplete input field with htmx, but what if we want something a bit more complex?
So in this post we're going to create such a control to select users with htmx, go (back-end) and bootstrap (styles and icons only). It's assumed you're already familiar with this stack, so there won't be any long intro about project initialization or something here.
The full source code is available here.
What we need to implement:
- Searching for users
- Selecting a user
- Displaying already selected users
- Removing a user from the selection
Before we start
For our back-end written in go we don't use any external dependencies - only standard packages:
-
html/template
for generating html output -
net/http
to handle requests
The control
type Control struct {
Name string // Field name
SearchURL string // Where to search
UpdateURL string // Where to get the updated control
Placeholder string // Search <input> placeholder
SelectedKeys []string // Current value
}
// <input> value to submit
func (c *Control) StringValue() string {
return strings.Join(c.SelectedKeys, ",")
}
The starter template:
<div id="{{.Name}}" class="musel w-50">
<input
type="hidden"
name="{{.Name}}"
value="{{.StringValue}}" />
<!-- ... -->
</div>
Searching
Let's extend the control's template with additional text input, loading indicator and search results:
<div id="{{.Name}}" ...>
...
<input
type="text"
name="{{.Name}}-query"
class="mt-2 form-control"
autocomplete="off"
hx-include="[name='{{.Name}}']"
hx-post="{{.SearchURL}}"
hx-target="#{{.Name}} .dropdown"
hx-trigger="input changed delay:500ms, search"
hx-indicator="#{{.Name}} .htmx-indicator"
placeholder="{{.Placeholder}}" />
<!-- Search results: -->
<div class="dropdown"></div>
<!-- Loading indicator: -->
<div class="htmx-indicator dropdown">
<div class="dropdown-menu show p-1">
<div class="spinner-border"></div>
</div>
</div>
</div>
So htmx handles this input value changes with hx-trigger
, sends a POST
request to hx-post
(url), shows hx-indicator
(css selector) and updates hx-target
(css selector) with the request's response.
The POST
request handler won't only get the search query (from <input name="{{.Name}}-query">
), but also the current value (from <input name="{{.Name}}">
), so you can filter out already selected search results.
To display the results we need to introduce two new structures:
type Option struct {
Key string
Title string
}
type Options struct {
SearchQuery string // Incoming string query
ControlName string // Control.Name
SelectURL string // Where to get the updated control
List []Option // Search results
EmptyText string // If no search results
}
... and new html template:
{{if .SearchQuery}}
<div class="dropdown-menu show">
{{range .List}}
<a
href="#"
hx-post="{{$.SelectURL}}"
hx-target="#{{$.ControlName}}"
hx-swap="outerHTML"
hx-include="[name='{{$.ControlName}}']"
hx-vals='{ "action": "select", "key": "{{.Key}}" }'
class="dropdown-item"
>{{.Title}}</a>
{{else}}
{{if .EmptyText}}<div class="p-2">{{.EmptyText}}</div>{{end}}
{{end}}
</div>
{{end}}
We'll dive into this in the next section.
The search handler is very simple, so there's no need to show its code here. It just parses the incoming request, searches for users, filter out already selected ones and render the template above.
Selecting and Deselecting
Well, the dropdown with search results is shown and now it's time to select values.
The idea is to send a request to hx-post
on every click. This request should return the updated control html, which will replace the current one (note hx-target
points to the control itself).
Every request is populated with a new value with hx-vals
and the current value with hx-include
.
To display the selection let's edit the control's template again:
<div id="{{.Name}}" ...>
{{range .SelectedKeys}}
<button
type="button"
class="mb-1 btn btn-dark"
hx-post="{{$.UpdateURL}}"
hx-target="#{{$.Name}}"
hx-swap="outerHTML"
hx-include="[name='{{$.Name}}']"
hx-vals='{ "action": "remove", "key": "{{.}}" }'
>{{.}} <i class="bi bi-trash"></i></button>
{{end}}
...
</div>
The same technique is used to remove an entry from the selection (note the "action": "remove"
in hx-vals
).
So here's a simplified version of the back-end:
func newUsersControl(keys []string) *Control {
return &Control{
Name: "users",
SearchURL: "/user-search",
UpdateURL: "/users-control",
SelectedKeys: keys,
Placeholder: "Search users...",
}
}
http.HandleFunc("/", func(wr http.ResponseWriter, req *http.Request) {
path := req.URL.String()
if path == "/" {
var keys []string
if req.Method == http.MethodPost {
req.ParseForm()
s := req.Form.Get("users")
keys = ControlSelectedKeysFromString(s)
}
uc := newUsersControl(keys)
renderIndexPageWithControl(wr, ...)
} else if strings.HasPrefix(path, "/user-search") {
req.ParseForm()
sq := strings.ToLower(req.Form.Get("users-query"))
selected := req.Form.Get("users")
opts := Options{
SearchQuery: sq,
ControlName: "users",
SelectURL: "/users-control",
EmptyText: "No results",
}
results := search(...)
renderSearchResults(wr, ...)
} else if path == "/users-control" && req.Method == http.MethodPost {
req.ParseForm()
form := req.Form
uc := newUsersControl(
ControlSelectedKeysFromString(form.Get("users")),
)
action := form.Get("action")
if action == "select" {
key := form.Get("key")
uc.SelectedKeys = append(uc.SelectedKeys, key)
} else if action == "remove" {
key := form.Get("key")
uc.RemoveKey(key)
}
renderControl(wr, ...)
} else {
http.NotFound(wr, req)
}
})
The ControlSelectedKeysFromString(s string)
function converts a comma-separated string to an array of strings with trimmed spaces.
The Control.RemoveKey(k string)
method finds and removes the provided key from the currently selected ones.
The full control's template:
<div id="{{.Name}}" class="musel w-50">
<input
type="hidden"
name="{{.Name}}"
value="{{.StringValue}}" />
{{range .SelectedKeys}}
<button
type="button"
class="mb-1 btn btn-dark"
hx-post="{{$.UpdateURL}}"
hx-target="#{{$.Name}}"
hx-swap="outerHTML"
hx-include="[name='{{$.Name}}']"
hx-vals='{ "action": "remove", "key": "{{.}}" }'
>{{.}} <i class="bi bi-trash"></i></button>
{{end}}
<input
type="text"
name="{{.Name}}-query"
class="mt-2 form-control"
autocomplete="off"
hx-include="[name='{{.Name}}']"
hx-post="{{.SearchURL}}"
hx-target="#{{.Name}} .dropdown"
hx-trigger="input changed delay:500ms, search"
hx-indicator="#{{.Name}} .htmx-indicator"
placeholder="{{.Placeholder}}" />
<div class="dropdown"></div>
<div class="htmx-indicator dropdown">
<div class="dropdown-menu show p-1">
<div class="spinner-border"></div>
</div>
</div>
</div>
Conclusion
Just imagine you write it in JavaScript :)
Thanks for reading!
You can check out the full source code here.
Posted on May 2, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.