teksilo_widgets/
banner.rs1use 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
38pub 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 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 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 pub fn info(title: impl Into<LocalizedString>) -> Self {
73 Self::new(BannerSeverity::Info, title)
74 }
75
76 pub fn success(title: impl Into<LocalizedString>) -> Self {
78 Self::new(BannerSeverity::Success, title)
79 }
80
81 pub fn warning(title: impl Into<LocalizedString>) -> Self {
83 Self::new(BannerSeverity::Warning, title)
84 }
85
86 pub fn error(title: impl Into<LocalizedString>) -> Self {
88 Self::new(BannerSeverity::Error, title)
89 }
90
91 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 pub fn action(mut self, widget: impl Widget + 'static) -> Self {
101 self.action = Some(Box::new(widget));
102 self
103 }
104
105 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 let glyph = ctx.add(SeverityBadge::new(
131 severity.into(),
132 banner_tokens::BANNER_GLYPH_SIZE,
133 ));
134
135 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 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 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 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 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 builder.set_role(teksilo_core::accesskit::Role::Status);
238 builder.set_live(teksilo_core::accesskit::Live::Polite);
239 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 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 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 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}