-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Rust Only Builder Pattern: Alternative to RSX Macro #5181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Introduces the new dioxus-builder package providing a typed builder API for Dioxus, including its implementation, tests, and workspace integration. Also adds a builder_demo example showcasing usage of the builder API.
Introduces the static_str! macro to guarantee compile-time const evaluation of static text in element builders, ensuring embedding in the template and skipping diffing. Updates documentation and README with usage examples and performance comparison, adds BuilderExt trait for pipe-style builder chaining, and provides tests for the new macro.
Replaces the legacy template cache with a unified hybrid template cache using hashable keys for static attributes and elements. Removes the old dynamic-only template path, ensuring all elements (static, dynamic, or mixed) use the hybrid cache. Updates tests to verify template cache reuse for static text, dynamic, and static element cases.
|
How do components work here? |
There is no implicit .component(item, props) or component() helper in the builder API for now like how we used to in rsx and props builder. .child(counter_section(count))And we can do something like this fn counter_section(mut count: Signal<i32>) -> Element {
div()
.class_list(["flex", "space-x-4", "items-center"])
.child(
button()
.class("px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition")
.onclick(move |_| count -= 1)
.text("-"),
)
.child(
span()
.class("text-2xl font-mono w-12 text-center")
.text(count.to_string()),
)
.child(
button()
.class("px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition")
.onclick(move |_| count += 1)
.text("+"),
)
.build()
}For making resuable components like in our components library it will be crazy like /// The props for the [`Toggle`] component.
#[derive(Props, Clone, PartialEq)]
pub struct ToggleProps {
/// The controlled pressed state of the toggle.
pub pressed: ReadSignal<Option<bool>>,
/// The default pressed state when uncontrolled.
#[props(default)]
pub default_pressed: bool,
/// Whether the toggle is disabled.
#[props(default)]
pub disabled: ReadSignal<bool>,
/// Callback fired when the pressed state changes.
#[props(default)]
pub on_pressed_change: Callback<bool>,
// https://github.com/DioxusLabs/dioxus/issues/2467
/// Callback fired when the toggle is mounted.
#[props(default)]
pub onmounted: Callback<Event<MountedData>>,
/// Callback fired when the toggle receives focus.
#[props(default)]
pub onfocus: Callback<Event<FocusData>>,
/// Callback fired when a key is pressed on the toggle.
#[props(default)]
pub onkeydown: Callback<Event<KeyboardData>>,
/// Callback fired when the toggle is clicked.
#[props(default)]
pub onclick: Callback<MouseEvent>,
/// Additional attributes to apply to the toggle element.
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
/// The children of the toggle component.
pub children: Element,
}
/// # Toggle
///
/// The `Toggle` component is a button that can be on or off.
pub fn Toggle(props: ToggleProps) -> Element {
let ToggleProps {
pressed,
default_pressed,
disabled,
on_pressed_change,
onmounted,
onfocus,
onkeydown,
onclick,
attributes,
children,
} = props;
let (pressed, set_pressed) = use_controlled(pressed, default_pressed, on_pressed_change);
let pressed_for_click = pressed.clone();
let set_pressed = set_pressed.clone();
let disabled_for_click = disabled;
let onclick = onclick.clone();
button()
.class_list([
"inline-flex",
"items-center",
"justify-center",
"rounded-md",
"border",
"border-gray-300",
"transition",
])
.class_if(pressed(), "bg-blue-600 text-white")
.class_if(!pressed(), "bg-white text-gray-800")
.class_if(disabled(), "opacity-50 cursor-not-allowed")
.r#type("button")
.disabled(disabled())
.attr("aria-pressed", pressed())
.attr("data-state", if pressed() { "on" } else { "off" })
.attr("data-disabled", disabled())
.onmounted(move |event| onmounted.call(event))
.onfocus(move |event| onfocus.call(event))
.onkeydown(move |event| onkeydown.call(event))
.onclick(move |event| {
if disabled_for_click() {
return;
}
onclick.call(event);
let new_pressed = !pressed_for_click();
set_pressed.call(new_pressed);
})
.attrs(attributes)
.child(children)
.build()
}
#[derive(Clone, Copy)]
pub enum ToggleVariant {
Solid,
Outline,
Ghost,
}
#[derive(Clone, Copy)]
pub enum ToggleSize {
Sm,
Md,
Lg,
}
pub struct ToggleBuilder {
pressed: ReadSignal<Option<bool>>,
default_pressed: bool,
disabled: ReadSignal<bool>,
on_pressed_change: Callback<bool>,
onmounted: Callback<Event<MountedData>>,
onfocus: Callback<Event<FocusData>>,
onkeydown: Callback<Event<KeyboardData>>,
onclick: Callback<MouseEvent>,
attributes: Vec<Attribute>,
children: Element,
variant: ToggleVariant,
size: ToggleSize,
}
impl ToggleBuilder {
pub fn new() -> Self {
Self {
pressed: ReadSignal::default(),
default_pressed: false,
disabled: ReadSignal::default(),
on_pressed_change: Callback::default(),
onmounted: Callback::default(),
onfocus: Callback::default(),
onkeydown: Callback::default(),
onclick: Callback::default(),
attributes: Vec::new(),
children: VNode::empty(),
variant: ToggleVariant::Solid,
size: ToggleSize::Md,
}
}
pub fn pressed(mut self, value: ReadSignal<Option<bool>>) -> Self {
self.pressed = value;
self
}
pub fn default_pressed(mut self, value: bool) -> Self {
self.default_pressed = value;
self
}
pub fn disabled(mut self, value: ReadSignal<bool>) -> Self {
self.disabled = value;
self
}
pub fn on_pressed_change(mut self, f: impl FnMut(bool) + 'static) -> Self {
self.on_pressed_change = Callback::new(f);
self
}
pub fn on_pressed_change_cb(mut self, cb: Callback<bool>) -> Self {
self.on_pressed_change = cb;
self
}
pub fn onmounted(mut self, f: impl FnMut(Event<MountedData>) + 'static) -> Self {
self.onmounted = Callback::new(f);
self
}
pub fn onfocus(mut self, f: impl FnMut(Event<FocusData>) + 'static) -> Self {
self.onfocus = Callback::new(f);
self
}
pub fn onkeydown(mut self, f: impl FnMut(Event<KeyboardData>) + 'static) -> Self {
self.onkeydown = Callback::new(f);
self
}
pub fn on_click(mut self, f: impl FnMut(MouseEvent) + 'static) -> Self {
self.onclick = Callback::new(f);
self
}
pub fn on_click_cb(mut self, cb: Callback<MouseEvent>) -> Self {
self.onclick = cb;
self
}
pub fn label(mut self, text: impl ToString) -> Self {
self.children = text_node(text);
self
}
pub fn child(mut self, child: impl IntoToggleChild) -> Self {
self.children = child.into_element();
self
}
pub fn variant(mut self, variant: ToggleVariant) -> Self {
self.variant = variant;
self
}
pub fn outline(self) -> Self {
self.variant(ToggleVariant::Outline)
}
pub fn ghost(self) -> Self {
self.variant(ToggleVariant::Ghost)
}
pub fn size(mut self, size: ToggleSize) -> Self {
self.size = size;
self
}
pub fn size_sm(self) -> Self {
self.size(ToggleSize::Sm)
}
pub fn size_lg(self) -> Self {
self.size(ToggleSize::Lg)
}
pub fn attr<T>(mut self, name: &'static str, value: impl IntoAttributeValue<T>) -> Self {
self.attributes
.push(Attribute::new(name, value, None, false));
self
}
pub fn class(mut self, value: impl IntoAttributeValue) -> Self {
self.attributes
.push(Attribute::new("class", value, None, false));
self
}
pub fn class_if(mut self, condition: bool, value: impl IntoAttributeValue) -> Self {
if condition {
self.attributes
.push(Attribute::new("class", value, None, false));
}
self
}
pub fn class_list<I, S>(mut self, classes: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let joined = classes
.into_iter()
.map(|c| c.as_ref().to_string())
.filter(|c| !c.is_empty())
.collect::<Vec<_>>()
.join(" ");
if !joined.is_empty() {
self.attributes
.push(Attribute::new("class", joined, None, false));
}
self
}
pub fn attrs(mut self, attrs: impl IntoIterator<Item = Attribute>) -> Self {
self.attributes.extend(attrs);
self
}
pub fn build(mut self) -> Element {
self = self.class_list([
"inline-flex",
"items-center",
"justify-center",
"rounded-md",
"transition",
]);
match self.variant {
ToggleVariant::Solid => {
self = self.class_list([
"bg-blue-600",
"text-white",
"hover:bg-blue-700",
"border",
"border-blue-700",
]);
}
ToggleVariant::Outline => {
self = self.class_list([
"border",
"border-gray-300",
"text-gray-900",
"hover:bg-gray-50",
]);
}
ToggleVariant::Ghost => {
self = self.class_list(["text-gray-900", "hover:bg-gray-100"]);
}
}
match self.size {
ToggleSize::Sm => self = self.class_list(["h-8", "w-8", "text-sm"]),
ToggleSize::Md => self = self.class_list(["h-10", "w-10", "text-base"]),
ToggleSize::Lg => self = self.class_list(["h-12", "w-12", "text-lg"]),
}
Toggle(ToggleProps {
pressed: self.pressed,
default_pressed: self.default_pressed,
disabled: self.disabled,
on_pressed_change: self.on_pressed_change,
onmounted: self.onmounted,
onfocus: self.onfocus,
onkeydown: self.onkeydown,
onclick: self.onclick,
attributes: self.attributes,
children: self.children,
})
}
}
pub fn toggle() -> ToggleBuilder {
ToggleBuilder::new()
}
pub trait IntoToggleChild {
fn into_element(self) -> Element;
}
impl IntoToggleChild for Element {
fn into_element(self) -> Element {
self
}
}
impl IntoToggleChild for ElementBuilder {
fn into_element(self) -> Element {
self.build()
}
}
impl IntoToggleChild for &str {
fn into_element(self) -> Element {
text_node(self)
}
}
impl IntoToggleChild for String {
fn into_element(self) -> Element {
text_node(self)
}
}which can be used like this inside of a child. .child(
toggle()
.pressed(pressed)
.disabled(disabled)
.on_pressed_change_cb(on_pressed_change)
.outline()
.size_lg()
.class("shadow-sm")
.label("B")
.build(),
)Its alot of boilerplate code for now to make a builder pattern for props which i think maybe https://github.com/elastio/bon can solve? I havent had a chance to look in depth for this. Lets see how it works at the end. |
The component macro currently has an implementation on bon inside of it. It's how the rsx macro works. We call You might be able to get something like this working with a trait: Toggle.new().field().field().build()Or Component::new(Toggle).field(123).field(abc).build()We could try and get fc(Toggle).field(123).field(abc)or Toggle.new().field(123).field(abc)Just tested it out - we CAN implement components that way! We need to change the builder to keep the original input function with it, but it's totally possible pub struct Element;
pub trait Props {
type Builder;
fn builder() -> Self::Builder;
}
pub trait FunctionComponent<P: Props> {
fn new(&self) -> <P as Props>::Builder;
}
impl<P, F> FunctionComponent<P> for F
where
F: Fn(P) -> Element,
P: Props,
{
fn new(&self) -> <P as Props>::Builder {
P::builder()
}
}
#[derive(bon::Builder)]
struct MyComponentProps {
title: String,
count: u32,
}
impl Props for MyComponentProps {
type Builder = MyComponentPropsBuilder;
fn builder() -> Self::Builder {
MyComponentProps::builder()
}
}
fn MyCoolComopnent(props: MyComponentProps) -> Element {
println!("Title: {}, Count: {}", props.title, props.count);
Element
}
fn it_works() {
MyCoolComopnent
.new()
.title("Hello World".to_string())
.count(42)
.build();
} |
|
I really like the last one from above with bon builder. I will try if i can do that way. Thanks for the testing and insights @jkelleyrtp |
Simplifies and modernizes the builder_demo example to use a type-safe counter app with signals, updates Tailwind CSS variables and utility classes for a smaller set of colors and spacing, and clarifies trait usage in dioxus-builder's Props implementation. The changes improve demo clarity, reduce unused CSS, and enhance builder ergonomics.
#[derive(Builder, Clone, PartialEq)]
struct MyComponentProps {
#[builder(into)]
title: String,
count: Signal<i32>,
}
impl Properties for MyComponentProps {
type Builder = MyComponentPropsBuilder;
fn builder() -> Self::Builder {
MyComponentProps::builder()
}
fn memoize(&mut self, other: &Self) -> bool {
self == other
}
}
impl<S> dioxus_core::IntoDynNode for MyComponentPropsBuilder<S>
where
S: my_component_props_builder::IsComplete,
{
fn into_dyn_node(self) -> dioxus_core::DynamicNode {
dioxus_core::IntoDynNode::into_dyn_node(MyCoolComponent(self.build()))
}
}
#[allow(non_snake_case)]
fn MyCoolComponent(props: MyComponentProps) -> Element {
div()
.class("p-4 rounded-lg border border-gray-200 bg-white shadow-sm")
.child(
h2().class("text-lg font-semibold text-gray-800")
.text(props.title),
)
.child(
p().class("text-sm text-gray-600")
.text(format!("Count is {}", props.count)),
)
.build()
} .child(
MyCoolComponent
.new()
.title("Builder + bon props")
.count(count),
)The above is working fine. Which is a good step ahead haha |
Introduces the new `dioxus-builder-macro` crate providing `#[derive(BuilderProps)]` and `#[builder_component]` macros for ergonomic, type-safe component props. Expands the builder API with ARIA and SVG attribute helpers, style composition (`with`, `style_prop`), and data attribute macro. Updates documentation and tests to cover new macros and features, and adds debug implementations for builder types.
Super draft, Not all code reviewed yet (Opus Generated), but things just works seamlessly without breaking anything from RSX. Dumping as we have this on roadmap's stretched goals.