radix_leptos_primitives/components/
context_menu.rs

1use crate::utils::{merge_classes, generate_id};
2use leptos::callback::Callback;
3use leptos::children::Children;
4use leptos::prelude::*;
5
6/// Context Menu component - Right-click context menus with keyboard navigation
7#[component]
8pub fn ContextMenu(
9    #[prop(optional)] class: Option<String>,
10    #[prop(optional)] style: Option<String>,
11    #[prop(optional)] children: Option<Children>,
12    #[prop(optional)] items: Option<Vec<ContextMenuItem>>,
13    #[prop(optional)] on_item_click: Option<Callback<ContextMenuItem>>,
14    #[prop(optional)] onopen: Option<Callback<()>>,
15    #[prop(optional)] on_close: Option<Callback<()>>,
16) -> impl IntoView {
17    let items = items.unwrap_or_default();
18    let isopen = create_rw_signal(false);
19    let selected_index = create_rw_signal(0);
20
21    let class = merge_classes(vec!["context-menu", class.as_deref().unwrap_or("")]);
22
23    let handle_right_click = move |event: web_sys::MouseEvent| {
24        event.prevent_default();
25        isopen.set(true);
26        if let Some(callback) = onopen {
27            callback.run(());
28        }
29    };
30
31    let handle_keydown = move |event: web_sys::KeyboardEvent| {
32        if !isopen.get() {
33            return;
34        }
35
36        match event.key().as_str() {
37            "Escape" => {
38                isopen.set(false);
39                if let Some(callback) = on_close {
40                    callback.run(());
41                }
42            }
43            "ArrowDown" => {
44                event.prevent_default();
45                let new_index = (selected_index.get() + 1) % items.len();
46                selected_index.set(new_index);
47            }
48            "ArrowUp" => {
49                event.prevent_default();
50                let new_index = if selected_index.get() == 0 {
51                    items.len() - 1
52                } else {
53                    selected_index.get() - 1
54                };
55                selected_index.set(new_index);
56            }
57            "Enter" | " " => {
58                event.prevent_default();
59                if let Some(item) = items.get(selected_index.get()) {
60                    if let Some(callback) = on_item_click {
61                        callback.run(item.clone());
62                    }
63                }
64            }
65            _ => {}
66        }
67    };
68
69    view! {
70        <div
71            class=class
72            style=style
73            role="menu"
74            aria-label="Context menu"
75            on:contextmenu=handle_right_click
76            on:keydown=handle_keydown
77            tabindex="0"
78        >
79            {children.map(|c| c())}
80        </div>
81    }
82}
83
84/// Context Menu Item structure
85#[derive(Debug, Clone, PartialEq)]
86pub struct ContextMenuItem {
87    pub id: String,
88    pub label: String,
89    pub icon: Option<String>,
90    pub _disabled: bool,
91    pub separator: bool,
92    pub submenu: Option<Vec<ContextMenuItem>>,
93}
94
95impl Default for ContextMenuItem {
96    fn default() -> Self {
97        Self {
98            id: "item".to_string(),
99            label: "Item".to_string(),
100            icon: None,
101            _disabled: false,
102            separator: false,
103            submenu: None,
104        }
105    }
106}
107
108/// Context Menu Item component
109#[component]
110pub fn ContextMenuItem(
111    #[prop(optional)] class: Option<String>,
112    #[prop(optional)] style: Option<String>,
113    #[prop(optional)] children: Option<Children>,
114    #[prop(optional)] item: Option<ContextMenuItem>,
115    #[prop(optional)] selected: Option<bool>,
116    #[prop(optional)] on_click: Option<Callback<ContextMenuItem>>,
117) -> impl IntoView {
118    let _item = item.unwrap_or_default();
119    let item_for_callback = _item.clone();
120    let _selected = selected.unwrap_or(false);
121
122    let _class = merge_classes(vec!["context-menu-item"]);
123
124    view! {
125        <div
126            class=_class
127            style=style
128            role="menuitem"
129            aria-disabled=_item._disabled
130            on:click=move |_| {
131                if let Some(callback) = on_click {
132                    callback.run(item_for_callback.clone());
133                }
134            }
135            tabindex="0"
136        >
137            {if _item.separator {
138                view! { <hr /> }.into_any()
139            } else {
140                view! {
141                    <span class="label">{_item.label}</span>
142                    {children.map(|c| c())}
143                }.into_any()
144            }}
145        </div>
146    }
147}
148
149/// Context Menu Trigger component
150#[component]
151pub fn ContextMenuTrigger(
152    #[prop(optional)] class: Option<String>,
153    #[prop(optional)] style: Option<String>,
154    #[prop(optional)] children: Option<Children>,
155    #[prop(optional)] disabled: Option<bool>,
156) -> impl IntoView {
157    let _disabled = disabled.unwrap_or(false);
158
159    let _class = merge_classes(vec!["context-menu-trigger"]);
160
161    view! {
162        <div
163            class=_class
164            style=style
165            role="button"
166            aria-disabled=_disabled
167        >
168            {children.map(|c| c())}
169        </div>
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use proptest::prelude::*;
176    use wasm_bindgen_test::*;
177
178    wasm_bindgen_test_configure!(run_in_browser);
179
180    // Unit Tests
181    #[test]
182    fn test_context_menu_creation() {}
183    #[test]
184    fn test_context_menu_with_class() {}
185    #[test]
186    fn test_context_menu_with_style() {}
187    #[test]
188    fn test_context_menu_with_items() {}
189    #[test]
190    fn test_context_menu_on_item_click() {}
191    #[test]
192    fn test_context_menu_onopen() {}
193    #[test]
194    fn test_context_menu_on_close() {}
195
196    // Context Menu Item tests
197    #[test]
198    fn test_context_menu_item_default() {}
199    #[test]
200    fn test_context_menu_item_creation() {}
201    #[test]
202    fn test_context_menu_item_with_icon() {}
203    #[test]
204    fn test_context_menu_itemdisabled() {}
205    #[test]
206    fn test_context_menu_item_separator() {}
207    #[test]
208    fn test_context_menu_item_submenu() {}
209
210    // Context Menu Trigger tests
211    #[test]
212    fn test_context_menu_trigger_creation() {}
213    #[test]
214    fn test_context_menu_trigger_with_class() {}
215    #[test]
216    fn test_context_menu_triggerdisabled() {}
217
218    // Helper function tests
219    #[test]
220    fn test_merge_classes_empty() {}
221    #[test]
222    fn test_merge_classes_single() {}
223    #[test]
224    fn test_merge_classes_multiple() {}
225    #[test]
226    fn test_merge_classes_with_empty() {}
227
228    // Property-based Tests
229    #[test]
230    fn test_context_menu_property_based() {
231        proptest!(|(____class in ".*", __style in ".*")| {
232
233        });
234    }
235
236    #[test]
237    fn test_context_menu_items_validation() {
238        proptest!(|(______item_count in 0..20usize)| {
239
240        });
241    }
242
243    #[test]
244    fn test_context_menu_keyboard_navigation() {
245        proptest!(|(____key in ".*")| {
246
247        });
248    }
249
250    // Integration Tests
251    #[test]
252    fn test_context_menu_user_interaction() {}
253    #[test]
254    fn test_context_menu_accessibility() {}
255    #[test]
256    fn test_context_menu_keyboard_navigation_workflow() {}
257    #[test]
258    fn test_context_menu_right_click_workflow() {}
259    #[test]
260    fn test_context_menu_submenu_interaction() {}
261
262    // Performance Tests
263    #[test]
264    fn test_context_menu_large_item_lists() {}
265    #[test]
266    fn test_context_menu_render_performance() {}
267    #[test]
268    fn test_context_menu_memory_usage() {}
269    #[test]
270    fn test_context_menu_animation_performance() {}
271}