Skip to main content

freya_components/
select.rs

1use freya_animation::prelude::*;
2use freya_core::prelude::*;
3use torin::prelude::*;
4
5use crate::{
6    define_theme,
7    get_theme,
8    icons::{
9        IconThemePartialExt,
10        arrow::ArrowIcon,
11    },
12    menu::MenuGroup,
13};
14
15define_theme! {
16    %[component]
17    pub Select {
18        %[fields]
19        width: Size,
20        margin: Gaps,
21        select_background: Color,
22        background_button: Color,
23        hover_background: Color,
24        border_fill: Color,
25        focus_border_fill: Color,
26        arrow_fill: Color,
27        color: Color,
28    }
29}
30
31#[derive(Debug, Default, PartialEq, Clone, Copy)]
32pub enum SelectStatus {
33    #[default]
34    Idle,
35    Hovering,
36}
37
38/// Select between different items component.
39///
40/// # Example
41///
42/// ```rust
43/// # use freya::prelude::*;
44/// fn app() -> impl IntoElement {
45///     let values = use_hook(|| {
46///         vec![
47///             "Rust".to_string(),
48///             "Turbofish".to_string(),
49///             "Crabs".to_string(),
50///         ]
51///     });
52///     let mut selected_select = use_state(|| 0);
53///
54///     Select::new()
55///         .selected_item(values[selected_select()].to_string())
56///         .children(values.iter().enumerate().map(|(i, val)| {
57///             MenuItem::new()
58///                 .selected(selected_select() == i)
59///                 .on_press(move |_| selected_select.set(i))
60///                 .child(val.to_string())
61///         }))
62/// }
63/// ```
64///
65/// See the [interactive components demo](https://freyaui.dev/demo).
66#[derive(Clone, PartialEq)]
67pub struct Select {
68    pub(crate) theme: Option<SelectThemePartial>,
69    selected_item: Option<Element>,
70    list_match_width: bool,
71    children: Vec<Element>,
72    cursor_icon: Option<CursorIcon>,
73    key: DiffKey,
74}
75
76impl ChildrenExt for Select {
77    fn children_mut(&mut self) -> &mut Vec<Element> {
78        &mut self.children
79    }
80}
81
82impl KeyExt for Select {
83    fn key_mut(&mut self) -> &mut DiffKey {
84        &mut self.key
85    }
86}
87
88impl Default for Select {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Select {
95    pub fn new() -> Self {
96        Self {
97            theme: None,
98            selected_item: None,
99            list_match_width: false,
100            children: Vec::new(),
101            cursor_icon: None,
102            key: DiffKey::None,
103        }
104    }
105
106    pub fn theme(mut self, theme: SelectThemePartial) -> Self {
107        self.theme = Some(theme);
108        self
109    }
110
111    pub fn selected_item(mut self, item: impl Into<Element>) -> Self {
112        self.selected_item = Some(item.into());
113        self
114    }
115
116    /// Match the dropdiwb width to the button width.
117    pub fn list_match_width(mut self, enabled: bool) -> Self {
118        self.list_match_width = enabled;
119        self
120    }
121
122    /// Override the cursor icon shown when hovering over this component.
123    pub fn cursor(mut self, cursor_icon: impl Into<Option<CursorIcon>>) -> Self {
124        self.cursor_icon = cursor_icon.into();
125        self
126    }
127}
128
129impl Component for Select {
130    fn render(&self) -> impl IntoElement {
131        let theme = get_theme!(&self.theme, SelectThemePreference, "select");
132        let a11y_id = use_a11y();
133        let focus = use_focus(a11y_id);
134        let mut status = use_state(SelectStatus::default);
135        let mut open = use_state(|| false);
136        let mut button_area = use_state(|| None::<Area>);
137        let mut list_size = use_state(|| None::<Size2D>);
138        use_provide_context(|| MenuGroup { group_id: a11y_id });
139
140        let focused = use_memo(move || {
141            a11y_id.is_focused()
142                || Platform::get()
143                    .current_window()
144                    .focused_accessibility_node
145                    .read()
146                    .member_of()
147                    == Some(a11y_id)
148        });
149        let visible = open() && focused();
150
151        let animation = use_animation_with_dependencies(&visible, move |conf, visible| {
152            conf.on_change(OnChange::Rerun);
153            conf.on_creation(OnCreation::Finish);
154
155            let opacity = AnimNum::new(0., 1.)
156                .time(90)
157                .ease(Ease::Out)
158                .function(Function::Quad);
159            let offset_y = AnimNum::new(-3., 1.)
160                .time(90)
161                .ease(Ease::Out)
162                .function(Function::Quad);
163            if *visible {
164                (opacity, offset_y)
165            } else {
166                (opacity.into_reversed(), offset_y.into_reversed())
167            }
168        });
169
170        let (opacity, slide) = animation.read().value();
171
172        // Clear the list size when the select dropdown is not rendered
173        if !visible && opacity == 0. && list_size().is_some() {
174            let _ = list_size.take();
175            open.set_if_modified(false);
176        }
177
178        let on_press = move |e: Event<PressEventData>| {
179            if a11y_id.is_focused() {
180                open.toggle();
181            } else {
182                a11y_id.request_focus();
183                open.set(true);
184            }
185            // Prevent global mouse up
186            e.prevent_default();
187            e.stop_propagation();
188        };
189
190        let on_pointer_enter = move |_| {
191            *status.write() = SelectStatus::Hovering;
192        };
193
194        let on_pointer_leave = move |_| {
195            *status.write() = SelectStatus::Idle;
196        };
197
198        // Close the select if clicked anywhere
199        let on_global_pointer_up = move |_: Event<PointerEventData>| {
200            open.set_if_modified(false);
201        };
202
203        let on_global_key_down = move |e: Event<KeyboardEventData>| match e.key {
204            Key::Named(NamedKey::Escape) => {
205                open.set_if_modified(false);
206            }
207            Key::Named(NamedKey::Enter) if a11y_id.is_focused() => {
208                open.toggle();
209            }
210            _ => {}
211        };
212
213        let offset_y = match (button_area(), list_size()) {
214            (Some(button), Some(list)) => {
215                let root_height = Platform::get().current_window().root_size.peek().height;
216                let space_below = root_height - button.max_y();
217                let space_above = button.min_y();
218                let flips = list.height > space_below && list.height <= space_above;
219                if flips {
220                    -(button.height() + list.height) - slide
221                } else {
222                    slide
223                }
224            }
225            _ => slide,
226        };
227
228        let opacity = if list_size().is_some() { opacity } else { 0. };
229
230        let background = match *status.read() {
231            SelectStatus::Hovering => theme.hover_background,
232            SelectStatus::Idle => theme.background_button,
233        };
234
235        let border = if focus() == Focus::Keyboard {
236            Border::new()
237                .fill(theme.focus_border_fill)
238                .width(2.)
239                .alignment(BorderAlignment::Inner)
240        } else {
241            Border::new()
242                .fill(theme.border_fill)
243                .width(1.)
244                .alignment(BorderAlignment::Inner)
245        };
246
247        rect()
248            .child(
249                rect()
250                    .a11y_id(a11y_id)
251                    .a11y_member_of(a11y_id)
252                    .a11y_role(AccessibilityRole::ListBox)
253                    .a11y_focusable(Focusable::Enabled)
254                    .on_pointer_enter(on_pointer_enter)
255                    .on_pointer_leave(on_pointer_leave)
256                    .cursor(self.cursor_icon)
257                    .on_press(on_press)
258                    .on_global_key_down(on_global_key_down)
259                    .on_global_pointer_up(on_global_pointer_up)
260                    .on_sized(move |e: Event<SizedEventData>| {
261                        button_area.set_if_modified(Some(e.area));
262                    })
263                    .width(theme.width)
264                    .margin(theme.margin)
265                    .background(background)
266                    .padding((8., 18., 8., 18.))
267                    .border(border)
268                    .horizontal()
269                    .content(Content::flex())
270                    .center()
271                    .color(theme.color)
272                    .corner_radius(8.)
273                    .maybe_child(self.selected_item.clone())
274                    .child(
275                        ArrowIcon::new()
276                            .margin((0., 0., 0., 8.))
277                            .rotate(0.)
278                            .fill(theme.arrow_fill),
279                    ),
280            )
281            .maybe_child((visible || opacity > 0.).then(|| {
282                rect().height(Size::px(0.)).width(Size::px(0.)).child(
283                    rect()
284                        .width(if self.list_match_width {
285                            Size::px(button_area().map_or(0., |area| area.width()))
286                        } else {
287                            Size::window_percent(100.)
288                        })
289                        .margin(Gaps::new(4., 0., 4., 0.))
290                        .offset_y(offset_y)
291                        .on_sized(move |e: Event<SizedEventData>| {
292                            list_size.set_if_modified(Some(e.area.size));
293                        })
294                        .child(
295                            rect()
296                                .layer(Layer::Overlay)
297                                .maybe(self.list_match_width, |el| el.width(Size::fill()))
298                                .border(
299                                    Border::new()
300                                        .fill(theme.border_fill)
301                                        .width(1.)
302                                        .alignment(BorderAlignment::Inner),
303                                )
304                                .overflow(Overflow::Clip)
305                                .corner_radius(10.)
306                                .background(theme.select_background)
307                                .padding(6.)
308                                .maybe(!self.list_match_width, |el| el.content(Content::Fit))
309                                .opacity(opacity)
310                                .children(self.children.clone()),
311                        ),
312                )
313            }))
314    }
315
316    fn render_key(&self) -> DiffKey {
317        self.key.clone().or(self.default_key())
318    }
319}