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.
gx add commandThe 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.
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>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"},
}},
}},
}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;// 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
Keywordsfor 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. |