Skip to main content

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}