1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//! InputGroup component — input with prefix/suffix/block addons as one visual unit.
//!
//! shadcn Base UI parity (P1 rows from gap list):
//! - [`Align`] — addon placement (inline-start, inline-end, block-start, block-end).
//! - [`addon`] — wrap arbitrary Markup in an addon slot at a given align.
//! - [`button`] — helper that renders a sized/variant button inside a trailing addon.
//! - [`text`] — helper for an `InputGroupText` (muted supplementary text) addon.
//! - [`input_el`] / [`textarea`] — render the control with `data-slot="input-group-control"`
//! so downstream styling / selectors can match shadcn's control hook.
use maud::{html, Markup};
use super::button;
use super::input;
use super::textarea;
/// Addon placement relative to the input control.
///
/// Mirrors shadcn's `InputGroupAddon.align` prop. The default is `InlineStart`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
/// Leading addon (left in LTR). Rendered BEFORE the control via `order: -1`.
#[default]
InlineStart,
/// Trailing addon (right in LTR). Rendered AFTER the control via `order: 99`.
InlineEnd,
/// Addon above the row (full-width banner). Uses `flex-basis: 100%` + `order: -2`.
BlockStart,
/// Addon below the row (full-width footer / hint). Uses `flex-basis: 100%` + `order: 100`.
BlockEnd,
}
impl Align {
fn modifier(self) -> &'static str {
match self {
Self::InlineStart => "mui-input-group__addon--inline-start",
Self::InlineEnd => "mui-input-group__addon--inline-end",
Self::BlockStart => "mui-input-group__addon--block-start",
Self::BlockEnd => "mui-input-group__addon--block-end",
}
}
}
pub struct InputGroupProps {
pub prefix: Option<Markup>,
pub suffix: Option<Markup>,
pub children: Markup,
}
pub fn render(props: InputGroupProps) -> Markup {
html! {
div.mui-input-group {
@if let Some(prefix) = props.prefix {
span.mui-input-group__prefix { (prefix) }
}
(props.children)
@if let Some(suffix) = props.suffix {
span.mui-input-group__suffix { (suffix) }
}
}
}
}
/// Render an addon at the requested [`Align`]. The addon itself is a plain `<div>`;
/// positioning is driven by the CSS modifier class so flex order and block-wrap
/// behavior is deterministic without re-rendering.
pub fn addon(children: Markup, align: Align) -> Markup {
let class = format!("mui-input-group__addon {}", align.modifier());
html! {
div class=(class) { (children) }
}
}
/// Render a button sized/variant-styled for use inside an input group addon slot.
/// Wraps the button in an `InlineEnd` addon by default — the most common shadcn usage
/// (password reveal, search submit, copy).
pub fn button(label: &str, size: button::Size, variant: button::Variant) -> Markup {
let btn = button::render(button::Props {
label: label.to_string(),
variant,
size,
..Default::default()
});
addon(btn, Align::InlineEnd)
}
/// Render `InputGroupText` — muted supplementary text wrapped as an addon.
/// By default placed at `InlineStart` (shadcn's common use: currency prefix, `@`, `https://`).
pub fn text(children: Markup) -> Markup {
let inner = html! {
span.mui-input-group__text { (children) }
};
addon(inner, Align::InlineStart)
}
/// Render an [`input::Props`]-configured input control and wrap it with
/// `data-slot="input-group-control"` so shadcn-compatible styling / selectors
/// can match the control inside a group.
pub fn input_el(props: input::Props) -> Markup {
html! {
div data-slot="input-group-control" class="mui-input-group__control" {
(input::render(props))
}
}
}
/// Render a [`textarea::Props`]-configured textarea control and wrap it with
/// `data-slot="input-group-control"`. Same rationale as [`input_el`].
pub fn textarea(props: textarea::Props) -> Markup {
html! {
div data-slot="input-group-control" class="mui-input-group__control" {
(textarea::render(props))
}
}
}
pub fn showcase() -> Markup {
html! {
div class="mui-showcase-section" {
h3 { "Input with $ prefix" }
(render(InputGroupProps {
prefix: Some(html! { "$" }),
suffix: None,
children: html! { input type="text" placeholder="Amount" {} },
}))
}
div class="mui-showcase-section" {
h3 { "Input with .com suffix" }
(render(InputGroupProps {
prefix: None,
suffix: Some(html! { ".com" }),
children: html! { input type="text" placeholder="Domain" {} },
}))
}
div class="mui-showcase-section" {
h3 { "Input with https:// prefix and Go button" }
(render(InputGroupProps {
prefix: Some(html! { "https://" }),
suffix: Some(html! { button { "Go" } }),
children: html! { input type="text" placeholder="URL" {} },
}))
}
div class="mui-showcase-section" {
h3 { "Email with @ prefix" }
(render(InputGroupProps {
prefix: Some(html! { "@" }),
suffix: None,
children: html! { input type="email" placeholder="username" {} },
}))
}
// P1 parity demo — Align::{InlineStart,InlineEnd,BlockEnd} with helpers.
div class="mui-showcase-section" {
h3 { "Addon alignment — prefix text, suffix button, block-end hint" }
p.mui-showcase__caption { "Uses `addon`, `text`, `button`, and `input_el` helpers. Prefix text is InlineStart, Go is InlineEnd, hint wraps below the row via BlockEnd." }
div.mui-input-group {
(text(html! { "https://" }))
(input_el(input::Props {
name: "site-url".into(),
id: "ig-site-url".into(),
input_type: input::InputType::Url,
placeholder: "your-site".into(),
aria_describedby: Some("ig-site-hint".into()),
..Default::default()
}))
(button("Go", button::Size::Sm, button::Variant::Primary))
(addon(
html! {
span id="ig-site-hint" style="font-size:0.75rem;color:var(--mui-text-muted);" {
"We'll prepend https:// and check the domain resolves."
}
},
Align::BlockEnd,
))
}
}
}
}