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#[derive(Props, Clone, PartialEq)]
33pub struct DropdownItemProps<T: 'static + Clone + PartialEq> {
34 pub theme: Option<DropdownItemThemeWith>,
36 pub children: Element,
38 pub value: T,
40 pub onpress: Option<EventHandler<()>>,
42}
43
44#[derive(Debug, Default, PartialEq, Clone, Copy)]
46pub enum DropdownItemStatus {
47 #[default]
49 Idle,
50 Hovering,
52}
53
54#[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#[derive(Props, Clone, PartialEq)]
155pub struct DropdownProps<T: 'static + Clone + PartialEq> {
156 pub theme: Option<DropdownThemeWith>,
158 pub children: Element,
160 pub value: T,
162}
163
164#[derive(Debug, Default, PartialEq, Clone, Copy)]
166pub enum DropdownStatus {
167 #[default]
169 Idle,
170 Hovering,
172}
173
174#[derive(Clone)]
175struct DropdownGroup {
176 group_id: AccessibilityId,
177}
178
179#[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 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 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 Key::Escape => {
276 opened.set(false);
277 }
278 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 let start_size = utils.sdom().get().layout().size();
420
421 assert_eq!(label.get(0).text(), Some("Value A"));
423
424 utils.click_cursor((15., 15.)).await;
426 utils.wait_for_update().await;
427
428 assert!(utils.sdom().get().layout().size() > start_size);
430
431 utils.click_cursor((200., 200.)).await;
433
434 assert_eq!(utils.sdom().get().layout().size(), start_size);
436
437 utils.click_cursor((15., 15.)).await;
439
440 utils.click_cursor((45., 90.)).await;
442 utils.wait_for_update().await;
443 utils.wait_for_update().await;
444
445 assert_eq!(utils.sdom().get().layout().size(), start_size);
447
448 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 let start_size = utils.sdom().get().layout().size();
488
489 assert_eq!(label.get(0).text(), Some("Value A"));
491
492 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 assert!(utils.sdom().get().layout().size() > start_size);
513
514 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 assert_eq!(utils.sdom().get().layout().size(), start_size);
525
526 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 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 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 assert_eq!(utils.sdom().get().layout().size(), start_size);
573
574 assert_eq!(label.get(0).text(), Some("Value B"));
576 }
577}