Skip to content

feat: support full attribute syntax on components#220

Open
dvaergiller wants to merge 1 commit into
vidhanio:mainfrom
dvaergiller:component-attributes-feature-parity
Open

feat: support full attribute syntax on components#220
dvaergiller wants to merge 1 commit into
vidhanio:mainfrom
dvaergiller:component-attributes-feature-parity

Conversation

@dvaergiller

@dvaergiller dvaergiller commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Hello!

I have a proposal for how the semantics for component attributes could be made
to enable the same syntax. The change you made earlier introducing bon to build
the components made this a whole lot easier because it allowed us to use the
.maybe_<attr>() builder function regardless if the parameter is an Option or
not. That was a blessing.

This PR contains an implementation of it and some tests to make sure
non-component elements and components can be called with the same attribute
syntax and render identically to each other.

There are cases (dynamic class lists and mixed group expressions) where a tradeoff
had to be made. In order to keep the call syntax transparent to the component itself
there are a couple of cases where a string needs to be allocated and the attribute
rendered to it. I cover that a bit more below.

Please let me know what you think!

Regular toggled attributes

An attribute value can be toggled like Component attr=<value>[bool_expr]{}.
That will expand to:

.maybe_<attr>(if <bool_expr> { Some(<value>) } else { None }))

Or with an Option<T> using Component attr=[<optional_value>]:

.maybe_<attr>(<optional_value>)

Boolean attributes

A boolean attribute can be either defined as bool or Option<bool> in the
component struct. When invoking a component with an empty attribute with no
toggle it will expand to:

.<attribute>(true)

Invoking it with a toggle will expand to:

.maybe_<attribute>(if <toggle_expr> { Some(true) } else { None })

Id attribute

If the component has an id attribute, maud syntax now allows shorthand syntax
like Component #some-id, or Component #some-id[<bool_expr>].

Classlist

This one was a bit of a dilemma since the classlist is a Vec<Class> where some
of the elements might be literals and some might be dynamic expressions. It
would be possible for the component to just take an impl Renderable but that
did not seem very ergonomic, and more importantly, will not compile if there is
not class set because the compiler won't be able to resolve the type.

Also, if all classes in the class list are static literals it would be nice to
pass them as &'a str. So I opted for the classlist to be &'a str or
Option<'a str> in the component. But if there are any dynamic elements in the
class list then the only way I could find for that to work while still allowing
&'a str in the component was to render the class list into an owned string and
passing a reference to it to the component.

That means memory allocation in the case where classlist is used and not all
elements are static.

Component .first-class .second-class .third-class {} expands to:

.class("first-class second-class third-class")

All good, but Component .first-class .second-class[<boolean>] .third-class {}
needs to be dynamically built:

let owned_0 = [
    Some("first-class"),
    if <boolean> { Some("second-class") } else { None },
    Some("third-class"),
]
    .into_iter()
    .flatten()
    .fold(
        String::new(),
        |mut classes, class| {
            if !classes.is_empty() {
                classes.push(' ');
            }
            classes.push_str(class);
            classes
        },
    );

And then in the builder:

.class(&owned_0)

Also I changed the parser for class list because the current parser parsed each
class as a group expression which I do not think is necessary (that is, the
class list was a vector of groups).

Group expressions

This is similar to the classlist because the group may or may not contain
dynamic expressions and the choice is either to pass it as a renderable to the
component which has the type resolution problem and also makes it clunky in the
component definition.

I think it is nice that the component can take an attribute as a &'a str and
then the caller without worrying about whether it was called with a literal, an
expression, or a group with a mix. But that means the caller would need to
render any group expression (and allocate).

Component attr={"one " (<dynamic_expr>) " three"} expands to:

let owned_0 = ::hypertext::Lazy::dangerously_create(|
        __hypertext_buffer: &mut ::hypertext::Buffer|
    {
        __hypertext_buffer
            .dangerously_get_string()
            .push_str("one ");
        ::hypertext::Renderable::render_to(
            &(class),
            __hypertext_buffer
                .with_context::<::hypertext::context::AttributeValue>(),
        );
        __hypertext_buffer
            .dangerously_get_string()
            .push_str(" three");
    })
    .render()
    .into_inner();

And then in builder:

.<attr>(&owned_0)

I think that is a fair compromise to support the syntax while making it
transparent to the component whether it was invoked with one syntax or another.

@dvaergiller dvaergiller force-pushed the component-attributes-feature-parity branch from 4de0ab0 to 2ba024c Compare June 18, 2026 22:08
Bring component invocation syntax to parity with regular elements:
- shorthand id (#id) and class (.class) including hyphenated values
- toggled attributes/classes/ids via [cond]
- boolean toggle attributes
- ident-valued attributes

ComponentDiv {...} now renders identically to div {...}
@dvaergiller dvaergiller force-pushed the component-attributes-feature-parity branch from 2ba024c to eec4b88 Compare June 18, 2026 22:09
@vidhanio

Copy link
Copy Markdown
Owner

Thank you, this looks great! I will give this a review over the weekend. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants