Combobox
A select with a text filter.
<combobox.Combobox id="combo-empty" name="framework" label="Framework" options={[]combobox.Option{
{Value: "gx", Label: "Gx"},
{Value: "templ", Label: "templ"},
{Value: "gomponents", Label: "gomponents"},
{Value: "next", Label: "Next.js"},
{Value: "rails", Label: "Rails", Disabled: true},
{Value: "sveltekit", Label: "SvelteKit"},
}} placeholder="Select a framework" />Installation
Run the command in the app module.
gx add comboboxThe command writes these files.
| File | Path in the app |
|---|---|
Combobox.gx |
ui/combobox/Combobox.gx |
Combobox.fixtures.go |
ui/combobox/Combobox.fixtures.go |
ComboboxInput.ts |
ui/combobox/ComboboxInput.ts |
styles.go |
ui/combobox/styles.go |
Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.
package combobox
props {
// Id is the id of the select. A label points at it.
Id string
// Name is the form field name. The form sends the value of the chosen option.
Name string = ""
// Value is the value of the chosen option of the first render.
Value string = ""
// Options are the options, in order.
Options []Option
// Placeholder is the text of the input with no chosen option.
Placeholder string = "Select an option"
// EmptyText is the text of the list when no option matches.
EmptyText string = "No option found."
// Label is the accessible name of the control. Leave it empty when a label points at Id.
Label string = ""
// Disabled disables the control.
Disabled bool = false
// Class adds classes to the root element.
Class string = ""
// Attrs adds HTML attributes to the select.
Attrs gx.Attrs = nil
}
<div data-slot="combobox" class={gx.Cx(rootClass, p.Class)}>
<select id={p.Id} name={p.Name} aria-label={p.Label} disabled={p.Disabled} class={selectClass} {...p.Attrs}>
<option value="" selected={p.Value == ""}>{p.placeholder()}</option>
for _, o := range p.Options {
<option value={o.Value} selected={o.Value == p.Value} disabled={o.Disabled}>{o.Label}</option>
}
</select>
<ComboboxInput select={p.Id} placeholder={p.placeholder()} emptyText={p.emptyText()} classes={inputClasses} load="eager" />
</div>package combobox
import "github.com/alternayte/gx"
var frameworks = []Option{
{Value: "gx", Label: "Gx"},
{Value: "templ", Label: "templ"},
{Value: "gomponents", Label: "gomponents"},
{Value: "next", Label: "Next.js"},
{Value: "rails", Label: "Rails", Disabled: true},
{Value: "sveltekit", Label: "SvelteKit"},
}
var ComboboxFixtures = gx.Fixtures[ComboboxProps]{
"Empty": {Id: "combo-empty", Name: "framework", Label: "Framework", Options: frameworks, Placeholder: "Select a framework"},
"Chosen": {Id: "combo-chosen", Name: "framework", Label: "Framework", Options: frameworks, Value: "templ"},
"Disabled": {Id: "combo-disabled", Name: "framework", Label: "Framework", Options: frameworks, Value: "gx", Disabled: true},
}import type { Mount } from "./ComboboxInput.props";
// The island turns one native select into a combobox: a text input that
// filters the options, with a list below it. The select stays the form
// control: the island writes the chosen value into it and sends its change
// event. With no script the page shows the select.
const mount: Mount = (root, props, ctx) => {
const select = document.getElementById(props.select);
if (!(select instanceof HTMLSelectElement)) {
throw new Error(`no select with the id ${props.select}`);
}
const c = props.classes;
const hadClass = select.className;
select.className = c.hidden;
select.tabIndex = -1;
select.setAttribute("aria-hidden", "true");
root.className = c.root;
const input = root.appendChild(document.createElement("input"));
input.type = "text";
input.className = c.input;
input.placeholder = props.placeholder;
input.autocomplete = "off";
input.spellcheck = false;
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-expanded", "false");
const listId = props.select + "-list";
input.setAttribute("aria-controls", listId);
const label = select.getAttribute("aria-label");
if (label) input.setAttribute("aria-label", label);
// A label for the select names the combobox too.
const labels = [...(select.labels ?? [])];
if (labels.length > 0) {
labels.forEach((l, i) => (l.id ||= `${props.select}-label-${i}`));
input.setAttribute("aria-labelledby", labels.map((l) => l.id).join(" "));
}
input.disabled = select.disabled;
const list = root.appendChild(document.createElement("ul"));
list.id = listId;
list.className = c.list;
list.hidden = true;
list.setAttribute("role", "listbox");
if (label) list.setAttribute("aria-label", label);
const empty = root.appendChild(document.createElement("div"));
empty.className = c.empty;
empty.hidden = true;
empty.textContent = props.emptyText;
type Option = { value: string; label: string; disabled: boolean };
const options: Option[] = [...select.options]
.filter((o) => o.value !== "")
.map((o) => ({ value: o.value, label: o.label, disabled: o.disabled }));
let open = false;
let shown: Option[] = [];
let active = -1;
const chosen = (): Option | undefined => options.find((o) => o.value === select.value);
const render = (): void => {
list.replaceChildren();
shown.forEach((option, i) => {
const item = list.appendChild(document.createElement("li"));
item.id = `${listId}-${i}`;
item.className = c.option;
item.textContent = option.label;
item.setAttribute("role", "option");
item.setAttribute("data-value", option.value);
item.setAttribute("aria-selected", String(option.value === select.value));
item.setAttribute("data-active", String(i === active));
if (option.disabled) item.setAttribute("aria-disabled", "true");
});
list.hidden = !open || shown.length === 0;
empty.hidden = !open || shown.length > 0;
input.setAttribute("aria-expanded", String(open));
if (open && active >= 0) {
input.setAttribute("aria-activedescendant", `${listId}-${active}`);
list.children[active]?.scrollIntoView({ block: "nearest" });
} else {
input.removeAttribute("aria-activedescendant");
}
};
// filter shows the options whose label holds the text. With no text, or
// with the text of the chosen option, it shows them all.
const filter = (): void => {
const text = input.value.trim().toLowerCase();
const all = text === "" || text === (chosen()?.label ?? "").toLowerCase();
shown = all ? options : options.filter((o) => o.label.toLowerCase().includes(text));
};
const show = (): void => {
if (open) return;
open = true;
filter();
active = shown.findIndex((o) => o.value === select.value);
render();
};
const hide = (): void => {
if (!open) return;
open = false;
active = -1;
// Text that names no option goes back to the chosen one.
input.value = chosen()?.label ?? "";
render();
};
const choose = (option: Option): void => {
if (option.disabled) return;
select.value = option.value;
input.value = option.label;
open = false;
active = -1;
render();
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
};
// move goes to the next option that is not disabled, in one direction.
const move = (from: number, step: number): void => {
for (let i = from; i >= 0 && i < shown.length; i += step) {
if (!shown[i].disabled) {
active = i;
break;
}
}
render();
};
const listen = { signal: ctx.abort };
input.addEventListener(
"input",
() => {
open = true;
filter();
active = shown.findIndex((o) => !o.disabled);
render();
},
listen,
);
input.addEventListener("click", show, listen);
input.addEventListener(
"keydown",
(event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (!open) show();
move(active + 1, 1);
break;
case "ArrowUp":
event.preventDefault();
if (!open) show();
move(active < 0 ? shown.length - 1 : active - 1, -1);
break;
case "Home":
if (!open) return;
event.preventDefault();
move(0, 1);
break;
case "End":
if (!open) return;
event.preventDefault();
move(shown.length - 1, -1);
break;
case "Enter":
if (!open || active < 0) return;
event.preventDefault();
choose(shown[active]);
break;
case "Escape":
if (!open) return;
event.preventDefault();
hide();
break;
}
},
listen,
);
// The pointer goes down on an option before the input loses the focus.
list.addEventListener("mousedown", (event) => event.preventDefault(), listen);
list.addEventListener(
"click",
(event) => {
const item = (event.target as Element).closest("[role=option]");
const option = item ? shown[[...list.children].indexOf(item)] : undefined;
if (option) choose(option);
},
listen,
);
input.addEventListener("blur", hide, listen);
// A reset of the form, or code of the page, can change the select.
select.addEventListener(
"change",
() => {
if (!open) input.value = chosen()?.label ?? "";
},
listen,
);
select.form?.addEventListener("reset", () => setTimeout(() => (input.value = chosen()?.label ?? "")), listen);
input.value = chosen()?.label ?? "";
render();
return () => {
select.className = hadClass;
select.removeAttribute("tabindex");
select.removeAttribute("aria-hidden");
root.replaceChildren();
root.className = "";
};
};
export default mount;// Package combobox is a select with a text filter: a native select that
// the ComboboxInput island shows as an input with a list of options.
package combobox
// Option is one option of a combobox.
type Option struct {
// Value is the value that the form sends.
Value string
// Label is the text of the option.
Label string
// Disabled takes the option out of the choice.
Disabled bool
}
// ComboboxInputProps are the props of the island that draws the input and
// the list. The island reads the options of the select with the id Select.
type ComboboxInputProps struct {
// Select is the id of the native select.
Select string `json:"select"`
// Placeholder is the text of the empty input.
Placeholder string `json:"placeholder"`
// EmptyText is the text of the list with no match.
EmptyText string `json:"emptyText"`
// Classes holds the classes of the parts. They are Go constants, so
// the stylesheet build sees them.
Classes InputClasses `json:"classes"`
}
// InputClasses are the classes of the parts that the island draws.
type InputClasses struct {
Root string `json:"root"`
Input string `json:"input"`
List string `json:"list"`
Option string `json:"option"`
Empty string `json:"empty"`
Hidden string `json:"hidden"`
}
// rootClass is the classes of the root. The list is placed below it.
const rootClass = "relative inline-block w-64"
// selectClass is the classes of the select with no script.
const selectClass = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30"
// inputClasses are the classes that the island gives to its parts.
var inputClasses = InputClasses{
Root: "block",
Input: "h-9 w-full rounded-md border border-input bg-transparent px-3 text-base shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
List: "absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
Option: "cursor-default rounded-sm px-2 py-1.5 text-sm select-none data-[active=true]:bg-accent data-[active=true]:text-accent-foreground aria-selected:font-medium aria-disabled:pointer-events-none aria-disabled:opacity-50",
Empty: "absolute top-full left-0 z-50 mt-1 w-full rounded-md border border-border bg-popover px-2 py-6 text-center text-sm text-popover-foreground shadow-md",
// The island keeps the select as the form control and takes it out of
// view.
Hidden: "sr-only",
}
// placeholder returns the text of the empty input; a zero value is
// "Select an option".
func (p ComboboxProps) placeholder() string {
if p.Placeholder == "" {
return "Select an option"
}
return p.Placeholder
}
// emptyText returns the text of the list with no match; a zero value is
// "No option found.".
func (p ComboboxProps) emptyText() string {
if p.EmptyText == "" {
return "No option found."
}
return p.EmptyText
}The theme must define these tokens: --input, --ring, --border, --popover, --popover-foreground, --accent, --accent-foreground, --muted-foreground.
Usage
<label for="framework">Framework</label>
<combobox.Combobox id="framework" name="framework" options={p.Frameworks} value={p.Framework} />
<combobox.Combobox id="city" name="city" options={p.Cities} label="City" placeholder="Select a city" />The component is a native <select> and an island that shows it as a text input with a list.
The island writes the chosen value into the select, so a form sends it.
With no script the page shows the select of the browser.
The filter finds each option whose label holds the typed text.
Examples
Empty
<combobox.Combobox id="combo-empty" name="framework" label="Framework" options={[]combobox.Option{
{Value: "gx", Label: "Gx"},
{Value: "templ", Label: "templ"},
{Value: "gomponents", Label: "gomponents"},
{Value: "next", Label: "Next.js"},
{Value: "rails", Label: "Rails", Disabled: true},
{Value: "sveltekit", Label: "SvelteKit"},
}} placeholder="Select a framework" />Chosen
<combobox.Combobox id="combo-chosen" name="framework" label="Framework" options={[]combobox.Option{
{Value: "gx", Label: "Gx"},
{Value: "templ", Label: "templ"},
{Value: "gomponents", Label: "gomponents"},
{Value: "next", Label: "Next.js"},
{Value: "rails", Label: "Rails", Disabled: true},
{Value: "sveltekit", Label: "SvelteKit"},
}} value="templ" />Disabled
<combobox.Combobox id="combo-disabled" name="framework" label="Framework" options={[]combobox.Option{
{Value: "gx", Label: "Gx"},
{Value: "templ", Label: "templ"},
{Value: "gomponents", Label: "gomponents"},
{Value: "next", Label: "Next.js"},
{Value: "rails", Label: "Rails", Disabled: true},
{Value: "sveltekit", Label: "SvelteKit"},
}} value="gx" disabled />API reference
A tag sets a prop by its name with a lower-case first letter: Class is class.
combobox.Combobox
| Prop | Type | Default | Description |
|---|---|---|---|
Id |
string |
Required | Id is the id of the select. A label points at it. |
Name |
string |
"" |
Name is the form field name. The form sends the value of the chosen option. |
Value |
string |
"" |
Value is the value of the chosen option of the first render. |
Options |
[]Option |
Required | Options are the options, in order. |
Placeholder |
string |
"Select an option" |
Placeholder is the text of the input with no chosen option. |
EmptyText |
string |
"No option found." |
EmptyText is the text of the list when no option matches. |
Label |
string |
"" |
Label is the accessible name of the control. Leave it empty when a label points at Id. |
Disabled |
bool |
false |
Disabled disables the control. |
Class |
string |
"" |
Class adds classes to the root element. |
Attrs |
gx.Attrs |
nil |
Attrs adds HTML attributes to the select. |
Do and do not
Do
- Give the combobox a
<label>for itsId, or aLabel. - Use it for a list of more than about seven options. Use a select for a short list.
- Check the value on the server. The browser can send any text.
Don't
- Do not use it for free text. Use an input.
- Do not use two comboboxes with one
Id.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the input. |
| Type | Opens the list and filters the options. |
| Arrow Down, Arrow Up | Opens the list and moves to the next or previous option. |
| Home, End | Moves to the first or last option. |
| Enter | Chooses the active option and closes the list. |
| Escape | Closes the list and keeps the chosen option. |