Skip to main content

patternfly_yew/components/menu/
mod.rs

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