1#[allow(clippy::struct_excessive_bools)]
8#[derive(Debug, Clone, Copy, Default)]
9pub struct FieldFlags {
10 pub required: bool,
12 pub disabled: bool,
14 pub readonly: bool,
16 pub autofocus: bool,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum InputType {
23 #[default]
25 Text,
26 Email,
28 Password,
30 Number,
32 Tel,
34 Url,
36 Search,
38 Date,
40 Time,
42 DateTimeLocal,
44 Month,
46 Week,
48 Color,
50 Range,
52 Hidden,
54 File,
56}
57
58impl InputType {
59 #[must_use]
61 pub const fn as_str(&self) -> &'static str {
62 match self {
63 Self::Text => "text",
64 Self::Email => "email",
65 Self::Password => "password",
66 Self::Number => "number",
67 Self::Tel => "tel",
68 Self::Url => "url",
69 Self::Search => "search",
70 Self::Date => "date",
71 Self::Time => "time",
72 Self::DateTimeLocal => "datetime-local",
73 Self::Month => "month",
74 Self::Week => "week",
75 Self::Color => "color",
76 Self::Range => "range",
77 Self::Hidden => "hidden",
78 Self::File => "file",
79 }
80 }
81}
82
83impl std::fmt::Display for InputType {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 write!(f, "{}", self.as_str())
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SelectOption {
92 pub value: String,
94 pub label: String,
96 pub disabled: bool,
98}
99
100impl SelectOption {
101 #[must_use]
103 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
104 Self {
105 value: value.into(),
106 label: label.into(),
107 disabled: false,
108 }
109 }
110
111 #[must_use]
113 pub fn disabled(value: impl Into<String>, label: impl Into<String>) -> Self {
114 Self {
115 value: value.into(),
116 label: label.into(),
117 disabled: true,
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
124pub enum FieldKind {
125 Input(InputType),
127 Textarea {
129 rows: Option<u32>,
131 cols: Option<u32>,
133 },
134 Select {
136 options: Vec<SelectOption>,
138 multiple: bool,
140 },
141 Checkbox {
143 checked: bool,
145 },
146 Radio {
148 options: Vec<SelectOption>,
150 },
151}
152
153impl Default for FieldKind {
154 fn default() -> Self {
155 Self::Input(InputType::default())
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct FormField {
162 pub name: String,
164 pub kind: FieldKind,
166 pub label: Option<String>,
168 pub placeholder: Option<String>,
170 pub value: Option<String>,
172 pub flags: FieldFlags,
174 pub autocomplete: Option<String>,
176 pub min_length: Option<usize>,
178 pub max_length: Option<usize>,
180 pub min: Option<String>,
182 pub max: Option<String>,
184 pub step: Option<String>,
186 pub pattern: Option<String>,
188 pub class: Option<String>,
190 pub id: Option<String>,
192 pub help_text: Option<String>,
194 pub htmx: HtmxFieldAttrs,
196 pub data_attrs: Vec<(String, String)>,
198 pub custom_attrs: Vec<(String, String)>,
200 pub file_attrs: FileFieldAttrs,
202}
203
204impl FormField {
205 #[must_use]
207 pub fn input(name: impl Into<String>, input_type: InputType) -> Self {
208 Self::new(name, FieldKind::Input(input_type))
209 }
210
211 #[must_use]
213 pub fn textarea(name: impl Into<String>) -> Self {
214 Self::new(
215 name,
216 FieldKind::Textarea {
217 rows: None,
218 cols: None,
219 },
220 )
221 }
222
223 #[must_use]
225 pub fn select(name: impl Into<String>) -> Self {
226 Self::new(
227 name,
228 FieldKind::Select {
229 options: Vec::new(),
230 multiple: false,
231 },
232 )
233 }
234
235 #[must_use]
237 pub fn checkbox(name: impl Into<String>) -> Self {
238 Self::new(name, FieldKind::Checkbox { checked: false })
239 }
240
241 #[must_use]
243 pub fn radio(name: impl Into<String>) -> Self {
244 Self::new(name, FieldKind::Radio { options: Vec::new() })
245 }
246
247 fn new(name: impl Into<String>, kind: FieldKind) -> Self {
248 Self {
249 name: name.into(),
250 kind,
251 label: None,
252 placeholder: None,
253 value: None,
254 flags: FieldFlags::default(),
255 autocomplete: None,
256 min_length: None,
257 max_length: None,
258 min: None,
259 max: None,
260 step: None,
261 pattern: None,
262 class: None,
263 id: None,
264 help_text: None,
265 htmx: HtmxFieldAttrs::default(),
266 data_attrs: Vec::new(),
267 custom_attrs: Vec::new(),
268 file_attrs: FileFieldAttrs::default(),
269 }
270 }
271
272 #[must_use]
274 pub fn effective_id(&self) -> &str {
275 self.id.as_deref().unwrap_or(&self.name)
276 }
277
278 #[must_use]
280 pub const fn is_input(&self) -> bool {
281 matches!(self.kind, FieldKind::Input(_))
282 }
283
284 #[must_use]
286 pub const fn is_textarea(&self) -> bool {
287 matches!(self.kind, FieldKind::Textarea { .. })
288 }
289
290 #[must_use]
292 pub const fn is_select(&self) -> bool {
293 matches!(self.kind, FieldKind::Select { .. })
294 }
295
296 #[must_use]
298 pub const fn is_checkbox(&self) -> bool {
299 matches!(self.kind, FieldKind::Checkbox { .. })
300 }
301
302 #[must_use]
304 pub const fn is_radio(&self) -> bool {
305 matches!(self.kind, FieldKind::Radio { .. })
306 }
307}
308
309#[derive(Debug, Clone, Default)]
311pub struct HtmxFieldAttrs {
312 pub get: Option<String>,
314 pub post: Option<String>,
316 pub put: Option<String>,
318 pub delete: Option<String>,
320 pub patch: Option<String>,
322 pub target: Option<String>,
324 pub swap: Option<String>,
326 pub trigger: Option<String>,
328 pub indicator: Option<String>,
330 pub vals: Option<String>,
332 pub validate: bool,
334}
335
336impl HtmxFieldAttrs {
337 #[must_use]
339 pub const fn has_any(&self) -> bool {
340 self.get.is_some()
341 || self.post.is_some()
342 || self.put.is_some()
343 || self.delete.is_some()
344 || self.patch.is_some()
345 || self.target.is_some()
346 || self.swap.is_some()
347 || self.trigger.is_some()
348 || self.indicator.is_some()
349 || self.vals.is_some()
350 || self.validate
351 }
352}
353
354#[derive(Debug, Clone, Default)]
356pub struct FileFieldAttrs {
357 pub accept: Option<String>,
360 pub multiple: bool,
362 pub max_size_mb: Option<u32>,
364 pub show_preview: bool,
366 pub drag_drop: bool,
368 pub progress_endpoint: Option<String>,
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_input_type_as_str() {
378 assert_eq!(InputType::Email.as_str(), "email");
379 assert_eq!(InputType::Password.as_str(), "password");
380 assert_eq!(InputType::DateTimeLocal.as_str(), "datetime-local");
381 }
382
383 #[test]
384 fn test_select_option() {
385 let opt = SelectOption::new("us", "United States");
386 assert_eq!(opt.value, "us");
387 assert_eq!(opt.label, "United States");
388 assert!(!opt.disabled);
389 }
390
391 #[test]
392 fn test_select_option_disabled() {
393 let opt = SelectOption::disabled("", "Select a country...");
394 assert!(opt.disabled);
395 }
396
397 #[test]
398 fn test_form_field_input() {
399 let field = FormField::input("email", InputType::Email);
400 assert_eq!(field.name, "email");
401 assert!(field.is_input());
402 assert!(!field.is_textarea());
403 }
404
405 #[test]
406 fn test_form_field_effective_id() {
407 let mut field = FormField::input("email", InputType::Email);
408 assert_eq!(field.effective_id(), "email");
409
410 field.id = Some("custom-email-id".into());
411 assert_eq!(field.effective_id(), "custom-email-id");
412 }
413
414 #[test]
415 fn test_htmx_field_attrs() {
416 let attrs = HtmxFieldAttrs::default();
417 assert!(!attrs.has_any());
418
419 let attrs_with_get = HtmxFieldAttrs {
420 get: Some("/search".into()),
421 ..Default::default()
422 };
423 assert!(attrs_with_get.has_any());
424 }
425}