HTMX: Multi-select Form Control without JS
Tags: htmx, html, tutorial, go
Published: 2024-05-02 11:25:03

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yomh4xxmu0c6w7jau6k8.png)

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](https://github.com/apleshkov/htmx-multiselect-control).

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

```go
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:
```html
<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:
```html
<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:
```go
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:
```html
{{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:
```html
<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:
```go
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:
```html
<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](https://github.com/apleshkov/htmx-multiselect-control).