yewlish_attr_passer/
lib.rs1use yew::{prelude::*, virtual_dom::VNode};
2use yewlish_synchi::*;
3
4type Attributes = Vec<(&'static str, AttrValue)>;
5
6#[derive(Debug, Clone, PartialEq, Default)]
7struct MergeAttributes(Attributes);
8
9impl Merge for MergeAttributes {
10 fn merge(&self, other: &Self) -> Self {
11 MergeAttributes(self.0.iter().chain(other.0.iter()).cloned().collect())
12 }
13}
14
15#[derive(Debug, Clone, PartialEq, Default)]
16pub struct AttrPasserContext {
17 pub name: &'static str,
18 pub index: Vec<usize>,
19}
20
21#[derive(Debug, Clone, PartialEq, Properties)]
22pub struct AttrPasserProps {
23 pub name: &'static str,
24 #[prop_or_default]
25 pub children: Children,
26 #[prop_or_default]
27 pub attributes: Attributes,
28}
29
30#[function_component(AttrPasser)]
31pub fn attr_passer(props: &AttrPasserProps) -> Html {
32 let channel = use_synchi_channel_with::<MergeAttributes>(
33 props.name,
34 MergeAttributes(props.attributes.clone()),
35 );
36
37 use_effect_with(
38 (channel.clone(), props.attributes.clone()),
39 |(channel, attributes)| {
40 channel
41 .borrow_mut()
42 .push(MergeAttributes(attributes.clone()));
43 },
44 );
45
46 let parent_context = use_context::<AttrPasserContext>();
47
48 html! {
49 <ContextProvider<AttrPasserContext> context={AttrPasserContext {
50 name: channel.borrow().name,
51 index: parent_context
52 .filter(|ctx| ctx.name == channel.borrow().name)
53 .map_or_else(
54 || vec![channel.borrow().index],
55 |ctx| ctx.index.into_iter().chain(std::iter::once(channel.borrow().index)).collect()
56 )
57 }}>
58 {props.children.clone()}
59 </ContextProvider<AttrPasserContext>>
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Properties)]
64pub struct AttrReceiverProps {
65 #[prop_or_default]
66 pub name: &'static str,
67 #[prop_or_default]
68 pub children: Children,
69}
70
71#[function_component(AttrReceiver)]
72pub fn attr_receiver(props: &AttrReceiverProps) -> Html {
73 let context = use_context::<AttrPasserContext>();
74
75 if context.is_none() {
76 return html! {
77 <>{props.children.clone()}</>
78 };
79 }
80
81 let context = context.unwrap();
82
83 if props.name != context.name {
84 return html! {
85 <>{props.children.clone()}</>
86 };
87 }
88
89 let attributes = use_synchi_channel_subscribe::<MergeAttributes>(
90 props.name,
91 if context.name == props.name {
92 context.index.clone()
93 } else {
94 vec![]
95 },
96 );
97
98 if props.children.is_empty() {
99 return html! {};
100 }
101
102 if props.children.len() > 1 {
103 log::warn!("AttrReceiver component only accepts one child");
104 return html! {};
105 }
106
107 let element = props.children.iter().next().unwrap();
108
109 if let VNode::VTag(tag) = element.clone() {
110 let mut tag = (*tag).clone();
111
112 for (key, value) in (*attributes).clone().0 {
113 tag.add_attribute(key, value);
114 }
115
116 let element = VNode::VTag(Box::new(tag));
117
118 return html! {
119 <>{element}</>
120 };
121 }
122
123 log::warn!("AttrReceiver component only accepts a tag element");
124
125 html! {}
126}
127
128#[macro_export]
129macro_rules! attributify {
130 ( $( $key:expr => $value:expr ),* $(,)? ) => {{
131 use yew::props;
132 use $crate::AttrPasserProps;
133
134 let mut attributes = vec![];
135
136 $(
137 attributes.push(($key, $value.into()));
138 )*
139
140 props! {
141 AttrPasserProps {
142 name: "",
143 attributes,
144 }
145 }
146 }};
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use wasm_bindgen_test::*;
153 use yewlish_testing_tools::*;
154
155 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
156
157 #[wasm_bindgen_test]
158 async fn test_attr_passer_for_one_receiver() {
159 let t = render!({
160 html! {
161 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
162 <AttrReceiver name="test">
163 <div></div>
164 </AttrReceiver>
165 </AttrPasser>
166 }
167 })
168 .await;
169
170 assert!(t.query_by_role("button").exists());
171 }
172
173 #[wasm_bindgen_test]
174 async fn test_several_attr_passer_for_one_receiver() {
175 let t = render!({
176 html! {
177 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
178 <AttrPasser name="test" ..attributify!{ "aria-label" => "button" }>
179 <AttrReceiver name="test">
180 <div></div>
181 </AttrReceiver>
182 </AttrPasser>
183 </AttrPasser>
184 }
185 })
186 .await;
187
188 let element = t.query_by_role("button");
189
190 assert!(element.exists());
191 assert_eq!(element.attribute("aria-label"), "button".to_string().into());
192 }
193
194 #[wasm_bindgen_test]
195 async fn test_attr_passer_for_several_receivers() {
196 let t = render!({
197 html! {
198 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
199 <AttrReceiver name="test">
200 <div></div>
201 </AttrReceiver>
202
203 <AttrReceiver name="test">
204 <div></div>
205 </AttrReceiver>
206 </AttrPasser>
207 }
208 })
209 .await;
210
211 assert_eq!(t.query_all_by_role("button").len(), 1);
212 }
213
214 #[wasm_bindgen_test]
215 async fn test_nested_attr_passer_with_same_name() {
216 let t = render!({
217 html! {
218 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
219 <AttrReceiver name="test">
220 <div>
221 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
222 <AttrPasser name="test" ..attributify!{ "aria-label" => "button" }>
223 <AttrReceiver name="test">
224 <div></div>
225 </AttrReceiver>
226 </AttrPasser>
227 </AttrPasser>
228 </div>
229 </AttrReceiver>
230 </AttrPasser>
231 }
232 })
233 .await;
234
235 assert_eq!(t.query_all_by_role("button").len(), 2);
236 assert_eq!(
237 t.query_all_by_role("button")[1].attribute("aria-label"),
238 "button".to_string().into()
239 );
240 }
241
242 #[wasm_bindgen_test]
243 async fn test_neighbor_attr_passer_with_same_name() {
244 let t = render!({
245 html! {
246 <>
247 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
248 <AttrReceiver name="test">
249 <div></div>
250 </AttrReceiver>
251 </AttrPasser>
252
253 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
254 <AttrReceiver name="test">
255 <div></div>
256 </AttrReceiver>
257 </AttrPasser>
258 </>
259 }
260 })
261 .await;
262
263 assert_eq!(t.query_all_by_role("button").len(), 2);
264 }
265
266 #[wasm_bindgen_test]
267 async fn test_attr_passer_with_mutable_attributes() {
268 let t = render!({
269 let role = use_state(|| "button".to_string());
270
271 let update_role = use_callback(role.clone(), |_event: MouseEvent, role| {
272 role.set("checkbox".to_string());
273 });
274
275 html! {
276 <AttrPasser name="test" ..attributify! { "role" => (*role).clone() }>
277 <AttrReceiver name="test">
278 <div onclick={&update_role}></div>
279 </AttrReceiver>
280 </AttrPasser>
281 }
282 })
283 .await;
284
285 let element = t.query_by_role("button");
286 assert!(element.exists());
287
288 element.click().await;
289
290 let element = t.query_by_role("checkbox");
291 assert!(element.exists());
292 }
293
294 #[wasm_bindgen_test]
295 async fn test_attr_passer_with_receiver_in_different_component() {
296 #[function_component(AttrReceiverInDifferentComponent)]
297 fn attr_receiver_in_different_component() -> Html {
298 html! {
299 <AttrReceiver name="test">
300 <div></div>
301 </AttrReceiver>
302 }
303 }
304
305 let t = render!({
306 html! {
307 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
308 <AttrReceiverInDifferentComponent />
309 </AttrPasser>
310 }
311 })
312 .await;
313
314 assert!(t.query_by_role("button").exists());
315 }
316
317 #[wasm_bindgen_test]
318 async fn test_attr_passer_with_receiver_in_different_component_rendered_conditionally() {
319 #[derive(Debug, Clone, PartialEq, Properties)]
320 struct AttrReceiverInDifferentComponentProps {
321 pub show: bool,
322 }
323
324 #[function_component(AttrReceiverInDifferentComponent)]
325 fn attr_receiver_in_different_component(
326 props: &AttrReceiverInDifferentComponentProps,
327 ) -> Html {
328 if !props.show {
329 return html! {};
330 }
331
332 html! {
333 <AttrReceiver name="test">
334 <div></div>
335 </AttrReceiver>
336 }
337 }
338
339 let t = render!({
340 let show = use_state(|| false);
341
342 let toggle_show = use_callback(show.clone(), |_event: MouseEvent, show| {
343 show.set(true);
344 });
345
346 html! {
347 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
348 <div data-testid="trigger" onclick={&toggle_show}>
349 <AttrReceiverInDifferentComponent show={*show} />
350 </div>
351 </AttrPasser>
352 }
353 })
354 .await;
355
356 let button = t.query_by_role("button");
357 assert!(!button.exists());
358
359 let trigger = t.query_by_testid("trigger");
360 trigger.click().await;
361
362 let button = t.query_by_role("button");
363 assert!(button.exists());
364 }
365
366 #[wasm_bindgen_test]
367 async fn test_attr_passer_with_elements_that_mount_and_unmount() {
368 let t = render!({
369 let show = use_state(|| true);
370
371 let toggle_show = {
372 let show = show.clone();
373 use_callback((), move |_: MouseEvent, ()| {
374 show.set(!*show);
375 })
376 };
377
378 html! {
379 <>
380 <button onclick={toggle_show.clone()}>{"Toggle"}</button>
381 <AttrPasser name="test" ..attributify!{ "role" => "button" }>
382 { if *show {
383 html! {
384 <AttrReceiver name="test">
385 <div></div>
386 </AttrReceiver>
387 }
388 } else {
389 html! {}
390 }}
391 </AttrPasser>
392 </>
393 }
394 })
395 .await;
396
397 let element = t.query_by_role("button");
399 assert!(element.exists());
400
401 let toggle_button = t.query_by_text("Toggle");
403 let toggle_button = toggle_button.click().await;
404
405 let element = t.query_by_role("button");
407 assert!(!element.exists());
408
409 toggle_button.click().await;
411
412 let element = t.query_by_role("button");
414 assert!(element.exists());
415 }
416}