Skip to main content

yewlish_attr_passer/
lib.rs

1use 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        // Initially, the element should exist
398        let element = t.query_by_role("button");
399        assert!(element.exists());
400
401        // Click the toggle button to unmount the element
402        let toggle_button = t.query_by_text("Toggle");
403        let toggle_button = toggle_button.click().await;
404
405        // The element should no longer exist
406        let element = t.query_by_role("button");
407        assert!(!element.exists());
408
409        // Click the toggle button again to remount the element
410        toggle_button.click().await;
411
412        // The element should exist again
413        let element = t.query_by_role("button");
414        assert!(element.exists());
415    }
416}