Skip to content
Gx
GitHub

Command

A list of commands with a search input.

<command.Command id="command-default" groups={[]command.Group{
  {Heading: "Suggestions", Items: []command.Item{
    {Label: "Calendar", Keywords: "date day"},
    {Label: "Search emoji", Keywords: "smile"},
    {Label: "Calculator", Disabled: true},
  }},
  {Heading: "Settings", Items: []command.Item{
    {Label: "Profile", Shortcut: "⌘P", Href: "#profile"},
    {Label: "Billing", Shortcut: "⌘B", Href: "#billing"},
    {Label: "Settings", Shortcut: "⌘S", Value: "settings"},
  }},
}} />

Installation

Run the command in the app module.

Terminal
gx add command

The command writes these files.

File Path in the app
Command.gx ui/command/Command.gx
Command.fixtures.go ui/command/Command.fixtures.go
CommandInput.ts ui/command/CommandInput.ts
styles.go ui/command/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/command/Command.gx
package command

props {
  // Id is the id of the root element.
  Id          string
  // Label is the accessible name of the search input and of the list.
  Label       string = "Commands"
  // Placeholder is the text of the empty search input.
  Placeholder string = "Type a command or search..."
  // EmptyText is the text that shows when no item matches.
  EmptyText   string = "No results found."
  // Groups are the groups of items, in order.
  Groups      []Group
  // Class adds classes to the root element.
  Class       string = ""
  // Attrs adds HTML attributes to the root element.
  Attrs       gx.Attrs = nil
}

<div id={p.Id} data-slot="command" class={gx.Cx(rootClass, p.Class)} {...p.Attrs}>
  <CommandInput list={p.listID()} empty={p.emptyID()} label={p.label()} placeholder={p.placeholder()} classes={inputClasses} load="eager" />
  <div id={p.listID()} role="listbox" aria-label={p.label()} class={listClass}>
    for gi, g := range p.Groups {
      <div role="group" aria-labelledby={p.headingID(gi)} data-command-group class={groupClass}>
        <div id={p.headingID(gi)} role="presentation" class={headingClass}>{g.Heading}</div>
        for _, it := range g.Items {
          if it.Href != "" {
            <a role="option" aria-selected="false" href={it.Href} data-command-item data-value={it.value()} data-label={it.Label} data-keywords={it.Keywords} aria-disabled={it.disabled()} class={itemClass}>
              <span>{it.Label}</span>
              if it.Shortcut != "" {
                <kbd class={shortcutClass}>{it.Shortcut}</kbd>
              }
            </a>
          } else {
            <button type="button" role="option" aria-selected="false" data-command-item data-value={it.value()} data-label={it.Label} data-keywords={it.Keywords} aria-disabled={it.disabled()} disabled={it.Disabled} class={itemClass}>
              <span>{it.Label}</span>
              if it.Shortcut != "" {
                <kbd class={shortcutClass}>{it.Shortcut}</kbd>
              }
            </button>
          }
        }
      </div>
    }
  </div>
  <p id={p.emptyID()} hidden class={emptyClass}>{p.emptyText()}</p>
</div>
ui/command/Command.fixtures.go
package command

import "github.com/alternayte/gx"

var CommandFixtures = gx.Fixtures[CommandProps]{
	"Default": {Id: "command-default", Groups: []Group{
		{Heading: "Suggestions", Items: []Item{
			{Label: "Calendar", Keywords: "date day"},
			{Label: "Search emoji", Keywords: "smile"},
			{Label: "Calculator", Disabled: true},
		}},
		{Heading: "Settings", Items: []Item{
			{Label: "Profile", Shortcut: "⌘P", Href: "#profile"},
			{Label: "Billing", Shortcut: "⌘B", Href: "#billing"},
			{Label: "Settings", Shortcut: "⌘S", Value: "settings"},
		}},
	}},
	"OneGroup": {Id: "command-one", Label: "Go to", Placeholder: "Search pages...", Groups: []Group{
		{Heading: "Pages", Items: []Item{
			{Label: "Home", Href: "#home"},
			{Label: "About", Href: "#about"},
		}},
	}},
}
ui/command/CommandInput.ts
import type { Mount } from "./CommandInput.props";

// The island adds a search input to a list of commands that the server
// rendered. It filters the items, moves an active item with the arrow keys
// and runs the active item on Enter. With no script the page shows the
// whole list, and each item is a link or a button that works by itself.
const mount: Mount = (root, props, ctx) => {
  const list = document.getElementById(props.list);
  if (!list) throw new Error(`no list with the id ${props.list}`);
  const empty = props.empty ? document.getElementById(props.empty) : null;
  const c = props.classes;

  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", "true");
  input.setAttribute("aria-controls", props.list);
  input.setAttribute("aria-label", props.label);

  const items = [...list.querySelectorAll<HTMLElement>("[data-command-item]")];
  items.forEach((item, i) => {
    item.id ||= `${props.list}-item-${i}`;
    // The input keeps the focus; the items are reached with the arrow keys.
    item.tabIndex = -1;
  });
  const groups = [...list.querySelectorAll<HTMLElement>("[data-command-group]")];
  let active: HTMLElement | null = null;

  const enabled = (item: HTMLElement): boolean => !item.hidden && item.getAttribute("aria-disabled") !== "true";
  const visible = (): HTMLElement[] => items.filter(enabled);

  const setActive = (item: HTMLElement | null): void => {
    active = item;
    for (const other of items) other.setAttribute("data-active", String(other === item));
    if (item) {
      input.setAttribute("aria-activedescendant", item.id);
      item.scrollIntoView({ block: "nearest" });
    } else {
      input.removeAttribute("aria-activedescendant");
    }
  };

  // filter shows each item whose text or keywords hold every word of the
  // input.
  const filter = (): void => {
    const words = input.value.toLowerCase().split(/\s+/).filter(Boolean);
    for (const item of items) {
      const text = `${item.getAttribute("data-label") ?? item.textContent ?? ""} ${item.getAttribute("data-keywords") ?? ""}`.toLowerCase();
      item.hidden = !words.every((word) => text.includes(word));
    }
    for (const group of groups) {
      group.hidden = ![...group.querySelectorAll<HTMLElement>("[data-command-item]")].some((item) => !item.hidden);
    }
    const shown = visible();
    if (empty) empty.hidden = items.some((item) => !item.hidden);
    setActive(shown[0] ?? null);
  };

  const listen = { signal: ctx.abort };
  input.addEventListener("input", filter, listen);
  input.addEventListener(
    "keydown",
    (event) => {
      const shown = visible();
      const at = active ? shown.indexOf(active) : -1;
      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          setActive(shown[Math.min(at + 1, shown.length - 1)] ?? null);
          break;
        case "ArrowUp":
          event.preventDefault();
          setActive(shown[Math.max(at - 1, 0)] ?? null);
          break;
        case "Home":
          event.preventDefault();
          setActive(shown[0] ?? null);
          break;
        case "End":
          event.preventDefault();
          setActive(shown[shown.length - 1] ?? null);
          break;
        case "Enter":
          if (!active) return;
          event.preventDefault();
          // A link goes to its address and a button sends its click.
          active.click();
          break;
      }
    },
    listen,
  );
  list.addEventListener(
    "pointermove",
    (event) => {
      const item = (event.target as Element).closest<HTMLElement>("[data-command-item]");
      if (item && item !== active && enabled(item)) setActive(item);
    },
    listen,
  );
  // An item tells the page that the user ran it. The page can listen on
  // the command element.
  list.addEventListener(
    "click",
    (event) => {
      const item = (event.target as Element).closest<HTMLElement>("[data-command-item]");
      if (!item || !enabled(item)) return;
      item.dispatchEvent(new CustomEvent("command-select", { bubbles: true, detail: { value: item.getAttribute("data-value") ?? "" } }));
    },
    listen,
  );
  filter();

  return () => {
    for (const item of items) {
      item.hidden = false;
      item.removeAttribute("tabindex");
      item.removeAttribute("data-active");
    }
    for (const group of groups) group.hidden = false;
    if (empty) empty.hidden = true;
    root.replaceChildren();
    root.className = "";
  };
};

export default mount;
ui/command/styles.go
// Package command is a list of commands with a search input: the server
// renders the list, and the CommandInput island filters it and runs an item
// from the keyboard.
package command

import (
	"strconv"

	"github.com/alternayte/gx"
)

// Group is one group of items with a heading.
type Group struct {
	// Heading is the text above the items.
	Heading string
	// Items are the items of the group, in order.
	Items []Item
}

// Item is one command.
type Item struct {
	// Label is the text of the item.
	Label string
	// Value is the value of the command-select event. Empty means Label.
	Value string
	// Href makes the item a link to a page. Empty makes it a button.
	Href gx.URL
	// Keywords are more words that the search finds.
	Keywords string
	// Shortcut is the key hint at the end of the item.
	Shortcut string
	// Disabled takes the item out of the list of choices.
	Disabled bool
}

// CommandInputProps are the props of the island that draws the search
// input. The island filters the items of the list with the id List.
type CommandInputProps struct {
	// List is the id of the list of items.
	List string `json:"list"`
	// Empty is the id of the element that shows when no item matches.
	Empty string `json:"empty"`
	// Label is the accessible name of the input.
	Label string `json:"label"`
	// Placeholder is the text of the empty input.
	Placeholder string `json:"placeholder"`
	// 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"`
}

const rootClass = "flex w-full max-w-md flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"

const listClass = "max-h-72 overflow-x-hidden overflow-y-auto p-1"

const groupClass = "overflow-hidden text-foreground"

const headingClass = "px-2 py-1.5 text-xs font-medium text-muted-foreground"

// itemClass is the classes of one item. The island marks the active item
// with data-active; with no script the focus ring marks it.
const itemClass = "flex w-full cursor-default items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50"

const shortcutClass = "ml-auto font-sans text-xs tracking-widest text-muted-foreground"

const emptyClass = "py-6 text-center text-sm"

var inputClasses = InputClasses{
	Root:  "block border-b border-border",
	Input: "h-10 w-full bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground",
}

func (p CommandProps) listID() string  { return p.Id + "-list" }
func (p CommandProps) emptyID() string { return p.Id + "-empty" }

// headingID returns the id of the heading of group i.
func (p CommandProps) headingID(i int) string { return p.Id + "-group-" + strconv.Itoa(i) }

// label returns the accessible name; a zero value is "Commands".
func (p CommandProps) label() string {
	if p.Label == "" {
		return "Commands"
	}
	return p.Label
}

// placeholder returns the text of the empty input.
func (p CommandProps) placeholder() string {
	if p.Placeholder == "" {
		return "Type a command or search..."
	}
	return p.Placeholder
}

// emptyText returns the text for no match.
func (p CommandProps) emptyText() string {
	if p.EmptyText == "" {
		return "No results found."
	}
	return p.EmptyText
}

// value returns the value of the command-select event.
func (it Item) value() string {
	if it.Value == "" {
		return it.Label
	}
	return it.Value
}

// disabled returns the aria-disabled value of an item.
func (it Item) disabled() string {
	if it.Disabled {
		return "true"
	}
	return "false"
}

The theme must define these tokens: --border, --popover, --popover-foreground, --accent, --accent-foreground, --muted-foreground, --foreground.

Usage

<command.Command id="palette" groups={p.Groups} />
<command.Command id="pages" label="Go to" placeholder="Search pages..." groups={p.Pages} />

The server renders the whole list. An item with an Href is a link, and an item with no Href is a button. The island adds the search input. It filters the items by their text and their Keywords. A button item sends the command-select event with its Value when the user runs it. With no script the page shows the list with no search input, and each link works.

Examples

Default

<command.Command id="command-default" groups={[]command.Group{
  {Heading: "Suggestions", Items: []command.Item{
    {Label: "Calendar", Keywords: "date day"},
    {Label: "Search emoji", Keywords: "smile"},
    {Label: "Calculator", Disabled: true},
  }},
  {Heading: "Settings", Items: []command.Item{
    {Label: "Profile", Shortcut: "⌘P", Href: "#profile"},
    {Label: "Billing", Shortcut: "⌘B", Href: "#billing"},
    {Label: "Settings", Shortcut: "⌘S", Value: "settings"},
  }},
}} />

One group

<command.Command id="command-one" label="Go to" placeholder="Search pages..." groups={[]command.Group{
  {Heading: "Pages", Items: []command.Item{
    {Label: "Home", Href: "#home"},
    {Label: "About", Href: "#about"},
  }},
}} />

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

command.Command

Prop Type Default Description
Id string Required Id is the id of the root element.
Label string "Commands" Label is the accessible name of the search input and of the list.
Placeholder string "Type a command or search..." Placeholder is the text of the empty search input.
EmptyText string "No results found." EmptyText is the text that shows when no item matches.
Groups []Group Required Groups are the groups of items, in order.
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Give each group a Heading.
  • Add Keywords for the words that a user types for an item.
  • Put the command in a dialog for a command palette.

Don't

  • Do not use it for the choice of a form value. Use a combobox.
  • Do not put more than about fifty items in one list.

Keyboard

Key Action
Tab Moves focus to the search input.
Type Filters the items.
Arrow Down, Arrow Up Moves to the next or previous item.
Home, End Moves to the first or last item.
Enter Runs the active item: follows the link or sends the event.