From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 230D21FF142 for ; Fri, 19 Jun 2026 09:44:39 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id D70F06E8D; Fri, 19 Jun 2026 09:44:38 +0200 (CEST) From: Dominik Csapak To: yew-devel@lists.proxmox.com Subject: [PATCH yew-widget-toolkit 2/2] widget: navigation drawer: make menu collapsing/expanding animated Date: Fri, 19 Jun 2026 09:44:28 +0200 Message-ID: <20260619074434.705653-3-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260619074434.705653-1-d.csapak@proxmox.com> References: <20260619074434.705653-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.049 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [focus.rs] Message-ID-Hash: GAAIVVZVV6NYWIWW2AXPPFFX2E2TJTDE X-Message-ID-Hash: GAAIVVZVV6NYWIWW2AXPPFFX2E2TJTDE X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Yew framework devel list at Proxmox List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This is a common and popular pattern for menus in navigation menus, so implement it here too, but with the capability to disable it. To achieve that we have to wrap the submenus in two containers: one that sets the height from outside and hides the overflow, and one that is being sized so it gives a 'sliding' animation. Since the elements are not in a flat list anymore, for keyboard navigation we have to introduce recursive tabindex search. Copy `roving_tabindex_next` to `roving_tabindex_next_recursive`, which searches recursively, and leave the original in place for all other use sites. Signed-off-by: Dominik Csapak --- src/dom/focus.rs | 30 ++++++++++++++++++++++++----- src/widget/nav/navigation_drawer.rs | 24 ++++++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/dom/focus.rs b/src/dom/focus.rs index 2dedce1..d14023f 100644 --- a/src/dom/focus.rs +++ b/src/dom/focus.rs @@ -96,7 +96,14 @@ pub fn get_first_focusable(item_el: web_sys::Element) -> Option() { - roving_tabindex_next_el(el, backwards, roving); + roving_tabindex_next_el(el, backwards, roving, false); + } +} + +/// Move focus to the next/previous focusable child including nested ones (calls [roving_tabindex_next_el]) . +pub fn roving_tabindex_next_recursive(node_ref: &NodeRef, backwards: bool, roving: bool) { + if let Some(el) = node_ref.cast::() { + roving_tabindex_next_el(el, backwards, roving, true); } } @@ -104,8 +111,15 @@ pub fn roving_tabindex_next(node_ref: &NodeRef, backwards: bool, roving: bool) { /// /// If `roving` is enabled, this also sets the `tabindex` attribute for the active child to `"0"`, /// and to `"-1"` for all other children. -pub fn roving_tabindex_next_el(el: web_sys::HtmlElement, backwards: bool, roving: bool) { - let list = roving_tabindex_members(&el); +/// +/// If `nesting` is enabled, all nested focusable elements are included +pub fn roving_tabindex_next_el( + el: web_sys::HtmlElement, + backwards: bool, + roving: bool, + recursive: bool, +) { + let list = roving_tabindex_members(&el, recursive); if list.is_empty() { return; @@ -251,7 +265,10 @@ pub fn update_roving_tabindex_el(el: web_sys::HtmlElement) { /// /// This kind of member selection makes it possible to include more complex widget like /// [MenuButton](crate::widget::menu::MenuButton)s inside a [Toolbar](crate::widget::Toolbar). -pub fn roving_tabindex_members(el: &web_sys::HtmlElement) -> Vec { +pub fn roving_tabindex_members( + el: &web_sys::HtmlElement, + recursive: bool, +) -> Vec { let mut members: Vec = Vec::new(); if let Ok(child_list) = el.query_selector_all(":scope > *") { @@ -263,6 +280,9 @@ pub fn roving_tabindex_members(el: &web_sys::HtmlElement) -> Vec().unwrap(); members.push(first_focusable_child); @@ -285,7 +305,7 @@ pub fn init_roving_tabindex(node_ref: &NodeRef) { /// This function makes sure that exactly one element has the `tabindex` attribute set to `"0"`. All /// other elements get a `tabindex` of `"-1"`. pub fn init_roving_tabindex_el(el: web_sys::HtmlElement, take_focus: bool) { - let list = roving_tabindex_members(&el); + let list = roving_tabindex_members(&el, false); if list.is_empty() { return; diff --git a/src/widget/nav/navigation_drawer.rs b/src/widget/nav/navigation_drawer.rs index 89b4b4b..d3f79db 100644 --- a/src/widget/nav/navigation_drawer.rs +++ b/src/widget/nav/navigation_drawer.rs @@ -15,7 +15,7 @@ use crate::props::{ use crate::state::{NavigationContext, NavigationContextExt, Selection}; use crate::{impl_class_prop_builder, impl_yew_std_props_builder}; -use crate::dom::focus::roving_tabindex_next; +use crate::dom::focus::roving_tabindex_next_recursive; use crate::widget::{Column, Container, Fa}; use super::{Menu, MenuEntry, MenuItem}; @@ -75,6 +75,13 @@ pub struct NavigationDrawer { #[builder] #[prop_or_default] router: bool, + + /// Enables animations + /// + /// If enabled, expanding/collapsing is animated + #[builder] + #[prop_or(true)] + animated: bool, } impl AsClassesMut for NavigationDrawer { @@ -221,7 +228,6 @@ impl PwtNavigationDrawer { (!hidden).then_some(if is_active { "0" } else { "-1" }), ) .class("pwt-nav-link") - .class(hidden.then_some("pwt-d-none")) .class(crate::css::AlignItems::Baseline) .class(is_active.then_some("active")) .onclick(onclick) @@ -271,9 +277,16 @@ impl PwtNavigationDrawer { menu.add_child(self.render_single_item(ctx, child, active, level, open, hidden)); if let Some(submenu) = &child.submenu { + let mut items = Column::new().min_height(0); for sub in submenu.children.iter() { - self.render_menu_entry(ctx, sub, menu, active, level + 1, !open) + self.render_menu_entry(ctx, sub, &mut items, active, level + 1, !open) } + menu.add_child( + Container::new() + .class("pwt-nav-menu-animation-container") + .class(open.then_some("expanded")) + .with_child(items), + ); } } MenuEntry::Component(comp) => { @@ -554,10 +567,10 @@ impl Component for PwtNavigationDrawer { let onkeydown = Callback::from(move |event: KeyboardEvent| { match event.key().as_str() { "ArrowDown" => { - roving_tabindex_next(&menu_ref, false, false); + roving_tabindex_next_recursive(&menu_ref, false, false); } "ArrowUp" => { - roving_tabindex_next(&menu_ref, true, false); + roving_tabindex_next_recursive(&menu_ref, true, false); } _ => return, } @@ -571,6 +584,7 @@ impl Component for PwtNavigationDrawer { .attribute("role", "navigation") .attribute("aria-label", props.aria_label.clone()) .class("pwt-nav-menu") + .class(props.animated.then_some("animated")) .class(OverflowX::Hidden) .class(OverflowY::Auto) .class(props.class.clone()) -- 2.47.3