Compare commits

...

9 Commits

Author SHA1 Message Date
Greg Johnston
3782a8ad70 chore: update for non-generic rendering 2024-09-30 20:32:24 -04:00
Greg Johnston
cb11ce0a5f feat: use new bind: syntax 2024-09-30 20:32:09 -04:00
Greg Johnston
37c794b283 chore: move Group into the reactive module, as that's the only place it has meaning 2024-09-30 20:20:41 -04:00
Greg Johnston
f5ff45863d chore: use ::leptos absolute path in macro 2024-09-30 20:15:48 -04:00
Greg Johnston
ca6f934039 Merge remote-tracking branch 'origin' into two-way-data-binding 2024-09-30 20:10:43 -04:00
Maccesch
6ea942f3c5 Merge branch 'main' into two-way-data-binding
# Conflicts:
#	leptos_macro/src/view/component_builder.rs
#	leptos_macro/src/view/slot_helper.rs
2024-09-20 12:43:23 +02:00
Maccesch
3a079ace46 moved bind into reactive_graph mod 2024-09-16 05:13:32 +02:00
Maccesch
dbf654aa86 added two-way data binding to radio groups 2024-09-16 04:47:14 +02:00
Maccesch
d16231aa3a added two-way data binding to dom elements 2024-09-15 20:50:17 +02:00
9 changed files with 570 additions and 37 deletions

View File

@@ -319,10 +319,7 @@ pub fn Todo(todo: Todo) -> impl IntoView {
node_ref=todo_input
class="toggle"
type="checkbox"
prop:checked=move || todo.completed.get()
on:input:target=move |ev| {
todo.completed.set(ev.target().checked());
}
bind:checked=todo.completed
/>
<label on:dblclick=move |_| {

View File

@@ -174,7 +174,7 @@ pub mod prelude {
pub use server_fn::{self, ServerFnError};
pub use tachys::{
self,
reactive_graph::{node_ref::*, Suspend},
reactive_graph::{bind::BindAttribute, node_ref::*, Suspend},
view::template::ViewTemplate,
};
}

View File

@@ -1,5 +1,5 @@
use super::{fragment_to_tokens, TagType};
use crate::view::attribute_absolute;
use crate::view::{attribute_absolute, utils::filter_prefixed_attrs};
use proc_macro2::{Ident, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
use rstml::node::{
@@ -105,20 +105,13 @@ pub(crate) fn component_to_tokens(
return None;
}
};
let inputs = &binding.inputs;
Some(quote! { #inputs })
})
.collect::<Vec<_>>();
let items_to_clone = attrs
.iter()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("clone:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let items_to_clone = filter_prefixed_attrs(attrs.iter(), "clone:");
// include all attribute that are either
// 1) blocks ({..attrs} or {attrs}),

View File

@@ -1,14 +1,19 @@
mod component_builder;
mod slot_helper;
mod utils;
use self::{
component_builder::component_to_tokens,
slot_helper::{get_slot, slot_to_tokens},
};
use convert_case::{Case::Snake, Casing};
use convert_case::{
Case::{Snake, UpperCamel},
Casing,
};
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use proc_macro_error2::abort;
use quote::{quote, quote_spanned, ToTokens};
use quote::{format_ident, quote, quote_spanned, ToTokens};
use rstml::node::{
CustomNode, KVAttributeValue, KeyedAttribute, Node, NodeAttribute,
NodeBlock, NodeElement, NodeName, NodeNameFragment,
@@ -874,6 +879,8 @@ fn attribute_to_tokens(
directive_call_from_attribute_node(node, name)
} else if let Some(name) = name.strip_prefix("on:") {
event_to_tokens(name, node)
} else if let Some(name) = name.strip_prefix("bind:") {
two_way_binding_to_tokens(name, node)
} else if let Some(name) = name.strip_prefix("class:") {
let class = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
@@ -1057,6 +1064,20 @@ pub(crate) fn attribute_absolute(
}
}
pub(crate) fn two_way_binding_to_tokens(
name: &str,
node: &KeyedAttribute,
) -> TokenStream {
let value = attribute_value(node);
let ident =
format_ident!("{}", name.to_case(UpperCamel), span = node.key.span());
quote! {
.bind(::leptos::attr::#ident, #value)
}
}
pub(crate) fn event_to_tokens(
name: &str,
node: &KeyedAttribute,

View File

@@ -1,7 +1,7 @@
use super::{convert_to_snake_case, ident_from_tag_name};
use crate::view::{fragment_to_tokens, TagType};
use crate::view::{fragment_to_tokens, utils::filter_prefixed_attrs, TagType};
use proc_macro2::{Ident, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
use quote::{quote, quote_spanned};
use rstml::node::{CustomNode, KeyedAttribute, NodeAttribute, NodeElement};
use std::collections::HashMap;
use syn::spanned::Spanned;
@@ -70,25 +70,9 @@ pub(crate) fn slot_to_tokens(
}
});
let items_to_bind = attrs
.iter()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("let:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let items_to_bind = filter_prefixed_attrs(attrs.iter(), "let:");
let items_to_clone = attrs
.iter()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("clone:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let items_to_clone = filter_prefixed_attrs(attrs.iter(), "clone:");
let dyn_attrs = attrs
.iter()

View File

@@ -0,0 +1,19 @@
use proc_macro2::Ident;
use quote::format_ident;
use rstml::node::KeyedAttribute;
use syn::spanned::Spanned;
pub fn filter_prefixed_attrs<'a, A>(attrs: A, prefix: &str) -> Vec<Ident>
where
A: IntoIterator<Item = &'a KeyedAttribute> + Clone,
{
attrs
.into_iter()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix(prefix)
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect()
}

View File

@@ -8,6 +8,7 @@ pub mod custom;
pub mod global;
mod key;
mod value;
use crate::view::{Position, ToTemplate};
pub use key::*;
use std::{fmt::Debug, future::Future};

View File

@@ -0,0 +1,515 @@
use crate::{
dom::{event_target_checked, event_target_value},
html::{
attribute::{Attribute, AttributeKey, AttributeValue, NextAttribute},
event::{change, input, on},
property::{prop, IntoProperty},
},
prelude::AddAnyAttr,
renderer::{types::Element, RemoveEventHandler},
view::{Position, ToTemplate},
};
use reactive_graph::{
signal::{ReadSignal, RwSignal, WriteSignal},
traits::{Get, Update},
wrappers::read::Signal,
};
use send_wrapper::SendWrapper;
use wasm_bindgen::JsValue;
/// `group` attribute used for radio inputs with `bind`.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub struct Group;
impl AttributeKey for Group {
const KEY: &'static str = "group";
}
/// Adds a two-way binding to the element, which adds an attribute and an event listener to the
/// element when the element is created or hydrated.
pub trait BindAttribute<Key, Sig, T>
where
Key: AttributeKey,
Sig: IntoSplitSignal<Value = T>,
T: FromEventTarget + AttributeValue + 'static,
{
/// The type of the element with the two-way binding added.
type Output;
/// Adds a two-way binding to the element, which adds an attribute and an event listener to the
/// element when the element is created or hydrated.
///
/// Example:
///
/// ```
/// // You can use `RwSignal`s
/// let is_awesome = RwSignal::new(true);
///
/// // And you can use split signals
/// let (text, set_text) = signal("Hello world".to_string());
///
/// // Use `Checked` and a `bool` signal for a checkbox
/// checkbox_element.bind(Checked, is_awesome);
///
/// // Use `Group` and `String` for radio inputs
/// radio_element.bind(Group, (text, set_text));
///
/// // Use `Value` and `String` for everything else
/// input_element.bind(Value, (text, set_text));
/// ```
///
/// Depending on the input different events are listened to.
/// - `<input type="checkbox">`, `<input type="radio">` and `<select>` use the `change` event;
/// - `<input>` with the rest of the types and `<textarea>` elements use the `input` event;
fn bind(self, key: Key, signal: Sig) -> Self::Output;
}
impl<V, Key, Sig, T> BindAttribute<Key, Sig, T> for V
where
V: AddAnyAttr,
Key: AttributeKey,
Sig: IntoSplitSignal<Value = T>,
T: FromEventTarget + AttributeValue + PartialEq + Sync + 'static,
Signal<BoolOrT<T>>: IntoProperty,
<Sig as IntoSplitSignal>::Read:
Get<Value = T> + Send + Sync + Clone + 'static,
<Sig as IntoSplitSignal>::Write: Send + Clone + 'static,
Element: GetValue<T>,
{
type Output = <Self as AddAnyAttr>::Output<
Bind<
Key,
T,
<Sig as IntoSplitSignal>::Read,
<Sig as IntoSplitSignal>::Write,
>,
>;
fn bind(self, key: Key, signal: Sig) -> Self::Output {
self.add_any_attr(bind(key, signal))
}
}
/// Adds a two-way binding to the element, which adds an attribute and an event listener to the
/// element when the element is created or hydrated.
#[inline(always)]
pub fn bind<Key, Sig, T>(
key: Key,
signal: Sig,
) -> Bind<Key, T, <Sig as IntoSplitSignal>::Read, <Sig as IntoSplitSignal>::Write>
where
Key: AttributeKey,
Sig: IntoSplitSignal<Value = T>,
T: FromEventTarget + AttributeValue + 'static,
<Sig as IntoSplitSignal>::Read: Get<Value = T> + Clone + 'static,
<Sig as IntoSplitSignal>::Write: Send + Clone + 'static,
{
let (read_signal, write_signal) = signal.into_split_signal();
Bind {
key,
read_signal,
write_signal,
}
}
/// Two-way binding of an attribute and an event listener
#[derive(Debug)]
pub struct Bind<Key, T, R, W>
where
Key: AttributeKey,
T: FromEventTarget + AttributeValue + 'static,
R: Get<Value = T> + Clone + 'static,
W: Update<Value = T>,
{
key: Key,
read_signal: R,
write_signal: W,
}
impl<Key, T, R, W> Clone for Bind<Key, T, R, W>
where
Key: AttributeKey,
T: FromEventTarget + AttributeValue + 'static,
R: Get<Value = T> + Clone + 'static,
W: Update<Value = T> + Clone,
{
fn clone(&self) -> Self {
Self {
key: self.key.clone(),
read_signal: self.read_signal.clone(),
write_signal: self.write_signal.clone(),
}
}
}
impl<Key, T, R, W> Bind<Key, T, R, W>
where
Key: AttributeKey,
T: FromEventTarget + AttributeValue + PartialEq + Sync + 'static,
R: Get<Value = T> + Clone + Send + Sync + 'static,
W: Update<Value = T> + Clone + 'static,
Element: ChangeEvent + GetValue<T>,
{
/// Attaches the event listener that updates the signal value to the element.
pub fn attach(self, el: &Element) -> RemoveEventHandler<Element> {
el.attach_change_event::<T, W>(Key::KEY, self.write_signal.clone())
}
/// Creates the signal to update the value of the attribute. This signal is different
/// when using a `"group"` attribute
pub fn read_signal(&self, el: &Element) -> Signal<BoolOrT<T>> {
let read_signal = self.read_signal.clone();
if Key::KEY == "group" {
let el = SendWrapper::new(el.clone());
Signal::derive(move || {
BoolOrT::Bool(el.get_value() == read_signal.get())
})
} else {
Signal::derive(move || BoolOrT::T(read_signal.get()))
}
}
/// Returns the key of the attribute. If the key is `"group"` it returns `"checked"`, otherwise
/// the one which was provided originally.
pub fn key(&self) -> &'static str {
if Key::KEY == "group" {
"checked"
} else {
Key::KEY
}
}
}
impl<Key, T, R, W> Attribute for Bind<Key, T, R, W>
where
Key: AttributeKey,
T: FromEventTarget + AttributeValue + PartialEq + Sync + 'static,
R: Get<Value = T> + Clone + Send + Sync + 'static,
Signal<BoolOrT<T>>: IntoProperty,
W: Update<Value = T> + Clone + Send + 'static,
Element: ChangeEvent + GetValue<T>,
{
const MIN_LENGTH: usize = 0;
type State = (
<Signal<BoolOrT<T>> as IntoProperty>::State,
(Element, Option<RemoveEventHandler<Element>>),
);
type AsyncOutput = Self;
type Cloneable = Bind<Key, T, R, W>;
type CloneableOwned = Bind<Key, T, R, W>;
fn html_len(&self) -> usize {
0
}
fn to_html(
self,
_buf: &mut String,
_class: &mut String,
_style: &mut String,
_inner_html: &mut String,
) {
}
#[inline(always)]
fn hydrate<const FROM_SERVER: bool>(self, el: &Element) -> Self::State {
let signal = self.read_signal(el);
let attr_state = prop(self.key(), signal).hydrate::<FROM_SERVER>(el);
let cleanup = self.attach(el);
(attr_state, (el.clone(), Some(cleanup)))
}
#[inline(always)]
fn build(self, el: &Element) -> Self::State {
let signal = self.read_signal(el);
let attr_state = prop(self.key(), signal).build(el);
let cleanup = self.attach(el);
(attr_state, (el.clone(), Some(cleanup)))
}
#[inline(always)]
fn rebuild(self, state: &mut Self::State) {
let (attr_state, (el, prev_cleanup)) = state;
let signal = self.read_signal(el);
prop(self.key(), signal).rebuild(attr_state);
if let Some(prev) = prev_cleanup.take() {
(prev.into_inner())(el);
}
*prev_cleanup = Some(self.attach(el));
}
fn into_cloneable(self) -> Self::Cloneable {
self.into_cloneable_owned()
}
fn into_cloneable_owned(self) -> Self::CloneableOwned {
self
}
fn dry_resolve(&mut self) {}
async fn resolve(self) -> Self::AsyncOutput {
self
}
}
impl<Key, T, R, W> NextAttribute for Bind<Key, T, R, W>
where
Key: AttributeKey,
T: FromEventTarget + AttributeValue + PartialEq + Sync + 'static,
R: Get<Value = T> + Clone + Send + Sync + 'static,
Signal<BoolOrT<T>>: IntoProperty,
W: Update<Value = T> + Clone + Send + 'static,
Element: ChangeEvent + GetValue<T>,
{
type Output<NewAttr: Attribute> = (Self, NewAttr);
fn add_any_attr<NewAttr: Attribute>(
self,
new_attr: NewAttr,
) -> Self::Output<NewAttr> {
(self, new_attr)
}
}
impl<Key, T, R, W> ToTemplate for Bind<Key, T, R, W>
where
Key: AttributeKey,
T: FromEventTarget + AttributeValue + 'static,
R: Get<Value = T> + Clone + 'static,
W: Update<Value = T> + Clone,
{
#[inline(always)]
fn to_template(
_buf: &mut String,
_class: &mut String,
_style: &mut String,
_inner_html: &mut String,
_position: &mut Position,
) {
}
}
/// Splits a combined signal into its read and write parts.
///
/// This allows you to either provide a `RwSignal` or a tuple `(ReadSignal, WriteSignal)`.
pub trait IntoSplitSignal {
/// The actual contained value of the signal
type Value;
/// The read part of the signal
type Read: Get<Value = Self::Value>;
/// The write part of the signal
type Write: Update<Value = Self::Value>;
/// Splits a combined signal into its read and write parts.
fn into_split_signal(self) -> (Self::Read, Self::Write);
}
impl<T> IntoSplitSignal for RwSignal<T>
where
T: Send + Sync + 'static,
ReadSignal<T>: Get<Value = T>,
{
type Value = T;
type Read = ReadSignal<T>;
type Write = WriteSignal<T>;
fn into_split_signal(self) -> (ReadSignal<T>, WriteSignal<T>) {
self.split()
}
}
impl<T, R, W> IntoSplitSignal for (R, W)
where
R: Get<Value = T>,
W: Update<Value = T>,
{
type Value = T;
type Read = R;
type Write = W;
fn into_split_signal(self) -> (Self::Read, Self::Write) {
self
}
}
/// Returns self from an event target.
pub trait FromEventTarget {
/// Returns self from an event target.
fn from_event_target(evt: &web_sys::Event) -> Self;
}
impl FromEventTarget for bool {
fn from_event_target(evt: &web_sys::Event) -> Self {
event_target_checked(evt)
}
}
impl FromEventTarget for String {
fn from_event_target(evt: &web_sys::Event) -> Self {
event_target_value(evt)
}
}
/// Attaches the appropriate change event listener to the element.
/// - `<input>` with text types and `<textarea>` elements use the `input` event;
/// - `<input type="checkbox">`, `<input type="radio">` and `<select>` use the `change` event;
pub trait ChangeEvent {
/// Attaches the appropriate change event listener to the element.
fn attach_change_event<T, W>(
&self,
key: &str,
write_signal: W,
) -> RemoveEventHandler<Self>
where
T: FromEventTarget + AttributeValue + 'static,
W: Update<Value = T> + 'static,
Self: Sized;
}
impl ChangeEvent for web_sys::Element {
fn attach_change_event<T, W>(
&self,
key: &str,
write_signal: W,
) -> RemoveEventHandler<Self>
where
T: FromEventTarget + AttributeValue + 'static,
W: Update<Value = T> + 'static,
{
if key == "group" {
let handler = move |evt| {
let checked = event_target_checked(&evt);
if checked {
write_signal
.try_update(|v| *v = T::from_event_target(&evt));
}
};
on::<_, _>(change, handler).attach(self)
} else {
let handler = move |evt| {
write_signal.try_update(|v| *v = T::from_event_target(&evt));
};
if key == "checked" || self.tag_name() == "SELECT" {
on::<_, _>(change, handler).attach(self)
} else {
on::<_, _>(input, handler).attach(self)
}
}
}
}
/// Get the value attribute of an element (input).
/// Reads `value` if `T` is `String` and `checked` if `T` is `bool`.
pub trait GetValue<T> {
/// Get the value attribute of an element (input).
fn get_value(&self) -> T;
}
impl GetValue<String> for web_sys::Element {
fn get_value(&self) -> String {
self.get_attribute("value").unwrap_or_default()
}
}
impl GetValue<bool> for web_sys::Element {
fn get_value(&self) -> bool {
self.get_attribute("checked").unwrap_or_default() == "true"
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
/// Bool or a type. Needed to make the `group` attribute work. It is decided at runtime
/// if the derived signal value is a bool or a type `T`.
pub enum BoolOrT<T> {
/// We have definitely a boolean value for the `group` attribute
Bool(bool),
/// Standard case with some type `T`
T(T),
}
impl<T> IntoProperty for BoolOrT<T>
where
T: IntoProperty<State = (Element, JsValue)>
+ Into<JsValue>
+ Clone
+ 'static,
{
type State = (Element, JsValue);
type Cloneable = Self;
type CloneableOwned = Self;
fn hydrate<const FROM_SERVER: bool>(
self,
el: &Element,
key: &str,
) -> Self::State {
match self.clone() {
Self::T(s) => {
s.hydrate::<FROM_SERVER>(el, key);
}
Self::Bool(b) => {
<bool as IntoProperty>::hydrate::<FROM_SERVER>(b, el, key);
}
};
(el.clone(), self.into())
}
fn build(self, el: &Element, key: &str) -> Self::State {
match self.clone() {
Self::T(s) => {
s.build(el, key);
}
Self::Bool(b) => {
<bool as IntoProperty>::build(b, el, key);
}
}
(el.clone(), self.into())
}
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
match self {
Self::T(s) => s.rebuild(&mut (el.clone(), prev.clone()), key),
Self::Bool(b) => <bool as IntoProperty>::rebuild(
b,
&mut (el.clone(), prev.clone()),
key,
),
}
}
fn into_cloneable(self) -> Self::Cloneable {
self
}
fn into_cloneable_owned(self) -> Self::CloneableOwned {
self
}
}
impl<T> From<BoolOrT<T>> for JsValue
where
T: Into<JsValue>,
{
fn from(value: BoolOrT<T>) -> Self {
match value {
BoolOrT::Bool(b) => b.into(),
BoolOrT::T(t) => t.into(),
}
}
}

View File

@@ -16,6 +16,8 @@ use std::{
sync::{Arc, Mutex},
};
/// Types for two way data binding.
pub mod bind;
mod class;
mod inner_html;
/// Provides a reactive [`NodeRef`](node_ref::NodeRef) type.
@@ -24,6 +26,7 @@ mod owned;
mod property;
mod style;
mod suspense;
pub use owned::*;
pub use suspense::*;