patternfly_yew/components/menu/
mod.rs

1//! Menu components
2mod 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    /// OUIA Component id
50    #[prop_or_default]
51    pub ouia_id: Option<String>,
52    /// OUIA Component Type
53    #[prop_or(OUIA.component_type())]
54    pub ouia_type: OuiaComponentType,
55    /// OUIA Component Safe
56    #[prop_or(OuiaSafe::TRUE)]
57    pub ouia_safe: OuiaSafe,
58}
59
60#[function_component(Menu)]
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-v5-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-v5-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#[function_component(MenuList)]
98pub(crate) fn menu_list(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            if !r#ref.contains(e.target()) {
105                return;
106            }
107
108            handle_key(&r#ref, e);
109        });
110    }
111
112    html!(
113        <ul ref={r#ref} class="pf-v5-c-menu__list" role="menu">
114            { for props.children.iter() }
115        </ul>
116    )
117}
118
119fn focusable_element(element: &HtmlElement) -> Option<HtmlElement> {
120    element
121        .query_selector("a, button, input")
122        .ok()??
123        .dyn_into::<HtmlElement>()
124        .ok()
125}
126
127fn handle_key(node: &NodeRef, e: KeyboardEvent) {
128    match e.key().as_str() {
129        "Enter" => {
130            if let Some(active) = gloo_utils::document()
131                .active_element()
132                .and_then(|element| element.dyn_into::<HtmlElement>().ok())
133            {
134                e.prevent_default();
135                active.click();
136            }
137        }
138        "ArrowUp" | "ArrowDown" => handle_arrows(node, e),
139        _ => {}
140    }
141}
142
143fn handle_arrows(node: &NodeRef, e: KeyboardEvent) {
144    e.prevent_default();
145    e.stop_immediate_propagation();
146
147    let active = gloo_utils::document()
148        .active_element()
149        .and_then(|element| element.dyn_into::<HtmlElement>().ok());
150
151    let elements = match node
152        .cast::<Element>()
153        .map(|ele| ele.get_elements_by_tag_name("LI"))
154    {
155        Some(elements) => elements,
156        None => return,
157    };
158
159    let items = IterableHtmlCollection(&elements)
160        .into_iter()
161        .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
162        .filter(|element| {
163            !element.class_list().contains("pf-m-disabled")
164                && !element.class_list().contains("pf-v5-c-divider")
165        })
166        .collect::<Vec<_>>();
167
168    let len = items.len();
169
170    let index = items
171        .iter()
172        .position(|node| focusable_element(node) == active);
173
174    let offset: isize = if e.key() == "ArrowDown" { 1 } else { -1 };
175
176    let next_index = index
177        // apply offset
178        .map(|index| index as isize + offset)
179        // handle overflow
180        .map(|index| {
181            if index < 0 {
182                len.saturating_sub(1)
183            } else if index as usize >= len {
184                0
185            } else {
186                index as _
187            }
188        })
189        // or default
190        .unwrap_or_else(|| if offset > 0 { 0 } else { len.saturating_sub(1) });
191
192    // get as node
193    let next_node = items
194        .get(next_index)
195        .and_then(focusable_element)
196        .and_then(|ele| ele.dyn_into::<HtmlElement>().ok());
197
198    // apply
199    if let Some(node) = &next_node {
200        if let Some(active) = &active {
201            active.set_tab_index(-1);
202        }
203
204        node.set_tab_index(0);
205        let _ = node.focus();
206    }
207}