Skip to content
Gx
GitHub

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.

Terminal
gx add combobox

The 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.

ui/combobox/Combobox.gx
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>
ui/combobox/Combobox.fixtures.go
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},
}
ui/combobox/ComboboxInput.ts
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;
ui/combobox/styles.go
// 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 its Id, or a Label.
  • 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.