From: Dominik Csapak <d.csapak@proxmox.com>
To: yew-devel@lists.proxmox.com
Subject: [PATCH yew-widget-toolkit 2/4] touch: navigation rail: move auto layout from AdaptiveScaffold
Date: Mon, 10 Aug 2026 16:34:52 +0200 [thread overview]
Message-ID: <20260810143536.31163-3-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260810143536.31163-1-d.csapak@proxmox.com>
The rail knows best when its own layout has to change, so let it
subscribe to the breakpoint itself instead of having every parent
compute the flag. This way the expanded variant also works when the
rail is used directly, e.g. in a plain Scaffold, and the new
`expanded` override allows pinning one of the two variants.
The AdaptiveScaffold keeps a pass-through for the breakpoint, but
loses its second media query. Its large query no longer implies the
rail layout on its own, the wide query alone decides between rail and
bottom bar now. This is OK since the wide query was true when the large
query triggered.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
src/touch/adaptive_scaffold.rs | 48 ++++++----------------
src/touch/navigation_rail.rs | 73 +++++++++++++++++++++++++++++++---
2 files changed, 79 insertions(+), 42 deletions(-)
diff --git a/src/touch/adaptive_scaffold.rs b/src/touch/adaptive_scaffold.rs
index 3ef6527..db378e9 100644
--- a/src/touch/adaptive_scaffold.rs
+++ b/src/touch/adaptive_scaffold.rs
@@ -18,17 +18,13 @@ use super::{NavigationBar, NavigationRail, Scaffold};
/// matches the value PMG, PVE and PBS dashboards use to decide between rail and bar layouts.
const DEFAULT_WIDE_QUERY: &str = "(min-width: 768px)";
-/// Default media query selecting the large layout. Mirrors Material Design's large breakpoint,
-/// where the expanded navigation rail is the recommended navigator.
-const DEFAULT_LARGE_QUERY: &str = "(min-width: 1200px)";
-
/// Material-style scaffold that adapts its primary navigator to the viewport width.
///
/// Below the configured wide viewport breakpoint a [Scaffold] with a bottom [NavigationBar] is
/// rendered (the touch-first layout used on phones and narrow windows). Above it the layout
/// becomes a [NavigationRail] anchored to the inline-start side, with the application bar and body
-/// to its right. Above the additional large breakpoint the rail switches to its expanded variant,
-/// which places icon and label side by side and widens each item into a full-width pill.
+/// to its right. The rail switches to its expanded variant on its own once the viewport gets large
+/// enough, see [NavigationRail::expanded_query].
///
/// Both navigators are populated from the same [TabBarItem] list and share the same selection /
/// router wiring, so a single declaration drives both layouts. The active layout swaps at runtime
@@ -96,12 +92,12 @@ pub struct AdaptiveScaffold {
#[prop_or(AttrValue::Static(DEFAULT_WIDE_QUERY))]
pub wide_query: AttrValue,
- /// CSS media query that selects the large layout, which renders
- /// the [NavigationRail] in its expanded (wide) variant. Defaults
- /// to `(min-width: 1200px)`.
+ /// CSS media query that selects the rail's expanded (wide) variant
+ /// (rail layout only, see [NavigationRail::expanded_query]).
+ /// Unset keeps the rail's own default.
#[builder(IntoPropValue, into_prop_value)]
- #[prop_or(AttrValue::Static(DEFAULT_LARGE_QUERY))]
- pub large_query: AttrValue,
+ #[prop_or_default]
+ pub rail_expanded_query: Option<AttrValue>,
/// Selection forwarded to the active navigator.
#[builder(IntoPropValue, into_prop_value)]
@@ -169,15 +165,12 @@ impl AdaptiveScaffold {
#[doc(hidden)]
pub enum Msg {
WideChanged(bool),
- LargeChanged(bool),
}
#[doc(hidden)]
pub struct PwtAdaptiveScaffold {
is_wide: bool,
- is_large: bool,
wide_query: Option<ViewportQuery>,
- large_query: Option<ViewportQuery>,
}
impl PwtAdaptiveScaffold {
@@ -186,6 +179,9 @@ impl PwtAdaptiveScaffold {
if let Some(leading) = &props.rail_leading {
rail = rail.leading(leading.clone());
}
+ if let Some(expanded_query) = &props.rail_expanded_query {
+ rail = rail.expanded_query(expanded_query.clone());
+ }
if let Some(default_active) = &props.default_active {
rail = rail.default_active(default_active.clone());
}
@@ -222,16 +218,10 @@ impl Component for PwtAdaptiveScaffold {
ctx.props().wide_query.as_str(),
ctx.link().callback(Msg::WideChanged),
);
- let (is_large, large_query) = ViewportQuery::subscribe(
- ctx.props().large_query.as_str(),
- ctx.link().callback(Msg::LargeChanged),
- );
Self {
is_wide,
- is_large,
wide_query,
- large_query,
}
}
@@ -244,13 +234,6 @@ impl Component for PwtAdaptiveScaffold {
self.is_wide = matches;
true
}
- Msg::LargeChanged(matches) => {
- if self.is_large == matches {
- return false;
- }
- self.is_large = matches;
- true
- }
}
}
@@ -261,12 +244,6 @@ impl Component for PwtAdaptiveScaffold {
ctx.link().callback(Msg::WideChanged),
);
}
- if ctx.props().large_query != old_props.large_query {
- (self.is_large, self.large_query) = ViewportQuery::subscribe(
- ctx.props().large_query.as_str(),
- ctx.link().callback(Msg::LargeChanged),
- );
- }
true
}
@@ -285,9 +262,8 @@ impl Component for PwtAdaptiveScaffold {
}
// Only the navigator slot differs; the rail versus bar switch lives in Scaffold itself.
- // A matching large query implies the rail layout even if the wide query is disjoint.
- scaffold = if self.is_wide || self.is_large {
- scaffold.navigation_rail(Self::build_rail(props).expanded(self.is_large))
+ scaffold = if self.is_wide {
+ scaffold.navigation_rail(Self::build_rail(props))
} else {
scaffold.navigation_bar(Self::build_bar(props))
};
diff --git a/src/touch/navigation_rail.rs b/src/touch/navigation_rail.rs
index 090d5a2..7cbc494 100644
--- a/src/touch/navigation_rail.rs
+++ b/src/touch/navigation_rail.rs
@@ -6,6 +6,7 @@ use yew::prelude::*;
use yew::virtual_dom::{Key, VComp, VNode};
use crate::css::JustifyContent;
+use crate::dom::ViewportQuery;
use crate::prelude::*;
use crate::props::{ContainerBuilder, EventSubscriber, WidgetBuilder};
use crate::state::{NavigationContext, NavigationContextExt, Selection};
@@ -15,6 +16,10 @@ use crate::widget::TabBarItem;
use pwt_macros::builder;
+/// Default media query selecting the expanded layout. Mirrors Material Design's large breakpoint,
+/// where the expanded navigation rail is the recommended navigator.
+const DEFAULT_EXPANDED_QUERY: &str = "(min-width: 1200px)";
+
/// Navigation rail
///
/// # Automatic routing.
@@ -22,6 +27,13 @@ use pwt_macros::builder;
/// [NavigationRail] supports fully automatic routing if you put the rail inside
/// a [NavigationContainer](crate::state::NavigationContainer) and
/// set the router flag.
+///
+/// # Collapsed and expanded layout.
+///
+/// The rail renders collapsed (icon above label) on smaller viewports and expanded (icon and label
+/// side by side, each item a full-width pill) as soon as
+/// [expanded_query](Self::expanded_query) matches. Set [expanded](Self::expanded) to pin one of the
+/// two variants instead.
// Note: This is Similatr to TabBar without keyboard support.
#[derive(Properties, Clone, PartialEq)]
@@ -39,11 +51,20 @@ pub struct NavigationRail {
#[prop_or(JustifyContent::Center)]
pub group_alignment: JustifyContent,
- /// Render the expanded (wide) rail variant with icon and label side by side, matching
- /// Material Design's expanded navigation rail for large viewports.
- #[builder]
+ /// Pin the layout variant instead of selecting it by viewport width.
+ ///
+ /// `Some(true)` always renders the expanded (wide) variant with icon and label side by side,
+ /// `Some(false)` always the collapsed one. `None` (the default) follows
+ /// [expanded_query](Self::expanded_query).
+ #[builder(IntoPropValue, into_prop_value)]
#[prop_or_default]
- pub expanded: bool,
+ pub expanded: Option<bool>,
+
+ /// CSS media query that selects the expanded (wide) variant. Defaults to
+ /// `(min-width: 1200px)`, Material Design's large breakpoint.
+ #[builder(IntoPropValue, into_prop_value)]
+ #[prop_or(AttrValue::Static(DEFAULT_EXPANDED_QUERY))]
+ pub expanded_query: AttrValue,
/// Navigation bar items.
items: Vec<TabBarItem>,
@@ -129,12 +150,18 @@ impl NavigationRail {
pub enum Msg {
Select(Option<Key>, bool),
SelectionChange(Selection),
+ ExpandedQueryChange(bool),
}
#[doc(hidden)]
pub struct PwtNavigationRail {
active: Option<Key>,
selection: Selection,
+ expanded: bool,
+ /// Last known match state of `expanded_query`, so a changing `expanded` property can fall back
+ /// to the automatic layout.
+ query_matches: bool,
+ expanded_query: Option<ViewportQuery>,
_nav_ctx_handle: Option<ContextHandle<NavigationContext>>,
}
@@ -167,6 +194,14 @@ impl PwtNavigationRail {
selection
}
+
+ fn set_expanded(&mut self, expanded: bool) -> bool {
+ if self.expanded == expanded {
+ return false;
+ }
+ self.expanded = expanded;
+ true
+ }
}
impl Component for PwtNavigationRail {
@@ -205,9 +240,17 @@ impl Component for PwtNavigationRail {
on_select.emit(active.clone());
}
+ let (query_matches, expanded_query) = ViewportQuery::subscribe(
+ props.expanded_query.as_str(),
+ ctx.link().callback(Msg::ExpandedQueryChange),
+ );
+
Self {
selection,
active,
+ expanded: props.expanded.unwrap_or(query_matches),
+ query_matches,
+ expanded_query,
_nav_ctx_handle,
}
}
@@ -266,6 +309,14 @@ impl Component for PwtNavigationRail {
true
}
+ Msg::ExpandedQueryChange(matches) => {
+ self.query_matches = matches;
+ // an explicitly pinned layout ignores the viewport
+ match props.expanded {
+ Some(_) => false,
+ None => self.set_expanded(matches),
+ }
+ }
}
}
@@ -274,6 +325,16 @@ impl Component for PwtNavigationRail {
if props.selection != old_props.selection {
self.selection = Self::init_selection(ctx, props.selection.clone(), &self.active);
}
+ let query_changed = props.expanded_query != old_props.expanded_query;
+ if query_changed {
+ (self.query_matches, self.expanded_query) = ViewportQuery::subscribe(
+ props.expanded_query.as_str(),
+ ctx.link().callback(Msg::ExpandedQueryChange),
+ );
+ }
+ if query_changed || props.expanded != old_props.expanded {
+ self.set_expanded(props.expanded.unwrap_or(self.query_matches));
+ }
true
}
@@ -311,7 +372,7 @@ impl Component for PwtNavigationRail {
);
// the collapsed rail anchors the badge to the icon corner, the expanded
// variant places it after the label instead
- let corner_badge = if props.expanded { None } else { badge.take() };
+ let corner_badge = if self.expanded { None } else { badge.take() };
Some(html! {<div {class}><i class={icon_class}/>{corner_badge}</div>})
}
None => None,
@@ -343,7 +404,7 @@ impl Component for PwtNavigationRail {
Container::new()
.class("pwt-navigation-rail")
- .class(props.expanded.then_some("pwt-navigation-rail-expanded"))
+ .class(self.expanded.then_some("pwt-navigation-rail-expanded"))
.with_optional_child(props.leading.clone())
.with_child(
Container::new()
--
2.47.3
next prev parent reply other threads:[~2026-08-10 14:35 UTC|newest]
Thread overview: 5+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-10 14:34 [PATCH yew-widget-toolkit/yew-widget-toolkit-assets 0/4] adaptive scaffold/navigation rail improvements Dominik Csapak
2026-08-10 14:34 ` [PATCH yew-widget-toolkit-assets 1/4] navigation rail: style the expand button Dominik Csapak
2026-08-10 14:34 ` Dominik Csapak [this message]
2026-08-10 14:34 ` [PATCH yew-widget-toolkit 3/4] touch: navigation rail: add an optional " Dominik Csapak
2026-08-10 14:34 ` [PATCH yew-widget-toolkit 4/4] touch: adaptive scaffold: pass the rail group alignment through Dominik Csapak
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260810143536.31163-3-d.csapak@proxmox.com \
--to=d.csapak@proxmox.com \
--cc=yew-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox