Editor panels

Give a component its own section of the paywall editor's right panel, written in Rust next to the component.

An editor panel is a component's section of the paywall editor's right panel. It says which controls to show and what they change. The editor draws those controls itself, with the same fields, sliders and color pickers its own sections use, so a component's panel looks built in.

A panel is optional. Without one, the editor shows a Props section generated from the component's props: one control per prop, grouped by each prop's group, with variable binding and translations where the prop allows them. Write a panel when that list is not the editor you want, for example to add tabs, show settings only when they apply, or reset several props at once.

A panel replaces only the Props section. The sections the component's traits bring stay, and so do the Component section, with its version and preview states, and Component actions, where events are bound to actions.

Add a panel

A panel lives in the component's crate, usually in src/panel.rs. voidhash-cli new creates one. To add one to an existing crate, give the crate the runtime and panel features first. See One crate, two modules.

Declare the panel with #[panel], naming the component's id:

src/panel.rs
use voidhash_ui::panel::prelude::*;

/// The badge's label and accent.
#[panel(for = "project/badge", title = "Badge", order = 13, multi_selectable)]
pub fn settings(_cx: &mut PanelCx) -> Panel {
    panel().child(
        section("Badge")
            .child(field("Label").child(text_field().placeholder("New").bind(bind::prop("label"))))
            .child(field("Accent").child(color_field().format("hex").bind(bind::prop("accent")))),
    )
}

Then list it after the component, once per crate, in src/lib.rs:

src/lib.rs
mod panel;

// `#[component]` on `fn Badge` generates the `BADGE` constant;
// `#[panel]` on `fn settings` generates `SETTINGS`.
voidhash_ui::export_panels!(BADGE => panel::SETTINGS);

export_panels! makes the component's definition declare an editor panel, so set "panel": true in component.json. A crate can list up to eight panels; the editor shows them in that order.

Panel options

OptionMeaning
forThe component's own id. A panel naming another id does not compile.
titleThe panel's name where the editor refers to it, such as an error note.
idUnique within the crate. Defaults to the function name in camelCase.
tab"design" (the default) or "interaction": the right panel tab it appears on.
orderIts place among the sections of its tab. Props is 13; the default 100 places it last.
multi_selectableShow the panel while several instances are selected.
style(…)The style keys the panel edits. See Edit style.

Build the panel

A panel function returns a tree that starts with panel(). Its children are usually sections, and each section holds fields: a label with one or more controls.

BuilderDraws
section(title)A titled section. .collapsible(true) lets it fold.
subsection(title), untitled_subsection()A group inside a section.
field(label)A labelled row of controls.
row(), column()Controls side by side or stacked, with .gap("sm").
text(content), callout(message)Static text, or a message banner with a .tone(…).
text_field(), number_field()A text input, or a number input you can scrub.
select_field(), toggle_group()A dropdown, or a segmented control, of .options(…).
switch_field(), checkbox_field(label)An on/off switch or a checkbox.
slider_field()A range slider.
color_field()A color input with its picker.
product_field(), image_field()A product picker, or an image picker.
button(label), menu()A button, or a dropdown menu of .items(…).
list_editor(label)A list with add and remove buttons. See Lists.
popover(), popover_trigger(), popover_content()A popover. See Popovers.

Number fields take .icon(…), .min(…), .max(…) and .step(…). Icons are names from the editor's own set, such as square, percent, settings or betweenHorizontalStart. An unknown icon draws nothing.

Bound fields

A bound field edits one value, and the editor does the editing. Bind a control to a prop with .bind(bind::prop("label")). The editor then shows the selected instances' value, or Mixed when they disagree. It writes each change itself: live while someone types, scrubs or drags, and as one undo step per gesture. Your panel is not called while that happens, so bound fields stay fast.

Text, number, select, toggle group, switch, checkbox, slider and color controls can be bound. Hints on the bind shape the value:

HintEffect
.range(min, max)Clamps typed numbers. .min(…) and .max(…) set one end.
.decimals(digits)Rounds the shown number.
.scale(factor)Shows the stored number times factor: 100 shows 0–1 as a percentage.
.fallback(value)The value assumed when an instance has none.
.label(text)Names the undo step.
.field(name)Binds one field of an object prop instead of the whole object.

Component color props store hex colors, so bind a color field with .format("hex").

Fields and props of enums can share the component's Rust type. enum_options lists an enum's options for a toggle group or select, and read reads the first selected instance's value as that type:

use voidhash_ui::panel::prelude::*;

use crate::Shape; // #[derive(PropEnum)] in src/lib.rs

#[panel(for = "project/badge", title = "Badge", order = 13, multi_selectable)]
pub fn settings(cx: &mut PanelCx) -> Panel {
    let mut section = section("Badge").child(
        field("Shape").child(
            toggle_group()
                .width("full")
                .options(enum_options::<Shape>())
                .bind(bind::prop("shape")),
        ),
    );
    // Only pills have padding to set.
    if cx.prop("shape").read::<Shape>() == Shape::Pill {
        section = section.child(
            field("Padding").child(
                number_field()
                    .icon("square")
                    .bind(bind::prop("padding").range(0.0, 32.0).decimals(0)),
            ),
        );
    }
    panel().child(section)
}

Read the selection

The panel function receives a PanelCx describing the selected instances. The editor calls it again whenever they change.

  • cx.prop(name) is a prop across the selection: .value(), .mixed() when instances disagree, .is_default(), .binding() for a prop bound to a variable, and .translated().
  • cx.count() and cx.selection() give the selected instances and each one's props.
  • cx.locale() tells which locale is being edited, and cx.state_override() which state.

UI state

Some state belongs to the panel rather than the paywall, such as the open tab. cx.ui_state(|| initial) keeps a value while the panel is shown. Read it with .get() and change it with .set(…); the panel renders again right away.

#[derive(Clone, Copy, PartialEq)]
enum Tab {
    Content,
    Style,
}

#[panel(for = "project/feature-list", title = "Feature list", order = 13, multi_selectable)]
pub fn settings(cx: &mut PanelCx) -> Panel {
    let tab = cx.ui_state(|| Tab::Content);
    let tabs = toggle_group()
        .width("full")
        .options([opt("content", "Content"), opt("style", "Style")])
        .value(if tab.get() == Tab::Content { "content" } else { "style" })
        .on_change(move |_cx, value: String| {
            tab.set(if value == "style" { Tab::Style } else { Tab::Content });
        });
    let body: Vec<Node> = match tab.get() {
        Tab::Content => content(cx),
        Tab::Style => style(cx),
    };
    panel().child(section("Feature list").child(tabs).children(body))
}

Here content and style are functions of your own that return each tab's fields as a Vec<Node>. Call ui_state on every render, in the same order, as you would a React hook.

Handlers

A control that is not bound takes handlers, such as .on_click(…), .on_change(…) or .on_commit(…). A handler never edits the paywall directly. It records what should change, and the editor checks each change against the panel's limits and applies it.

CallRecords
cx.set_prop(name, value)A new value for a prop of the selected instances.
cx.reset_prop(name)The prop's default.
cx.bind_prop(name, variable)A binding of the prop to a variable.
cx.set_style(key, value)A style value, for a key the panel declared.
cx.reset_style(&[keys])Removes a state's override of those keys.
cx.transaction(label, |intents| …)Several changes as one undo step named label.
cx.in_draft(…), cx.commit_draft_as(label)A live preview that lands as one undo step.
cx.toast_error(message)An error toast.

A button that resets two props in one undo step:

button("Reset rows").variant("ghost").on_click(|cx, ()| {
    cx.transaction("Reset rows", |intents| {
        intents.reset_prop("rowPadding");
        intents.reset_prop("rowRadius");
    });
})

Lists

A list_editor shows a list prop with add and remove buttons; each child is one item. Its handlers write the whole list:

fn features(cx: &PanelCx) -> Node {
    let field = cx.prop("features");
    let list: Vec<Value> = field.value().items().to_vec();

    let add = {
        let list = list.clone();
        move |cx: &mut EventCx, ()| {
            let mut next = list.clone();
            next.push(Value::from("New feature"));
            cx.transaction("Add feature", |intents| {
                intents.set_prop("features", next);
            });
        }
    };
    let remove = {
        let list = list.clone();
        move |cx: &mut EventCx, index: f64| {
            let mut next = list.clone();
            if (index as usize) < next.len() {
                next.remove(index as usize);
                cx.transaction("Remove feature", |intents| {
                    intents.set_prop("features", next);
                });
            }
        }
    };
    let items = list.iter().enumerate().map(|(index, item)| {
        let list = list.clone();
        text_field()
            .value(item.as_str().unwrap_or(""))
            .on_commit(move |cx, text: String| {
                let mut next = list.clone();
                next[index] = Value::from(text);
                cx.transaction("Edit feature", |intents| {
                    intents.set_prop("features", next);
                });
            })
    });

    list_editor("Features")
        .add_label("Add feature")
        .can_add(!field.mixed() && list.len() < 6)
        .mixed(field.mixed())
        .on_add(add)
        .on_remove(remove)
        .children(items)
        .into()
}

Popovers

A popover keeps settings people change less often out of the way. It holds a trigger and its content:

popover()
    .child(popover_trigger().child(button("Row style").icon("settings").variant("outline").size("sm")))
    .child(
        popover_content()
            .align("start")
            .side("left")
            .title("Rows")
            .child(
                column()
                    .gap("sm")
                    .child(number_field().icon("square").bind(bind::prop("rowPadding")))
                    .child(number_field().icon("squareRoundCorner").bind(bind::prop("rowRadius"))),
            ),
    )

Edit style

A panel can also edit the style its component's traits bring. List the style keys in style(…), then bind fields to them with bind::style. This panel edits the opacity the appearance trait brings, shown as a percentage:

#[panel(for = "project/feature-list", title = "Feature list", order = 13, style("opacity"))]
pub fn settings(_cx: &mut PanelCx) -> Panel {
    panel().child(
        section("Feature list").child(
            field("Opacity").child(
                number_field().icon("percent").bind(
                    bind::style("opacity").scale(100.0).range(0.0, 100.0).decimals(0).fallback(1.0),
                ),
            ),
        ),
    )
}

Style writes go to the state being edited when someone edits one. A panel can list only keys of traits its component declares; a build names any other key and refuses the panel.

Test a panel

voidhash_ui::panel::testing runs a panel in cargo test. Mount it with a context written as JSON, render its tree, and fire its handlers to see what they record:

#[cfg(test)]
mod tests {
    extern crate std;

    use super::*;
    use voidhash_ui::panel::testing::{Harness, find, is, json};

    const CONTEXT: &str = r#"{
        "selection": [{ "id": "a", "type": "component", "definition": "project/feature-list" }],
        "props": { "features": { "value": ["Unlimited projects"], "mixed": false, "isDefault": false } }
    }"#;

    #[test]
    fn adds_a_feature() {
        let mut harness = Harness::mount(&SETTINGS, CONTEXT);
        let tree = harness.render();
        let list = find(&tree, |node| is(node, "listEditor", "label", "Features")).unwrap();
        assert_eq!(
            harness.fire(list, "onAdd", "[]"),
            json(
                r#"[{ "type": "transaction", "label": "Add feature", "intents": [
                    { "type": "setProp", "prop": "features", "value": ["Unlimited projects", "New feature"] }
                ] }]"#
            )
        );
    }
}

To see the panel in the editor while you write it, link the dev server.

Limits

A panel runs isolated in your browser, apart from the editor. It has no network, storage, page or clock access: what it knows comes from the selection, and what it changes goes through the editor.

  • Its own instances. A panel edits only the selected instances of its own component. It reads and writes their props, and only the style keys of the traits the component declares.
  • Size. The panel module is at most 512 KiB and declares one to eight panels.
  • Trees. A rendered tree is at most 256 KiB, 2,000 nodes and 32 levels deep. One handler records at most 64 changes.
  • Time. The editor stops a panel that takes longer than a second to answer, that renders or records changes in a runaway loop, or that crashes.

When the editor refuses a panel's output or stops it, the component's instances show the default Props section instead, with a note that the component's panel stopped working. The panel stays stopped for the rest of the editing session.