Skip to main content

herogpui_components/
input_group.rs

1//! InputGroup — port of `@heroui/input-group` (v3).
2//!
3//! Combines a field with adjacent addons and controls behind one shared piece
4//! of field chrome, so a prefix label, the input itself and a trailing button
5//! read as a single control.
6
7use gpui::prelude::FluentBuilder;
8use gpui::{
9    div, px, AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton,
10    ParentElement, Pixels, RenderOnce, SharedString, Styled, Window,
11};
12use herogpui_core::{element_id, FieldVariant};
13use herogpui_theme::ActiveTheme;
14
15use crate::{
16    a11y::{self, A11y as _},
17    util,
18};
19
20/// A static, non-interactive segment of an [`InputGroup`] — the `$` before an
21/// amount, or a `.com` suffix.
22#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
23#[derive(IntoElement)]
24pub struct InputAddon {
25    text: SharedString,
26    /// The `sx` slot, refined over the root style at the end of render.
27    sx: Option<Box<gpui::StyleRefinement>>,
28}
29
30impl InputAddon {
31    /// Creates an addon showing the given text.
32    pub fn new(text: impl Into<SharedString>) -> Self {
33        Self {
34            text: text.into(),
35            sx: None,
36        }
37    }
38
39    /// The one slot for caller-owned low-level styling: GPUI's styling methods
40    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
41    /// applied to the addon's root element after every value the active theme
42    /// chose, so they win.
43    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
44        util::refine_sx(&mut self.sx, style);
45        self
46    }
47}
48
49impl RenderOnce for InputAddon {
50    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
51        // `.input-group__prefix` / `__suffix`: `px-3`, transparent, and drawn in
52        // `--field-placeholder`.
53        let el = div()
54            .flex()
55            .items_center()
56            .flex_shrink_0()
57            .px(px(12.))
58            .text_color(cx.colors().field.placeholder)
59            .child(self.text.to_string());
60        util::apply_sx(el, &self.sx)
61    }
62}
63
64/// HeroUI InputGroup.
65#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
66#[derive(IntoElement)]
67pub struct InputGroup {
68    variant: FieldVariant,
69    full_width: bool,
70    /// Optional group geometry/chrome overrides; defaults are the stock box.
71    field: util::FieldBox,
72    /// The group box's hover endpoint, in place of the variant's token.
73    group_hover_bg: Option<gpui::Hsla>,
74    /// The family the held field is drawn and measured with; unset keeps the
75    /// field's own setting.
76    font_family: Option<SharedString>,
77    /// The corner radius used by the group's shared chrome. The held field is
78    /// transparent and unrounded so this remains the only visible outline.
79    radius: Option<Pixels>,
80    is_disabled: bool,
81    is_invalid: bool,
82    is_required: bool,
83    label: Option<SharedString>,
84    description: Option<SharedString>,
85    error_message: Option<SharedString>,
86    /// `InputGroup.Prefix` — the leading addon.
87    prefix: Option<AnyElement>,
88    /// `InputGroup.Suffix` — the trailing addon.
89    suffix: Option<AnyElement>,
90    /// `InputGroup.Input` / `InputGroup.TextArea` — held rather than rendered
91    /// so the group can strip its chrome and tell it which sides an addon
92    /// occupies.
93    input: Option<crate::input::Input>,
94    /// Whether the held field came from [`InputGroup::text_area`], so the
95    /// group can play the pinned `:has([data-slot="input-group-textarea"])`
96    /// rules: top alignment, auto height, 8px addon top padding — and the
97    /// `querySelector("input")` exception to root-click focusing.
98    is_textarea: bool,
99    children: Vec<AnyElement>,
100    id: Option<ElementId>,
101    /// The `sx` slot, refined over the root style at the end of render.
102    sx: Option<Box<gpui::StyleRefinement>>,
103}
104
105impl InputGroup {
106    /// Creates an empty input group.
107    pub fn new() -> Self {
108        Self {
109            variant: FieldVariant::Primary,
110            full_width: false,
111            field: util::FieldBox::default(),
112            group_hover_bg: None,
113            font_family: None,
114            radius: None,
115            is_disabled: false,
116            is_invalid: false,
117            is_required: false,
118            label: None,
119            description: None,
120            error_message: None,
121            prefix: None,
122            suffix: None,
123            input: None,
124            is_textarea: false,
125            children: Vec::new(),
126            id: None,
127            sx: None,
128        }
129    }
130
131    /// Names this group so it can report `role="group"`. Unnamed groups
132    /// produce no AccessKit node.
133    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
134        self.id = Some(id.into());
135        self
136    }
137
138    /// Sets the field variant.
139    pub fn variant(mut self, variant: FieldVariant) -> Self {
140        self.variant = variant;
141        self
142    }
143
144    /// Sets whether the group fills the available width.
145    pub fn full_width(mut self, v: bool) -> Self {
146        self.full_width = v;
147        self
148    }
149
150    /// Replaces the group's 36px minimum height and propagates the explicit
151    /// height to the held single-line field; a textarea group stays
152    /// content-sized.
153    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
154        self.field.height = Some(h.into());
155        self
156    }
157
158    /// Replaces the held field's `px-3` inset on every side without an addon;
159    /// a side with a prefix or suffix keeps that addon's own padding.
160    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
161        self.field.padding_x = Some(p.into());
162        self
163    }
164
165    /// Renders the group with no background, border, field shadow, focus ring
166    /// or hover fill, for a caller painting around it.
167    pub fn is_bare(mut self, v: bool) -> Self {
168        self.field.is_bare = v;
169        self.field.is_bare_is_set = true;
170        self
171    }
172
173    /// The group box's fill while hovered, in place of `--field-hover`
174    /// (`--default-hover` on the secondary variant). The 150ms ease-smooth
175    /// fade and the border hover are unchanged; a focused, invalid, disabled
176    /// or bare group does not hover, so the override never reaches those
177    /// states. Not a v3 prop: v3 tints the group with a class.
178    pub fn group_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
179        self.group_hover_bg = Some(color.into());
180        self
181    }
182
183    /// Shows or hides only the group's visual focus ring. The held field
184    /// remains focusable and editable when set to `false`.
185    pub fn focus_ring(mut self, v: bool) -> Self {
186        self.field.focus_ring = Some(v);
187        self
188    }
189
190    /// The family the held field is drawn and measured with; unset keeps the
191    /// field's own setting.
192    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
193        self.font_family = Some(family.into());
194        self
195    }
196
197    /// The corner radius of the group box, in place of the owning
198    /// `field_radius` helper. The held field remains transparent and
199    /// unrounded. Not a v3 prop; the removed v2
200    /// `radius` prop is prohibited and this is a per-component repository
201    /// extension.
202    ///
203    /// The group's chrome uses the resolved radius; the inner field keeps the
204    /// upstream `rounded-none` grouped-input contract.
205    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
206        self.radius = Some(radius.into());
207        self
208    }
209
210    /// Sets whether the group is disabled.
211    pub fn is_disabled(mut self, v: bool) -> Self {
212        self.is_disabled = v;
213        self
214    }
215
216    /// `isRequired` — marks the group's label as required. v3's examples get
217    /// this from the `TextField` around the group.
218    pub fn is_required(mut self, v: bool) -> Self {
219        self.is_required = v;
220        self
221    }
222
223    /// Sets whether the group is invalid.
224    pub fn is_invalid(mut self, v: bool) -> Self {
225        self.is_invalid = v;
226        self
227    }
228
229    /// Sets the label text.
230    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
231        self.label = Some(text.into());
232        self
233    }
234
235    /// Sets the description text.
236    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
237        self.description = Some(text.into());
238        self
239    }
240
241    /// Sets the error message text.
242    pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
243        self.error_message = Some(text.into());
244        self
245    }
246
247    /// `InputGroup.Prefix` — content before the field.
248    pub fn prefix(mut self, el: impl IntoElement) -> Self {
249        self.prefix = Some(el.into_any_element());
250        self
251    }
252
253    /// `InputGroup.Suffix` — content after the field.
254    pub fn suffix(mut self, el: impl IntoElement) -> Self {
255        self.suffix = Some(el.into_any_element());
256        self
257    }
258
259    /// `InputGroup.Input` — the field itself.
260    ///
261    /// Taken as an [`crate::input::Input`] rather than an element so the group
262    /// can strip its chrome: v3's group paints the box, and the inner input is
263    /// transparent and flush against the addons. Passing one as a plain child
264    /// instead leaves a second, smaller field drawn inside the group.
265    pub fn input(mut self, input: crate::input::Input) -> Self {
266        self.input = Some(input);
267        self.is_textarea = false;
268        self
269    }
270
271    /// `InputGroup.TextArea` — a multi-line field in the same shared chrome.
272    pub fn text_area(mut self, text_area: crate::textarea::TextArea) -> Self {
273        self.input = Some(text_area.into_group_input());
274        self.is_textarea = true;
275        self
276    }
277
278    /// The one slot for caller-owned low-level styling: GPUI's styling methods
279    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
280    /// applied to the group's root element — the column holding the label,
281    /// the group box and the message — after every value the variant and the
282    /// active theme chose, so they win. The group box's own chrome stays with
283    /// the variant.
284    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
285        util::refine_sx(&mut self.sx, style);
286        self
287    }
288}
289
290impl Default for InputGroup {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296impl ParentElement for InputGroup {
297    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
298        self.children.extend(elements);
299    }
300}
301
302impl RenderOnce for InputGroup {
303    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
304        // `.input-group` rings on `focus-within`, and what is inside it is a
305        // real `Input` or `TextArea`, so their state is where the focus is.
306        // A `TextArea` is converted to an `Input` by `text_area`, so there is one
307        // slot to ask.
308        let focus_within = self
309            .input
310            .as_ref()
311            .is_some_and(|input| input.state_focus(cx).is_focused(window));
312        // The hover fade owns a keyed animation state, so the render below
313        // needs to borrow the app mutably after reading the theme snapshot.
314        // Keep the values local and stable for the complete field shell.
315        let colors = cx.colors().clone();
316        let layout = cx.layout().clone();
317        let is_invalid = self.is_invalid || self.error_message.is_some();
318        let (is_disabled, is_textarea) = (self.is_disabled, self.is_textarea);
319        let field_box = self.field;
320        // Resolve the radius passed to the group's shared field chrome.
321        let radius = self.radius.unwrap_or_else(|| util::field_radius(cx));
322        // A textarea group grows with its content and ignores the height
323        // override; every other group defaults to the 36px row.
324        let explicit_height = if is_textarea { None } else { field_box.height };
325        // The held field's state entity names this instance's probes; two
326        // groups sharing one state would share the probes and the field both.
327        let entity = self
328            .input
329            .as_ref()
330            .map(|input| input.state().entity_id().as_u64());
331        // NamedInteger keeps the held field's entity identity in the element
332        // path without flattening it into a formatted string (which can make
333        // two scoped paths collide on a separator). An input-less group uses
334        // the zero seed only for its listener-free fallback surface.
335        let group_id = ElementId::named_usize("input-group", entity.unwrap_or(0) as usize);
336
337        // `.input-group` is `inline-flex min-h-9 items-center` with no padding
338        // of its own: the prefix, the input and the suffix each carry `px-3`,
339        // which is what keeps the addons flush with the field's edges. With a
340        // textarea inside, the pinned `:has` rule switches the group to
341        // `items-start` with `height: auto`, so the box grows downward around
342        // the multi-line field instead of centring it in a row.
343        let mut group = div()
344            .id(group_id.clone())
345            .flex()
346            .flex_row()
347            .map(|g| {
348                if is_textarea {
349                    g.items_start()
350                } else {
351                    g.items_center()
352                }
353            })
354            .min_h(explicit_height.unwrap_or(util::FIELD_HEIGHT))
355            .when_some(explicit_height, |g, h| g.h(h))
356            .text_size(util::FIELD_TEXT)
357            .line_height(px(20.))
358            .text_color(colors.field.foreground);
359        if let Some(entity) = entity {
360            group = group.debug_selector(move || format!("input-group-{entity}-group"));
361        }
362
363        // v3 rings the *group* on `focus-within`, so the state comes from the
364        // field inside it.
365        if !field_box.is_bare {
366            // The held field carries the same value, so the group box and the
367            // box it holds keep one corner.
368            // The group box is a plain flex row that never clips, so the
369            // ring rides as an overlay child and keeps a corner concentric
370            // with the box's own rather than the squarer one a spread
371            // shadow re-uses.
372            group = util::apply_field_chrome_overlay(
373                group,
374                self.variant,
375                is_invalid,
376                focus_within,
377                field_box.focus_ring.unwrap_or(true),
378                Some(radius),
379                cx,
380            );
381
382            // HeroUI's focus-within and invalid rules both use the field-focus
383            // endpoint.  The shared chrome supplies the ring/border; this
384            // explicit fill keeps the group surface in sync for both variants
385            // (secondary uses its neutral default endpoint).
386            if focus_within || is_invalid {
387                group = group.bg(match self.variant {
388                    FieldVariant::Primary => colors.field.focus(),
389                    FieldVariant::Secondary => colors.default.color,
390                });
391            }
392        }
393        if self.full_width {
394            group = group.w_full();
395        }
396        // `status-disabled` is one dim over the whole group box, so the held
397        // field, the addon slots and any composed children dim exactly once;
398        // the propagated field skips its own coat (`group_dim`), and the
399        // folded label dims itself beside the box. v3's `pointer-events:
400        // none` cannot come with it — an arbitrary child of a disabled group
401        // only dims here, it does not go inert.
402        if is_disabled {
403            group = group.opacity(layout.disabled_opacity);
404        }
405
406        // `.input-group:hover:not(:focus-within)` is `bg-field-hover` plus
407        // `--field-border-hover`, and `.input-group--secondary` swaps only the
408        // fill for `--input-group-bg-hover: var(--default-hover)`. The
409        // refinement is baked off while the focus is inside, and a focus
410        // change repaints through a re-render, so the suppressed hover never
411        // paints over the focused chrome. v3's `status-disabled` is
412        // `pointer-events: none` first, so a disabled group hovers never.
413        if !field_box.is_bare && !focus_within && !is_invalid && !is_disabled {
414            let hover_bg = self.group_hover_bg.unwrap_or(match self.variant {
415                FieldVariant::Primary => colors.field.hover(),
416                FieldVariant::Secondary => colors.default.hover(),
417            });
418            let hover_border = colors.field.border_hover();
419            // Keep the group identity and focus listeners stable while only
420            // the hover surface interpolates over HeroUI's 150ms ease-smooth
421            // transition. The border endpoint remains an immediate refinement.
422            group = crate::anim::hover_fade_with_duration_and_easing(
423                group,
424                element_id::scoped(&group_id, "hover-fade"),
425                (
426                    match self.variant {
427                        FieldVariant::Primary => colors.field.background,
428                        FieldVariant::Secondary => colors.default.color,
429                    },
430                    hover_bg,
431                ),
432                None,
433                Some(hover_border),
434                |fill| fill.rounded(radius),
435                Some(150),
436                crate::anim::HoverFadeEasing::EaseSmooth,
437                window,
438                cx,
439            );
440        }
441
442        // v3.2.4 `InputGroupRoot.handleClick`: a click on the group outside
443        // the contained input focuses the input (`target !== input &&
444        // !input.contains(target)` -> `input.focus()`), so clicking the
445        // prefix or the suffix starts typing in the field instead of
446        // blurring it. The handler sits on this root box — no wrapper above
447        // the field — and bubble order runs the deeper element first, so the
448        // dispatch has already settled which handle the press lands on:
449        // a click inside the input finds the field holding the focus (its
450        // own mouse-down placed the caret) and must not write a second focus
451        // behind it, while a click on a focusable suffix (a Button) finds
452        // the button holding it and hands the focus to the field — the
453        // button's click still fires on mouse-up, because gpui decides
454        // clicks by hover, not focus, so the pinned order "action, then the
455        // field" holds. That check is the focused handle itself, not
456        // `default_prevented`: the input's own focus transfer prevented
457        // default too, and skipping on that alone would exempt the very
458        // suffix button v3 hands the focus past. `prevent_default` runs on
459        // both arms — it is what keeps the press from falling through to a
460        // focusable ancestor, which would otherwise take the focus the
461        // moment the dispatch reaches the app focus root. Two pinned
462        // exceptions keep the handler off the box: `querySelector("input")`
463        // finds nothing in a textarea-only group, so a click there focuses
464        // nothing; and a disabled field — the group's own flag, propagated
465        // below, or the field's — never takes a browser focus.
466        let field_disabled = self
467            .input
468            .as_ref()
469            .is_some_and(|input| input.builder_is_disabled());
470        if !is_textarea && !is_disabled && !field_disabled {
471            if let Some(focus) = self.input.as_ref().map(|input| input.state_focus(cx)) {
472                group = group.on_mouse_down(MouseButton::Left, move |_, window, cx| {
473                    if !window.focused(cx).is_some_and(|held| held == focus) {
474                        window.focus(&focus, cx);
475                    }
476                    window.prevent_default();
477                });
478            }
479        }
480
481        // Order matters: prefix, field, suffix, then anything else the caller
482        // put in. The field is told which sides an addon occupies so it can
483        // drop that padding. Group-disabled reaches the field itself — the
484        // propagated field stops tracking focus and answering keys, exactly
485        // as a browser's disabled `<input>` does, and skips its own dim
486        // because the box above already carries it.
487        let addon_slot = |el: AnyElement, name: &'static str| -> AnyElement {
488            // Prefix/suffix are real slots in v3: their side border belongs to
489            // the slot, not to the input.  A zero-width field border keeps the
490            // stock theme invisible while custom bordered fields retain the
491            // seam. The wrapper also gives arbitrary prefix/suffix children
492            // the same placeholder colour and vertical alignment as InputAddon.
493            let mut slot = div()
494                .flex_shrink_0()
495                .when(!is_textarea, |slot| slot.h_full())
496                .flex()
497                .items_center()
498                .justify_center()
499                .text_color(colors.field.placeholder)
500                .when(name == "prefix", |slot| {
501                    slot.border_r(layout.field_border_width)
502                        .border_color(colors.field.border)
503                })
504                .when(name == "suffix", |slot| {
505                    slot.border_l(layout.field_border_width)
506                        .border_color(colors.field.border)
507                });
508            // `:has([data-slot="input-group-textarea"])` top-aligns the
509            // addons and gives each `padding-top: 0.5rem`, so addon text starts
510            // level with the textarea's first line.
511            if is_textarea {
512                slot = slot.items_start().pt(px(8.));
513            }
514            if let Some(entity) = entity {
515                slot = slot.debug_selector(move || format!("input-group-{entity}-{name}"));
516            }
517            slot.child(el).into_any_element()
518        };
519        let (has_prefix, has_suffix) = (self.prefix.is_some(), self.suffix.is_some());
520        if let Some(prefix) = self.prefix {
521            group = group.child(addon_slot(prefix, "prefix"));
522        }
523        if let Some(input) = self.input {
524            let input = if is_disabled {
525                input.is_disabled(true).group_dim(true)
526            } else {
527                input
528            };
529            let input = input.in_group(has_prefix, has_suffix);
530            let input = match explicit_height {
531                Some(height) => input.height(height),
532                None => input,
533            };
534            let input = match field_box.padding_x {
535                Some(padding_x) => input.group_padding_x(padding_x),
536                None => input,
537            };
538            let input = match self.font_family.clone() {
539                Some(family) => input.font_family(family),
540                None => input,
541            };
542            let input = match self.radius {
543                Some(radius) => input.radius(radius),
544                None => input,
545            };
546            let input = match field_box.focus_ring {
547                Some(show) => input.focus_ring(show),
548                None => input,
549            };
550            group = group.child(input.is_bare(field_box.is_bare));
551        }
552        if let Some(suffix) = self.suffix {
553            group = group.child(addon_slot(suffix, "suffix"));
554        }
555        group = group.children(self.children);
556
557        // `.input-group` wrapper is `gap-1`, like every other field.
558        let mut root = div().flex().flex_col().gap(px(4.));
559        if self.full_width {
560            // `.input-group--full-width` is `w-full`, and the wrapper above
561            // the group has to carry the width too: gpui resolves a
562            // percentage against the parent, and a content-sized parent
563            // stretches nothing.
564            root = root.w_full();
565        }
566        if let Some(label) = self.label {
567            root = root.child(
568                crate::field::Label::new(label)
569                    .is_invalid(is_invalid)
570                    .is_required(self.is_required)
571                    .is_disabled(is_disabled),
572            );
573        }
574        root = root.child(group);
575
576        let error = is_invalid.then(|| self.error_message.clone()).flatten();
577        if let Some(error) = crate::anim::field_error_panel(&group_id, error, window, cx) {
578            root = root.child(error);
579        } else if let Some(description) = self.description {
580            root = root.child(crate::field::Description::new(description));
581        }
582
583        root = util::apply_sx(root, &self.sx);
584        match self.id {
585            Some(id) => root.id(id).a11y(a11y::Role::Group).into_any_element(),
586            None => root.into_any_element(),
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    // The pinned `.input-group--secondary:hover` fill is
594    // `--input-group-bg-hover: var(--default-hover)`, which
595    // `RoleColor::hover()` computes; `soft_hover()` is a different, lighter
596    // token. The two accessors differ by one word and the wrong one still
597    // looks plausible on screen, so the check is mechanical: the source must
598    // name the pinned accessor, and the wrong one must not appear at all.
599    #[test]
600    fn secondary_hover_uses_the_pinned_default_hover_token() {
601        // Scan the implementation only; this test module's own text mentions
602        // the wrong accessor to forbid it.
603        let source = include_str!("input_group.rs")
604            .split("#[cfg(test)]")
605            .next()
606            .expect("the implementation section is always present");
607        assert!(
608            source.contains("FieldVariant::Secondary => colors.default.hover()"),
609            "the secondary group hover must read `colors.default.hover()` \
610             (pinned `--input-group-bg-hover: var(--default-hover)`)"
611        );
612        assert!(
613            !source.contains("soft_hover()"),
614            "`soft_hover()` is not the pinned hover token for the group and \
615             must not come back"
616        );
617    }
618
619    // v3's `status-disabled` is `pointer-events: none` before it is an
620    // opacity, so a disabled group paints the hover refinement never.
621    #[test]
622    fn the_disabled_group_paints_no_hover() {
623        let source = include_str!("input_group.rs")
624            .split("#[cfg(test)]")
625            .next()
626            .expect("the implementation section is always present");
627        assert!(
628            source.contains(
629                "if !field_box.is_bare && !focus_within && !is_invalid && !is_disabled {"
630            ),
631            "the hover refinement must be gated off while the group is \
632             disabled, not only while the focus is inside"
633        );
634    }
635
636    #[test]
637    fn focus_and_invalid_use_the_pinned_field_focus_fill() {
638        let source = include_str!("input_group.rs")
639            .split("#[cfg(test)]")
640            .next()
641            .expect("the implementation section is always present");
642        assert!(
643            source.contains("if focus_within || is_invalid")
644                && source.contains("FieldVariant::Primary => colors.field.focus()")
645                && source.contains("FieldVariant::Secondary => colors.default.color"),
646            "focus-within and invalid group states must paint the HeroUI field-focus \
647             endpoint for both variants"
648        );
649    }
650
651    #[test]
652    fn group_hover_uses_the_pinned_smooth_transition() {
653        let source = include_str!("input_group.rs")
654            .split("#[cfg(test)]")
655            .next()
656            .expect("the implementation section is always present");
657        assert!(
658            source.contains("hover_fade_with_duration_and_easing")
659                && source.contains("Some(150)")
660                && source.contains("HoverFadeEasing::EaseSmooth"),
661            "InputGroup hover must animate with HeroUI's 150ms ease-smooth \
662             transition"
663        );
664    }
665
666    #[test]
667    fn addon_slots_keep_the_pinned_side_seams() {
668        let source = include_str!("input_group.rs")
669            .split("#[cfg(test)]")
670            .next()
671            .expect("the implementation section is always present");
672        assert!(
673            source.contains("slot.border_r(layout.field_border_width)")
674                && source.contains("slot.border_l(layout.field_border_width)"),
675            "prefix and suffix slots must own their respective field-border seams"
676        );
677    }
678
679    // One dim per box: the group carries the whole `status-disabled` opacity
680    // and the propagated field skips its own coat, so the opacity must not
681    // nest (`group_dim`), and the dim must sit on the group box so composed
682    // children dim with it.
683    #[test]
684    fn the_disabled_dim_covers_the_box_once() {
685        let source = include_str!("input_group.rs")
686            .split("#[cfg(test)]")
687            .next()
688            .expect("the implementation section is always present");
689        assert!(
690            source.contains("input.is_disabled(true).group_dim(true)"),
691            "the propagated field must be told the group already dims the box"
692        );
693        assert!(
694            source.contains("group = group.opacity(layout.disabled_opacity)"),
695            "the one dim must sit on the group box so composed children dim \
696             with it"
697        );
698    }
699}
700
701crate::util::impl_component_styled!(InputAddon, InputGroup);