Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Toolbar

Buttons, toggles, and inputs in a row, composed from primitives.

Installation

pnpm dlx zazz-ui add toolbar
npx zazz-ui add toolbar
yarn dlx zazz-ui add toolbar
bunx zazz-ui add toolbar

Also adds separator, fields, kbd, button, button-group, toggle, toggle-group, popover, input, select, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples



<div
  aria-label="Formatting options"
  style="
    --display: flex;
    --flex-wrap: wrap;
    --items: center;
    --p: 2;
    --gap: 2;
    --border-color: var(--color-border);
    --border-width: 1px;
    --rounded: var(--radius-lg);
  "
>
  <ui-button-group role="group" aria-label="History">
    <button data-ui="button" data-button-variant="outline" type="button">Undo</button>
    <button data-ui="button" data-button-variant="outline" type="button">Redo</button>
  </ui-button-group>

  <hr
    data-ui="separator"
    data-separator-orientation="vertical"
    aria-orientation="vertical"
    style="--mx: 2"
  />

  <ui-toggle-group role="group" aria-label="Text formatting">
    <label data-ui="toggle" data-toggle-size="icon">
      <input type="checkbox" name="format" value="bold" aria-label="Bold" checked />
      <span aria-hidden="true" style="--font-weight: strong">B</span>
    </label>
    <label data-ui="toggle" data-toggle-size="icon">
      <input type="checkbox" name="format" value="italic" aria-label="Italic" />
      <span aria-hidden="true" style="--font-style: italic">I</span>
    </label>
    <label data-ui="toggle" data-toggle-size="icon">
      <input type="checkbox" name="format" value="underline" aria-label="Underline" />
      <u aria-hidden="true">U</u>
    </label>
  </ui-toggle-group>

  <hr
    data-ui="separator"
    data-separator-orientation="vertical"
    aria-orientation="vertical"
    style="--mx: 2"
  />

  <select data-ui="select" name="paragraph-style" aria-label="Paragraph style" style="--w: auto">
    <button>
      <selectedcontent></selectedcontent>
    </button>
    <option value="paragraph" selected>Paragraph</option>
    <option value="heading">Heading</option>
    <option value="quote">Quote</option>
  </select>

  <input
    data-ui="input"
    type="search"
    name="find"
    aria-label="Find in document"
    placeholder="Find"
    style="
      --ml: auto;
      --max-w: 16ch;
    "
  />
</div>
// primitives/select/multiselect.js
"use strict";
/**
 * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown.
 * @description Light-DOM custom element that progressively enhances a real
 * `<select multiple data-ui="select">`. `appearance: base-select` does not
 * apply to multi-selects in stable engines, so the script hides the
 * select (which remains the form value carrier and source of truth) and
 * stamps a `[data-ui~="select"]`-look trigger button plus an anchored popover of
 * checkbox rows projected from the options. Checkbox changes write back to
 * `option.selected` and re-dispatch `change` on the select; external writes
 * and form resets flow the other way. Without JavaScript the native
 * multi-select listbox renders: fully functional and accessible.
 *
 * The trigger reads `<first selection> (+N more)`.
 *
 * Attributes on `<ui-multiselect>`:
 * - `data-multiselect-placeholder`: trigger text when nothing is selected.
 * - `data-multiselect-label-more`: overflow template, default `"(+{n} more)"`.
 * - `data-multiselect-side` / `data-multiselect-align`: forwarded to the stamped panel as `data-popover-*` (popover
 *   placement matrix).
 *
 * Stamped parts: `multiselect-trigger` (a `[data-ui~="select"]`-identity button),
 * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`),
 * `multiselect-option` (label + checkbox per option).
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
import { effect, state } from "../../base/signals.js";
/**
 * @description Formats the trigger label from the selected option labels.
 *
 * @param labels - Labels of the selected options, in DOM order.
 * @param placeholder - Text for an empty selection.
 * @param moreTemplate - Overflow template; `{n}` is the remaining count.
 * @returns The trigger text.
 */
function resolveTriggerLabel(labels, placeholder, moreTemplate) {
    if (labels.length === 0)
        return placeholder;
    if (labels.length === 1)
        return labels[0];
    return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;
}
let multiselectIdCounter = 0;
class UiMultiselect extends ZazzElement {
    #stamped = [];
    #select = null;
    setup(signal) {
        const select = this.querySelector("select[multiple]");
        if (!(select instanceof HTMLSelectElement))
            return;
        this.#select = select;
        const placeholder = this.getAttribute("data-multiselect-placeholder") ?? "Select…";
        const moreTemplate = this.getAttribute("data-multiselect-label-more") ?? "(+{n} more)";
        const { trigger, label, panel, checkboxes } = this.#stamp(select);
        select.setAttribute("data-multiselect-state", "enhanced");
        const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label);
        const selection = state(selectedLabels());
        // Checkbox → option: the select stays the source of truth
        checkboxes.forEach((checkbox, index) => {
            checkbox.addEventListener("change", () => {
                const option = select.options[index];
                if (option)
                    option.selected = checkbox.checked;
                selection.set(selectedLabels());
                select.dispatchEvent(new Event("change", { bubbles: true }));
            }, { signal });
        });
        // External writes and form resets → re-read (reset applies after the event)
        select.addEventListener("change", () => selection.set(selectedLabels()), { signal });
        select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal });
        // Mirror popover open state onto the trigger's aria-expanded
        panel.addEventListener("toggle", (event) => {
            trigger.setAttribute("aria-expanded", String(event.newState === "open"));
        }, { signal });
        // Output adapter: trigger label + row checked states together
        effect(() => {
            selection.get();
            label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate);
            checkboxes.forEach((checkbox, index) => {
                const option = select.options[index];
                if (option && checkbox.checked !== option.selected)
                    checkbox.checked = option.selected;
            });
        }, { signal });
    }
    teardown() {
        for (const node of this.#stamped)
            node.remove();
        this.#stamped = [];
        this.#select?.removeAttribute("data-multiselect-state");
        this.#select = null;
    }
    /**
     * @description Builds the trigger button and checkbox panel from the
     * select's options and inserts them after it.
     *
     * @param select - The enhanced select.
     * @returns The stamped parts and the checkbox list (option order).
     * @private
     */
    #stamp(select) {
        const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`;
        const trigger = document.createElement("button");
        trigger.type = "button";
        trigger.dataset.ui = "select";
        trigger.setAttribute("data-multiselect-slot", "trigger");
        trigger.setAttribute("popovertarget", panelId);
        trigger.setAttribute("aria-expanded", "false");
        const label = document.createElement("span");
        label.setAttribute("data-multiselect-slot", "label");
        const icon = document.createElement("span");
        icon.setAttribute("data-multiselect-slot", "icon");
        icon.setAttribute("aria-hidden", "true");
        trigger.append(label, icon);
        const panel = document.createElement("div");
        panel.id = panelId;
        panel.setAttribute("data-multiselect-slot", "panel");
        panel.setAttribute("popover", "auto");
        const side = this.getAttribute("data-multiselect-side");
        const align = this.getAttribute("data-multiselect-align");
        if (side)
            panel.setAttribute("data-popover-side", side);
        if (align)
            panel.setAttribute("data-popover-align", align);
        const checkboxes = [];
        for (const [index, option] of Array.from(select.options).entries()) {
            const row = document.createElement("label");
            row.setAttribute("data-multiselect-slot", "option");
            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            // an id, not a name: the select submits, and Chrome flags a field with neither
            checkbox.id = `${panelId}-${index}`;
            checkbox.checked = option.selected;
            checkbox.disabled = option.disabled;
            row.append(checkbox, document.createTextNode(option.label));
            panel.append(row);
            checkboxes.push(checkbox);
        }
        select.after(trigger, panel);
        this.#stamped = [trigger, panel];
        return { trigger, label, panel, checkboxes };
    }
}
defineZazzElement("ui-multiselect", UiMultiselect);
export { UiMultiselect, resolveTriggerLabel };

Attributes

This primitive is a composition of other primitives and utilities: it has no presets, slots, or hooks of its own. Copy its example and adapt it.

Requires: button, button-group, input, select, separator, toggle, toggle-group.