patternfly_yew/components/menu/
mod.rs1mod child;
3mod context;
4mod group;
5mod item;
6mod loading;
7mod toggle;
8mod variant;
9
10pub use child::*;
11pub use context::*;
12pub use group::*;
13pub use item::*;
14pub use loading::*;
15pub use toggle::*;
16pub use variant::*;
17
18use crate::ouia;
19use crate::prelude::OuiaComponentType;
20use crate::utils::{Ouia, OuiaSafe};
21use wasm_bindgen::JsCast;
22use web_sys::{Element, HtmlElement};
23use web_tools::prelude::*;
24use yew::{html::ChildrenRenderer, prelude::*};
25use yew_hooks::use_event_with_window;
26
27const OUIA: Ouia = ouia!("Menu");
28
29#[derive(Clone, Debug, PartialEq, Properties)]
30pub struct MenuProperties {
31 #[prop_or_default]
32 pub id: Option<String>,
33
34 #[prop_or_default]
35 pub style: AttrValue,
36
37 #[prop_or_default]
38 pub r#ref: NodeRef,
39
40 #[prop_or_default]
41 pub scrollable: bool,
42
43 #[prop_or_default]
44 pub plain: bool,
45
46 #[prop_or_default]
47 pub children: ChildrenRenderer<MenuChildVariant>,
48
49 #[prop_or_default]
51 pub ouia_id: Option<String>,
52 #[prop_or(OUIA.component_type())]
54 pub ouia_type: OuiaComponentType,
55 #[prop_or(OuiaSafe::TRUE)]
57 pub ouia_safe: OuiaSafe,
58}
59
60#[component]
61pub fn Menu(props: &MenuProperties) -> Html {
62 let ouia_id = use_memo(props.ouia_id.clone(), |id| {
63 id.clone().unwrap_or(OUIA.generated_id())
64 });
65 let mut class = classes!("pf-v6-c-menu");
66
67 if props.scrollable {
68 class.push(classes!("pf-m-scrollable"));
69 }
70
71 if props.plain {
72 class.push(classes!("pf-m-plain"));
73 }
74
75 html!(
76 <div
77 ref={props.r#ref.clone()}
78 id={props.id.clone()}
79 style={&props.style}
80 {class}
81 data-ouia-component-id={(*ouia_id).clone()}
82 data-ouia-component-type={props.ouia_type}
83 data-ouia-safe={props.ouia_safe}
84 >
85 <div class="pf-v6-c-menu__content">
86 <MenuList>{ props.children.clone() }</MenuList>
87 </div>
88 </div>
89 )
90}
91
92#[derive(Clone, Debug, PartialEq, Properties)]
93pub(crate) struct MenuListProperties {
94 pub(crate) children: ChildrenRenderer<MenuChildVariant>,
95}
96
97#[component]
98pub(crate) fn MenuList(props: &MenuListProperties) -> Html {
99 let r#ref = use_node_ref();
100
101 {
102 let r#ref = r#ref.clone();
103 use_event_with_window("keydown", move |e: KeyboardEvent| {
104 handle_key(&r#ref, e);
105 });
106 }
107
108 html!(
109 <ul ref={r#ref} class="pf-v6-c-menu__list" role="menu">
110 { for props.children.iter() }
111 </ul>
112 )
113}
114
115fn focusable_element(element: &HtmlElement) -> Option<HtmlElement> {
116 element
117 .query_selector("a, button, input")
118 .ok()??
119 .dyn_into::<HtmlElement>()
120 .ok()
121}
122
123fn handle_key(node: &NodeRef, e: KeyboardEvent) {
124 match e.key().as_str() {
125 "Enter" => {
126 if let Some(active) = gloo_utils::document()
127 .active_element()
128 .and_then(|element| element.dyn_into::<HtmlElement>().ok())
129 {
130 e.prevent_default();
131 active.click();
132 }
133 }
134 "ArrowUp" | "ArrowDown" => handle_arrows(node, e),
135 _ => {}
136 }
137}
138
139fn handle_arrows(node: &NodeRef, e: KeyboardEvent) {
140 e.prevent_default();
141 e.stop_immediate_propagation();
142
143 let active = gloo_utils::document()
144 .active_element()
145 .and_then(|element| element.dyn_into::<HtmlElement>().ok());
146
147 let elements = match node
148 .cast::<Element>()
149 .map(|ele| ele.get_elements_by_tag_name("LI"))
150 {
151 Some(elements) => elements,
152 None => return,
153 };
154
155 let items = IterableHtmlCollection(&elements)
156 .into_iter()
157 .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
158 .filter(|element| {
159 !element.class_list().contains("pf-m-disabled")
160 && !element.class_list().contains("pf-v6-c-divider")
161 })
162 .collect::<Vec<_>>();
163
164 let len = items.len();
165
166 let index = items
167 .iter()
168 .position(|node| focusable_element(node) == active);
169
170 let offset: isize = if e.key() == "ArrowDown" { 1 } else { -1 };
171
172 let next_index = index
173 .map(|index| index as isize + offset)
175 .map(|index| {
177 if index < 0 {
178 len.saturating_sub(1)
179 } else if index as usize >= len {
180 0
181 } else {
182 index as _
183 }
184 })
185 .unwrap_or_else(|| if offset > 0 { 0 } else { len.saturating_sub(1) });
187
188 let next_node = items
190 .get(next_index)
191 .and_then(focusable_element)
192 .and_then(|ele| ele.dyn_into::<HtmlElement>().ok());
193
194 if let Some(node) = &next_node {
196 if let Some(active) = &active {
197 active.set_tab_index(-1);
198 }
199
200 node.set_tab_index(0);
201 let _ = node.focus();
202 }
203}