Skip to content
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

Add support for optional attributes on HTML elements/tags #1433

Closed
wants to merge 11 commits into from
14 changes: 14 additions & 0 deletions docs/concepts/html/elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ html! {
```
<!--END_DOCUSAURUS_CODE_TABS-->

## Optional attributes for HTML elements

Most HTML attributes can be marked as optional by placing a `?` in front of
the `=` sign. This makes them accept the same type of value as otherwise, but
wrapped in an `Option<T>`:
```rust
let maybe_id = Some("foobar");

html! {
<div id?=maybe_id></div>
}
```
If the attribute is set to `None`, it will behave as though it wasn't set.

## Classes

There are a number of convenient ways to specify classes for an element:
Expand Down
9 changes: 8 additions & 1 deletion yew-macro/src/html_tree/html_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ impl ToTokens for HtmlComponent {

let init_props = match &props.prop_type {
PropType::List(list_props) => {
let set_props = list_props.iter().map(|HtmlProp { label, value }| {
let set_props = list_props.iter().map(|HtmlProp { label, value, .. }| {
quote_spanned! { value.span()=> .#label(
<::yew::virtual_dom::VComp as ::yew::virtual_dom::Transformer<_, _>>::transform(
#value
Expand Down Expand Up @@ -405,6 +405,13 @@ impl Parse for Props {
return Err(syn::Error::new_spanned(&prop.label, "expected identifier"));
}

if prop.question_mark.is_some() {
return Err(syn::Error::new_spanned(
&prop.label,
"optional attributes are only supported on HTML tags. Yew components can use `Option<T>` properties to accomplish the same thing.",
));
}

match props.prop_type {
ref mut prop_type @ PropType::None => {
*prop_type = PropType::List(vec![prop]);
Expand Down
2 changes: 1 addition & 1 deletion yew-macro/src/html_tree/html_dashed_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,6 @@ impl ToTokens for HtmlDashedName {
let dashes = extended.iter().map(|(dash, _)| quote! {#dash});
let idents = extended.iter().map(|(_, ident)| quote! {#ident});
let extended = quote! { #(#dashes#idents)* };
tokens.extend(quote! {#name#extended});
tokens.extend(quote! { #name#extended });
}
}
9 changes: 8 additions & 1 deletion yew-macro/src/html_tree/html_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ impl PeekValue<()> for HtmlListOpen {
// make sure it's either a property (key=value) or it's immediately closed
if let Some((_, cursor)) = HtmlDashedName::peek(cursor) {
let (punct, _) = cursor.punct()?;
(punct.as_char() == '=').as_option()
(punct.as_char() == '=' || punct.as_char() == '?').as_option()
} else {
let (punct, _) = cursor.punct()?;
(punct.as_char() == '>').as_option()
Expand Down Expand Up @@ -136,6 +136,13 @@ impl Parse for HtmlListProps {
));
}

if prop.question_mark.is_some() {
return Err(syn::Error::new_spanned(
prop.label,
"the 'key' attribute does not support being used as an optional attribute",
));
}

Some(prop.value)
};

Expand Down
12 changes: 11 additions & 1 deletion yew-macro/src/html_tree/html_prop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use syn::{Expr, Token};

pub struct HtmlProp {
pub label: HtmlPropLabel,
pub question_mark: Option<Token![?]>,
pub value: Expr,
}

Expand All @@ -19,6 +20,11 @@ impl PeekValue<()> for HtmlProp {
impl Parse for HtmlProp {
fn parse(input: ParseStream) -> ParseResult<Self> {
let label = input.parse::<HtmlPropLabel>()?;
let question_mark = if input.peek(Token![?]) {
Some(input.parse()?)
} else {
None
};
let equals = input
.parse::<Token![=]>()
.map_err(|_| syn::Error::new_spanned(&label, "this prop doesn't have a value"))?;
Expand All @@ -31,7 +37,11 @@ impl Parse for HtmlProp {
let value = input.parse::<Expr>()?;
// backwards compat
let _ = input.parse::<Token![,]>();
Ok(HtmlProp { label, value })
Ok(Self {
label,
question_mark,
value,
})
}
}

Expand Down
125 changes: 102 additions & 23 deletions yew-macro/src/html_tree/html_tag/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,32 +136,84 @@ impl ToTokens for HtmlTag {
} = &attributes;

let vtag = Ident::new("__yew_vtag", tag_name.span());
let attr_pairs = attributes.iter().map(|TagAttribute { label, value }| {
let attr_pairs = attributes.iter().map(|TagAttribute { label, question_mark, value }| {
let label_str = label.to_string();
quote_spanned! {value.span()=> (#label_str.to_owned(), (#value).to_string()) }
if question_mark.is_some() {
quote_spanned! {value.span()=>
{
let __yew_value = ::std::option::Option::as_ref(&(#value)).map(::std::string::ToString::to_string);
(::std::string::String::from(#label_str), __yew_value)
}
}
} else {
quote_spanned! {value.span()=>
{
(::std::string::String::from(#label_str), ::std::option::Option::Some(::std::string::ToString::to_string(&#value)))
}
}
}
});
let set_booleans = booleans.iter().map(|TagAttribute { label, value }| {
let set_booleans = booleans.iter().map(|TagAttribute { label, value, .. }| {
let label_str = label.to_string();
quote_spanned! {value.span()=>
if #value {
#vtag.add_attribute(&#label_str, &#label_str);
{
if #value {
#vtag.add_attribute(#label_str, &#label_str);
}
}
}
});
let set_kind = kind.iter().map(|kind| {
quote_spanned! {kind.span()=> #vtag.set_kind(&(#kind)); }
let value = &kind.value;
if kind.question_mark.is_some() {
quote_spanned! {value.span()=>
{
if let ::std::option::Option::Some(__yew_kind) = ::std::option::Option::as_ref(&(#value)) {
siku2 marked this conversation as resolved.
Show resolved Hide resolved
#vtag.set_kind(__yew_kind)
}
}
}
} else {
quote_spanned! {value.span()=> #vtag.set_kind(&(#value)); }
}
});
let set_value = value.iter().map(|value| {
quote_spanned! {value.span()=> #vtag.set_value(&(#value)); }
let value_value = &value.value;
if value.question_mark.is_some() {
quote_spanned! {value_value.span()=>
{
if let ::std::option::Option::Some(__yew_value) = ::std::option::Option::as_ref(&(#value_value)) {
#vtag.set_value(__yew_value);
}
}
}
} else {
quote_spanned! {value_value.span()=> #vtag.set_value(&(#value_value)); }
}
});
let add_href = href.iter().map(|href| {
quote_spanned! {href.span()=>
let __yew_href: ::yew::html::Href = (#href).into();
#vtag.add_attribute("href", &__yew_href);
let value = &href.value;
if href.question_mark.is_some() {
quote_spanned! {value.span()=>
{
let __yew_href = ::std::option::Option::map(#value, ::yew::html::Href::from);
if let ::std::option::Option::Some(__yew_href) = __yew_href {
#vtag.add_attribute("href", &__yew_href);
}
}
}
} else {
quote_spanned! {value.span()=>
{
let __yew_href = ::yew::html::Href::from(#value);
#vtag.add_attribute("href", &__yew_href);
}
}
}
});
let set_checked = checked.iter().map(|checked| {
quote_spanned! {checked.span()=> #vtag.set_checked(#checked); }
let value = &checked.value;
quote_spanned! {value.span()=> #vtag.set_checked(#value); }
});
let set_classes = classes.iter().map(|classes_form| match classes_form {
ClassesForm::Tuple(classes) => quote! {
Expand All @@ -171,7 +223,7 @@ impl ToTokens for HtmlTag {
}
},
ClassesForm::Single(classes) => quote! {
let __yew_classes = ::std::convert::Into::<::yew::virtual_dom::Classes>::into(#classes);
let __yew_classes = ::yew::virtual_dom::Classes::from(#classes);
if !__yew_classes.is_empty() {
#vtag.add_attribute("class", &__yew_classes);
}
Expand All @@ -191,13 +243,27 @@ impl ToTokens for HtmlTag {
let name = &listener.label.name;
let callback = &listener.value;

quote_spanned! {name.span()=> {
::yew::html::#name::Wrapper::new(
<::yew::virtual_dom::VTag as ::yew::virtual_dom::Transformer<_, _>>::transform(
#callback
)
)
}}
if listener.question_mark.is_some() {
quote_spanned! {name.span()=>
{
::std::option::Option::map(#callback, |__yew_callback| {
::yew::html::#name::Wrapper::new(
<::yew::virtual_dom::VTag as ::yew::virtual_dom::Transformer<_, _>>::transform(
__yew_callback,
),
)
})
}
}
} else {
quote_spanned! {name.span()=> {
::std::option::Option::Some(::yew::html::#name::Wrapper::new(
<::yew::virtual_dom::VTag as ::yew::virtual_dom::Transformer<_, _>>::transform(
#callback
)
))
}}
}
});

// These are the runtime-checks exclusive to dynamic tags.
Expand Down Expand Up @@ -241,8 +307,20 @@ impl ToTokens for HtmlTag {
#(#set_classes)*
#(#set_node_ref)*
#(#set_key)*
#vtag.add_attributes(vec![#(#attr_pairs),*]);
#vtag.add_listeners(vec![#(::std::rc::Rc::new(#listeners)),*]);
#vtag.add_attributes({
let mut attributes = vec![];
#(if let (l, Some(v)) = #attr_pairs {
attributes.push((l, v));
})*
attributes
});
#vtag.add_listeners({
let mut listeners: ::std::vec::Vec<::std::rc::Rc<dyn ::yew::virtual_dom::Listener>> = vec![];
#(if let Some(l) = #listeners {
listeners.push(::std::rc::Rc::new(l));
})*
listeners
});
#vtag.add_children(#children);
#dyn_tag_runtime_checks
::yew::virtual_dom::VNode::from(#vtag)
Expand Down Expand Up @@ -383,10 +461,11 @@ impl Parse for HtmlTagOpen {
match name.to_ascii_lowercase_string().as_str() {
"input" | "textarea" => {}
_ => {
if let Some(value) = attributes.value.take() {
if let Some(attribute) = attributes.value.take() {
attributes.attributes.push(TagAttribute {
label: HtmlDashedName::new(Ident::new("value", Span::call_site())),
value,
question_mark: attribute.question_mark,
value: attribute.value,
});
}
}
Expand Down
58 changes: 50 additions & 8 deletions yew-macro/src/html_tree/html_tag/tag_attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ pub struct TagAttributes {
pub listeners: Vec<TagAttribute>,
pub classes: Option<ClassesForm>,
pub booleans: Vec<TagAttribute>,
pub value: Option<Expr>,
pub kind: Option<Expr>,
pub checked: Option<Expr>,
pub value: Option<TagAttribute>,
pub kind: Option<TagAttribute>,
pub checked: Option<TagAttribute>,
pub node_ref: Option<Expr>,
pub key: Option<Expr>,
pub href: Option<Expr>,
pub href: Option<TagAttribute>,
}

pub enum ClassesForm {
Expand Down Expand Up @@ -242,11 +242,11 @@ impl TagAttributes {
drained
}

fn remove_attr(attrs: &mut Vec<TagAttribute>, name: &str) -> Option<Expr> {
fn remove_attr(attrs: &mut Vec<TagAttribute>, name: &str) -> Option<TagAttribute> {
let mut i = 0;
while i < attrs.len() {
if attrs[i].label.to_string() == name {
return Some(attrs.remove(i).value);
return Some(attrs.remove(i));
} else {
i += 1;
}
Expand Down Expand Up @@ -307,14 +307,56 @@ impl Parse for TagAttributes {
i += 1;
}
let booleans = TagAttributes::drain_boolean(&mut attributes);
for boolean in &booleans {
if boolean.question_mark.is_some() {
return Err(syn::Error::new_spanned(
&boolean.label,
"boolean attributes don't support being used as an option attribute (hint: a value of false results in the attribute not being set)"
));
}
}

let classes =
TagAttributes::remove_attr(&mut attributes, "class").map(TagAttributes::map_classes);
let classes = TagAttributes::remove_attr(&mut attributes, "class");
if let Some(classes) = &classes {
if classes.question_mark.is_some() {
return Err(syn::Error::new_spanned(
&classes.label,
"the 'class' attribute does not support being used as an optional attribute",
));
}
}
let classes = classes.map(|a| TagAttributes::map_classes(a.value));
let value = TagAttributes::remove_attr(&mut attributes, "value");
let kind = TagAttributes::remove_attr(&mut attributes, "type");
let checked = TagAttributes::remove_attr(&mut attributes, "checked");
if let Some(checked) = &checked {
if checked.question_mark.is_some() {
return Err(syn::Error::new_spanned(
&checked.label,
"boolean attributes don't support being used as an option attribute (hint: a value of false results in the attribute not being set)",
));
}
}
let node_ref = TagAttributes::remove_attr(&mut attributes, "ref");
if let Some(node_ref) = &node_ref {
if node_ref.question_mark.is_some() {
return Err(syn::Error::new_spanned(
&node_ref.label,
"the 'ref' attribute does not support being used as an optional attribute",
));
}
}
let node_ref = node_ref.map(|n| n.value);
let key = TagAttributes::remove_attr(&mut attributes, "key");
if let Some(key) = &key {
if key.question_mark.is_some() {
return Err(syn::Error::new_spanned(
&key.label,
"the 'key' attribute does not support being used as an optional attribute",
));
}
}
let key = key.map(|k| k.value);

let href = TagAttributes::remove_attr(&mut attributes, "href");

Expand Down
2 changes: 2 additions & 0 deletions yew-macro/tests/macro/html-component-fail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ fn compile_fail() {
<span>{ 1 }</span>
<span>{ 2 }</span>
};

html! { <TestComponent value?="not_supported" /> };
}

fn main() {}
8 changes: 8 additions & 0 deletions yew-macro/tests/macro/html-component-fail.stderr
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ error: only one root html element is allowed (hint: you can wrap multiple html e
|
= note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

error: optional attributes are only supported on HTML tags. Yew components can use `Option<T>` properties to accomplish the same thing.
--> $DIR/html-component-fail.rs:125:28
|
125 | html! { <TestComponent value?="not_supported" /> };
| ^^^^^
|
= note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0425]: cannot find value `blah` in this scope
--> $DIR/html-component-fail.rs:91:25
|
Expand Down
Loading