1use crate::{
10 core::{
11 assets,
12 attrs::{component_attributes, open_attribute},
13 platform::{DomBridge, MenuPoint},
14 },
15 models::context_menu as model,
16 prelude::*,
17 theme::use_stylesheets,
18};
19const BEM: Bem = Bem::new("fc-menu");
20const ITEM: Bem = Bem::new("fc-menu__item");
21fc_modifier! { enum ItemTone for ITEM { Normal => None, Danger => "danger" } }
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum MenuEntry {
26 Item {
28 id: String,
30 label: String,
32 shortcut: Option<String>,
34 danger: bool,
36 disabled: bool,
38 },
39 Checkbox {
41 id: String,
43 label: String,
45 checked: bool,
47 disabled: bool,
49 },
50 Submenu {
52 id: String,
54 label: String,
56 },
57 Group(String),
59 Separator,
61}
62impl MenuEntry {
63 #[must_use]
65 pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
66 Self::Item {
67 id: id.into(),
68 label: label.into(),
69 shortcut: None,
70 danger: false,
71 disabled: false,
72 }
73 }
74 fn modeled(&self) -> Option<model::MenuItem> {
75 let (id, label, kind, disabled, checked, shortcut) = match self {
76 Self::Item {
77 id,
78 label,
79 shortcut,
80 disabled,
81 ..
82 } => (
83 id,
84 label,
85 model::ItemKind::Normal,
86 *disabled,
87 false,
88 shortcut.clone(),
89 ),
90 Self::Checkbox {
91 id,
92 label,
93 checked,
94 disabled,
95 } => (
96 id,
97 label,
98 model::ItemKind::Checkbox,
99 *disabled,
100 *checked,
101 None,
102 ),
103 Self::Submenu { id, label } => {
104 (id, label, model::ItemKind::Submenu, false, false, None)
105 }
106 Self::Group(_) | Self::Separator => return None,
107 };
108 Some(model::MenuItem {
109 id: id.clone(),
110 label: label.clone(),
111 kind,
112 disabled,
113 checked,
114 shortcut,
115 })
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub enum MenuAnchor {
122 Below(Id),
124 At(MenuPoint),
126}
127
128#[derive(Clone, Copy)]
129struct MenuState {
130 model: Signal<Option<model::Menu>>,
131 pending_focus: Signal<bool>,
132 typing: Signal<u64>,
133 expecting_open: Signal<bool>,
134 was_open: Signal<bool>,
136 leaving: Signal<bool>,
139}
140
141#[component]
144pub fn Menu(
145 label: AccessibleName,
146 entries: Vec<MenuEntry>,
147 open: bool,
148 anchor: MenuAnchor,
149 return_focus: Id,
151 onclose: EventHandler<()>,
152 onselect: EventHandler<String>,
153 #[props(default)]
155 from_end: bool,
156 #[props(default)]
158 menu_id: Option<Id>,
159 #[props(default)] class: Option<String>,
160 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
161) -> Element {
162 let generated = use_id();
163 let id = menu_id.unwrap_or(generated);
164 let built = model::Menu::new(entries.iter().filter_map(MenuEntry::modeled).collect()).ok();
165 let mut state = use_hook(|| MenuState {
166 model: Signal::new(None),
167 pending_focus: Signal::new(false),
168 typing: Signal::new(0),
169 expecting_open: Signal::new(false),
170 was_open: Signal::new(false),
171 leaving: Signal::new(false),
172 });
173 let effect_id = id.clone();
174 let effect_anchor = anchor.clone();
175 let effect_return = return_focus.clone();
176 use_effect(use_reactive!(|open, from_end| {
177 let (id, anchor, back) = (
178 effect_id.clone(),
179 effect_anchor.clone(),
180 effect_return.clone(),
181 );
182 let closing = !open && *state.was_open.peek();
183 state.was_open.set(open);
184 if open {
185 state.expecting_open.set(true);
186 spawn(async move {
187 let _ = DomBridge.toggle_popover(&id, true).await;
188 let point = match anchor {
189 MenuAnchor::At(point) => Ok(point),
190 MenuAnchor::Below(trigger) => DomBridge.point_below(&trigger).await,
191 };
192 if let Ok(point) = point {
193 let _ = DomBridge.place_at(&id, point).await;
194 }
195 });
196 let current = state.model.peek().clone();
198 if let Some(mut menu) = current {
199 if from_end {
200 menu.focus_last();
201 } else {
202 menu.focus_first();
203 }
204 menu.reset_typeahead();
205 state.model.set(Some(menu));
206 }
207 state.pending_focus.set(true);
208 } else if closing {
209 let restore = !*state.leaving.peek();
210 state.leaving.set(false);
211 spawn(async move {
212 let _ = DomBridge.toggle_popover(&id, false).await;
213 if restore {
214 let _ = DomBridge.focus(&back).await;
215 }
216 });
217 }
218 }));
219 if state.model.peek().as_ref().map(|m| signature(m.items()))
223 != built.as_ref().map(|m| signature(m.items()))
224 || state.model.peek().is_none()
225 {
226 let active = state
227 .model
228 .peek()
229 .as_ref()
230 .and_then(model::Menu::active_index);
231 let mut next = built.clone();
232 if let Some(menu) = &mut next {
233 menu.adopt_state(active, String::new());
234 }
235 state.model.set(next);
236 }
237 let active = state
238 .model
239 .read()
240 .as_ref()
241 .and_then(model::Menu::active_index);
242 let base = id.to_string();
243 let item_id = move |index: usize| format!("{base}-item-{index}");
244 let focus_id = id.clone();
245 use_effect(move || {
246 if (state.pending_focus)() {
247 state.pending_focus.set(false);
248 let target = state
249 .model
250 .peek()
251 .as_ref()
252 .and_then(model::Menu::active_index);
253 let menu_id = focus_id.clone();
254 spawn(async move {
255 let target = match target {
256 Some(index) => Id::try_from(format!("{menu_id}-item-{index}")).ok(),
257 None => Some(menu_id),
260 };
261 if let Some(target) = target {
262 let _ = DomBridge.focus(&target).await;
263 }
264 });
265 }
266 });
267 let mut attributes = component_attributes(
268 attributes,
269 ClassList::new(BEM.block()).extend(class.as_deref()),
270 &["role", "aria-label", "popover", "tabindex"],
271 );
272 attributes.push(Attribute::new("popover", "auto", None, false));
273 attributes.extend(open_attribute(open.then_some(true)));
274 let mut modeled = 0_usize;
275 let rows: Vec<(MenuEntry, Option<usize>)> = entries
276 .iter()
277 .map(|entry| {
278 let index = entry.modeled().map(|_| {
279 modeled += 1;
280 modeled - 1
281 });
282 (entry.clone(), index)
283 })
284 .collect();
285 let mut step = move |change: &dyn Fn(&mut model::Menu)| {
286 if let Some(menu) = state.model.write().as_mut() {
287 menu.reset_typeahead();
288 change(menu);
289 }
290 state.pending_focus.set(true);
291 };
292 rsx! {
293 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_MENU_CSS } }
294 div { id: "{id}", role: "menu", aria_label: label.to_string(), tabindex: "-1",
295 ontoggle: move |_| {
296 if (state.expecting_open)() { state.expecting_open.set(false); } else if open { onclose.call(()); }
299 },
300 onkeydown: move |event| {
301 let key = event.key();
302 match key {
303 Key::ArrowDown => { event.prevent_default(); step(&|m| m.move_by(1)); }
304 Key::ArrowUp => { event.prevent_default(); step(&|m| m.move_by(-1)); }
305 Key::Home => { event.prevent_default(); step(&|m| m.focus_first()); }
306 Key::End => { event.prevent_default(); step(&|m| m.focus_last()); }
307 Key::Escape => { event.prevent_default(); onclose.call(()); }
308 Key::Tab => { state.leaving.set(true); onclose.call(()); }
309 Key::Character(text) if !event.modifiers().ctrl() && !event.modifiers().meta() && !event.modifiers().alt() && text != " " => {
310 event.prevent_default();
311 let moved = state.model.write().as_mut().is_some_and(|m| m.typeahead(&text));
312 if moved { state.pending_focus.set(true); }
313 let generation = (state.typing)() + 1;
315 state.typing.set(generation);
316 spawn(async move {
317 if DomBridge.delay(500).await.is_ok() && (state.typing)() == generation {
318 if let Some(menu) = state.model.write().as_mut() { menu.reset_typeahead(); }
319 }
320 });
321 }
322 _ => {}
323 }
324 },
325 ..attributes,
326 for (row, (entry, index)) in rows.into_iter().enumerate() {
327 {entry_view(entry, index.map(|i| (i, item_id(i), active == Some(i))), row, onselect, onclose)}
328 }
329 }
330 }
331}
332
333fn entry_view(
334 entry: MenuEntry,
335 item: Option<(usize, String, bool)>,
336 row: usize,
337 onselect: EventHandler<String>,
338 onclose: EventHandler<()>,
339) -> Element {
340 let check = BEM.element("check");
341 let label_class = BEM.element("label");
342 match (entry, item) {
343 (MenuEntry::Group(label), _) => {
344 rsx! { div { key: "{row}", class: BEM.element("group-label"), role: "presentation", "{label}" } }
345 }
346 (MenuEntry::Separator, _) => {
347 rsx! { hr { key: "{row}", class: BEM.element("separator"), role: "separator" } }
348 }
349 (
350 MenuEntry::Item {
351 id,
352 label,
353 shortcut,
354 danger,
355 disabled,
356 },
357 Some((_, dom_id, active)),
358 ) => {
359 let class = ClassList::new(ITEM.block())
360 .with_modifier(
361 ITEM,
362 if danger {
363 ItemTone::Danger
364 } else {
365 ItemTone::Normal
366 },
367 )
368 .to_string();
369 rsx! {
370 button { key: "{row}", r#type: "button", class, role: "menuitem", id: dom_id, tabindex: if active { "0" } else { "-1" },
371 aria_disabled: disabled.then_some("true"),
372 onclick: move |_| if !disabled { onselect.call(id.clone()); onclose.call(()); },
373 span { class: check, aria_hidden: "true" }
374 span { class: label_class, "{label}" }
375 if let Some(shortcut) = shortcut { span { class: BEM.element("shortcut"), aria_hidden: "true", "{shortcut}" } }
376 }
377 }
378 }
379 (
380 MenuEntry::Checkbox {
381 id,
382 label,
383 checked,
384 disabled,
385 },
386 Some((_, dom_id, active)),
387 ) => rsx! {
388 button { key: "{row}", r#type: "button", class: ITEM.block(), role: "menuitemcheckbox", id: dom_id,
389 tabindex: if active { "0" } else { "-1" }, aria_checked: checked.to_string(), aria_disabled: disabled.then_some("true"),
390 onclick: move |_| if !disabled { onselect.call(id.clone()); onclose.call(()); },
391 span { class: check, aria_hidden: "true", if checked { "✓" } }
392 span { class: label_class, "{label}" }
393 }
394 },
395 (MenuEntry::Submenu { id, label }, Some((_, dom_id, active))) => rsx! {
396 button { key: "{row}", r#type: "button", class: ITEM.block(), role: "menuitem", id: dom_id,
397 tabindex: if active { "0" } else { "-1" }, aria_haspopup: "menu",
398 onclick: move |_| onselect.call(id.clone()),
399 span { class: check, aria_hidden: "true" }
400 span { class: label_class, "{label}" }
401 span { class: BEM.element("submenu-arrow"), aria_hidden: "true" }
402 }
403 },
404 _ => rsx! {},
405 }
406}
407
408#[component]
412pub fn MenuButton(
413 label: AccessibleName,
415 entries: Vec<MenuEntry>,
416 onselect: EventHandler<String>,
417 #[props(default)] variant: ButtonVariant,
418 #[props(default)] size: ButtonSize,
419 #[props(default)]
421 icon_label: Option<AccessibleName>,
422 #[props(default)] availability: Availability,
423 children: Element,
424) -> Element {
425 let trigger = use_id();
426 let mut open = use_signal(|| false);
427 let mut from_end = use_signal(|| false);
428 let blocked = availability != Availability::Enabled;
429 let menu_id = trigger.suffixed("menu");
430 rsx! {
431 span {
434 onkeydown: move |event: KeyboardEvent| {
435 if blocked { return; }
436 match event.key() {
437 Key::ArrowDown => { event.prevent_default(); from_end.set(false); open.set(true); }
438 Key::ArrowUp => { event.prevent_default(); from_end.set(true); open.set(true); }
439 _ => {}
440 }
441 },
442 Button { variant, size, icon_label, availability, id: "{trigger}",
443 aria_haspopup: "menu", aria_expanded: open().to_string(), aria_controls: "{menu_id}",
444 onclick: move |_| { from_end.set(false); open.toggle(); },
445 {children}
446 }
447 }
448 Menu { label, entries, open: open(), from_end: from_end(), anchor: MenuAnchor::Below(trigger.clone()), return_focus: trigger.clone(),
449 onclose: move |()| open.set(false), onselect, menu_id }
450 }
451}
452
453#[component]
456pub fn ContextMenuArea(
457 label: AccessibleName,
458 entries: Vec<MenuEntry>,
459 onselect: EventHandler<String>,
460 #[props(default)] class: Option<String>,
461 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
462 children: Element,
463) -> Element {
464 let area = use_id();
465 let mut at = use_signal(|| None::<MenuPoint>);
466 let attributes = component_attributes(
467 attributes,
468 ClassList::default().extend(class.as_deref()),
469 &["id"],
470 );
471 let attributes: Vec<_> = attributes
472 .into_iter()
473 .filter(|a| {
474 !(a.name == "class"
475 && matches!(&a.value, dioxus::core::AttributeValue::Text(t) if t.is_empty()))
476 })
477 .collect();
478 rsx! {
479 div { id: "{area}", tabindex: "0",
480 oncontextmenu: move |event| {
481 event.prevent_default();
482 let point = event.client_coordinates();
483 at.set(Some(MenuPoint { x: point.x, y: point.y, flip_y: None, edge: None }));
484 },
485 ..attributes,
486 {children}
487 }
488 Menu { label, entries, open: at().is_some(),
489 anchor: MenuAnchor::At(at().unwrap_or(MenuPoint { x: 0.0, y: 0.0, flip_y: None, edge: None })),
490 return_focus: area.clone(), onclose: move |()| at.set(None), onselect }
491 }
492}
493
494fn signature(items: &[model::MenuItem]) -> Vec<(&str, &str, bool, bool, bool)> {
496 items
497 .iter()
498 .map(|item| {
499 (
500 item.id.as_str(),
501 item.label.as_str(),
502 item.disabled,
503 item.checked,
504 matches!(item.kind, model::ItemKind::Separator),
505 )
506 })
507 .collect()
508}