1use super::controls::{button_class, ButtonSize, ButtonVariant};
3use super::display::Tooltip;
4use super::icons::{Icon, IconName};
5use leptos::ev::KeyboardEvent;
6use leptos::prelude::*;
7
8static MENU_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9
10#[derive(Clone, Debug, PartialEq)]
12pub struct MenuItem {
13 pub id: &'static str,
14 pub label: String,
15 pub icon: Option<IconName>,
16 pub danger: bool,
18 pub disabled: Option<&'static str>,
20 pub shortcut: Option<&'static str>,
21 pub separated: bool,
23}
24
25impl MenuItem {
26 pub fn new(id: &'static str, label: impl Into<String>) -> Self {
27 Self {
28 id,
29 label: label.into(),
30 icon: None,
31 danger: false,
32 disabled: None,
33 shortcut: None,
34 separated: false,
35 }
36 }
37
38 pub fn icon(mut self, icon: IconName) -> Self {
39 self.icon = Some(icon);
40 self
41 }
42
43 pub fn danger(mut self) -> Self {
44 self.danger = true;
45 self
46 }
47
48 #[allow(dead_code)] pub fn disabled(mut self, reason: &'static str) -> Self {
50 self.disabled = Some(reason);
51 self
52 }
53
54 #[allow(dead_code)] pub fn shortcut(mut self, keys: &'static str) -> Self {
56 self.shortcut = Some(keys);
57 self
58 }
59
60 pub fn separated(mut self) -> Self {
61 self.separated = true;
62 self
63 }
64}
65
66fn step_enabled(items: &[MenuItem], cur: Option<usize>, delta: i32) -> Option<usize> {
68 let enabled: Vec<usize> = items
69 .iter()
70 .enumerate()
71 .filter(|(_, item)| item.disabled.is_none())
72 .map(|(i, _)| i)
73 .collect();
74 if enabled.is_empty() {
75 return None;
76 }
77 let len = enabled.len() as i32;
78 match cur.and_then(|c| enabled.iter().position(|&i| i == c)) {
79 Some(pos) => Some(enabled[(pos as i32 + delta).rem_euclid(len) as usize]),
80 None if delta < 0 => enabled.last().copied(),
81 None => enabled.first().copied(),
82 }
83}
84
85#[cfg(target_arch = "wasm32")]
87fn focus_by_id(element_id: &str) {
88 use wasm_bindgen::JsCast;
89 if let Some(el) = web_sys::window()
90 .and_then(|w| w.document())
91 .and_then(|d| d.get_element_by_id(element_id))
92 .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
93 {
94 let _ = el.focus();
95 }
96}
97
98#[cfg(target_arch = "wasm32")]
103fn measure_panel(
104 trigger_id: &str,
105 item_count: usize,
106 placement: &'static str,
107 align: &'static str,
108) -> Option<(String, &'static str)> {
109 let document = web_sys::window()?.document()?;
110 let rect = document
111 .get_element_by_id(trigger_id)?
112 .get_bounding_client_rect();
113 let root = document.document_element()?;
114 let (width, height) = (root.client_width() as f64, root.client_height() as f64);
115 let placement = if placement == "bottom"
116 && crate::components::forms::measure_flip(trigger_id, item_count)
117 {
118 "top"
119 } else {
120 placement
121 };
122 let vertical = if placement == "top" {
123 format!("bottom: {}px;", height - rect.top() + 6.0)
124 } else {
125 format!("top: {}px;", rect.bottom() + 6.0)
126 };
127 let horizontal = if align == "start" {
128 format!("left: {}px;", rect.left())
129 } else {
130 format!("right: {}px;", width - rect.right())
131 };
132 Some((format!("{vertical} {horizontal}"), placement))
133}
134
135#[component]
151pub fn Menu(
152 #[prop(into)] label: String,
153 #[prop(into)] items: Signal<Vec<MenuItem>>,
154 #[prop(into)] on_select: Callback<&'static str>,
155 #[prop(default = IconName::Ellipsis)] icon: IconName,
156 #[prop(default = ButtonSize::Sm)] size: ButtonSize,
157 #[prop(default = "bottom".to_string(), into)] placement: String,
158 #[prop(default = "end".to_string(), into)] align: String,
159) -> impl IntoView {
160 let menu_id = RwSignal::new(None::<usize>);
162 Effect::new(move |_| {
163 if menu_id.get_untracked().is_none() {
164 menu_id.set(Some(
165 MENU_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
166 ));
167 }
168 });
169 let trigger_id = move || menu_id.get().map(|n| format!("menu-{n}-trigger"));
170 let panel_id = move || menu_id.get().map(|n| format!("menu-{n}"));
171 let item_dom_id = move |i: usize| {
172 format!(
173 "menu-{}-item-{i}",
174 menu_id.get_untracked().unwrap_or_default()
175 )
176 };
177
178 let open = RwSignal::new(false);
179 let panel_pos = RwSignal::new(None::<(String, &'static str)>);
181 let focused = RwSignal::new(None::<usize>);
182
183 Effect::new(move |_| {
185 if let (true, Some(_i)) = (open.get(), focused.get()) {
186 #[cfg(target_arch = "wasm32")]
187 focus_by_id(&item_dom_id(_i));
188 }
189 });
190
191 let close = move |return_focus: bool| {
192 open.set(false);
193 focused.set(None);
194 panel_pos.set(None);
195 #[cfg(target_arch = "wasm32")]
196 if return_focus {
197 if let Some(id) = trigger_id() {
198 focus_by_id(&id);
199 }
200 }
201 #[cfg(not(target_arch = "wasm32"))]
202 let _ = return_focus;
203 };
204
205 let placement: &'static str = if placement == "top" { "top" } else { "bottom" };
206 let align: &'static str = if align == "start" { "start" } else { "end" };
207 let measure = move || {
209 #[cfg(target_arch = "wasm32")]
210 if let Some(id) = trigger_id() {
211 let item_count = items.with_untracked(|items| items.len());
212 panel_pos.set(measure_panel(&id, item_count, placement, align));
213 }
214 };
215 let actual_placement = move || {
216 panel_pos
217 .with(|pos| pos.as_ref().map(|(_, actual)| *actual))
218 .unwrap_or(placement)
219 };
220 let panel_style = move || panel_pos.with(|pos| pos.as_ref().map(|(style, _)| style.clone()));
221 let tooltip_placement = Signal::derive(move || {
223 if actual_placement() == "top" {
224 "bottom"
225 } else {
226 "top"
227 }
228 .to_string()
229 });
230 let trigger_class = button_class(ButtonVariant::Ghost, size);
231 let icon_px = if size == ButtonSize::Xs { 14 } else { 16 };
232
233 let open_with = move |delta: i32| {
234 measure();
235 open.set(true);
236 focused.set(items.with_untracked(|items| step_enabled(items, None, delta)));
237 };
238
239 view! {
240 <span class="menu-wrap">
241 <Tooltip tip=label.clone() placement=tooltip_placement>
242 <button
243 id=trigger_id
244 class=format!("{trigger_class} btn-icon")
245 type="button"
246 aria-label=label
247 aria-haspopup="menu"
248 aria-expanded=move || open.get().to_string()
249 aria-controls=move || if open.get() { panel_id() } else { None }
250 on:click=move |_| {
251 if open.get_untracked() {
252 close(false);
253 } else {
254 open_with(1);
255 }
256 }
257 on:keydown=move |e: KeyboardEvent| {
258 if open.get_untracked() {
259 return;
260 }
261 match e.key().as_str() {
262 "ArrowDown" => {
263 e.prevent_default();
264 open_with(1);
265 }
266 "ArrowUp" => {
267 e.prevent_default();
268 open_with(-1);
269 }
270 _ => {}
271 }
272 }
273 >
274 <Icon name=icon size=icon_px/>
275 </button>
276 </Tooltip>
277 {move || {
278 open.get()
279 .then(|| {
280 view! {
281 <div
282 class="fixed inset-0 z-40"
283 on:click=move |_| close(false)
284 on:wheel=move |_| close(false)
286 ></div>
287 <div
288 id=panel_id
289 class="menu"
290 role="menu"
291 aria-labelledby=trigger_id
292 data-placement=actual_placement
293 data-align=align
294 style=panel_style
295 on:keydown=move |e: KeyboardEvent| {
296 let step = |from: Option<usize>, delta: i32| {
297 items.with_untracked(|items| step_enabled(items, from, delta))
298 };
299 match e.key().as_str() {
300 "ArrowDown" => {
301 e.prevent_default();
302 focused.set(step(focused.get_untracked(), 1));
303 }
304 "ArrowUp" => {
305 e.prevent_default();
306 focused.set(step(focused.get_untracked(), -1));
307 }
308 "Home" => {
309 e.prevent_default();
310 focused.set(step(None, 1));
311 }
312 "End" => {
313 e.prevent_default();
314 focused.set(step(None, -1));
315 }
316 "Escape" => {
317 e.prevent_default();
318 close(true);
319 }
320 "Tab" => close(false),
321 _ => {}
322 }
323 }
324 >
325 {move || {
326 items
327 .get()
328 .into_iter()
329 .enumerate()
330 .map(|(i, item)| {
331 view! {
332 <MenuRow
333 id=item_dom_id(i)
334 item=item
335 focused=Signal::derive(move || focused.get() == Some(i))
336 on_hover=Callback::new(move |()| focused.set(Some(i)))
337 on_pick=Callback::new(move |id: &'static str| {
338 close(true);
339 on_select.run(id);
340 })
341 />
342 }
343 })
344 .collect_view()
345 }}
346 </div>
347 }
348 })
349 }}
350 </span>
351 }
352}
353
354#[component]
356fn MenuRow(
357 id: String,
358 item: MenuItem,
359 #[prop(into)] focused: Signal<bool>,
360 #[prop(into)] on_hover: Callback<()>,
361 #[prop(into)] on_pick: Callback<&'static str>,
362) -> impl IntoView {
363 let enabled = item.disabled.is_none();
364 let reason = item.disabled.unwrap_or_default();
365 let item_id = item.id;
366 view! {
367 {item.separated.then(|| view! { <div class="menu-separator" role="separator"></div> })}
368 <button
369 id=id
370 class="menu-item"
371 type="button"
372 role="menuitem"
373 tabindex=move || if focused.get() { "0" } else { "-1" }
374 data-tone=if item.danger { "danger" } else { "" }
375 aria-disabled=(!enabled).then_some("true")
376 title=reason
377 on:mouseenter=move |_| {
378 if enabled {
379 on_hover.run(());
380 }
381 }
382 on:click=move |_| {
383 if enabled {
384 on_pick.run(item_id);
385 }
386 }
387 >
388 {item.icon.map(|icon| view! { <Icon name=icon size=15/> })}
389 <span>{item.label}</span>
390 {item.shortcut.map(|keys| view! { <kbd>{keys}</kbd> })}
391 </button>
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn items() -> Vec<MenuItem> {
400 vec![
401 MenuItem::new("edit", "编辑"),
402 MenuItem::new("archive", "归档").disabled("已归档"),
403 MenuItem::new("copy", "复制链接"),
404 MenuItem::new("trash", "移入回收站").danger().separated(),
405 ]
406 }
407
408 #[test]
409 fn step_enabled_skips_disabled_items_and_wraps() {
410 let items = items();
411 assert_eq!(step_enabled(&items, None, 1), Some(0));
412 assert_eq!(step_enabled(&items, None, -1), Some(3));
413 assert_eq!(step_enabled(&items, Some(0), 1), Some(2)); assert_eq!(step_enabled(&items, Some(3), 1), Some(0)); assert_eq!(step_enabled(&items, Some(0), -1), Some(3)); assert_eq!(step_enabled(&items, Some(1), 1), Some(0)); }
418
419 #[test]
420 fn step_enabled_handles_all_disabled() {
421 let items = vec![MenuItem::new("a", "A").disabled("不可用")];
422 assert_eq!(step_enabled(&items, None, 1), None);
423 }
424
425 #[component]
426 fn Harness() -> impl IntoView {
427 view! { <Menu label="更多操作" items=items() on_select=|_| {}/> }
428 }
429
430 #[test]
431 fn closed_menu_renders_only_the_trigger() {
432 let html = Owner::new().with(|| view! { <Harness/> }.to_html());
433 assert!(html.contains("aria-haspopup=\"menu\""), "{html}");
434 assert!(html.contains("aria-expanded=\"false\""), "{html}");
435 assert!(!html.contains("role=\"menu\""), "{html}");
436 }
437}