freya_components/
dropdown.rs

1use std::fmt::Display;
2
3use dioxus::prelude::*;
4use freya_core::{
5    platform::CursorIcon,
6    types::AccessibilityId,
7};
8use freya_elements::{
9    self as dioxus_elements,
10    events::{
11        keyboard::Key,
12        KeyboardEvent,
13        MouseEvent,
14    },
15};
16use freya_hooks::{
17    theme_with,
18    use_applied_theme,
19    use_focus,
20    use_platform,
21    DropdownItemTheme,
22    DropdownItemThemeWith,
23    DropdownTheme,
24    DropdownThemeWith,
25    IconThemeWith,
26    UseFocus,
27};
28
29use crate::icons::ArrowIcon;
30
31/// Properties for the [`DropdownItem`] component.
32#[derive(Props, Clone, PartialEq)]
33pub struct DropdownItemProps<T: 'static + Clone + PartialEq> {
34    /// Theme override.
35    pub theme: Option<DropdownItemThemeWith>,
36    /// Selectable items, like [`DropdownItem`]
37    pub children: Element,
38    /// Selected value.
39    pub value: T,
40    /// Handler for the `onpress` event.
41    pub onpress: Option<EventHandler<()>>,
42}
43
44/// Current status of the DropdownItem.
45#[derive(Debug, Default, PartialEq, Clone, Copy)]
46pub enum DropdownItemStatus {
47    /// Default state.
48    #[default]
49    Idle,
50    /// Dropdown item is being hovered.
51    Hovering,
52}
53
54/// # Styling
55/// Inherits the [`DropdownItemTheme`](freya_hooks::DropdownItemTheme) theme.
56#[allow(non_snake_case)]
57pub fn DropdownItem<T>(
58    DropdownItemProps {
59        theme,
60        children,
61        value,
62        onpress,
63    }: DropdownItemProps<T>,
64) -> Element
65where
66    T: Clone + PartialEq + 'static,
67{
68    let selected = use_context::<Signal<T>>();
69    let theme = use_applied_theme!(&theme, dropdown_item);
70    let focus = use_focus();
71    let mut status = use_signal(DropdownItemStatus::default);
72    let platform = use_platform();
73    let dropdown_group = use_context::<DropdownGroup>();
74
75    let a11y_id = focus.attribute();
76    let a11y_member_of = UseFocus::attribute_for_id(dropdown_group.group_id);
77    let is_selected = *selected.read() == value;
78
79    let DropdownItemTheme {
80        font_theme,
81        background,
82        hover_background,
83        select_background,
84        border_fill,
85        select_border_fill,
86    } = &theme;
87
88    let background = match *status.read() {
89        _ if is_selected => select_background,
90        DropdownItemStatus::Hovering => hover_background,
91        DropdownItemStatus::Idle => background,
92    };
93    let border = if focus.is_focused_with_keyboard() {
94        format!("2 inner {select_border_fill}")
95    } else {
96        format!("1 inner {border_fill}")
97    };
98
99    use_drop(move || {
100        if *status.peek() == DropdownItemStatus::Hovering {
101            platform.set_cursor(CursorIcon::default());
102        }
103    });
104
105    let onmouseenter = move |_| {
106        platform.set_cursor(CursorIcon::Pointer);
107        status.set(DropdownItemStatus::Hovering);
108    };
109
110    let onmouseleave = move |_| {
111        platform.set_cursor(CursorIcon::default());
112        status.set(DropdownItemStatus::default());
113    };
114
115    let onkeydown = {
116        to_owned![onpress];
117        move |ev: KeyboardEvent| {
118            if ev.key == Key::Enter {
119                if let Some(onpress) = &onpress {
120                    onpress.call(())
121                }
122            }
123        }
124    };
125
126    let onclick = move |_: MouseEvent| {
127        if let Some(onpress) = &onpress {
128            onpress.call(())
129        }
130    };
131
132    rsx!(
133        rect {
134            width: "fill-min",
135            color: "{font_theme.color}",
136            a11y_id,
137            a11y_role: "button",
138            a11y_member_of,
139            background: "{background}",
140            border,
141            padding: "6 10",
142            corner_radius: "6",
143            main_align: "center",
144            onmouseenter,
145            onmouseleave,
146            onclick,
147            onkeydown,
148            {children}
149        }
150    )
151}
152
153/// Properties for the [`Dropdown`] component.
154#[derive(Props, Clone, PartialEq)]
155pub struct DropdownProps<T: 'static + Clone + PartialEq> {
156    /// Theme override.
157    pub theme: Option<DropdownThemeWith>,
158    /// Selectable items, like [`DropdownItem`]
159    pub children: Element,
160    /// Selected value.
161    pub value: T,
162}
163
164/// Current status of the Dropdown.
165#[derive(Debug, Default, PartialEq, Clone, Copy)]
166pub enum DropdownStatus {
167    /// Default state.
168    #[default]
169    Idle,
170    /// Dropdown is being hovered.
171    Hovering,
172}
173
174#[derive(Clone)]
175struct DropdownGroup {
176    group_id: AccessibilityId,
177}
178
179/// Select from multiple options, use alongside [`DropdownItem`].
180///
181/// # Styling
182/// Inherits the [`DropdownTheme`](freya_hooks::DropdownTheme) theme.
183///
184/// # Example
185/// ```rust
186/// # use freya::prelude::*;
187///
188/// fn app() -> Element {
189///     let values = use_hook(|| vec!["Value A".to_string(), "Value B".to_string(), "Value C".to_string()]);
190///     let mut selected_dropdown = use_signal(|| "Value A".to_string());
191///     rsx!(
192///         Dropdown {
193///             value: selected_dropdown.read().clone(),
194///             for ch in values {
195///                 DropdownItem {
196///                     value: ch.to_string(),
197///                     onpress: {
198///                         to_owned![ch];
199///                         move |_| selected_dropdown.set(ch.clone())
200///                     },
201///                     label { "{ch}" }
202///                 }
203///             }
204///         }
205///     )
206/// }
207/// # use freya_testing::prelude::*;
208/// # launch_doc(|| {
209/// #   rsx!(
210/// #       Preview {
211/// #           {app()}
212/// #       }
213/// #   )
214/// # }, (250., 250.).into(), "./images/gallery_dropdown.png");
215/// ```
216///
217/// # Preview
218/// ![Dropdown Preview][dropdown]
219#[cfg_attr(feature = "docs",
220    doc = embed_doc_image::embed_image!("dropdown", "images/gallery_dropdown.png")
221)]
222#[allow(non_snake_case)]
223pub fn Dropdown<T>(props: DropdownProps<T>) -> Element
224where
225    T: PartialEq + Clone + Display + 'static,
226{
227    let mut selected = use_context_provider(|| Signal::new(props.value.clone()));
228    let theme = use_applied_theme!(&props.theme, dropdown);
229    let mut focus = use_focus();
230    let mut status = use_signal(DropdownStatus::default);
231    let mut opened = use_signal(|| false);
232    let platform = use_platform();
233
234    use_context_provider(|| DropdownGroup {
235        group_id: focus.id(),
236    });
237
238    let is_opened = *opened.read();
239    let is_focused = focus.is_focused();
240    let a11y_id = focus.attribute();
241    let a11y_member_of = focus.attribute();
242
243    if *selected.peek() != props.value {
244        *selected.write() = props.value;
245    }
246
247    // Close if the focused node is not part of the Dropdown
248    use_effect(move || {
249        if let Some(member_of) = focus.focused_node().read().member_of() {
250            if member_of != focus.id() {
251                opened.set(false);
252            }
253        }
254    });
255
256    use_drop(move || {
257        if *status.peek() == DropdownStatus::Hovering {
258            platform.set_cursor(CursorIcon::default());
259        }
260    });
261
262    // Close the dropdown if clicked anywhere
263    let onglobalclick = move |_: MouseEvent| {
264        opened.set(false);
265    };
266
267    let onclick = move |_| {
268        focus.request_focus();
269        opened.set(true)
270    };
271
272    let onglobalkeydown = move |e: KeyboardEvent| {
273        match e.key {
274            // Close when `Escape` key is pressed
275            Key::Escape => {
276                opened.set(false);
277            }
278            // Open the dropdown items when the `Enter` key is pressed
279            Key::Enter if is_focused && !is_opened => {
280                opened.set(true);
281            }
282            _ => {}
283        }
284    };
285
286    let onmouseenter = move |_| {
287        platform.set_cursor(CursorIcon::Pointer);
288        status.set(DropdownStatus::Hovering);
289    };
290
291    let onmouseleave = move |_| {
292        platform.set_cursor(CursorIcon::default());
293        status.set(DropdownStatus::default());
294    };
295
296    let DropdownTheme {
297        width,
298        margin,
299        font_theme,
300        dropdown_background,
301        background_button,
302        hover_background,
303        border_fill,
304        focus_border_fill,
305        arrow_fill,
306    } = &theme;
307
308    let background = match *status.read() {
309        DropdownStatus::Hovering => hover_background,
310        DropdownStatus::Idle => background_button,
311    };
312    let border = if focus.is_focused_with_keyboard() {
313        format!("2 inner {focus_border_fill}")
314    } else {
315        format!("1 inner {border_fill}")
316    };
317
318    let selected = selected.read().to_string();
319
320    rsx!(
321        rect {
322            direction: "vertical",
323            spacing: "4",
324            rect {
325                width: "{width}",
326                onmouseenter,
327                onmouseleave,
328                onclick,
329                onglobalkeydown,
330                margin: "{margin}",
331                a11y_id,
332                a11y_member_of,
333                background: "{background}",
334                color: "{font_theme.color}",
335                corner_radius: "8",
336                padding: "6 16",
337                border,
338                direction: "horizontal",
339                main_align: "center",
340                cross_align: "center",
341                label {
342                    "{selected}"
343                }
344                ArrowIcon {
345                    rotate: "0",
346                    fill: "{arrow_fill}",
347                    theme: theme_with!(IconTheme {
348                        margin : "0 0 0 8".into(),
349                    })
350                }
351            }
352            if *opened.read() {
353                rect {
354                    height: "0",
355                    width: "0",
356                    rect {
357                        width: "100v",
358                        rect {
359                            onglobalclick,
360                            onglobalkeydown,
361                            layer: "-1000",
362                            margin: "{margin}",
363                            border: "1 inner {border_fill}",
364                            overflow: "clip",
365                            corner_radius: "8",
366                            background: "{dropdown_background}",
367                            shadow: "0 2 4 0 rgb(0, 0, 0, 0.15)",
368                            padding: "6",
369                            content: "fit",
370                            {props.children}
371                        }
372                    }
373                }
374            }
375        }
376    )
377}
378
379#[cfg(test)]
380mod test {
381    use freya::prelude::*;
382    use freya_testing::prelude::*;
383
384    #[tokio::test]
385    pub async fn dropdown() {
386        fn dropdown_app() -> Element {
387            let values = use_hook(|| {
388                vec![
389                    "Value A".to_string(),
390                    "Value B".to_string(),
391                    "Value C".to_string(),
392                ]
393            });
394            let mut selected_dropdown = use_signal(|| "Value A".to_string());
395
396            rsx!(
397                Dropdown {
398                    value: selected_dropdown.read().clone(),
399                    for ch in values {
400                        DropdownItem {
401                            value: ch.clone(),
402                            onpress: {
403                                to_owned![ch];
404                                move |_| selected_dropdown.set(ch.clone())
405                            },
406                            label { "{ch}" }
407                        }
408                    }
409                }
410            )
411        }
412
413        let mut utils = launch_test(dropdown_app);
414        let root = utils.root();
415        let label = root.get(0).get(0).get(0);
416        utils.wait_for_update().await;
417
418        // Currently closed
419        let start_size = utils.sdom().get().layout().size();
420
421        // Default value
422        assert_eq!(label.get(0).text(), Some("Value A"));
423
424        // Open the dropdown
425        utils.click_cursor((15., 15.)).await;
426        utils.wait_for_update().await;
427
428        // Now that the dropwdown is opened, there are more nodes in the layout
429        assert!(utils.sdom().get().layout().size() > start_size);
430
431        // Close the dropdown by clicking outside of it
432        utils.click_cursor((200., 200.)).await;
433
434        // Now the layout size is like in the begining
435        assert_eq!(utils.sdom().get().layout().size(), start_size);
436
437        // Open the dropdown again
438        utils.click_cursor((15., 15.)).await;
439
440        // Click on the second option
441        utils.click_cursor((45., 90.)).await;
442        utils.wait_for_update().await;
443        utils.wait_for_update().await;
444
445        // Now the layout size is like in the begining, again
446        assert_eq!(utils.sdom().get().layout().size(), start_size);
447
448        // The second optio was selected
449        assert_eq!(label.get(0).text(), Some("Value B"));
450    }
451
452    #[tokio::test]
453    pub async fn dropdown_keyboard_navigation() {
454        fn dropdown_keyboard_navigation_app() -> Element {
455            let values = use_hook(|| {
456                vec![
457                    "Value A".to_string(),
458                    "Value B".to_string(),
459                    "Value C".to_string(),
460                ]
461            });
462            let mut selected_dropdown = use_signal(|| "Value A".to_string());
463
464            rsx!(
465                Dropdown {
466                    value: selected_dropdown.read().clone(),
467                    for ch in values {
468                        DropdownItem {
469                            value: ch.clone(),
470                            onpress: {
471                                to_owned![ch];
472                                move |_| selected_dropdown.set(ch.clone())
473                            },
474                            label { "{ch}" }
475                        }
476                    }
477                }
478            )
479        }
480
481        let mut utils = launch_test(dropdown_keyboard_navigation_app);
482        let root = utils.root();
483        let label = root.get(0).get(0).get(0);
484        utils.wait_for_update().await;
485
486        // Currently closed
487        let start_size = utils.sdom().get().layout().size();
488
489        // Default value
490        assert_eq!(label.get(0).text(), Some("Value A"));
491
492        // Open the dropdown
493        utils.push_event(TestEvent::Keyboard {
494            name: EventName::KeyDown,
495            key: Key::Tab,
496            code: Code::Tab,
497            modifiers: Modifiers::default(),
498        });
499        utils.wait_for_update().await;
500        utils.wait_for_update().await;
501        utils.push_event(TestEvent::Keyboard {
502            name: EventName::KeyDown,
503            key: Key::Enter,
504            code: Code::Enter,
505            modifiers: Modifiers::default(),
506        });
507        utils.wait_for_update().await;
508        utils.wait_for_update().await;
509        utils.wait_for_update().await;
510
511        // Now that the dropwdown is opened, there are more nodes in the layout
512        assert!(utils.sdom().get().layout().size() > start_size);
513
514        // Close the dropdown by pressinc Esc
515        utils.push_event(TestEvent::Keyboard {
516            name: EventName::KeyDown,
517            key: Key::Escape,
518            code: Code::Escape,
519            modifiers: Modifiers::default(),
520        });
521        utils.wait_for_update().await;
522
523        // Now the layout size is like in the begining
524        assert_eq!(utils.sdom().get().layout().size(), start_size);
525
526        // Open the dropdown again
527        utils.push_event(TestEvent::Keyboard {
528            name: EventName::KeyDown,
529            key: Key::Enter,
530            code: Code::Enter,
531            modifiers: Modifiers::default(),
532        });
533        utils.wait_for_update().await;
534
535        // Click on the second option
536        utils.push_event(TestEvent::Keyboard {
537            name: EventName::KeyDown,
538            key: Key::Tab,
539            code: Code::Tab,
540            modifiers: Modifiers::default(),
541        });
542        utils.wait_for_update().await;
543        utils.wait_for_update().await;
544        utils.push_event(TestEvent::Keyboard {
545            name: EventName::KeyDown,
546            key: Key::Tab,
547            code: Code::Tab,
548            modifiers: Modifiers::default(),
549        });
550        utils.wait_for_update().await;
551        utils.wait_for_update().await;
552        utils.push_event(TestEvent::Keyboard {
553            name: EventName::KeyDown,
554            key: Key::Enter,
555            code: Code::Enter,
556            modifiers: Modifiers::default(),
557        });
558        utils.wait_for_update().await;
559        utils.wait_for_update().await;
560
561        // Close with Escape
562        utils.push_event(TestEvent::Keyboard {
563            name: EventName::KeyDown,
564            key: Key::Escape,
565            code: Code::Escape,
566            modifiers: Modifiers::default(),
567        });
568        utils.wait_for_update().await;
569        utils.wait_for_update().await;
570
571        // Now the layout size is like in the begining, again
572        assert_eq!(utils.sdom().get().layout().size(), start_size);
573
574        // The second option was selected
575        assert_eq!(label.get(0).text(), Some("Value B"));
576    }
577}