Skip to main content

teksilo_widgets/
banner.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Banner — persistent inline status strip (info / success / warning / error).
5//!
6//! A non-transient, full-width callout for app-level conditions: deprecation
7//! notices, "you have unsaved changes", trial-expiry warnings, license
8//! issues, restored-from-cache notices, etc. Distinct from
9//! [`Snackbar`](crate::snackbar::Snackbar) (transient, corner-anchored) and
10//! [`MessageBox`](crate::message_box::MessageBox) (modal).
11//!
12//! ```ignore
13//! Banner::warning(tr!(unsaved_changes()))
14//!     .description(tr!(close_loses_changes()))
15//!     .action(Button::new(tr!(save_now()))
16//!         .on_activate_fn(|ctx| ctx.send_intent(AppIntent::SaveNow)))
17//!     .on_dismiss(|ctx| ctx.send_intent(AppIntent::DismissBanner))
18//! ```
19
20use std::rc::Rc;
21
22use teksilo_canvas::{Rect, SizeProposal};
23use teksilo_core::accessibility::AccessNodeBuilder;
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::styles::{BannerStyleConfig, SharedBannerStyle};
26use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
27use teksilo_core::widget_id::WidgetId;
28use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
29
30pub use teksilo_core::styles::BannerSeverity;
31
32use crate::icon_button::IconButton;
33use crate::primitives::{Expand, HStack, TextWidget, VStack};
34use crate::severity_badge::SeverityBadge;
35use crate::styles::recipe_banner_style as banner_tokens;
36use teksilo_i18n::LocalizedString;
37
38/// A persistent inline status strip.
39pub struct Banner {
40    severity: BannerSeverity,
41    title: LocalizedString,
42    description: Option<LocalizedString>,
43    action: Option<Box<dyn Widget>>,
44    on_dismiss: Option<Box<dyn Fn(&mut EventContext)>>,
45    /// Per-call override for the banner strip chrome.
46    style_override: Option<SharedBannerStyle>,
47    root_child_id: Option<WidgetId>,
48}
49
50impl Banner {
51    fn new(severity: BannerSeverity, title: impl Into<LocalizedString>) -> Self {
52        let ls: LocalizedString = title.into();
53        Self {
54            severity,
55            title: ls,
56            description: None,
57            action: None,
58            on_dismiss: None,
59            style_override: None,
60            root_child_id: None,
61        }
62    }
63
64    /// Per-call style override for the banner strip chrome. Replaces
65    /// the theme-wide default `BannerStyle` for just this instance.
66    pub fn style(mut self, style: impl teksilo_core::styles::BannerStyle) -> Self {
67        self.style_override = Some(Rc::new(style));
68        self
69    }
70
71    /// Construct an info-severity banner.
72    pub fn info(title: impl Into<LocalizedString>) -> Self {
73        Self::new(BannerSeverity::Info, title)
74    }
75
76    /// Construct a success-severity banner.
77    pub fn success(title: impl Into<LocalizedString>) -> Self {
78        Self::new(BannerSeverity::Success, title)
79    }
80
81    /// Construct a warning-severity banner.
82    pub fn warning(title: impl Into<LocalizedString>) -> Self {
83        Self::new(BannerSeverity::Warning, title)
84    }
85
86    /// Construct an error-severity banner.
87    pub fn error(title: impl Into<LocalizedString>) -> Self {
88        Self::new(BannerSeverity::Error, title)
89    }
90
91    /// Optional secondary line of text rendered below the title.
92    pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
93        let ls: LocalizedString = text.into();
94        self.description = Some(ls);
95        self
96    }
97
98    /// Trailing widget — typically a [`Button`](crate::button::Button) or
99    /// an `HStack` of buttons. Placed before the optional dismiss button.
100    pub fn action(mut self, widget: impl Widget + 'static) -> Self {
101        self.action = Some(Box::new(widget));
102        self
103    }
104
105    /// Attach a trailing dismiss (X) button. The closure runs when the
106    /// user clicks it; the host is expected to remove the banner from the
107    /// tree (typically by toggling a `Signal<bool>` driving a `Switcher`).
108    pub fn on_dismiss(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
109        self.on_dismiss = Some(Box::new(f));
110        self
111    }
112}
113
114impl std::fmt::Debug for Banner {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("Banner")
117            .field("severity", &self.severity)
118            .field("title", &self.title)
119            .field("description", &self.description)
120            .finish()
121    }
122}
123
124impl Widget for Banner {
125    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
126        let severity = self.severity;
127
128        // Severity glyph — a two-tone status badge, built by the widget
129        // (principle 6) and handed to the style as the leading glyph.
130        let glyph = ctx.add(SeverityBadge::new(
131            severity.into(),
132            banner_tokens::BANNER_GLYPH_SIZE,
133        ));
134
135        // Title + optional description column.
136        let title = ctx.add(
137            TextWidget::new(self.title.clone())
138                .style(TextStyleRole::BodyBold)
139                .color(TextRole::Primary)
140                .single_line(),
141        );
142        let mut text_column = VStack::new()
143            .spacing(banner_tokens::BANNER_TITLE_DESCRIPTION_GAP)
144            .add_child(title);
145        if let Some(description) = &self.description {
146            let desc = ctx.add(
147                TextWidget::new(description.clone())
148                    .style(TextStyleRole::Body)
149                    .color(TextRole::Secondary),
150            );
151            text_column = text_column.add_child(desc);
152        }
153        let text_column_id = ctx.add(text_column);
154
155        // Content row: [text + spacer expanding] [action] [dismiss].
156        // The leading severity glyph is prepended by the `BannerStyle`.
157        let mut content = HStack::new()
158            .spacing(banner_tokens::BANNER_CONTENT_GAP)
159            .alignment(VAlignment::Center)
160            .add_child(ctx.add(Expand::horizontal().child_id(text_column_id)));
161        if let Some(action) = self.action.take() {
162            content = content.add_child(ctx.add_boxed(action));
163        }
164        if let Some(on_dismiss) = self.on_dismiss.take() {
165            // IconButton::clear() ships with its own translated
166            // "Clear" tooltip / a11y label — adequate for a banner
167            // dismiss button without inventing a new i18n key.
168            // `.embedded()` keeps the icon dim until hover so the X
169            // doesn't compete with the banner's title/body text.
170            let btn = IconButton::clear()
171                .embedded()
172                .on_activate_fn(move |c| on_dismiss(c));
173            content = content.add_child(ctx.add(btn));
174        }
175        let content_id = ctx.add(content);
176
177        // The strip chrome (per-severity surface tint, corner radius,
178        // padding, glyph-content arrangement) is owned by the active
179        // `BannerStyle`; this widget keeps its `Role::Status` node.
180        let style: SharedBannerStyle = self
181            .style_override
182            .clone()
183            .or_else(|| ctx.theme().style_slots.banner.clone())
184            .unwrap_or_else(|| Rc::new(crate::styles::RecipeBannerStyle::default()));
185        let root = style.make_body(
186            &BannerStyleConfig {
187                severity,
188                content: content_id,
189                leading_glyph: glyph,
190            },
191            ctx,
192        );
193        self.root_child_id = Some(root);
194        vec![root]
195    }
196
197    fn layout_response(
198        &self,
199        proposal: SizeProposal,
200        ctx: &LayoutContext,
201    ) -> teksilo_core::widget::LayoutResponse {
202        // Banners are a "fill width, hug height" surface. ZStack queries
203        // its children with `SizeProposal::unspecified`, so delegating
204        // straight to `child_size` would return the row's natural width
205        // and the banner would collapse to its content. Take the
206        // proposed width as the source of truth and use the inner
207        // height (computed against that width) for the visual.
208        let inner_proposal = SizeProposal {
209            width: proposal.width,
210            height: None,
211        };
212        let inner = self
213            .root_child_id
214            .and_then(|id| ctx.child_size(id, inner_proposal))
215            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
216        let width = proposal.width.unwrap_or(inner.width);
217        teksilo_canvas::Size::new(width, inner.height).into()
218    }
219
220    fn place_children(
221        &self,
222        bounds: Rect,
223        _proposal: SizeProposal,
224        children: &mut [WidgetPlacement],
225        _ctx: &LayoutContext,
226    ) {
227        for child in children.iter_mut() {
228            child.origin = bounds.origin();
229            child.size = bounds.size();
230        }
231    }
232
233    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
234        // Banners convey status without consuming focus. `Role::Status`
235        // matches the ARIA pattern for a non-modal status region; pair
236        // with `Live::Polite` so screen readers announce changes.
237        builder.set_role(teksilo_core::accesskit::Role::Status);
238        builder.set_live(teksilo_core::accesskit::Live::Polite);
239        // Use the title alone as the AT name; the description is read by
240        // descending into the body text widget.
241        builder.set_name(self.title.clone());
242    }
243
244    fn children(&self) -> Vec<WidgetId> {
245        self.root_child_id.into_iter().collect()
246    }
247
248    fn clips_children(&self) -> bool {
249        false
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use teksilo_core::widget_tree::WidgetTree;
257    use teksilo_i18n::lit;
258
259    #[test]
260    fn banner_builds_and_lays_out() {
261        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
262        let id = tree.add(
263            Banner::warning(lit!("Unsaved changes"))
264                .description(lit!("Close will discard your edits.")),
265        );
266        tree.layout(SizeProposal {
267            width: Some(640.0),
268            height: None,
269        });
270        let b = tree.bounds(id);
271        assert!((b.width - 640.0).abs() < 0.01);
272        assert!(b.height > 0.0);
273    }
274
275    #[test]
276    fn banner_a11y_role_and_name() {
277        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
278        let id = tree.add(Banner::info(lit!("Heads up")));
279        tree.layout(SizeProposal {
280            width: Some(400.0),
281            height: None,
282        });
283        let info = tree.accessibility_node(id);
284        assert_eq!(info.role(), teksilo_core::accesskit::Role::Status);
285        assert_eq!(info.name(), Some("Heads up"));
286    }
287
288    #[test]
289    fn banner_fills_width_inside_vstack() {
290        // Regression: ZStack queries children with `unspecified` proposal,
291        // so a naive `child_size(root, proposal)` delegate makes Banner
292        // collapse to its content width inside a normal VStack parent.
293        // Banner's `layout_response` overrides the width to the proposal.
294        use crate::primitives::VStack;
295        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
296        let banner = Banner::warning(lit!("Unsaved changes"))
297            .description(lit!("Close will discard your edits."));
298        let stack_id = tree.add(VStack::new().spacing(8.0).child(banner));
299        tree.layout(SizeProposal {
300            width: Some(640.0),
301            height: None,
302        });
303
304        // Walk the VStack's first descendant Banner (Role::Status) and
305        // check its bounds.
306        let mut queue = vec![stack_id];
307        let mut banner_bounds = None;
308        while let Some(id) = queue.pop() {
309            let info = tree.accessibility_node(id);
310            if info.role() == teksilo_core::accesskit::Role::Status {
311                banner_bounds = Some(tree.bounds(id));
312                break;
313            }
314            queue.extend(tree.children(id));
315        }
316        let b = banner_bounds.expect("Banner should be in the tree under the VStack");
317        assert!(
318            (b.width - 640.0).abs() < 0.5,
319            "Banner inside VStack should span the proposed width 640 dp, got {}",
320            b.width
321        );
322    }
323
324    #[test]
325    fn banner_with_dismiss_emits_clear_button() {
326        use std::cell::Cell;
327        use std::rc::Rc;
328        let dismissed = Rc::new(Cell::new(false));
329        let dismissed_clone = dismissed.clone();
330        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
331        tree.add(
332            Banner::error(lit!("Disk almost full")).on_dismiss(move |_| dismissed_clone.set(true)),
333        );
334        tree.layout(SizeProposal {
335            width: Some(640.0),
336            height: None,
337        });
338        // Find the clear button by tooltip / a11y label and click it.
339        let dismiss = tree
340            .find_by_label("Clear")
341            .or_else(|| tree.find_by_label("Effacer"))
342            .expect("dismiss button should be present");
343        tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
344            action: teksilo_core::accesskit::Action::Click,
345            target: Some(dismiss),
346            target_node: teksilo_core::accessibility::root_node_id(),
347            data: None,
348        });
349        assert!(dismissed.get(), "on_dismiss should have fired");
350    }
351}