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_builder::WidgetBuilder;
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
30
31pub use teksilo_core::styles::BannerSeverity;
32
33use crate::icon_button::IconButton;
34use crate::primitives::{Expand, HStack, TextWidget, VStack};
35use crate::severity_badge::SeverityBadge;
36use crate::styles::recipe_banner_style as banner_tokens;
37use teksilo_i18n::LocalizedString;
38
39/// A persistent inline status strip.
40pub struct Banner {
41    severity: BannerSeverity,
42    title: LocalizedString,
43    description: Option<LocalizedString>,
44    action: Option<Box<dyn Widget>>,
45    on_dismiss: Option<Box<dyn Fn(&mut EventContext)>>,
46    /// Per-call override for the banner strip chrome.
47    style_override: Option<SharedBannerStyle>,
48    root_child_id: Option<WidgetId>,
49}
50
51impl Banner {
52    fn new(severity: BannerSeverity, title: impl Into<LocalizedString>) -> Self {
53        let ls: LocalizedString = title.into();
54        Self {
55            severity,
56            title: ls,
57            description: None,
58            action: None,
59            on_dismiss: None,
60            style_override: None,
61            root_child_id: None,
62        }
63    }
64
65    /// Per-call style override for the banner strip chrome. Replaces
66    /// the theme-wide default `BannerStyle` for just this instance.
67    pub fn style(mut self, style: impl teksilo_core::styles::BannerStyle) -> Self {
68        self.style_override = Some(Rc::new(style));
69        self
70    }
71
72    /// Construct an info-severity banner.
73    pub fn info(title: impl Into<LocalizedString>) -> Self {
74        Self::new(BannerSeverity::Info, title)
75    }
76
77    /// Construct a success-severity banner.
78    pub fn success(title: impl Into<LocalizedString>) -> Self {
79        Self::new(BannerSeverity::Success, title)
80    }
81
82    /// Construct a warning-severity banner.
83    pub fn warning(title: impl Into<LocalizedString>) -> Self {
84        Self::new(BannerSeverity::Warning, title)
85    }
86
87    /// Construct an error-severity banner.
88    pub fn error(title: impl Into<LocalizedString>) -> Self {
89        Self::new(BannerSeverity::Error, title)
90    }
91
92    /// Optional secondary line of text rendered below the title.
93    pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
94        let ls: LocalizedString = text.into();
95        self.description = Some(ls);
96        self
97    }
98
99    /// Trailing widget — typically a [`Button`](crate::button::Button) or
100    /// an `HStack` of buttons. Placed before the optional dismiss button.
101    pub fn action(mut self, widget: impl Widget + 'static) -> Self {
102        self.action = Some(Box::new(widget));
103        self
104    }
105
106    /// Attach a trailing dismiss (X) button. The closure runs when the
107    /// user clicks it; the host is expected to remove the banner from the
108    /// tree (typically by toggling a `Signal<bool>` driving a `Switcher`).
109    pub fn on_dismiss(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
110        self.on_dismiss = Some(Box::new(f));
111        self
112    }
113}
114
115impl std::fmt::Debug for Banner {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("Banner")
118            .field("severity", &self.severity)
119            .field("title", &self.title)
120            .field("description", &self.description)
121            .finish()
122    }
123}
124
125impl Widget for Banner {
126    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
127        let severity = self.severity;
128
129        // Severity glyph — a two-tone status badge, built by the widget
130        // (principle 6) and handed to the style as the leading glyph.
131        let glyph = ctx.add(SeverityBadge::new(
132            severity.into(),
133            banner_tokens::BANNER_GLYPH_SIZE,
134        ));
135
136        // Title + optional description column.
137        let title = ctx.add(
138            TextWidget::new(self.title.clone())
139                .style(TextStyleRole::BodyBold)
140                .color(TextRole::Primary)
141                .single_line()
142                // The banner's own Role::Status node carries this title as
143                // its name, set on the node rather than drawn from this label
144                // through `labelled_by`: the adapters announce a live node
145                // when its own name changes, and `accesskit_consumer` reports
146                // a node as changed only when its own data does, so a title
147                // changing on the label alone would go unannounced.
148                .a11y_hidden(),
149        );
150        let mut text_column = VStack::new()
151            .spacing(banner_tokens::BANNER_TITLE_DESCRIPTION_GAP)
152            .child(title);
153        if let Some(description) = &self.description {
154            let desc = ctx.add(
155                TextWidget::new(description.clone())
156                    .style(TextStyleRole::Body)
157                    .color(TextRole::Secondary),
158            );
159            text_column = text_column.child(desc);
160        }
161        let text_column_id = ctx.add(text_column);
162
163        // Content row: [text + spacer expanding] [action] [dismiss].
164        // The leading severity glyph is prepended by the `BannerStyle`.
165        let mut content = HStack::new()
166            .spacing(banner_tokens::BANNER_CONTENT_GAP)
167            .alignment(VAlignment::Center)
168            .child(ctx.add(Expand::horizontal().child(text_column_id)));
169        if let Some(action) = self.action.take() {
170            content = content.child(ctx.add_boxed(action));
171        }
172        if let Some(on_dismiss) = self.on_dismiss.take() {
173            // IconButton::clear() ships with its own translated
174            // "Clear" tooltip / a11y label — adequate for a banner
175            // dismiss button without inventing a new i18n key.
176            // `.embedded()` keeps the icon dim until hover so the X
177            // doesn't compete with the banner's title/body text.
178            let btn = IconButton::clear()
179                .embedded()
180                .on_activate_fn(move |c| on_dismiss(c));
181            content = content.child(ctx.add(btn));
182        }
183        // `Live::Off` on the row that holds the description, the action and
184        // the dismiss button. The banner is a polite live region named by
185        // its title, and `accesskit_consumer` hands its politeness down to
186        // every descendant that sets none (node.rs:906-910), while all three
187        // adapters announce each named node entering the filtered tree with
188        // an inherited politeness (atspi_common adapter.rs:71-77, windows
189        // adapter.rs:255-263, macos event.rs:236-241). Without it a banner
190        // was heard as its title, then its description, its action and its
191        // dismiss button, each cutting off the one before; in a card, a
192        // panel, a tab or a dialog, whose wrappers used to hide it whole, it
193        // was not heard at all.
194        let content_id = ctx.add(content.access_live(teksilo_core::accesskit::Live::Off));
195
196        // The strip chrome (per-severity surface tint, corner radius,
197        // padding, glyph-content arrangement) is owned by the active
198        // `BannerStyle`; this widget keeps its `Role::Status` node.
199        let style: SharedBannerStyle = self
200            .style_override
201            .clone()
202            .or_else(|| ctx.theme().style_slots.banner.clone())
203            .unwrap_or_else(|| {
204                Rc::new(crate::styles::RecipeBannerStyle::for_tokens(
205                    &ctx.theme().input,
206                ))
207            });
208        let root = style.make_body(
209            &BannerStyleConfig {
210                severity,
211                content: content_id,
212                leading_glyph: glyph,
213            },
214            ctx,
215        );
216        self.root_child_id = Some(root);
217        vec![root]
218    }
219
220    fn layout_response(
221        &self,
222        proposal: SizeProposal,
223        ctx: &LayoutContext,
224    ) -> teksilo_core::widget::LayoutResponse {
225        // Banners are a "fill width, hug height" surface. ZStack queries
226        // its children with `SizeProposal::unspecified`, so delegating
227        // straight to `child_size` would return the row's natural width
228        // and the banner would collapse to its content. Take the
229        // proposed width as the source of truth and use the inner
230        // height (computed against that width) for the visual.
231        let inner_proposal = SizeProposal {
232            width: proposal.width,
233            height: None,
234        };
235        let inner = self
236            .root_child_id
237            .and_then(|id| ctx.child_size(id, inner_proposal))
238            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
239        let width = proposal.width.unwrap_or(inner.width);
240        teksilo_canvas::Size::new(width, inner.height).into()
241    }
242
243    fn place_children(
244        &self,
245        bounds: Rect,
246        _proposal: SizeProposal,
247        children: &mut [WidgetPlacement],
248        _ctx: &LayoutContext,
249    ) {
250        for child in children.iter_mut() {
251            child.origin = bounds.origin();
252            child.size = bounds.size();
253        }
254    }
255
256    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
257        // Banners convey status without consuming focus. `Role::Status`
258        // matches the ARIA pattern for a non-modal status region; pair
259        // with `Live::Polite` so screen readers announce changes.
260        builder.set_role(teksilo_core::accesskit::Role::Status);
261        builder.set_live(teksilo_core::accesskit::Live::Polite);
262        // Use the title alone as the AT name; the description is read by
263        // descending into the body text widget.
264        builder.set_name(self.title.clone());
265    }
266
267    fn children(&self) -> Vec<WidgetId> {
268        self.root_child_id.into_iter().collect()
269    }
270
271    fn clips_children(&self) -> bool {
272        false
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use teksilo_core::widget_tree::WidgetTree;
280    use teksilo_i18n::lit;
281
282    #[test]
283    fn banner_builds_and_lays_out() {
284        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
285        let id = tree.add(
286            Banner::warning(lit!("Unsaved changes"))
287                .description(lit!("Close will discard your edits.")),
288        );
289        tree.layout(SizeProposal {
290            width: Some(640.0),
291            height: None,
292        });
293        let b = tree.bounds(id);
294        assert!((b.width - 640.0).abs() < 0.01);
295        assert!(b.height > 0.0);
296    }
297
298    #[test]
299    fn banner_a11y_role_and_name() {
300        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
301        let id = tree.add(Banner::info(lit!("Heads up")));
302        tree.layout(SizeProposal {
303            width: Some(400.0),
304            height: None,
305        });
306        let info = tree.accessibility_node(id);
307        assert_eq!(info.role(), teksilo_core::accesskit::Role::Status);
308        assert_eq!(info.name(), Some("Heads up"));
309    }
310
311    #[test]
312    fn banner_fills_width_inside_vstack() {
313        // Regression: ZStack queries children with `unspecified` proposal,
314        // so a naive `child_size(root, proposal)` delegate makes Banner
315        // collapse to its content width inside a normal VStack parent.
316        // Banner's `layout_response` overrides the width to the proposal.
317        use crate::primitives::VStack;
318        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
319        let banner = Banner::warning(lit!("Unsaved changes"))
320            .description(lit!("Close will discard your edits."));
321        let stack_id = tree.add(VStack::new().spacing(8.0).child(banner));
322        tree.layout(SizeProposal {
323            width: Some(640.0),
324            height: None,
325        });
326
327        // Walk the VStack's first descendant Banner (Role::Status) and
328        // check its bounds.
329        let mut queue = vec![stack_id];
330        let mut banner_bounds = None;
331        while let Some(id) = queue.pop() {
332            let info = tree.accessibility_node(id);
333            if info.role() == teksilo_core::accesskit::Role::Status {
334                banner_bounds = Some(tree.bounds(id));
335                break;
336            }
337            queue.extend(tree.children(id));
338        }
339        let b = banner_bounds.expect("Banner should be in the tree under the VStack");
340        assert!(
341            (b.width - 640.0).abs() < 0.5,
342            "Banner inside VStack should span the proposed width 640 dp, got {}",
343            b.width
344        );
345    }
346
347    #[test]
348    fn banner_title_label_is_hidden_from_accessibility_tree() {
349        // The Banner's Role::Status node already carries the title as its
350        // own name (set on the node, not drawn through `labelled_by`, so
351        // that a new title is a change of the node the adapters announce),
352        // so the embedded title Label must not
353        // reach the AT tree as a second, duplicate-named stop. The
354        // description is a distinct, non-duplicate label and must stay.
355        use teksilo_core::accessibility::widget_id_to_node_id;
356        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
357        let id = tree.add(
358            Banner::warning(lit!("Unsaved changes"))
359                .description(lit!("Close will discard your edits.")),
360        );
361        tree.layout(SizeProposal {
362            width: Some(640.0),
363            height: None,
364        });
365        let update = tree.sync_accessibility();
366
367        let banner_node_id = widget_id_to_node_id(id);
368        let banner_node = update
369            .nodes
370            .iter()
371            .find(|(nid, _)| *nid == banner_node_id)
372            .map(|(_, node)| node)
373            .expect("banner node");
374        assert_eq!(banner_node.role(), teksilo_core::accesskit::Role::Status);
375        assert_eq!(
376            banner_node.label(),
377            Some("Unsaved changes"),
378            "hiding the title label must not take the banner's own name away with it",
379        );
380        let banner_name = banner_node.label().unwrap_or_default().to_string();
381
382        assert!(
383            !update.nodes.iter().any(|(_, node)| {
384                node.role() == teksilo_core::accesskit::Role::Label
385                    && node.label() == Some(banner_name.as_str())
386            }),
387            "the title Label must not survive as a duplicate-named node",
388        );
389
390        // Exactly one Role::Label node remains: the description's. Were
391        // the title's label not hidden, there would be two.
392        let label_count = update
393            .nodes
394            .iter()
395            .filter(|(_, node)| node.role() == teksilo_core::accesskit::Role::Label)
396            .count();
397        assert_eq!(
398            label_count, 1,
399            "only the description Label should remain once the title is hidden",
400        );
401    }
402
403    #[test]
404    fn banner_with_dismiss_emits_clear_button() {
405        use std::cell::Cell;
406        use std::rc::Rc;
407        let dismissed = Rc::new(Cell::new(false));
408        let dismissed_clone = dismissed.clone();
409        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
410        tree.add(
411            Banner::error(lit!("Disk almost full")).on_dismiss(move |_| dismissed_clone.set(true)),
412        );
413        tree.layout(SizeProposal {
414            width: Some(640.0),
415            height: None,
416        });
417        // Find the clear button by tooltip / a11y label and click it.
418        let dismiss = tree
419            .find_by_label("Clear")
420            .or_else(|| tree.find_by_label("Effacer"))
421            .expect("dismiss button should be present");
422        tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
423            action: teksilo_core::accesskit::Action::Click,
424            target: Some(dismiss),
425            target_node: teksilo_core::accessibility::root_node_id(),
426            data: None,
427        });
428        assert!(dismissed.get(), "on_dismiss should have fired");
429    }
430}