* [PATCH yew-widget-toolkit v2 1/3] widget: navigation drawer: fix first open for default collapsed menus
2026-09-04 10:26 [PATCH datacenter-manager/yew-widget-toolkit v2 0/3] ui: persist the main menus expanded state Dominik Csapak
@ 2026-09-04 10:26 ` Dominik Csapak
2026-09-04 10:26 ` [PATCH yew-widget-toolkit v2 2/3] widget: navigation drawer: allow persisting the expanded state Dominik Csapak
2026-09-04 10:26 ` [PATCH datacenter-manager v2 3/3] ui: main menu: make expanded/collapsed state persistent Dominik Csapak
2 siblings, 0 replies; 4+ messages in thread
From: Dominik Csapak @ 2026-09-04 10:26 UTC (permalink / raw)
To: pdm-devel
A menu item can have it's default expanded state have set to collapsed.
This was correctly displayed when the menu was created, but the first
toggle falsely relied on the fact that non-existent entries in the
`menu_states` HashMap should be interpreted as expanded.
Fix this by introducing a `NavigationDrawerState` struct that saves
which items where changed from the default. The default states for all
entries is collected on creation or when the menu changes.
This eliminates the multiple hardcoded default values and it keeps the
saved data minimal, by only saving the keys of menus and only keeping
the state of the ones that changed.
It also prepares us to save the data later, when we want to have the
collapsed/expanded state persistent.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
src/widget/nav/navigation_drawer.rs | 97 ++++++++++++++++++++++-------
1 file changed, 76 insertions(+), 21 deletions(-)
diff --git a/src/widget/nav/navigation_drawer.rs b/src/widget/nav/navigation_drawer.rs
index 87f0743..eae2ca4 100644
--- a/src/widget/nav/navigation_drawer.rs
+++ b/src/widget/nav/navigation_drawer.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use yew::html::{IntoEventCallback, IntoPropValue};
@@ -146,6 +146,12 @@ impl NavigationDrawer {
}
}
+/// A struct that holds the current state that can get saved to local storage
+#[derive(Default, Clone, PartialEq)]
+struct NavigationDrawerState {
+ changed_expanded: HashSet<String>, // contains all keys that deviate from their default
+}
+
pub enum Msg {
Select(Option<Key>, bool, bool),
SelectionChange(Selection),
@@ -154,12 +160,31 @@ pub enum Msg {
MenuOpen(Key),
}
+// iterates over a menu and saves all submenus that are collapsed by default
+fn collect_default_collapse_states(menu: &Menu) -> HashMap<Key, bool> {
+ let mut defaults = HashMap::new();
+ collect_default_collapsed_impl(&mut defaults, menu);
+ defaults
+}
+
+fn collect_default_collapsed_impl(defaults: &mut HashMap<Key, bool>, menu: &Menu) {
+ for entry in menu.children.iter() {
+ if let MenuEntry::Item(item) = entry
+ && let (Some(key), Some(submenu)) = (&item.key, &item.submenu)
+ {
+ defaults.insert(key.clone(), item.default_collapsed);
+ collect_default_collapsed_impl(defaults, submenu);
+ }
+ }
+}
+
#[doc(hidden)]
pub struct PwtNavigationDrawer {
node_ref: NodeRef,
active: Option<Key>,
selection: Selection,
- menu_states: HashMap<Key, bool>, // true = open
+ default_collapsed: HashMap<Key, bool>,
+ state: NavigationDrawerState,
_nav_ctx_handle: Option<ContextHandle<NavigationContext>>,
}
@@ -269,16 +294,8 @@ impl PwtNavigationDrawer {
) {
match item {
MenuEntry::Item(child) => {
- // An absent state means the user has not toggled this submenu yet, so fall back
- // to its default: open, unless the item opted into starting collapsed. A submenu
- // on the path to the active item is force-opened by open_ancestors, so a
- // deep-linked target stays visible even under default_collapsed.
let open = match &child.key {
- Some(key) => self
- .menu_states
- .get(key)
- .copied()
- .unwrap_or(!child.default_collapsed),
+ Some(key) => self.is_expanded(key),
None => false,
};
@@ -373,7 +390,8 @@ impl PwtNavigationDrawer {
if item.selectable {
Some(entry)
} else {
- self.menu_states.insert(desired.clone(), true);
+ // the selection moves on to a child, so make sure the submenu is visible
+ self.set_expanded(desired, true);
find_first_key_recursive(&submenu.children)
}
}
@@ -407,7 +425,7 @@ impl PwtNavigationDrawer {
let mut path = Vec::new();
if collect(&props.menu.children, key, &mut path) {
for key in path {
- self.menu_states.insert(key, true);
+ self.set_expanded(&key, true);
}
}
}
@@ -439,6 +457,29 @@ impl PwtNavigationDrawer {
}
}
}
+
+ fn toggle_expanded(&mut self, key: &Key) {
+ self.set_expanded(key, !self.is_expanded(key));
+ }
+
+ fn is_expanded(&self, key: &Key) -> bool {
+ let key_string = key.to_string();
+ let changed = self.state.changed_expanded.contains(&key_string);
+ let default_collapsed = *self.default_collapsed.get(key).unwrap_or(&false);
+ // open means either collapsed by default and toggled, or open by default and untouched
+ changed == default_collapsed
+ }
+
+ fn set_expanded(&mut self, key: &Key, expanded: bool) {
+ let key_string = key.to_string();
+ let default_open = !self.default_collapsed.get(key).unwrap_or(&false);
+ // only record the keys that deviate from their default, so the set stays minimal
+ if expanded == default_open {
+ self.state.changed_expanded.remove(&key_string);
+ } else {
+ self.state.changed_expanded.insert(key_string);
+ }
+ }
}
fn get_active_or_default(props: &NavigationDrawer, active: &Option<Key>) -> Option<Key> {
@@ -490,7 +531,8 @@ impl Component for PwtNavigationDrawer {
node_ref: NodeRef::default(),
active: active.clone(),
selection,
- menu_states: HashMap::new(),
+ default_collapsed: collect_default_collapse_states(&props.menu),
+ state: NavigationDrawerState::default(),
_nav_ctx_handle,
};
@@ -562,9 +604,7 @@ impl Component for PwtNavigationDrawer {
self.emit_item_activate(&key, ctx);
}
if toggle {
- let entry =
- *self.menu_states.entry(key.clone()).or_insert_with(|| true);
- self.menu_states.insert(key, !entry);
+ self.toggle_expanded(&key);
}
}
return true;
@@ -592,16 +632,15 @@ impl Component for PwtNavigationDrawer {
true
}
Msg::MenuToggle(key) => {
- let entry = *self.menu_states.entry(key.clone()).or_insert_with(|| true);
- self.menu_states.insert(key, !entry);
+ self.toggle_expanded(&key);
true
}
Msg::MenuClose(key) => {
- self.menu_states.insert(key, false);
+ self.set_expanded(&key, false);
true
}
Msg::MenuOpen(key) => {
- self.menu_states.insert(key, true);
+ self.set_expanded(&key, true);
true
}
}
@@ -612,6 +651,22 @@ impl Component for PwtNavigationDrawer {
if props.selection != old_props.selection {
self.selection = Self::init_selection(ctx, props.selection.clone(), &self.active);
}
+ if props.menu != old_props.menu {
+ let new_default_collapsed = collect_default_collapse_states(&props.menu);
+ // for all old keys where the default changed and still exist, toggle them
+ let mut keys = Vec::new();
+ for (key, old_default) in self.default_collapsed.iter() {
+ if let Some(new_default) = new_default_collapsed.get(key)
+ && *new_default != *old_default
+ {
+ keys.push(key.clone());
+ }
+ }
+ for key in keys {
+ self.toggle_expanded(&key);
+ }
+ self.default_collapsed = new_default_collapsed;
+ }
true
}
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread* [PATCH yew-widget-toolkit v2 2/3] widget: navigation drawer: allow persisting the expanded state
2026-09-04 10:26 [PATCH datacenter-manager/yew-widget-toolkit v2 0/3] ui: persist the main menus expanded state Dominik Csapak
2026-09-04 10:26 ` [PATCH yew-widget-toolkit v2 1/3] widget: navigation drawer: fix first open for default collapsed menus Dominik Csapak
@ 2026-09-04 10:26 ` Dominik Csapak
2026-09-04 10:26 ` [PATCH datacenter-manager v2 3/3] ui: main menu: make expanded/collapsed state persistent Dominik Csapak
2 siblings, 0 replies; 4+ messages in thread
From: Dominik Csapak @ 2026-09-04 10:26 UTC (permalink / raw)
To: pdm-devel
By exposing a `stateful_id` property that is used to save the
`NavigationDrawerState` in the browser local storage.
This is loaded on component creation and when the stateful id changes.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
src/widget/nav/navigation_drawer.rs | 60 ++++++++++++++++++++++++++---
1 file changed, 54 insertions(+), 6 deletions(-)
diff --git a/src/widget/nav/navigation_drawer.rs b/src/widget/nav/navigation_drawer.rs
index eae2ca4..890eb04 100644
--- a/src/widget/nav/navigation_drawer.rs
+++ b/src/widget/nav/navigation_drawer.rs
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
+use serde::{Deserialize, Serialize};
use yew::html::{IntoEventCallback, IntoPropValue};
use yew::prelude::*;
use yew::virtual_dom::{Key, VComp, VNode};
@@ -10,9 +11,9 @@ use pwt_macros::builder;
use crate::css::{OverflowX, OverflowY};
use crate::props::{
AsClassesMut, AsCssStylesMut, ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, CssStyles,
- EventSubscriber, IntoOptionalKey, IntoVTag, WidgetBuilder, WidgetStyleBuilder,
+ EventSubscriber, IntoOptionalKey, IntoVTag, StorageLocation, WidgetBuilder, WidgetStyleBuilder,
};
-use crate::state::{NavigationContext, NavigationContextExt, Selection};
+use crate::state::{NavigationContext, NavigationContextExt, PersistentState, Selection};
use crate::{impl_class_prop_builder, impl_yew_std_props_builder};
use crate::dom::focus::roving_tabindex_next_recursive;
@@ -82,6 +83,14 @@ pub struct NavigationDrawer {
#[builder]
#[prop_or(true)]
animated: bool,
+
+ /// If set, saves the open/collapsed state in the browser local storage
+ ///
+ /// The value is used as the storage key, so it has to be unique within the application. Only
+ /// the menu items that deviate from their default state are stored.
+ #[builder(IntoPropValue, into_prop_value)]
+ #[prop_or_default]
+ stateful_id: Option<AttrValue>,
}
impl AsClassesMut for NavigationDrawer {
@@ -147,8 +156,10 @@ impl NavigationDrawer {
}
/// A struct that holds the current state that can get saved to local storage
-#[derive(Default, Clone, PartialEq)]
+#[derive(Default, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
struct NavigationDrawerState {
+ #[serde(default, skip_serializing_if = "HashSet::is_empty")]
changed_expanded: HashSet<String>, // contains all keys that deviate from their default
}
@@ -185,6 +196,7 @@ pub struct PwtNavigationDrawer {
selection: Selection,
default_collapsed: HashMap<Key, bool>,
state: NavigationDrawerState,
+ stateful_id: Option<AttrValue>, // just a local copy so we don't have pass ctx around
_nav_ctx_handle: Option<ContextHandle<NavigationContext>>,
}
@@ -474,10 +486,39 @@ impl PwtNavigationDrawer {
let key_string = key.to_string();
let default_open = !self.default_collapsed.get(key).unwrap_or(&false);
// only record the keys that deviate from their default, so the set stays minimal
- if expanded == default_open {
- self.state.changed_expanded.remove(&key_string);
+ let modified = if expanded == default_open {
+ self.state.changed_expanded.remove(&key_string)
} else {
- self.state.changed_expanded.insert(key_string);
+ self.state.changed_expanded.insert(key_string)
+ };
+ if modified {
+ self.save_state();
+ }
+ }
+
+ /// Loads the deviations from the default state from the local storage, if an `id` is set.
+ fn load_state(&mut self) {
+ if let Some(id) = self.stateful_id.clone() {
+ let state =
+ PersistentState::<Option<NavigationDrawerState>>::new(StorageLocation::Local(id));
+ if let Some(state) = state.into_inner() {
+ self.state = state;
+ }
+ }
+ }
+
+ /// Saves the deviations from the default state to the local storage, if an `id` is set.
+ fn save_state(&self) {
+ if let Some(id) = self.stateful_id.clone() {
+ let mut state =
+ PersistentState::<Option<NavigationDrawerState>>::new(StorageLocation::Local(id));
+
+ let drawer_state = if self.state == NavigationDrawerState::default() {
+ None
+ } else {
+ Some(self.state.clone())
+ };
+ state.update(drawer_state);
}
}
}
@@ -533,9 +574,12 @@ impl Component for PwtNavigationDrawer {
selection,
default_collapsed: collect_default_collapse_states(&props.menu),
state: NavigationDrawerState::default(),
+ stateful_id: props.stateful_id.clone(),
_nav_ctx_handle,
};
+ this.load_state();
+
// expand the path to the initially active item, so a deep-linked entry is visible
if let Some(active) = &active {
this.open_ancestors(props, active);
@@ -667,6 +711,10 @@ impl Component for PwtNavigationDrawer {
}
self.default_collapsed = new_default_collapsed;
}
+ if props.stateful_id != old_props.stateful_id {
+ self.stateful_id = props.stateful_id.clone();
+ self.load_state();
+ }
true
}
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread