Skip to content

Quill docs

Headless components (qquill-ui)

qquill-ui is the headless layer of Quill. A headless component is a pure state machine plus an attribute contract — no styling, no client JavaScript, no DOM. Each primitive hands you a State value, pure transitions that take a state and return a brand-new state (they never mutate observably and are mostly const), and an attrs() method that emits the exact ARIA and data-* attributes for the current state. You stamp those onto a qquill_view::Node to produce static server-rendered HTML. Accessibility lives here once, so the styled layer above never has to re-derive it. The crate has zero third-party dependencies: just std and the sibling qquill-view crate.

What "headless" means#

A qquill-ui component is four things working together: (1) a State value such as Button, Tabs, or Dialog; (2) pure transitions — methods like button.press(), tabs.select(2), or dialog.close() that consume a state and return a new one; (3) an attrs() that produces the correct ARIA / data-* attributes for the current state; and (4) a bridge, Component::apply_attrs / Attrs::apply_to, that stamps those attributes onto a Node for static SSR HTML.

  • No styling, no JS runtime, no DOM — the crate renders only static HTML server-side.
  • Transitions are pure and #[must_use]: the input state is left untouched; the result is the new state. Most are const fn.
  • Attribute order is deterministic (insertion order), so rendered HTML is reproducible.
  • Accessibility is centralized here; the styled layer (qquill-design) inherits it via apply_attrs and only adds presentation.

The Component trait#

Every headless primitive implements the Component trait. data_q_ui() returns the component's stable data-q-ui token — the single machine-readable hook the design layer and the future client runtime dispatch on (e.g. "button", "tabs", "menu"). attrs() returns the attribute set for the component's root node in its current state. apply_attrs() is the default-implemented bridge that applies attrs() onto a Node.

rust
pub trait Component {
    /// The component's stable `data-q-ui` token.
    fn data_q_ui(&self) -> &'static str;
    /// The attribute set for this component's *root* node in its current state.
    fn attrs(&self) -> Attrs;

    /// Apply `attrs()` onto a node — the headless -> Node bridge.
    fn apply_attrs(&self, node: Node) -> Node {
        self.attrs().apply_to(node)
    }
}

Multi-part primitives (tabs, disclosure, dialog, menu, tooltip, …) implement attrs() for their root part and additionally expose per-part attribute methods such as tab_attrs, panel_attrs, region_attrs, trigger_attrs, and item_attrs. attrs() always describes the root part.

The attribute contract: Attrs, aria, data#

Attrs is an ordered accumulator of (&'static str, AttrVal) pairs — the same pair shape qquill_view::Node stores, so applying them is a cheap move-append. Order is insertion order, which keeps rendering deterministic. You build an Attrs by chaining pushers; each returns Self.

str(name, value)
Push a string-valued attribute (e.g. id, aria-controls).
boolean(name, present)
Push a bare HTML boolean attribute (disabled, hidden, required, checked-form): present when true, omitted when false. Renders as disabled, not disabled="true".
tri(name, value)
Push a tri-state ARIA attribute: emits name="true" / name="false" when present, omits it entirely when Tri::Absent.
opt(name, value)
Push an attribute only when value is Some — used for caller-supplied wiring like aria-labelledby.
apply_to(node)
Append the accumulated pairs onto a Node. Only Node::Element carries attributes; on any other node this is a no-op.
pairs() / get(name)
Inspect the accumulated attributes (mainly for tests).

Attribute names are not loose string literals scattered through components. Every ARIA name a component can emit is a &'static str constant in the aria module, and every structural / data-* name is a constant in the data module. This keeps the attribute universe closed, grep-able, and deduped to single static slots.

ModuleConstants
ariaPRESSED, EXPANDED, CONTROLS, SELECTED, DISABLED, CHECKED, HIDDEN, MODAL, HASPOPUP, LABELLEDBY, DESCRIBEDBY, INVALID, REQUIRED, ORIENTATION
dataQ_UI (data-q-ui), STATE (data-state), PART (data-q-part), ROLE (role), TYPE (type), ID (id), TABINDEX (tabindex), HIDDEN (hidden), DISABLED (disabled)

Tri-state: true / false / absent#

Several ARIA states are meaningfully tri-state: emitting aria-pressed="false" is semantically different from omitting the attribute (the latter says "not a toggle"). Tri models exactly this with True, False, and Absent. Tri::as_str() is a const-evaluable match -> &'static str the compiler folds (no runtime string formatting), and Tri::Absent has no wire string — Attrs::tri() checks presence and simply omits absent attributes.

rust
pub enum Tri { True, False, Absent }

impl Tri {
    pub const fn as_str(self) -> &'static str {
        match self {
            Tri::True => "true",
            Tri::False => "false",
            Tri::Absent => "",
        }
    }
    pub const fn is_present(self) -> bool { !matches!(self, Tri::Absent) }
    pub const fn from_bool(b: bool) -> Tri { if b { Tri::True } else { Tri::False } }
}

Not every state is binary. The checkbox uses its own three-valued token because ARIA's checked has a third value, "mixed" (indeterminate), that is not a boolean. Checked::aria_str() lowers to "false" / "true" / "mixed" through its own const table rather than reusing Tri.

Dispatch is const tables, not an if-chain#

Rather than a 30-branch if/match ladder keyed on attribute identity, qquill-ui models the ARIA/data surface as const lookup tables. Each attribute name is an interned &'static str constant; the tri-state values lower through a const match -> &'static str; and a component's attrs() is just straight-line code that pushes the fixed set of cells it owns. The set of cells per component is fixed (a const-sized contract), so there is no runtime branch over an open attribute universe. The only allocations on the path are genuinely dynamic values — caller-supplied ids and aria-controls references.

Worked example: Button (single-part)#

Button covers both a plain action button and a toggle button in one type, so the design layer has a single type to wrap. A plain button has aria-pressed absent; a toggle tracks On/Off via Pressed. Button::action() is a non-toggle; Button::toggle(on) starts a toggle in a known state. press() is a pure transition that flips a toggle (and is a no-op while disabled or non-toggle). The data-state token is "on"/"off" for a toggle and "idle" for a plain button.

Request

use qquill_ui::{Button, Component};
use qquill_view::{el, render, Node};

let b = Button::toggle(true);          // a toggle button, currently ON
let node = b.apply_attrs(el("button").child(Node::Text("Save".into())));
let html = render(&node);

Response

// b.attrs() pushes, in insertion order:
//   data-q-ui   = "button"
//   data-state  = "on"
//   aria-pressed = "true"   (Pressed::On -> Tri::True)
//   (aria-disabled omitted; disabled omitted — not disabled)

Output

<button data-q-ui="button" data-state="on" aria-pressed="true">Save</button>

Contrast the off and plain cases. Button::toggle(false).attrs() emits aria-pressed="false" and data-state="off". Button::action().attrs() emits data-state="idle" and no aria-pressed at all — proving the tri-state Absent path. press() is pure: an Off button's .press() returns a new On state while the original stays Off; a disabled toggle's .press() returns the same state unchanged.

Request

let b = Button::action().disabled(true);
let html = render(&b.apply_attrs(el("button")));

Response

// disabled -> aria-disabled="true" (tri) AND a bare `disabled` (boolean)

Output

<button ... aria-disabled="true" disabled>...</button>
// the bare `disabled` has no value (not disabled="true")

Worked example: Tabs (multi-part)#

Tabs is the canonical multi-part component: one tablist (root), N tab triggers, N panels. Its state is just "which index is selected" plus an id_base used to derive deterministic ids and wiring. Tabs::new(id_base, count, selected) clamps count to at least 1 and selected into range. Orientation (Horizontal default / Vertical) drives aria-orientation. The pure transitions select(i) (clamped), next() and prev() (both wrapping) return new states; the original is untouched.

attrs() (root tablist)
data-q-ui="tabs", data-q-part="tablist", role="tablist", aria-orientation=<orientation>.
tab_attrs(i)
role="tab", derived id {base}-tab-{i}, aria-selected true/false, aria-controls {base}-panel-{i}, and roving tabindex (0 for the selected tab, -1 for the rest).
panel_attrs(i)
role="tabpanel", id {base}-panel-{i}, aria-labelledby {base}-tab-{i}; non-selected panels get bare hidden + aria-hidden="true"; the selected one is visible.

Request

use qquill_ui::{Tabs, Component};
use qquill_view::el;

let t = Tabs::new("nav", 3, 1);   // 3 tabs, the second selected
let list   = t.apply_attrs(el("div"));        // root tablist
let tab1   = t.tab_attrs(1).apply_to(el("button"));
let panel1 = t.panel_attrs(1).apply_to(el("div"));
let panel0 = t.panel_attrs(0).apply_to(el("div"));

Response

// t.tab_attrs(1) (the SELECTED tab) yields:
//   data-q-ui="tabs" data-q-part="tab" role="tab"
//   id="nav-tab-1" data-state="active"
//   aria-selected="true" aria-controls="nav-panel-1" tabindex="0"
//
// t.tab_attrs(0) (UNSELECTED): aria-selected="false", tabindex="-1"
//
// t.panel_attrs(1) (selected, visible):
//   role="tabpanel" id="nav-panel-1" aria-labelledby="nav-tab-1"  (no hidden)
// t.panel_attrs(0) (hidden): adds bare `hidden` and aria-hidden="true"

Output

<div data-q-ui="tabs" data-q-part="tablist" role="tablist" aria-orientation="horizontal"></div>
<button data-q-ui="tabs" data-q-part="tab" role="tab" id="nav-tab-1" data-state="active" aria-selected="true" aria-controls="nav-panel-1" tabindex="0"></button>

Navigation wraps and stays pure. For Tabs::new("nav", 3, 2): next() -> selected 0 (wrap forward), prev() -> selected 1, and Tabs::new("n", 3, 0).prev() -> selected 2 (wrap back). select(99) clamps to 2. In every case the source state's selected is unchanged.

Patterns across components#

Once you know Button and Tabs, the rest follow the same shape. A few recurring contracts:

ComponentKey transitionsNotable attrs
Button / Togglepress() / flip()Button uses aria-pressed; Toggle uses role="switch" + aria-checked (never aria-pressed)
Checkboxtoggle() (Mixed -> Checked first click; disabled no-op)role="checkbox", aria-checked tri-state true/false/mixed
Fieldvalidate(bool), reset_validity()type, aria-invalid (true only when invalid, else absent), aria-required, aria-labelledby, aria-describedby
Disclosure / Accordiontoggle() (disabled no-op)aria-expanded, aria-controls, region id + hidden when closed
Dialogshow(), close()role="dialog", aria-modal true/false, hidden when closed; trigger_attrs gives aria-haspopup="dialog" + aria-controls
Menuopen(), close(), highlight_next()/prev()trigger aria-haspopup="menu"+aria-expanded; surface role="menu"; item_attrs(i, disabled) gives role="menuitem" + roving tabindex
Tooltipshow(), hide()trigger aria-describedby; bubble role="tooltip" + id, hidden until shown

The full headless set re-exported from qquill_ui includes: Accordion, Alert, Avatar, Badge, Breadcrumb, Button, Card, Checkbox, Combobox, CommandPalette, Dialog, Disclosure, Divider, Drawer, Field, List, Menu, Navbar, NumberInput, PageNav, Pagination, Popover, Progress, Radio/RadioGroup, Select, Skeleton, Slider, Spinner, Stat, Stepper, SwitchGroup, Table, Tabs, Textarea, Toast/ToastRegion, Toggle, and Tooltip.