teksilo_widgets/primitives/validation_strip.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ValidationStrip — a small inline message shown below a text field to
5//! surface a validation outcome.
6//!
7//! Bound to a `Signal<ValidationFeedback>` produced by a
8//! [`TextInputField`](super::text_input_field::TextInputField). The strip
9//! renders nothing when the feedback is `Pristine` or `Valid`, and shows a
10//! single-line message in the appropriate role when `Invalid` (error colour,
11//! `Live::Assertive`) or `Corrected` (secondary text, `Live::Polite`).
12//! The strip is layout-stable: in the hidden state it reports zero height so
13//! the surrounding layout does not reflow on every commit.
14//! It carries `Role::Status` so screen readers announce the message through
15//! the appropriate live region without any composite-side wiring.
16//!
17//! ```ignore
18//! // ValidationStrip is constructed with a `Signal<ValidationFeedback>`
19//! // obtained from a live `TextInputField` — it needs BuildContext to wire up.
20//! // Typical usage inside a composing widget's build():
21//! let (field_id, fb_signal) = build_text_input_field(ctx, ...);
22//! let strip = ctx.add(ValidationStrip::new(fb_signal));
23//! ```
24
25use teksilo_canvas::{Rect, Size, SizeProposal};
26use teksilo_core::accessibility::AccessNodeBuilder;
27use teksilo_core::accesskit::{Live, Role};
28use teksilo_core::build_context::BuildContext;
29use teksilo_core::signal::Signal;
30use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
31use teksilo_core::widget_id::WidgetId;
32use teksilo_i18n::lit;
33use teksilo_tokens::{TextRole, TextStyleRole};
34
35use super::TextWidget;
36use super::text_input_field::ValidationFeedback;
37
38/// Inline validation-feedback strip. See module docs.
39pub struct ValidationStrip {
40 feedback: Signal<ValidationFeedback>,
41 root_id: Option<WidgetId>,
42}
43
44impl std::fmt::Debug for ValidationStrip {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("ValidationStrip").finish()
47 }
48}
49
50impl ValidationStrip {
51 /// Construct a strip bound to a feedback signal — typically
52 /// `field.validation_feedback_signal()` from the same widget.
53 pub fn new(feedback: Signal<ValidationFeedback>) -> Self {
54 Self {
55 feedback,
56 root_id: None,
57 }
58 }
59}
60
61impl Widget for ValidationStrip {
62 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
63 // Reactive label text + color: the inner TextWidget binds
64 // both via signals derived from `feedback`. Pristine / Valid
65 // states produce empty text → the TextWidget renders zero
66 // size and the strip is invisible.
67 // Zip with locale signal so messages re-resolve on locale change.
68 let locale_signal = ctx.locale_signal();
69 let text_signal = self.feedback.zip(&locale_signal).map(|(fb, _)| match fb {
70 ValidationFeedback::Invalid { message }
71 | ValidationFeedback::Corrected { message, .. } => message.resolve_now(),
72 _ => String::new(),
73 });
74
75 // Color: error roles for Invalid; secondary for Corrected
76 // (Int UI's "low-key informational" tone). Pristine / Valid
77 // also pick secondary but render nothing because the text is
78 // empty, so the choice is moot.
79 let color_signal: Signal<TextRole> = self.feedback.map(|fb| match fb {
80 ValidationFeedback::Invalid { .. } => TextRole::Error,
81 _ => TextRole::Secondary,
82 });
83
84 let label = TextWidget::new(lit!(""))
85 .style(TextStyleRole::Small)
86 .text(text_signal)
87 .color(teksilo_core::color_prop::ColorProp::DynamicTextRole(
88 color_signal,
89 ))
90 .single_line()
91 .a11y_hidden();
92 let label_id = ctx.add(label);
93 self.root_id = Some(label_id);
94
95 // Bind feedback at AccessibilityOnly so the strip's AT node
96 // refreshes its `Live` region politeness when the outcome
97 // changes (Polite for Corrected, Assertive for Invalid).
98 let self_id = ctx.self_id();
99 self.feedback.bind_to(
100 self_id,
101 ctx.binding_registry(),
102 teksilo_core::binding::BindingLevel::AccessibilityOnly,
103 );
104
105 vec![label_id]
106 }
107
108 fn layout_response(
109 &self,
110 proposal: SizeProposal,
111 ctx: &LayoutContext,
112 ) -> teksilo_core::widget::LayoutResponse {
113 // Layout-stable empty state: when feedback carries no message
114 // (Pristine / Valid), report zero size so the parent's slot
115 // collapses entirely. An empty TextWidget would otherwise
116 // contribute its style line-height (~12 dp), accumulating
117 // across stacks of fields and pushing siblings offscreen.
118 if !matches!(
119 self.feedback.get(),
120 ValidationFeedback::Invalid { .. } | ValidationFeedback::Corrected { .. }
121 ) {
122 return Size::ZERO.into();
123 }
124 match self.root_id {
125 Some(id) => ctx.child_size(id, proposal).unwrap_or(Size::ZERO),
126 None => Size::ZERO,
127 }
128 .into()
129 }
130
131 fn place_children(
132 &self,
133 bounds: Rect,
134 _proposal: SizeProposal,
135 children: &mut [WidgetPlacement],
136 _ctx: &LayoutContext,
137 ) {
138 for child in children.iter_mut() {
139 child.origin = bounds.origin();
140 child.size = bounds.size();
141 }
142 }
143
144 fn children(&self) -> Vec<WidgetId> {
145 self.root_id.into_iter().collect()
146 }
147
148 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
149 builder.set_role(Role::Status);
150 let fb = self.feedback.get();
151 match &fb {
152 ValidationFeedback::Invalid { message } => {
153 builder.set_name(message.clone());
154 builder.set_live(Live::Assertive);
155 }
156 ValidationFeedback::Corrected { message, .. } => {
157 builder.set_name(message.clone());
158 builder.set_live(Live::Polite);
159 }
160 _ => {
161 // Empty Status node — present in the AT tree but not
162 // announcing anything. Live::Off keeps it silent.
163 builder.set_live(Live::Off);
164 }
165 }
166 }
167}