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);