From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id C7A5B1FF0E1 for ; Mon, 10 Aug 2026 16:35:41 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 92703217A9; Mon, 10 Aug 2026 16:35:41 +0200 (CEST) From: Dominik Csapak 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 Message-ID: <20260810143536.31163-3-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260810143536.31163-1-d.csapak@proxmox.com> References: <20260810143536.31163-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 1.039 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: TDMSXGMEZCSHQB4AHEOIHOKA2OHCL4IN X-Message-ID-Hash: TDMSXGMEZCSHQB4AHEOIHOKA2OHCL4IN 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: 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 --- 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, /// 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, - large_query: Option, } 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, + + /// 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, @@ -129,12 +150,18 @@ impl NavigationRail { pub enum Msg { Select(Option, bool), SelectionChange(Selection), + ExpandedQueryChange(bool), } #[doc(hidden)] pub struct PwtNavigationRail { active: Option, 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, _nav_ctx_handle: Option>, } @@ -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! {
{corner_badge}
}) } 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