API
Toolbar
Buttons, toggles, and inputs in a row, composed from primitives.
Installation
pnpm dlx zazz-ui add toolbarnpx zazz-ui add toolbaryarn dlx zazz-ui add toolbarbunx zazz-ui add toolbarAlso 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.