use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, Node};
use yew::prelude::*;
use super::focus::{focusable_elements, focused_position, next_focus_index};
fn menu_items(menu_ref: &NodeRef) -> Vec<HtmlElement> {
focusable_elements(menu_ref, "a[href], button:not([disabled])")
.into_iter()
.filter(|element| {
element
.closest(".nav-dropdown-item.disabled")
.ok()
.flatten()
.is_none()
})
.collect()
}
#[derive(Properties, Clone, PartialEq, Debug, Default)]
pub struct NavDropdownProps {
#[prop_or_default]
pub classes: Classes,
#[prop_or(AttrValue::Static("dropdown"))]
pub toggle_text: AttrValue,
#[prop_or_default]
pub id: Option<AttrValue>,
#[prop_or_default]
pub children: Children
}
#[function_component]
pub fn NavDropdown(props: &NavDropdownProps) -> Html {
let mut classes = props.classes.clone();
classes.push("nav-dropdown");
let open = use_state(|| false);
let open_focus_last = use_mut_ref(|| false);
let container_ref = use_node_ref();
let toggle_ref = use_node_ref();
let menu_ref = use_node_ref();
let on_toggle = {
let open = open.clone();
let open_focus_last = open_focus_last.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
*open_focus_last.borrow_mut() = false;
open.set(!*open);
})
};
let on_keydown = {
let open = open.clone();
let open_focus_last = open_focus_last.clone();
let toggle_ref = toggle_ref.clone();
let menu_ref = menu_ref.clone();
Callback::from(move |event: KeyboardEvent| {
let key = event.key();
if key == "Escape" && *open {
event.prevent_default();
open.set(false);
if let Some(toggle) = toggle_ref.cast::<HtmlElement>() {
let _ = toggle.focus();
}
return;
}
if !matches!(key.as_str(), "ArrowDown" | "ArrowUp" | "Home" | "End") {
return;
}
let items = menu_items(&menu_ref);
if items.is_empty() {
return;
}
event.prevent_default();
if !*open {
*open_focus_last.borrow_mut() = key == "ArrowUp";
open.set(true);
return;
}
let position = focused_position(&items);
if let Some(item) = next_focus_index(&key, position, items.len(), true)
.and_then(|index| items.get(index))
{
let _ = item.focus();
}
})
};
let on_focusout = {
let open = open.clone();
let container_ref = container_ref.clone();
Callback::from(move |event: FocusEvent| {
if !*open {
return;
}
let stays_inside = match (container_ref.cast::<Node>(), event.related_target()) {
(Some(container), Some(related)) => related
.dyn_into::<Node>()
.is_ok_and(|node| container.contains(Some(&node))),
_ => false
};
if !stays_inside {
open.set(false);
}
})
};
{
let menu_ref = menu_ref.clone();
use_effect_with(*open, move |is_open| {
if *is_open {
let items = menu_items(&menu_ref);
let focus_last = std::mem::take(&mut *open_focus_last.borrow_mut());
let target = if focus_last {
items.last()
} else {
items.first()
};
if let Some(item) = target {
let _ = item.focus();
}
}
|| ()
});
}
let menu_class = if *open {
"nav-dropdown-menu open"
} else {
"nav-dropdown-menu"
};
html! {
<li
ref={container_ref}
class={classes}
onkeydown={on_keydown}
onfocusout={on_focusout}
>
<button
ref={toggle_ref}
type="button"
class="nav-dropdown-toggle"
aria-expanded={if *open { "true" } else { "false" }}
aria-controls={props.id.clone()}
onclick={on_toggle}
>
{ props.toggle_text.clone() }
<span class="nav-dropdown-caret" aria-hidden="true">{" ▼"}</span>
</button>
<ul ref={menu_ref} id={props.id.clone()} class={menu_class}>
{ for props.children.iter() }
</ul>
</li>
}
}
#[derive(Properties, Clone, PartialEq, Debug, Default)]
pub struct NavDropdownItemProps {
#[prop_or_default]
pub classes: Classes,
#[prop_or_default]
pub disabled: bool,
pub children: Children
}
#[function_component]
pub fn NavDropdownItem(props: &NavDropdownItemProps) -> Html {
let mut classes = props.classes.clone();
classes.push("nav-dropdown-item");
if props.disabled {
classes.push("disabled");
}
let aria_disabled = props.disabled.then_some("true");
html! {
<li class={classes} aria-disabled={aria_disabled}>
{ for props.children.iter() }
</li>
}
}
#[derive(Properties, Clone, PartialEq, Eq, Debug, Default)]
pub struct NavDropdownDividerProps {
#[prop_or_default]
pub classes: Classes
}
#[function_component]
pub fn NavDropdownDivider(props: &NavDropdownDividerProps) -> Html {
let mut classes = props.classes.clone();
classes.push("nav-dropdown-divider");
html! {
<li class={classes} role="separator" />
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nav_dropdown_props_default() {
let props = NavDropdownProps {
classes: Classes::default(),
toggle_text: AttrValue::Static("Menu"),
id: None,
children: Children::new(vec![])
};
assert_eq!(props.toggle_text, "Menu");
assert!(props.id.is_none());
}
#[test]
fn nav_dropdown_item_default() {
let props = NavDropdownItemProps {
classes: Classes::default(),
disabled: false,
children: Children::new(vec![])
};
assert!(!props.disabled);
}
#[test]
fn nav_dropdown_item_disabled() {
let props = NavDropdownItemProps {
classes: Classes::default(),
disabled: true,
children: Children::new(vec![])
};
assert!(props.disabled);
}
#[test]
fn nav_dropdown_divider_props() {
let props = NavDropdownDividerProps {
classes: Classes::default()
};
assert!(props.classes.is_empty());
}
#[test]
fn nav_dropdown_with_custom_id() {
let props = NavDropdownProps {
classes: Classes::default(),
toggle_text: AttrValue::Static("Menu"),
id: Some(AttrValue::Static("my-dropdown")),
children: Children::new(vec![])
};
assert_eq!(props.id.as_deref(), Some("my-dropdown"));
}
#[test]
fn nav_dropdown_item_with_classes() {
let mut classes = Classes::new();
classes.push("custom-item");
let props = NavDropdownItemProps {
classes,
disabled: false,
children: Children::new(vec![])
};
assert!(props.classes.contains("custom-item"));
}
#[test]
fn nav_dropdown_disabled_item() {
let props = NavDropdownItemProps {
classes: Classes::default(),
disabled: true,
children: Children::new(vec![])
};
assert!(props.disabled);
}
#[test]
fn nav_dropdown_with_children() {
let children = Children::new(vec![html! { <div>{ "child" }</div> }]);
let props = NavDropdownProps {
classes: Classes::default(),
toggle_text: AttrValue::Static("Test"),
id: None,
children
};
assert_eq!(props.children.len(), 1);
}
}