HTMX: Multi-select Form Control without JS

apleshkov

Andrew P

Posted on May 2, 2024

HTMX: Multi-select Form Control without JS

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' :)

Demo

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, ",")
}
Enter fullscreen mode Exit fullscreen mode

The starter template:

<div id="{{.Name}}" class="musel w-50">
    <input 
        type="hidden" 
        name="{{.Name}}" 
        value="{{.StringValue}}" />
    <!-- ... -->
</div>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

... 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}}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
    }
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Just imagine you write it in JavaScript :)

Thanks for reading!

You can check out the full source code here.

💖 💪 🙅 🚩
apleshkov
Andrew P

Posted on May 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related