Skip to main content

teksilo_widgets/
text_input.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TextInput` — styled single-line text field composite.
5//!
6//! Wraps the [`TextInputField`]
7//! editing primitive in a bordered, padded frame with placeholder
8//! overlay, validation, optional clear button, and leading/trailing
9//! slots. All actual text editing is delegated to the field: every
10//! configuration method here has a direct counterpart on the
11//! primitive.
12//!
13//! Most applications want `TextInput`. Choose
14//! [`TextInputField`] directly
15//! when you're building a composite of your own that already
16//! supplies its frame — `SpinBox` is the canonical in-tree example.
17//!
18//! # Example
19//!
20//! ```ignore
21//! let search = ctx.signal(String::new());
22//! TextInput::new(search.clone())
23//!     .placeholder("Search...")
24//!     .show_clear_button(true)
25//!     .leading_slot(IconWidget::from_svg(SEARCH_ICON))
26//!     .on_submit_fn(|ctx| ctx.send_intent(AppIntent::Search))
27//! ```
28
29#[cfg(test)]
30mod tests;
31
32use std::rc::Rc;
33
34use teksilo_canvas::{Point, Rect, SizeProposal};
35use teksilo_core::accessibility::AccessNodeBuilder;
36use teksilo_core::build_context::BuildContext;
37use teksilo_core::signal::{Prop, Signal};
38use teksilo_core::styles::{
39    SharedTextInputStyle, TextInputStyle, TextInputStyleConfig, TextInputValidationLevel,
40};
41use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
42use teksilo_core::widget_builder::WidgetBuilder;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{Alignment, TextRole, TextStyleRole};
45
46use crate::button::InteractionState;
47use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
48use crate::primitives::validation_strip::ValidationStrip;
49use crate::primitives::{Expand, HStack, MinSize, Padding, Shrinkable, TextWidget, VStack, ZStack};
50use crate::tooltip::{self, RichTooltipSource};
51
52// Re-export the variant enum at module top so callers can write
53// `TextInput::new(text).variant(TextInputVariant::Filled)` without a
54// deeper import path.
55pub use teksilo_core::styles::TextInputVariant;
56use teksilo_i18n::LocalizedString;
57
58/// Validation state for the text input field.
59///
60/// Drives the inline feedback strip and border tint of [`TextInput`].
61#[derive(Debug, Clone, Default)]
62pub enum ValidationState {
63    /// No validation message — the field is pristine or valid.
64    #[default]
65    None,
66    /// The committed value is invalid; `LocalizedString` is shown in red below the field.
67    Error(LocalizedString),
68    /// The committed value is suspicious but accepted; `LocalizedString` is shown as a warning.
69    Warning(LocalizedString),
70    /// Last commit was auto-corrected; the field's value has already
71    /// been replaced with the normalized form. The composite renders
72    /// the message in secondary text and tints the border accent
73    /// briefly (decay-managed by the framework's frame loop, not a
74    /// concern of this enum).
75    Corrected(LocalizedString),
76}
77
78/// Styled single-line text input composite.
79///
80/// See the [module-level documentation](self) for usage examples.
81pub struct TextInput {
82    // ── Configuration forwarded to the inner TextInputField ─────────
83    text: Signal<String>,
84    placeholder: LocalizedString,
85    /// Enabled state, static or reactive; forwarded to the arena and the
86    /// inner `TextInputField` at build time.
87    enabled: Prop<bool>,
88    read_only: bool,
89    max_length: Option<usize>,
90    on_submit: Option<Box<dyn Fn(&mut EventContext)>>,
91    on_blur: Option<Box<dyn Fn(&mut EventContext)>>,
92    char_filter: Option<std::rc::Rc<dyn Fn(char) -> bool>>,
93    suffix: String,
94    /// Optional input-mask grammar string (Qt syntax). Forwarded
95    /// 1:1 to `TextInputField::input_mask`. Used by composing
96    /// widgets like `DateEdit` that need a position-aware filter
97    /// + auto-derived placeholder template (`__/__/____`).
98    input_mask: Option<String>,
99    /// Semantic input purpose (WCAG 1.3.5) forwarded to the inner
100    /// `TextInputField` to select a specialised AT role.
101    input_purpose: crate::primitives::text_input_field::InputPurpose,
102    /// ARIA combobox wiring, forwarded verbatim to the inner
103    /// `TextInputField` (the node that actually holds focus).
104    active_descendant: Option<Signal<Option<WidgetId>>>,
105    controls: Option<Signal<Option<WidgetId>>>,
106    /// Optional validator closure. Forwarded 1:1 to
107    /// `TextInputField::validator`. Runs on commit (Enter, Tab-out,
108    /// blur). Set this AND `validation_feedback` together for
109    /// the standard validator → feedback display pattern.
110    validator: Option<crate::primitives::text_input_field::ValidatorFn>,
111    /// Captured pre-build so composing widgets can read live caret
112    /// position (DateEdit-style segment-stepping). Populated by
113    /// `caret_position()` on first call; the inner field's own
114    /// signal is mirrored into it during `build`.
115    caret_position_slot: std::rc::Rc<std::cell::RefCell<Option<Signal<usize>>>>,
116    /// Same idea as `caret_position_slot` but for the setter
117    /// closure. Captured pre-build by `caret_setter()`.
118    caret_setter_slot: std::rc::Rc<std::cell::RefCell<Option<std::rc::Rc<dyn Fn(usize)>>>>,
119    /// Handed out by [`Self::handle`] before build, adopted by the inner field
120    /// at build time — so the two are one handle, not two that agree by luck.
121    field_handle: crate::primitives::TextFieldHandle,
122    /// Arena id of the inner `TextInputField`, filled in by `build`.
123    /// Shared, so a handle taken before `ctx.add` sees it afterwards.
124    field_id_slot: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
125    /// Mirrored from the inner field's `validation_feedback_signal`
126    /// during `build`. Composing widgets that install a `validator`
127    /// read this to compose feedback across multiple fields (range
128    /// editor's worse-of-two ladder, etc.).
129    feedback_signal: Signal<ValidationFeedback>,
130
131    // ── Configuration owned by this composite only ──────────────────
132    label: Option<LocalizedString>,
133    /// Optional override for the frame's intrinsic minimum width
134    /// (default 65 dp). Composing widgets like `DateEdit` /
135    /// `TimeEdit` raise this so the frame stays at the design
136    /// width even when typed content shrinks. Wired into the inner
137    /// `MinSize` wrapper around the ZStack frame — NOT the outer
138    /// VStack — so the floor doesn't fight the VStack's
139    /// `proposal.width.unwrap_or(max_width)` rule.
140    min_width: Option<f32>,
141    show_clear_button: bool,
142    leading_slot: Option<Box<dyn Widget>>,
143    trailing_slot: Option<Box<dyn Widget>>,
144    validation: Signal<ValidationState>,
145    /// Set by `.validation_feedback(...)`; wired via `ctx.effect`
146    /// in `build()` so the bridge outlives construction.
147    feedback_to_bridge: Option<Signal<ValidationFeedback>>,
148    tooltip_text: Option<LocalizedString>,
149    rich_tooltip_source: Option<RichTooltipSource>,
150    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
151
152    /// Tier-1 design-language variant. Drives which chrome the active
153    /// `TextInputStyle` paints around the editor (Outlined / Filled /
154    /// Underline / Bare).
155    variant: TextInputVariant,
156    /// Per-call style override.
157    style_override: Option<SharedTextInputStyle>,
158
159    // ── Internal (set during build) ─────────────────────────────────
160    interaction: Signal<InteractionState>,
161    root_child_id: Option<WidgetId>,
162}
163
164impl std::fmt::Debug for TextInput {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("TextInput")
167            .field("placeholder", &self.placeholder)
168            .field("enabled", &self.enabled.get())
169            .finish_non_exhaustive()
170    }
171}
172
173impl TextInput {
174    /// Construct a new text input bound to `text`.
175    pub fn new(text: Signal<String>) -> Self {
176        Self {
177            text,
178            placeholder: LocalizedString::literal(String::new()),
179            enabled: Prop::Static(true),
180            read_only: false,
181            max_length: None,
182            on_submit: None,
183            on_blur: None,
184            char_filter: None,
185            suffix: String::new(),
186            input_mask: None,
187            input_purpose: crate::primitives::text_input_field::InputPurpose::Normal,
188            active_descendant: None,
189            controls: None,
190            validator: None,
191            caret_position_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
192            caret_setter_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
193            field_handle: crate::primitives::TextFieldHandle::detached(),
194            field_id_slot: std::rc::Rc::new(std::cell::Cell::new(None)),
195            feedback_signal: Signal::new(ValidationFeedback::Pristine),
196            label: None,
197            min_width: None,
198            show_clear_button: false,
199            leading_slot: None,
200            trailing_slot: None,
201            validation: Signal::new(ValidationState::None),
202            feedback_to_bridge: None,
203            tooltip_text: None,
204            rich_tooltip_source: None,
205            composite_tooltip_content: None,
206            variant: TextInputVariant::default(),
207            style_override: None,
208            interaction: Signal::new(InteractionState::Idle),
209            root_child_id: None,
210        }
211    }
212
213    /// Pick a Tier-1 design-language variant
214    /// ([`TextInputVariant::Outlined`] / `Filled` / `Underline` / `Bare`).
215    /// The IntUI default ([`crate::styles::RecipeTextInputStyle`]) honours
216    /// `Outlined`, `Filled`, and `Bare`; `Underline` falls back to
217    /// `Outlined` until per-side stroke recipes land.
218    pub fn variant(mut self, variant: TextInputVariant) -> Self {
219        self.variant = variant;
220        self
221    }
222
223    /// Override the active [`TextInputStyle`] for this widget instance
224    /// only. The widget keeps responsibility for caret blinking, IME
225    /// composition, the placeholder layering, the leading / trailing
226    /// slots and the validation strip — the style only paints the
227    /// frame (border / fill / corner radius).
228    pub fn style(mut self, style: impl TextInputStyle) -> Self {
229        self.style_override = Some(Rc::new(style));
230        self
231    }
232
233    // ── Builder methods ─────────────────────────────────────────────
234    //
235    // Every method below that has a direct analogue on
236    // `TextInputField` forwards to it 1:1 at build time — the
237    // `TextInput` composite just owns the framing around the field.
238
239    /// Set the placeholder text shown when the field is empty.
240    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
241        let ls: LocalizedString = text.into();
242        self.placeholder = ls;
243        self
244    }
245
246    /// Accessible name for the field.
247    ///
248    /// Applied to the inner `TextInputField` — the node that carries
249    /// `Role::TextInput`, holds focus, and reports the document's value.
250    /// It deliberately does *not* go on the composite's outer node: that
251    /// node is a `Role::GenericContainer`, which
252    /// `accesskit_consumer::common_filter` drops from the filtered tree
253    /// unconditionally, so a name placed there would be invisible to every
254    /// screen reader on every platform.
255    ///
256    /// Stays locale-reactive: a `tr!(...)` name is re-resolved when the
257    /// locale changes, without a rebuild.
258    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
259        let ls: LocalizedString = label.into();
260        self.label = Some(ls);
261        self
262    }
263
264    /// Set the enabled state, statically or reactively. Forwarded to the
265    /// arena and the inner `TextInputField` at build time.
266    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
267        self.enabled = enabled.into();
268        self
269    }
270
271    /// Set the field read-only: text is selectable and copyable but not editable.
272    pub fn read_only(mut self, read_only: bool) -> Self {
273        self.read_only = read_only;
274        self
275    }
276
277    /// Limit the number of Unicode scalar values the field will accept.
278    pub fn max_length(mut self, max_length: usize) -> Self {
279        self.max_length = Some(max_length);
280        self
281    }
282
283    /// Show or hide the trailing ✕ button that clears the field text. Default: hidden.
284    pub fn show_clear_button(mut self, show: bool) -> Self {
285        self.show_clear_button = show;
286        self
287    }
288
289    /// Override the frame's intrinsic minimum width (default 65 dp).
290    /// Use to express a design width for date / time / phone-number
291    /// fields whose content is well-known and whose collapse to the
292    /// generic 65 dp floor would look out of place.
293    pub fn min_width(mut self, w: f32) -> Self {
294        self.min_width = Some(w.max(0.0));
295        self
296    }
297
298    /// Set an arbitrary widget in the leading slot (before the text area).
299    /// Typically an `IconButton` or `IconWidget`.
300    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
301        self.leading_slot = Some(Box::new(widget));
302        self
303    }
304
305    /// Set an arbitrary widget in the trailing slot (after the text area).
306    /// Typically an `IconButton` or `IconWidget`.
307    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
308        self.trailing_slot = Some(Box::new(widget));
309        self
310    }
311
312    /// Closure invoked on Enter. Forwarded to `TextInputField`.
313    pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
314        self.on_submit = Some(Box::new(f));
315        self
316    }
317
318    /// Closure invoked on focus loss. Forwarded to `TextInputField`.
319    pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
320        self.on_blur = Some(Box::new(f));
321        self
322    }
323
324    /// Per-character input-filter predicate. Forwarded to
325    /// `TextInputField`.
326    pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
327        self.char_filter = Some(std::rc::Rc::new(f));
328        self
329    }
330
331    /// Non-editable trailing string (Qt's `QSpinBox::suffix`).
332    /// Forwarded to `TextInputField`.
333    pub fn suffix(mut self, text: impl Into<String>) -> Self {
334        self.suffix = text.into();
335        self
336    }
337
338    /// Install an input mask (Qt grammar). Forwarded 1:1 to
339    /// [`TextInputField::input_mask`]. Composing widgets like
340    /// `DateEdit` use this to project the date format pattern
341    /// onto the editing surface.
342    pub fn input_mask(mut self, mask: impl Into<String>) -> Self {
343        self.input_mask = Some(mask.into());
344        self
345    }
346
347    /// Declare the field's semantic [`InputPurpose`](crate::primitives::InputPurpose)
348    /// (WCAG 1.3.5), forwarded to the inner `TextInputField` to select a
349    /// specialised AT role (e.g. `Role::EmailInput`).
350    pub fn input_purpose(
351        mut self,
352        purpose: crate::primitives::text_input_field::InputPurpose,
353    ) -> Self {
354        self.input_purpose = purpose;
355        self
356    }
357
358    /// Publish `active_descendant` on the inner field, pointing at the row a
359    /// separate listbox is currently highlighting (the ARIA combobox pattern).
360    /// Forwarded 1:1 to [`TextInputField::active_descendant`], which is where
361    /// it has to land: AT follows the *focused* node's active descendant, and
362    /// the inner field is the focusable one.
363    pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
364        self.active_descendant = Some(active);
365        self
366    }
367
368    /// Publish a `controls` relation to the listbox this input drives.
369    /// Forwarded 1:1 to [`TextInputField::controls`].
370    pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
371        self.controls = Some(listbox);
372        self
373    }
374
375    /// Install a commit-time validator. Forwarded 1:1 to
376    /// [`TextInputField::validator`]. Pair with
377    /// [`Self::validation_feedback_signal`] (or
378    /// [`Self::validation_feedback`]) to surface the outcome
379    /// in the inline strip.
380    pub fn validator(
381        mut self,
382        f: impl Fn(&str) -> crate::primitives::text_input_field::ValidationOutcome + 'static,
383    ) -> Self {
384        self.validator = Some(std::rc::Rc::new(f));
385        self
386    }
387
388    /// Reactive caret position. Mirrors the inner field's
389    /// [`TextInputField::caret_position`] after `build`. Capture
390    /// before `ctx.add(text_input)` — used by composing widgets
391    /// (`DateEdit` segment-stepping) that need to know which
392    /// segment Up/Down should step.
393    pub fn caret_position(&self) -> Signal<usize> {
394        let mut slot = self.caret_position_slot.borrow_mut();
395        if slot.is_none() {
396            *slot = Some(Signal::new(0));
397        }
398        slot.as_ref().unwrap().clone()
399    }
400
401    /// A live handle on the inner field — its text-editing commands, for a
402    /// host outside the widget.
403    ///
404    /// Mirrors [`TextInputField::handle`], and exists for the same reason: an
405    /// application that routes Undo, Cut, Copy, Paste and Select All to
406    /// "whichever text surface holds the caret" must be able to reach *every*
407    /// such surface. A `TextInput` that could not be reached would silently
408    /// lose its own Ctrl+Z to whatever the host routed the chord at instead.
409    ///
410    /// Like [`caret_setter`](Self::caret_setter), safe to take before `build`:
411    /// the handle reaches the field through a slot the widget fills in.
412    pub fn handle(&self) -> crate::primitives::TextFieldHandle {
413        self.field_handle.clone()
414    }
415
416    /// The arena id of the inner field: the node that holds focus, carries
417    /// `Role::TextInput` and reports the document's value.
418    ///
419    /// A `TextInput` is a composite whose outer node is a
420    /// `Role::GenericContainer`. That node is neither focusable nor present in
421    /// the filtered accessibility tree, so a host that has to *name* the focus
422    /// target cannot use the id `ctx.add` returned it. Two cases need the
423    /// name: a form sending focus back to the field a validator refused, and a
424    /// modal whose own `initial_focus_hint` picks one field out of several.
425    ///
426    /// Empty until `build` runs, like [`caret_setter`](Self::caret_setter);
427    /// take the handle before `ctx.add(text_input)` and read it after.
428    ///
429    /// A host that only needs "focus this input, whichever node that is" wants
430    /// [`EventContext::request_focus_into`] on the outer id instead, and no
431    /// handle at all.
432    ///
433    /// [`EventContext::request_focus_into`]: teksilo_core::widget::EventContext::request_focus_into
434    pub fn field_id(&self) -> std::rc::Rc<std::cell::Cell<Option<WidgetId>>> {
435        self.field_id_slot.clone()
436    }
437
438    /// Programmatic caret setter. Mirrors the inner field's
439    /// [`TextInputField::caret_setter`]. Returns a closure that
440    /// is a no-op until `build` runs; afterwards it walks the
441    /// inner field's state and moves the document cursor. Capture
442    /// before `ctx.add(text_input)`.
443    pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
444        let slot = self.caret_setter_slot.clone();
445        std::rc::Rc::new(move |position: usize| {
446            if let Some(setter) = slot.borrow().as_ref() {
447                (setter)(position);
448            }
449        })
450    }
451
452    /// Reactive published validation feedback. Mirrors the inner
453    /// field's [`TextInputField::validation_feedback_signal`]
454    /// after `build`. Composing widgets observe this to compose
455    /// feedback across multiple fields (range editor's
456    /// worse-of-two ladder, etc.).
457    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
458        self.feedback_signal.clone()
459    }
460
461    /// Bind an external [`ValidationState`] signal directly (e.g. when
462    /// validation runs server-side), or set a fixed initial value. Use
463    /// [`validation_feedback`](Self::validation_feedback)
464    /// when wiring a local validator's output.
465    ///
466    /// A bound `Signal` becomes the shared write target used internally
467    /// (by the validator-feedback bridge) and externally by the caller —
468    /// preserving the two-way channel this method has always offered. A
469    /// static value seeds a fresh, unshared signal.
470    pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
471        self.validation = validation.into().as_signal();
472        self
473    }
474
475    /// Bridge a `Signal<ValidationFeedback>` (typically from a
476    /// validator-equipped widget like `DateEdit::validation_feedback_signal`
477    /// or a custom `TextInputField`) into this composite's
478    /// `ValidationState`. The feedback is mirrored on every change,
479    /// translating outcomes into the composite's display vocabulary:
480    ///
481    /// - `Pristine` / `Valid` → `ValidationState::None`
482    /// - `Corrected { message, .. }` → `ValidationState::Corrected(message)`
483    /// - `Invalid { message }` → `ValidationState::Error(message)`
484    pub fn validation_feedback(mut self, feedback: Signal<ValidationFeedback>) -> Self {
485        let target = self.validation.clone();
486        // Snapshot once now so we observe the current state at construction
487        // time too (subsequent changes flow via the field's own commit
488        // pipeline; ctx.effect installed in build() does the live tracking).
489        target.set(feedback_to_state(&feedback.get()));
490        self.feedback_to_bridge = Some(feedback);
491        self
492    }
493
494    /// Attach a plain tooltip. Accepts `tr!(...)` or `lit!(...)`.
495    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
496        self.tooltip_text = Some(text.into());
497        self.rich_tooltip_source = None;
498        self.composite_tooltip_content = None;
499        self
500    }
501
502    /// Attach a registry-driven rich tooltip by key. Mutually exclusive with
503    /// `tooltip` and `composite_tooltip` (last call wins).
504    pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self {
505        self.rich_tooltip_source = Some(RichTooltipSource::Key(key.into()));
506        self.tooltip_text = None;
507        self.composite_tooltip_content = None;
508        self
509    }
510
511    /// Attach an inline rich tooltip from a pre-built [`tooltip::TooltipContent`].
512    /// Mutually exclusive with `tooltip` and `composite_tooltip` (last call wins).
513    pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self {
514        self.rich_tooltip_source = Some(RichTooltipSource::Content(content));
515        self.tooltip_text = None;
516        self.composite_tooltip_content = None;
517        self
518    }
519
520    /// Attach an inline rich tooltip from a pre-built [`tooltip::TooltipContent`].
521    /// Canonical alias for [`Self::rich_tooltip`] — matches the name used by
522    /// `Button`, `ComboBox`, and other widgets. Mutually exclusive with
523    /// `tooltip` and `composite_tooltip` (last call wins).
524    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
525        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
526        self.tooltip_text = None;
527        self.composite_tooltip_content = None;
528        self
529    }
530
531    /// Attach a composite tooltip — third tier, hosting an arbitrary
532    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
533    pub fn composite_tooltip(
534        mut self,
535        content: impl teksilo_core::widget::Widget + 'static,
536    ) -> Self {
537        self.composite_tooltip_content = Some(Box::new(content));
538        self.tooltip_text = None;
539        self.rich_tooltip_source = None;
540        self
541    }
542
543    // ── Signal accessors (call before add to tree) ──────────────────
544
545    /// The reactive text content signal.
546    pub fn text(&self) -> Signal<String> {
547        self.text.clone()
548    }
549}
550
551impl Widget for TextInput {
552    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
553        // TextInput is a heavy composite. We snapshot the theme once for
554        // static layout params (padding, border width, field height); the
555        // placeholder, clear-icon tint, and border/width are driven by
556        // roles and state signals, so theme switches repaint via the
557        // paint-time role resolver without riding through a zip here.
558        let _theme = ctx.theme();
559        use crate::styles::recipe_text_input_style as field_dims;
560        let self_id = ctx.self_id();
561        // Forward the enabled state into the arena; see IconButton.
562        ctx.enabled_when(self_id, self.enabled.clone());
563        let interaction = self.interaction.clone();
564        let validation = self.validation.clone();
565
566        // ── Build the inner editing primitive ──────────────────────
567        //
568        // The inner field owns the bound text signal, the document,
569        // engine, caret, clipboard, context menu — everything
570        // interactive. The composite just styles it.
571        let inner_height =
572            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
573        let text_area_height =
574            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
575
576        let mut field = TextInputField::new(self.text.clone()).share_handle(&self.field_handle);
577        field = field
578            .enabled(self.enabled.clone())
579            .read_only(self.read_only)
580            .placeholder(self.placeholder.clone())
581            .text_height(text_area_height)
582            .interaction_signal(interaction.clone());
583        if let Some(max) = self.max_length {
584            field = field.max_length(max);
585        }
586        if let Some(f) = self.char_filter.take() {
587            // Re-wrap the Rc'd closure into a plain closure for the
588            // primitive's builder surface, which owns its own Rc.
589            field = field.char_filter(move |c| (f)(c));
590        }
591        if let Some(cb) = self.on_submit.take() {
592            field = field.on_submit_fn(move |ctx| (cb)(ctx));
593        }
594        if let Some(cb) = self.on_blur.take() {
595            field = field.on_blur_fn(move |ctx| (cb)(ctx));
596        }
597        if !self.suffix.is_empty() {
598            field = field.suffix(std::mem::take(&mut self.suffix));
599        }
600        if let Some(mask) = self.input_mask.take() {
601            field = field.input_mask(mask);
602        }
603        field = field.input_purpose(self.input_purpose);
604        if let Some(active) = self.active_descendant.clone() {
605            field = field.active_descendant(active);
606        }
607        if let Some(controls) = self.controls.clone() {
608            field = field.controls(controls);
609        }
610        let validator_installed = self.validator.is_some();
611        if let Some(validator) = self.validator.take() {
612            // ValidatorFn is `Rc<dyn Fn(&str) -> ValidationOutcome>`.
613            // The primitive's builder takes a fresh closure; wrap the
614            // Rc in one so the caller can keep their own clones if
615            // they captured it before.
616            field = field.validator(move |s| (validator)(s));
617        }
618
619        // Expose the field's text signal for downstream reactivity
620        // (placeholder visibility, clear-button visibility) before
621        // the field is consumed by `ctx.add`.
622        let text_signal_for_vis = field.text();
623
624        // Capture the inner field's reactive accessors BEFORE
625        // `ctx.add` consumes it, so composing widgets that called
626        // `caret_position()` / `caret_setter()` /
627        // `validation_feedback_signal()` on us pre-build see live
628        // updates through the slots we mirror into.
629        let inner_caret = field.caret_position();
630        let inner_setter = field.caret_setter();
631        let inner_feedback = field.validation_feedback_signal();
632
633        // Add the field directly so we can capture its own WidgetId (needed to
634        // wire the validation strip as its `described_by`, below); wrap it by
635        // id instead of moving it into `Padding`.
636        //
637        // The accessible name goes on the *field*, not on the composite's
638        // outer node. The outer node is a `Role::GenericContainer`, and
639        // `accesskit_consumer::common_filter` excludes that role from the
640        // filtered tree unconditionally — a name written there reaches no
641        // screen reader on any platform. The field is the node that carries
642        // `Role::TextInput` and holds focus, so it is the one that must be
643        // named. (`PasswordField` names its inner field the same way.)
644        // `LocalizedString -> Prop<String>` keeps the name locale-reactive.
645        let field_id = match self.label.clone() {
646            Some(label) => ctx.add(field.access_label(label)),
647            None => ctx.add(field),
648        };
649        self.field_id_slot.set(Some(field_id));
650
651        // Text editing area, wrapped in vertical padding so slots
652        // (IconButton etc.) sit flush against top/bottom of the
653        // inner border area and are vertically centered by the HStack.
654        let padded_field = Padding::new(
655            field_dims::TEXT_FIELD_PADDING_VERTICAL,
656            0.0,
657            field_dims::TEXT_FIELD_PADDING_VERTICAL,
658            0.0,
659        )
660        .child_id(field_id);
661
662        // The placeholder lives in a local ZStack with the text field so
663        // it shares the same column in the HStack — no overlap with
664        // leading/trailing slots. The text field is the last ZStack child
665        // so it wins hit-testing (ZStack tests children in reverse order).
666        // `respect_intrinsic` on these `Expand` wrappers preserves the
667        // wrapped field's natural width (≈200 dp from `TextInputField`)
668        // as the column's intrinsic width. The enclosing `ZStack`
669        // measures its children with an unspecified proposal, so the
670        // parent's offered width never reaches the `HStack` during
671        // measurement — without auto-basis the column reports 0 dp and
672        // the whole composite collapses to `MinSize`'s 65 dp floor.
673        let text_column_id = if !self.placeholder.resolve_now().is_empty() {
674            // Match the inner TextInputField's text style + single-line
675            // behaviour so the placeholder layout box has the same
676            // intrinsic height as the rich-text engine's frame. Without
677            // `single_line()` the placeholder defaults to Wrap, which
678            // can report extra vertical leading space.
679            let ph = TextWidget::new(self.placeholder.clone())
680                .style(TextStyleRole::Body)
681                .color(TextRole::Secondary)
682                .single_line()
683                .a11y_hidden();
684            // Align the placeholder on the column's vertical midline,
685            // pinned to the leading edge where the typed text starts.
686            // `Padding(top=padding_vertical, bottom=padding_vertical)`
687            // pinned the placeholder to the top of its inset box, but
688            // the rich-text engine inside the field paints glyphs with
689            // its own line-leading offset, so the two paths drifted
690            // by a few pixels; aligning purely on the layout-box midline
691            // matches the engine's frame midline. Align mode measures the
692            // placeholder under the column's bounds, so the `single_line()`
693            // TextWidget caps itself at the available width and truncates
694            // with a trailing "…" when the field is too narrow, instead of
695            // painting its full line past the frame.
696            let ph_id = ctx.add(
697                Expand::new()
698                    .respect_intrinsic()
699                    .align_child(Alignment::CENTER_LEADING)
700                    .child(ph),
701            );
702            let visible = text_signal_for_vis.map(|t| t.is_empty());
703            ctx.visible_when(ph_id, visible);
704
705            // `Expand::horizontal().respect_intrinsic()` keeps the field's
706            // natural (mask-aware) width as the column's basis — so the
707            // composite reports a snug width when unconstrained and fills a
708            // wide frame via flex. Wrapping it in `Shrinkable` adds a shrink
709            // weight so a narrow row compresses the column below that basis and
710            // the field scrolls instead of overflowing.
711            ctx.add(
712                Shrinkable::new().child(
713                    Expand::horizontal().respect_intrinsic().child(
714                        ZStack::new()
715                            .add_child(ph_id) // below (placeholder)
716                            .child(padded_field), // on top (text field, gets hits)
717                    ),
718                ),
719            )
720        } else {
721            ctx.add(
722                Shrinkable::new()
723                    .child(Expand::horizontal().respect_intrinsic().child(padded_field)),
724            )
725        };
726
727        // HStack: [leading] [text_column] [clear] [trailing]
728        let mut row = HStack::new().spacing(4.0);
729
730        if let Some(leading) = self.leading_slot.take() {
731            let leading_id = ctx.add_boxed(leading);
732            row = row.add_child(leading_id);
733        }
734
735        row = row.add_child(text_column_id);
736
737        // Clear button (opt-in). The clear affordance clears the
738        // bound text signal — the field's ext→internal effect
739        // picks this up and wipes the document.
740        if self.show_clear_button {
741            let icon = (crate::icon_button::BuiltInIcons::global().clear)()
742                .icon_size(12.0)
743                .color(TextRole::Secondary);
744            let text_for_clear = self.text.clone();
745            let clear_id = ctx.add(
746                MinSize::new(16.0, 16.0)
747                    .child(crate::primitives::Center::new().child(icon))
748                    .on_tap(move |_pos, ctx| {
749                        text_for_clear.set(String::new());
750                        ctx.request_frame();
751                    })
752                    .cursor(CursorIcon::Pointer),
753            );
754            let visible = text_signal_for_vis.map(|t| !t.is_empty());
755            ctx.visible_when(clear_id, visible);
756            let reserve_id = ctx.add(
757                crate::primitives::FixedSize::new()
758                    .width(16.0_f32)
759                    .height(16.0_f32)
760                    .child_id(clear_id),
761            );
762            row = row.add_child(reserve_id);
763        }
764
765        if let Some(trailing) = self.trailing_slot.take() {
766            let trailing_id = ctx.add_boxed(trailing);
767            row = row.add_child(trailing_id);
768        }
769
770        let row_id = ctx.add(row);
771
772        // Derive the cfg signals the style needs. Map our internal
773        // `InteractionState` (5-way) to the trait's 3 boolean signals,
774        // and the composite `ValidationState` (carries a message) to
775        // the trait's flat `TextInputValidationLevel` enum.
776        let is_focused = interaction.map(|s| *s == InteractionState::Focused);
777        let is_hovered = interaction.map(|s| *s == InteractionState::Hovered);
778        // `is_disabled` derives from the arena (not from interaction).
779        let effective_enabled = ctx.effective_enabled_signal(self_id);
780        let is_disabled = effective_enabled.map(|on| !*on);
781        let validation_level = validation.map(|v| match v {
782            ValidationState::None => TextInputValidationLevel::None,
783            ValidationState::Error(_) => TextInputValidationLevel::Error,
784            ValidationState::Warning(_) => TextInputValidationLevel::Warning,
785            ValidationState::Corrected(_) => TextInputValidationLevel::Corrected,
786        });
787
788        // Resolve the active style: per-call override > theme slot >
789        // built-in `RecipeTextInputStyle` default. The style paints the
790        // bordered/filled frame + the corner radius + the horizontal
791        // padding around the editor row.
792        let style: SharedTextInputStyle = self
793            .style_override
794            .clone()
795            .or_else(|| ctx.theme().style_slots.text_input.clone())
796            .unwrap_or_else(|| Rc::new(crate::styles::RecipeTextInputStyle::default()));
797
798        let cfg = TextInputStyleConfig {
799            editor: row_id,
800            is_focused,
801            is_hovered,
802            is_disabled,
803            validation: validation_level,
804            variant: self.variant,
805        };
806        let chrome_id = style.make_body(&cfg, ctx);
807
808        let min_w = self.min_width.unwrap_or(65.0);
809        let frame_id =
810            ctx.add(MinSize::new(min_w, field_dims::TEXT_FIELD_HEIGHT).child_id(chrome_id));
811
812        // ── Inline validation strip ────────────────────────────────
813        // Maps `Signal<ValidationState>` to the `Signal<ValidationFeedback>`
814        // that `ValidationStrip` consumes. Empty/Pristine renders nothing
815        // (zero height) so the layout doesn't reflow.
816        let strip_feedback: Signal<ValidationFeedback> = self.validation.map(|v| match v {
817            ValidationState::None => ValidationFeedback::Pristine,
818            ValidationState::Error(msg) | ValidationState::Warning(msg) => {
819                ValidationFeedback::Invalid {
820                    message: msg.clone(),
821                }
822            }
823            ValidationState::Corrected(msg) => ValidationFeedback::Corrected {
824                message: msg.clone(),
825                since: std::time::Instant::now(),
826            },
827        });
828        let strip_id = ctx.add(ValidationStrip::new(strip_feedback));
829
830        // WCAG 3.3.1 / 3.3.3 (EN 301 549 11.5.2.7): associate the inline
831        // validation strip with the field so a screen reader announces the
832        // error / warning / correction message as the field's description when
833        // it gains focus. The strip renders nothing while Pristine, but the
834        // relation is harmless then and live the moment a message appears.
835        ctx.access_described_by(field_id, strip_id);
836
837        // Wrap frame + strip in a VStack with the configured gap. The frame is
838        // wrapped in `Expand::horizontal().respect_intrinsic()` so it claims
839        // the VStack's full width (a `VStack` lays a child out at its measured
840        // width, not stretched) while keeping the frame's natural width as the
841        // basis when unconstrained. A bounded proposal narrows it and the
842        // `Shrinkable` column compresses to fit.
843        let framed_id = ctx.add(Expand::horizontal().respect_intrinsic().child_id(frame_id));
844        let root_id = ctx.add(
845            VStack::new()
846                .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
847                .add_child(framed_id)
848                .add_child(strip_id),
849        );
850
851        // Tooltip — three mutually-exclusive setters; setters clear
852        // the others so exactly one branch runs.
853        if let Some(content) = self.composite_tooltip_content.take() {
854            let delay = ctx.theme().motion.tooltip_delay_heavy;
855            tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
856        } else if let Some(source) = self.rich_tooltip_source.take() {
857            let delay = ctx.theme().motion.tooltip_delay;
858            tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
859        } else if let Some(text) = self.tooltip_text.clone() {
860            let delay = ctx.theme().motion.tooltip_delay;
861            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
862        }
863
864        // The interaction signal no longer carries Disabled — the
865        // framework's arena enabled-state is the single source of
866        // truth. Style chrome that needs `is_disabled` derives it
867        // from `effective_enabled_signal(self_id)`.
868
869        // Bridge `validation_feedback` source → composite state.
870        // No dedupe — each commit changes the feedback identity even
871        // when the user-visible message stays the same (e.g. repeated
872        // Invalid commits), and the strip is cheap to repaint.
873        if let Some(src) = self.feedback_to_bridge.clone() {
874            let target = self.validation.clone();
875            ctx.effect(&src, move |fb| {
876                target.set(feedback_to_state(fb));
877            });
878        } else if validator_installed {
879            // Auto-bridge: a validator was installed but no explicit
880            // `validation_feedback` source was provided. Mirror
881            // the inner field's published outcome into our display
882            // state so calling `.validator(...)` on TextInput "just
883            // works" — the strip and border respond without a
884            // separate `.validation_feedback(...)` call.
885            let target = self.validation.clone();
886            let src = inner_feedback.clone();
887            ctx.effect(&src, move |fb| {
888                target.set(feedback_to_state(fb));
889            });
890        }
891
892        // Mirror inner field accessors into the slots that were
893        // captured before build by composing widgets.
894        //
895        // - caret_position: only mirror if the slot was lazy-initialized
896        //   (i.e. someone called `caret_position()` on us pre-build).
897        //   Seed with the current value, then forward changes.
898        // - caret_setter: store the inner field's setter Rc; the closure
899        //   we returned to callers forwards through this slot at call time.
900        // - validation_feedback_signal: always mirror (the slot's signal
901        //   is created in `new()` and may already have observers).
902        if let Some(target) = self.caret_position_slot.borrow().clone() {
903            target.set(inner_caret.get());
904            ctx.effect(&inner_caret, move |pos| {
905                if target.get() != *pos {
906                    target.set(*pos);
907                }
908            });
909        }
910        *self.caret_setter_slot.borrow_mut() = Some(inner_setter);
911        let outer_feedback = self.feedback_signal.clone();
912        outer_feedback.set(inner_feedback.get());
913        ctx.effect(&inner_feedback, move |fb| {
914            outer_feedback.set(fb.clone());
915        });
916
917        self.root_child_id = Some(root_id);
918        vec![root_id]
919    }
920
921    fn layout_response(
922        &self,
923        proposal: SizeProposal,
924        ctx: &LayoutContext,
925    ) -> teksilo_core::widget::LayoutResponse {
926        // The `Shrinkable` + `respect_intrinsic` editor column reports the
927        // field's natural (mask-aware) width when unconstrained, fills a wide
928        // frame via flex, and compresses on a deficit — so the composite just
929        // forwards its child's response.
930        self.root_child_id
931            .and_then(|id| ctx.child_size(id, proposal))
932            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
933            .into()
934    }
935
936    fn place_children(
937        &self,
938        bounds: Rect,
939        _proposal: SizeProposal,
940        children: &mut [WidgetPlacement],
941        _ctx: &LayoutContext,
942    ) {
943        if let Some(p) = children.first_mut() {
944            p.origin = Point::new(bounds.x, bounds.y);
945            p.size = bounds.size();
946        }
947    }
948
949    fn children(&self) -> Vec<WidgetId> {
950        self.root_child_id.into_iter().collect()
951    }
952
953    /// Focus belongs on the inner field, never on this composite: the outer
954    /// node is a `Role::GenericContainer` and is not focusable at all.
955    ///
956    /// Without this, a `TextInput` inside deferred modal content could not be
957    /// focused on open. The modal pipeline asks the content tree for a hint
958    /// before falling back to the first focusable descendant, and a composite
959    /// that answered nothing was skipped over.
960    fn initial_focus_hint(&self) -> Option<WidgetId> {
961        self.field_id_slot.get()
962    }
963
964    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
965        // The inner TextInputField handles Role::TextInput. The outer
966        // composite is transparent to a11y: `Role::GenericContainer` is
967        // excluded from the filtered tree by
968        // `accesskit_consumer::common_filter`, so nothing written here
969        // reaches a screen reader. In particular the `label` is NOT set
970        // here — it is applied to the inner field in `build`, which is the
971        // node that survives the filter and holds focus.
972        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
973        // Framework a11y walker sets `set_disabled` from arena state.
974    }
975}
976
977/// Project a `ValidationFeedback` (validator-pipeline outcome) onto a
978/// `ValidationState` (composite display state). `Pristine` and `Valid`
979/// both clear; `Corrected` and `Invalid` carry their messages through.
980fn feedback_to_state(fb: &ValidationFeedback) -> ValidationState {
981    match fb {
982        ValidationFeedback::Pristine | ValidationFeedback::Valid => ValidationState::None,
983        ValidationFeedback::Corrected { message, .. } => {
984            ValidationState::Corrected(message.clone())
985        }
986        ValidationFeedback::Invalid { message } => ValidationState::Error(message.clone()),
987    }
988}