dioxus_ui_system/molecules/
dropdown_menu.rs1use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8
9#[derive(Clone, PartialEq)]
11pub struct DropdownMenuItem {
12 pub label: String,
14 pub value: String,
16 pub icon: Option<String>,
18 pub disabled: bool,
20 pub shortcut: Option<String>,
22}
23
24impl DropdownMenuItem {
25 pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
27 Self {
28 label: label.into(),
29 value: value.into(),
30 icon: None,
31 disabled: false,
32 shortcut: None,
33 }
34 }
35
36 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
38 self.icon = Some(icon.into());
39 self
40 }
41
42 pub fn disabled(mut self, disabled: bool) -> Self {
44 self.disabled = disabled;
45 self
46 }
47
48 pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
50 self.shortcut = Some(shortcut.into());
51 self
52 }
53}
54
55#[derive(Props, Clone, PartialEq)]
57pub struct DropdownMenuProps {
58 pub trigger: Element,
60 pub items: Vec<DropdownMenuItem>,
62 pub on_select: EventHandler<String>,
64 #[props(default)]
66 pub align: DropdownAlign,
67 #[props(default)]
69 pub style: Option<String>,
70}
71
72#[derive(Default, Clone, PartialEq)]
74pub enum DropdownAlign {
75 #[default]
77 Start,
78 End,
80 Center,
82}
83
84#[component]
86pub fn DropdownMenu(props: DropdownMenuProps) -> Element {
87 let _theme = use_theme();
88 let mut is_open = use_signal(|| false);
89
90 let position = match props.align {
91 DropdownAlign::Start => "left: 0;",
92 DropdownAlign::End => "right: 0;",
93 DropdownAlign::Center => "left: 50%; transform: translateX(-50%);",
94 };
95
96 let menu_style = use_style(|t| {
97 Style::new()
98 .absolute()
99 .top("calc(100% + 4px)")
100 .min_w_px(160)
101 .max_w_px(280)
102 .rounded(&t.radius, "md")
103 .border(1, &t.colors.border)
104 .bg(&t.colors.popover)
105 .shadow(&t.shadows.lg)
106 .flex()
107 .flex_col()
108 .p(&t.spacing, "xs")
109 .z_index(50)
110 .build()
111 });
112
113 rsx! {
114 div {
115 style: "position: relative; display: inline-block;",
116
117 div {
119 onclick: move |_| is_open.toggle(),
120 {props.trigger}
121 }
122
123 if is_open() {
125 div {
127 style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 40;",
128 onclick: move |_| is_open.set(false),
129 }
130
131 div {
132 style: "{menu_style} {position} {props.style.clone().unwrap_or_default()}",
133 onclick: move |e| e.stop_propagation(),
134
135 for item in props.items.clone() {
136 DropdownMenuItemView {
137 item: item.clone(),
138 on_select: props.on_select.clone(),
139 on_close: move || is_open.set(false),
140 }
141 }
142 }
143 }
144 }
145 }
146}
147
148#[derive(Props, Clone, PartialEq)]
149struct DropdownMenuItemViewProps {
150 item: DropdownMenuItem,
151 on_select: EventHandler<String>,
152 on_close: EventHandler<()>,
153}
154
155#[component]
156fn DropdownMenuItemView(props: DropdownMenuItemViewProps) -> Element {
157 let _theme = use_theme();
158 let mut is_hovered = use_signal(|| false);
159
160 let item_style = use_style(move |t| {
161 let base = Style::new()
162 .w_full()
163 .flex()
164 .items_center()
165 .justify_between()
166 .px(&t.spacing, "sm")
167 .py(&t.spacing, "sm")
168 .rounded(&t.radius, "sm")
169 .text(&t.typography, "sm")
170 .cursor(if props.item.disabled { "not-allowed" } else { "pointer" })
171 .transition("all 100ms ease");
172
173 if is_hovered() && !props.item.disabled {
174 base.bg(&t.colors.accent)
175 .text_color(&t.colors.accent_foreground)
176 } else {
177 base
178 }.build()
179 });
180
181 let handle_click = move |_| {
182 if !props.item.disabled {
183 props.on_select.call(props.item.value.clone());
184 props.on_close.call(());
185 }
186 };
187
188 rsx! {
189 button {
190 style: "{item_style} background: none; border: none; text-align: left; color: inherit;",
191 disabled: props.item.disabled,
192 onclick: handle_click,
193 onmouseenter: move |_| if !props.item.disabled { is_hovered.set(true) },
194 onmouseleave: move |_| is_hovered.set(false),
195
196 div {
197 style: "display: flex; align-items: center; gap: 8px;",
198
199 if let Some(icon) = props.item.icon.clone() {
200 DropdownIcon { name: icon }
201 }
202
203 span {
204 style: if props.item.disabled { "opacity: 0.5;" } else { "" },
205 "{props.item.label}"
206 }
207 }
208
209 if let Some(shortcut) = props.item.shortcut.clone() {
210 span {
211 style: "font-size: 11px; color: #94a3b8; margin-left: 24px;",
212 "{shortcut}"
213 }
214 }
215 }
216 }
217}
218
219#[derive(Props, Clone, PartialEq)]
220struct DropdownIconProps {
221 name: String,
222}
223
224#[component]
225fn DropdownIcon(props: DropdownIconProps) -> Element {
226 rsx! {
227 svg {
228 view_box: "0 0 24 24",
229 fill: "none",
230 stroke: "currentColor",
231 stroke_width: "2",
232 stroke_linecap: "round",
233 stroke_linejoin: "round",
234 style: "width: 16px; height: 16px;",
235
236 match props.name.as_str() {
237 "edit" => rsx! {
238 path { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }
239 path { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }
240 },
241 "copy" => rsx! {
242 rect { x: "9", y: "9", width: "13", height: "13", rx: "2", ry: "2" }
243 path { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }
244 },
245 "trash" => rsx! {
246 polyline { points: "3 6 5 6 21 6" }
247 path { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }
248 },
249 _ => rsx! {
250 circle { cx: "12", cy: "12", r: "10" }
251 },
252 }
253 }
254 }
255}
256
257#[component]
259pub fn DropdownMenuSeparator() -> Element {
260 let _theme = use_theme();
261
262 let separator_style = use_style(|t| {
263 Style::new()
264 .h_px(1)
265 .mx(&t.spacing, "sm")
266 .my(&t.spacing, "xs")
267 .bg(&t.colors.border)
268 .build()
269 });
270
271 rsx! {
272 div {
273 style: "{separator_style}",
274 }
275 }
276}
277
278#[derive(Props, Clone, PartialEq)]
280pub struct DropdownMenuLabelProps {
281 pub children: Element,
282}
283
284#[component]
285pub fn DropdownMenuLabel(props: DropdownMenuLabelProps) -> Element {
286 rsx! {
287 div {
288 style: "padding: 6px 8px; font-size: 12px; font-weight: 500; color: #64748b;",
289 {props.children}
290 }
291 }
292}