1use 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
39pub 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 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 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 pub fn info(title: impl Into<LocalizedString>) -> Self {
74 Self::new(BannerSeverity::Info, title)
75 }
76
77 pub fn success(title: impl Into<LocalizedString>) -> Self {
79 Self::new(BannerSeverity::Success, title)
80 }
81
82 pub fn warning(title: impl Into<LocalizedString>) -> Self {
84 Self::new(BannerSeverity::Warning, title)
85 }
86
87 pub fn error(title: impl Into<LocalizedString>) -> Self {
89 Self::new(BannerSeverity::Error, title)
90 }
91
92 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 pub fn action(mut self, widget: impl Widget + 'static) -> Self {
102 self.action = Some(Box::new(widget));
103 self
104 }
105
106 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 let glyph = ctx.add(SeverityBadge::new(
132 severity.into(),
133 banner_tokens::BANNER_GLYPH_SIZE,
134 ));
135
136 let title = ctx.add(
138 TextWidget::new(self.title.clone())
139 .style(TextStyleRole::BodyBold)
140 .color(TextRole::Primary)
141 .single_line()
142 .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 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 let btn = IconButton::clear()
179 .embedded()
180 .on_activate_fn(move |c| on_dismiss(c));
181 content = content.child(ctx.add(btn));
182 }
183 let content_id = ctx.add(content.access_live(teksilo_core::accesskit::Live::Off));
195
196 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 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 builder.set_role(teksilo_core::accesskit::Role::Status);
261 builder.set_live(teksilo_core::accesskit::Live::Polite);
262 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 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 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 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 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 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}