dioxus_ui_system/molecules/
dropdown_menu.rs1use crate::atoms::{Icon, IconColor, IconSize};
7use crate::styles::Style;
8use crate::theme::{use_style, use_theme};
9use dioxus::prelude::*;
10
11#[derive(Clone, PartialEq)]
13pub struct DropdownMenuItem {
14 pub label: String,
16 pub value: String,
18 pub icon: Option<String>,
20 pub disabled: bool,
22 pub shortcut: Option<String>,
24}
25
26impl DropdownMenuItem {
27 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
29 Self {
30 label: label.into(),
31 value: value.into(),
32 icon: None,
33 disabled: false,
34 shortcut: None,
35 }
36 }
37
38 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
40 self.icon = Some(icon.into());
41 self
42 }
43
44 pub fn disabled(mut self) -> Self {
46 self.disabled = true;
47 self
48 }
49
50 pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
52 self.shortcut = Some(shortcut.into());
53 self
54 }
55}
56
57#[derive(Props, Clone, PartialEq)]
59pub struct DropdownMenuProps {
60 pub trigger: Element,
62 pub items: Vec<DropdownMenuItem>,
64 pub on_select: EventHandler<String>,
66 #[props(default)]
68 pub align: DropdownAlign,
69 #[props(default)]
71 pub style: Option<String>,
72}
73
74#[derive(Default, Clone, PartialEq)]
76pub enum DropdownAlign {
77 #[default]
79 Start,
80 End,
82 Center,
84}
85
86#[component]
91pub fn DropdownMenu(props: DropdownMenuProps) -> Element {
92 let _theme = use_theme();
93 let mut is_open = use_signal(|| false);
94 let mut menu_position = use_signal(|| (0i32, 0i32));
95
96 let menu_base_style = use_style(|t| {
97 Style::new()
98 .rounded(&t.radius, "md")
99 .border(1, &t.colors.border)
100 .bg(&t.colors.popover)
101 .shadow(&t.shadows.lg)
102 .flex()
103 .flex_col()
104 .py(&t.spacing, "xs")
105 .z_index(9999)
106 .build()
107 });
108
109 let align = props.align.clone();
111
112 let handle_trigger_click = move |event: Event<MouseData>| {
113 if !is_open() {
114 let coords = event.data().page_coordinates();
116 let click_x = coords.x as i32;
117 let click_y = coords.y as i32;
118
119 let menu_width = 180;
121
122 let (menu_x, menu_y) = match align {
125 DropdownAlign::Start => (click_x - 20, click_y + 20), DropdownAlign::End => (click_x - menu_width + 20, click_y + 20), DropdownAlign::Center => (click_x - menu_width / 2, click_y + 20), };
129
130 let padding = 8;
132 let final_x = menu_x.max(padding);
133 let final_y = menu_y.max(padding);
134
135 menu_position.set((final_x, final_y));
136 }
137 is_open.toggle();
138 };
139
140 let (menu_x, menu_y) = menu_position();
141 let position_style = format!(
142 "position: fixed; left: {}px; top: {}px; width: 180px;",
143 menu_x, menu_y
144 );
145 let custom_style = props.style.clone().unwrap_or_default();
146
147 rsx! {
148 div {
149 style: "position: relative; display: inline-block;",
150
151 div {
153 onclick: handle_trigger_click,
154 {props.trigger}
155 }
156
157 if is_open() {
159 div {
160 style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;",
161 onclick: move |_| is_open.set(false),
162 }
163 }
164
165 if is_open() {
167 div {
168 style: "{menu_base_style} {position_style} {custom_style}",
169 onclick: move |e| e.stop_propagation(),
170
171 for item in props.items.clone() {
172 DropdownMenuItemView {
173 key: "{item.value}",
174 item: item.clone(),
175 on_select: props.on_select.clone(),
176 on_close: move || is_open.set(false),
177 }
178 }
179 }
180 }
181 }
182 }
183}
184
185#[derive(Props, Clone, PartialEq)]
186struct DropdownMenuItemViewProps {
187 item: DropdownMenuItem,
188 on_select: EventHandler<String>,
189 on_close: EventHandler<()>,
190}
191
192#[component]
193fn DropdownMenuItemView(props: DropdownMenuItemViewProps) -> Element {
194 let _theme = use_theme();
195 let mut is_hovered = use_signal(|| false);
196
197 let item_style = use_style(move |t| {
198 let base = Style::new()
199 .w_full()
200 .flex()
201 .items_center()
202 .justify_between()
203 .gap(&t.spacing, "sm")
204 .px(&t.spacing, "sm")
205 .py(&t.spacing, "sm")
206 .rounded(&t.radius, "sm")
207 .text(&t.typography, "sm")
208 .cursor(if props.item.disabled {
209 "not-allowed"
210 } else {
211 "pointer"
212 })
213 .opacity(if props.item.disabled { 0.5 } else { 1.0 });
214
215 if is_hovered() && !props.item.disabled {
216 base.bg(&t.colors.accent).build()
217 } else {
218 base.build()
219 }
220 });
221
222 let value = props.item.value.clone();
223 let on_select = props.on_select.clone();
224 let on_close = props.on_close.clone();
225
226 rsx! {
227 div {
228 style: "{item_style}",
229 onmouseenter: move |_| is_hovered.set(true),
230 onmouseleave: move |_| is_hovered.set(false),
231 onclick: move |_| {
232 if !props.item.disabled {
233 on_select.call(value.clone());
234 on_close.call(());
235 }
236 },
237
238 div {
240 style: "display: flex; align-items: center; gap: 8px;",
241 if let Some(icon) = &props.item.icon {
242 Icon { name: icon.clone(), size: IconSize::Small, color: IconColor::Muted }
243 }
244 span { "{props.item.label}" }
245 }
246
247 if let Some(shortcut) = &props.item.shortcut {
249 span {
250 style: "font-size: 12px; color: rgb(148,163,184); margin-left: 24px;",
251 "{shortcut}"
252 }
253 }
254 }
255 }
256}
257
258#[component]
260pub fn DropdownMenuSeparator() -> Element {
261 let _theme = use_theme();
262
263 let separator_style = use_style(|t| {
264 Style::new()
265 .h_px(1)
266 .mx(&t.spacing, "sm")
267 .my(&t.spacing, "xs")
268 .bg(&t.colors.border)
269 .build()
270 });
271
272 rsx! {
273 div {
274 style: "{separator_style}",
275 }
276 }
277}
278
279#[derive(Props, Clone, PartialEq)]
281pub struct DropdownMenuLabelProps {
282 pub children: Element,
283}
284
285#[component]
286pub fn DropdownMenuLabel(props: DropdownMenuLabelProps) -> Element {
287 rsx! {
288 div {
289 style: "padding: 6px 8px; font-size: 12px; font-weight: 500; color: #64748b;",
290 {props.children}
291 }
292 }
293}