Monet
@etched/monet 0.0.1

Autocomplete

Ported

A text field that suggests values as you type. The value is the text: a suggestion fills it in, and any other text is a value too. Use it for searches and free answers, such as a name, a link or a company. When the value must be one of the options, use Combobox, which makes the user pick. Holds an AutocompleteInput and an AutocompleteContent; with label, description or error, it is wrapped in a Field.

Kumo source
import { Autocomplete, AutocompleteContent, AutocompleteInput, AutocompleteItem, AutocompleteList } from "@etched/monet/autocomplete";

const companies = ["Etched", "Contoso", "Fabrikam", "Northwind Traders", "Tailspin Toys", "Wide World Importers", "Woodgrove Bank"];

/** Company names, as the field's suggestions. */
function CompanySuggestions() {
  return (
    <AutocompleteContent>
      <AutocompleteList>
        {(company: string) => (
          <AutocompleteItem key={company} value={company}>
            {company}
          </AutocompleteItem>
        )}
      </AutocompleteList>
    </AutocompleteContent>
  );
}

export function Preview() {
  return (
    <Autocomplete items={companies}>
      <AutocompleteInput aria-label="Company" placeholder="Company" />
      <CompanySuggestions />
    </Autocomplete>
  );
}

Installation

import { Autocomplete, AutocompleteCollection, AutocompleteContent, AutocompleteEmpty, AutocompleteGroup, AutocompleteGroupLabel, AutocompleteInput, AutocompleteItem, AutocompleteList, AutocompleteSeparator, AutocompleteStatus } from "@etched/monet/autocomplete";

Examples

Free text

The value is what is typed. A suggestion fills it in, and a name that is not in the list is a value too. When the value must be one of the options, use Combobox: it makes the user pick.

Choose a suggestion, or type any name.

import { useState } from "react";
import { Autocomplete, AutocompleteContent, AutocompleteInput, AutocompleteItem, AutocompleteList } from "@etched/monet/autocomplete";

const companies = ["Etched", "Contoso", "Fabrikam", "Northwind Traders", "Tailspin Toys", "Wide World Importers", "Woodgrove Bank"];

/** Company names, as the field's suggestions. */
function CompanySuggestions() {
  return (
    <AutocompleteContent>
      <AutocompleteList>
        {(company: string) => (
          <AutocompleteItem key={company} value={company}>
            {company}
          </AutocompleteItem>
        )}
      </AutocompleteList>
    </AutocompleteContent>
  );
}

export function FreeText() {
  const [company, setCompany] = useState("");

  return (
    <div className="grid">
      <Autocomplete
        label="Current company"
        description={company ? `The value is “${company}”.` : "Choose a suggestion, or type any name."}
        items={companies}
        value={company}
        onValueChange={setCompany}
      >
        <AutocompleteInput placeholder="Company" />
        <CompanySuggestions />
      </Autocomplete>
    </div>
  );
}

Suggestions with details

Groups, with an icon, a description line and a count on each suggestion, as in Orbit's warm paths search. Enter on a highlighted suggestion fills the field; with none highlighted, Enter submits the form.

import { BuildingsIcon, GraduationCapIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Autocomplete, AutocompleteCollection, AutocompleteContent, AutocompleteEmpty, AutocompleteGroup, AutocompleteGroupLabel, AutocompleteInput, AutocompleteItem, AutocompleteList } from "@etched/monet/autocomplete";

interface Organization {
  value: string;
  label: string;
  description: string;
  people: number;
}

interface OrganizationGroup {
  label: string;
  items: Organization[];
}

const organizations: OrganizationGroup[] = [
  {
    label: "Companies",
    items: [
      { value: "contoso", label: "Contoso", description: "Company · Semiconductors", people: 14 },
      { value: "fabrikam", label: "Fabrikam", description: "Company · Hardware", people: 6 },
      { value: "northwind", label: "Northwind Traders", description: "Company · Logistics", people: 1 },
    ],
  },
  {
    label: "Schools",
    items: [
      { value: "north-state", label: "North State University", description: "School · Not matched on LinkedIn", people: 9 },
      { value: "west-tech", label: "West Tech", description: "School · West Institute of Technology", people: 3 },
    ],
  },
];

export function SuggestionsWithDetails() {
  const [query, setQuery] = useState("");
  const [submitted, setSubmitted] = useState("");

  return (
    <form
      className="row"
      onSubmit={(event) => {
        event.preventDefault();
        setSubmitted(query);
      }}
    >
      <Autocomplete items={organizations} value={query} onValueChange={setQuery}>
        <AutocompleteInput aria-label="Company, school or LinkedIn link" placeholder="Company, school or LinkedIn link" style={{ inlineSize: "20rem" }} />
        <AutocompleteContent>
          <AutocompleteEmpty>No company or school matches.</AutocompleteEmpty>
          <AutocompleteList>
            {(group: OrganizationGroup) => (
              <AutocompleteGroup key={group.label} items={group.items}>
                <AutocompleteGroupLabel>{group.label}</AutocompleteGroupLabel>
                <AutocompleteCollection>
                  {(organization: Organization) => (
                    <AutocompleteItem
                      key={organization.value}
                      value={organization}
                      icon={group.label === "Schools" ? <GraduationCapIcon /> : <BuildingsIcon />}
                      description={organization.description}
                      detail={`${organization.people} ${organization.people === 1 ? "person" : "people"}`}
                    >
                      {organization.label}
                    </AutocompleteItem>
                  )}
                </AutocompleteCollection>
              </AutocompleteGroup>
            )}
          </AutocompleteList>
        </AutocompleteContent>
      </Autocomplete>
      <Button type="submit" variant="primary">
        Find paths
      </Button>
      {submitted && <span>Searched for “{submitted}”</span>}
    </form>
  );
}

Search on a server

filter={null} shows the suggestions as the app gets them. AutocompleteStatus says when it searches, and screen readers hear it.

import { useEffect, useState } from "react";
import { Autocomplete, AutocompleteContent, AutocompleteInput, AutocompleteItem, AutocompleteList, AutocompleteStatus } from "@etched/monet/autocomplete";

const companies = ["Etched", "Contoso", "Fabrikam", "Northwind Traders", "Tailspin Toys", "Wide World Importers", "Woodgrove Bank"];

export function SearchOnAServer() {
  const [query, setQuery] = useState("");
  const [found, setFound] = useState<string[]>([]);
  const [searching, setSearching] = useState(false);

  // A stand-in for a server search: suggestions come 300 ms after the last key.
  useEffect(() => {
    const words = query.trim().toLowerCase();
    setSearching(Boolean(words));
    setFound([]);

    if (!words) return;

    const timer = setTimeout(() => {
      setFound(companies.filter((company) => company.toLowerCase().includes(words)));
      setSearching(false);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div className="grid">
      <Autocomplete label="Company" items={found} filter={null} value={query} onValueChange={setQuery}>
        <AutocompleteInput placeholder="Search companies" />
        <AutocompleteContent>
          <AutocompleteStatus>{searching ? "Searching…" : query.trim() && !found.length ? "No companies match." : null}</AutocompleteStatus>
          <AutocompleteList>
            {(company: string) => (
              <AutocompleteItem key={company} value={company}>
                {company}
              </AutocompleteItem>
            )}
          </AutocompleteList>
        </AutocompleteContent>
      </Autocomplete>
    </div>
  );
}

Sizes and states

Enter the company they left.
import { Autocomplete, AutocompleteContent, AutocompleteInput, AutocompleteItem, AutocompleteList } from "@etched/monet/autocomplete";

const companies = ["Etched", "Contoso", "Fabrikam", "Northwind Traders", "Tailspin Toys", "Wide World Importers", "Woodgrove Bank"];

/** Company names, as the field's suggestions. */
function CompanySuggestions() {
  return (
    <AutocompleteContent>
      <AutocompleteList>
        {(company: string) => (
          <AutocompleteItem key={company} value={company}>
            {company}
          </AutocompleteItem>
        )}
      </AutocompleteList>
    </AutocompleteContent>
  );
}

export function SizesAndStates() {
  return (
    <div className="grid">
      <Autocomplete label="Small" items={companies}>
        <AutocompleteInput size="sm" placeholder="Company" />
        <CompanySuggestions />
      </Autocomplete>
      <Autocomplete label="Read-only" items={companies} defaultValue="Etched" readOnly>
        <AutocompleteInput />
        <CompanySuggestions />
      </Autocomplete>
      <Autocomplete label="Disabled" items={companies} defaultValue="Etched" disabled>
        <AutocompleteInput />
        <CompanySuggestions />
      </Autocomplete>
      <Autocomplete label="Previous company" items={companies} error="Enter the company they left.">
        <AutocompleteInput placeholder="Company" />
        <CompanySuggestions />
      </Autocomplete>
    </div>
  );
}

Accessibility

  • ArrowUp/ArrowDown open the list and move the highlight. Enter fills in the highlighted suggestion; with none highlighted, it submits the form.
  • Escape closes the list and keeps the text. Escape on a closed list clears the text.
  • The field is a combobox, named by label or aria-label. Suggestions are options in a listbox; an AutocompleteGroup is a group named by its label.
  • Focus stays in the field; aria-activedescendant points to the highlight. aria-expanded is false while nothing shows. AutocompleteEmpty and AutocompleteStatus are polite status regions.
  • Without label, give AutocompleteInput an aria-label. For a server search, add an AutocompleteStatus that says "Searching…".

API reference

AutocompleteContent

PropTypeDefaultDescription
classNamestring–
children *ReactNode–AutocompleteList, and AutocompleteEmpty or AutocompleteStatus.
containerBaseAutocomplete.Portal.Props["container"]document.bodyThe element the portal goes into, for example a shadow root.

AutocompleteEmpty

PropTypeDefaultDescription
classNamestring–

AutocompleteGroupLabel

PropTypeDefaultDescription
classNamestring–

AutocompleteGroup

PropTypeDefaultDescription
classNamestring–

AutocompleteInput

PropTypeDefaultDescription
classNamestring–
size"sm" | "base""base"Same heights as Input.

AutocompleteItem

PropTypeDefaultDescription
classNamestring–
children *ReactNode–The suggestion's text.
descriptionReactNode–A second line, such as a kind or a place.
iconReactNode–Before the text, such as a logo or an icon.
detailReactNode–At the end of the row, such as a count.

AutocompleteList

PropTypeDefaultDescription
classNamestring–

Autocomplete

PropTypeDefaultDescription
labelReactNode–Visible label. Without one, give the control an aria-label.
labelTooltipReactNode–More about the field, behind an info button after the label.
labelTooltipLabelstring"More information"Names the info button.
descriptionReactNode–Help text below the control. Hidden while an error shows.
errorFieldError–
itemsreadonly Item[] | readonly { items: readonly Item[] }[]–The suggestions: items, or groups that each have an items array (and anything else, such as a label).

AutocompleteSeparator

PropTypeDefaultDescription
classNamestring–

AutocompleteStatus

PropTypeDefaultDescription
classNamestring–