Monet
@etched/monet 0.0.1

Toolbar

Ported

Controls joined into one card, such as a filter bar above a table or text formatting. The toolbar is one Tab stop; arrow keys move between its controls and wrap around. Give it an aria-label. Put in ToolbarButtons, a ToolbarInput, Toggles and ToggleGroups, and menu triggers (<DropdownMenuTrigger render={<ToolbarButton>Status</ToolbarButton>} />). For two or three buttons that act as one, use ButtonGroup.

Kumo source
import { FunnelSimpleIcon, GearSixIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import { Toolbar, ToolbarButton, ToolbarInput } from "@etched/monet/toolbar";

export function Preview() {
  return (
    <Toolbar aria-label="Runs" style={{ inlineSize: "100%", maxInlineSize: "24rem" }}>
      <ToolbarInput icon={<MagnifyingGlassIcon />} aria-label="Search runs" placeholder="Search runs" />
      <ToolbarButton shape="square" aria-label="Filter" icon={<FunnelSimpleIcon />} />
      <ToolbarButton shape="square" aria-label="Settings" icon={<GearSixIcon />} />
    </Toolbar>
  );
}

Installation

import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from "@etched/monet/toolbar";

Examples

Filter bar above a table

A search field that takes the free width, two menus, a toggle, and after a separator a button that clears the filters. The toolbar is one Tab stop: arrow keys move between its controls. A disabled button stays in that order.

RunStatusOwner
Thermal soak 1284PassedSam Lee
Power cycle 1285FailedKai Ito
Memory stress 1286RunningSam Lee
import { ArchiveIcon, CaretDownIcon, FunnelSimpleIcon, MagnifyingGlassIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@etched/monet/dropdown";
import { Pill, type PillTone } from "@etched/monet/pill";
import { Table } from "@etched/monet/table";
import { Toggle } from "@etched/monet/toggle";
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from "@etched/monet/toolbar";

interface Run {
  id: string;
  name: string;
  status: "Passed" | "Failed" | "Running";
  owner: string;
  archived: boolean;
}

const runs: Run[] = [
  { id: "1284", name: "Thermal soak", status: "Passed", owner: "Sam Lee", archived: false },
  { id: "1285", name: "Power cycle", status: "Failed", owner: "Kai Ito", archived: false },
  { id: "1286", name: "Memory stress", status: "Running", owner: "Sam Lee", archived: false },
  { id: "1251", name: "Fan curve", status: "Passed", owner: "Ana Ruiz", archived: true },
];

const tones = new Map<Run["status"], PillTone>([
  ["Passed", "green"],
  ["Failed", "red"],
  ["Running", "blue"],
]);

const owners = ["Anyone", "Sam Lee", "Kai Ito", "Ana Ruiz"];

export function FilterBarAboveATable() {
  const [query, setQuery] = useState("");
  const [statuses, setStatuses] = useState<Run["status"][]>([]);
  const [owner, setOwner] = useState("Anyone");
  const [archived, setArchived] = useState(false);

  const shown = runs.filter(
    (run) =>
      run.name.toLowerCase().includes(query.trim().toLowerCase()) &&
      (!statuses.length || statuses.includes(run.status)) &&
      (owner === "Anyone" || run.owner === owner) &&
      (archived || !run.archived),
  );

  const filtered = Boolean(query || statuses.length || owner !== "Anyone" || archived);

  const clear = () => {
    setQuery("");
    setStatuses([]);
    setOwner("Anyone");
    setArchived(false);
  };

  return (
    <div style={{ display: "grid", gap: "var(--et-space-3)" }}>
      <Toolbar aria-label="Filter runs">
        <ToolbarInput icon={<MagnifyingGlassIcon />} aria-label="Search runs" placeholder="Search runs" value={query} onChange={(event) => setQuery(event.target.value)} />
        <DropdownMenu>
          <DropdownMenuTrigger
            render={
              <ToolbarButton icon={<FunnelSimpleIcon />}>
                {statuses.length ? `Status: ${statuses.length}` : "Status"}
                <CaretDownIcon />
              </ToolbarButton>
            }
          />
          <DropdownMenuContent align="start">
            {[...tones.keys()].map((status) => (
              <DropdownMenuCheckboxItem
                key={status}
                checked={statuses.includes(status)}
                onCheckedChange={(on) => setStatuses((current) => (on ? [...current, status] : current.filter((s) => s !== status)))}
              >
                {status}
              </DropdownMenuCheckboxItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
        <DropdownMenu>
          <DropdownMenuTrigger
            render={
              <ToolbarButton icon={<UserCircleIcon />}>
                {owner === "Anyone" ? "Owner" : owner}
                <CaretDownIcon />
              </ToolbarButton>
            }
          />
          <DropdownMenuContent align="start">
            <DropdownMenuRadioGroup value={owner} onValueChange={setOwner}>
              {owners.map((name) => (
                <DropdownMenuRadioItem key={name} value={name}>
                  {name}
                </DropdownMenuRadioItem>
              ))}
            </DropdownMenuRadioGroup>
          </DropdownMenuContent>
        </DropdownMenu>
        <Toggle icon={<ArchiveIcon />} pressed={archived} onPressedChange={setArchived}>
          Archived
        </Toggle>
        <ToolbarSeparator />
        <ToolbarButton disabled={!filtered} onClick={clear}>
          Clear
        </ToolbarButton>
      </Toolbar>
      <div className="card">
        <Table aria-label="Runs">
          <thead>
            <tr>
              <th style={{ inlineSize: "40%" }}>Run</th>
              <th>Status</th>
              <th>Owner</th>
            </tr>
          </thead>
          <tbody>
            {shown.map((run) => (
              <tr key={run.id}>
                <th scope="row">
                  {run.name} {run.id}
                </th>
                <td>
                  <Pill tone={tones.get(run.status)}>{run.status}</Pill>
                </td>
                <td>{run.owner}</td>
              </tr>
            ))}
            {!shown.length && (
              <tr>
                <td colSpan={3}>No runs match these filters.</td>
              </tr>
            )}
          </tbody>
        </Table>
      </div>
    </div>
  );
}

Formatting

Toggle groups and a button, split into groups by separators. Arrow keys move through all of them.

import { LinkSimpleIcon, TextAlignCenterIcon, TextAlignLeftIcon, TextAlignRightIcon, TextBIcon, TextItalicIcon, TextUnderlineIcon } from "@phosphor-icons/react";
import { ToggleGroup } from "@etched/monet/toggle-group";
import { Toggle } from "@etched/monet/toggle";
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@etched/monet/toolbar";

export function Formatting() {
  return (
    <Toolbar aria-label="Formatting">
      <ToggleGroup multiple aria-label="Text style" defaultValue={["bold"]}>
        <Toggle value="bold" shape="square" aria-label="Bold" icon={<TextBIcon />} />
        <Toggle value="italic" shape="square" aria-label="Italic" icon={<TextItalicIcon />} />
        <Toggle value="underline" shape="square" aria-label="Underline" icon={<TextUnderlineIcon />} />
      </ToggleGroup>
      <ToolbarSeparator />
      <ToggleGroup aria-label="Text alignment" defaultValue={["left"]}>
        <Toggle value="left" shape="square" aria-label="Align left" icon={<TextAlignLeftIcon />} />
        <Toggle value="center" shape="square" aria-label="Align center" icon={<TextAlignCenterIcon />} />
        <Toggle value="right" shape="square" aria-label="Align right" icon={<TextAlignRightIcon />} />
      </ToggleGroup>
      <ToolbarSeparator />
      <ToolbarButton icon={<LinkSimpleIcon />}>Link</ToolbarButton>
    </Toolbar>
  );
}

Small

size="sm" makes every control 28 px high, for a toolbar inside a table card or a panel.

import { DownloadSimpleIcon, GearSixIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import { Toolbar, ToolbarButton, ToolbarInput } from "@etched/monet/toolbar";

export function Small() {
  return (
    <Toolbar size="sm" aria-label="Run actions" style={{ maxInlineSize: "24rem" }}>
      <ToolbarInput icon={<MagnifyingGlassIcon />} aria-label="Filter rows" placeholder="Filter rows" />
      <ToolbarButton icon={<DownloadSimpleIcon />}>Export</ToolbarButton>
      <ToolbarButton shape="square" aria-label="Settings" icon={<GearSixIcon />} />
    </Toolbar>
  );
}

Accessibility

  • The toolbar is one Tab stop, and Shift+Tab comes back to the last control used. ArrowLeft/ArrowRight move between its controls and wrap.
  • In a ToolbarInput, ArrowLeft/ArrowRight move the caret, and go on to the next control at the ends of the text.
  • A toolbar named by its aria-label. A ToggleGroup in it is a named group, and ToolbarSeparator is a vertical separator.
  • A disabled ToolbarButton stays in the arrow-key order with aria-disabled, so it can show a tooltip. focusableWhenDisabled={false} takes it out.
  • Give the toolbar and its ToolbarInput an aria-label. An icon-only ToolbarButton needs one too; the types require it.

API reference

Toolbar

PropTypeDefaultDescription
size"sm" | "base""base"Height of every control in it.
classNamestring–Class for layout, such as width.

ToolbarButton

PropTypeDefaultDescription
iconReactNode–Icon element before the label, for example <FunnelIcon />.
loadingboolean–Shows a spinner in place of the icon and disables the button, as on Button.
classNamestring–
shape"base" | "square""base"As wide as it is high.
aria-labelstring–
aria-labelledbystring–

ToolbarInput

PropTypeDefaultDescription
iconReactNode–Icon before the text, for example a search glass. A press on it focuses the field.
classNamestring–Class on the field's frame, for layout such as a width. The field takes the toolbar's free width.

ToolbarSeparator

PropTypeDefaultDescription
classNamestring–