acton_htmx/forms/
field.rs

1//! Form field types and input configuration
2//!
3//! Defines the various input types and field configurations
4//! supported by the form builder.
5
6/// Field attribute flags grouped for better ergonomics
7#[allow(clippy::struct_excessive_bools)]
8#[derive(Debug, Clone, Copy, Default)]
9pub struct FieldFlags {
10    /// Whether field is required
11    pub required: bool,
12    /// Whether field is disabled
13    pub disabled: bool,
14    /// Whether field is read-only
15    pub readonly: bool,
16    /// Autofocus this field
17    pub autofocus: bool,
18}
19
20/// HTML input types
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum InputType {
23    /// Text input (default)
24    #[default]
25    Text,
26    /// Email input with validation
27    Email,
28    /// Password input (masked)
29    Password,
30    /// Number input
31    Number,
32    /// Telephone input
33    Tel,
34    /// URL input
35    Url,
36    /// Search input
37    Search,
38    /// Date input
39    Date,
40    /// Time input
41    Time,
42    /// Date and time input
43    DateTimeLocal,
44    /// Month input
45    Month,
46    /// Week input
47    Week,
48    /// Color picker
49    Color,
50    /// Range slider
51    Range,
52    /// Hidden input
53    Hidden,
54    /// File upload
55    File,
56}
57
58impl InputType {
59    /// Get the HTML type attribute value
60    #[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/// Option for select dropdowns
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SelectOption {
92    /// Value attribute
93    pub value: String,
94    /// Display text
95    pub label: String,
96    /// Whether this option is disabled
97    pub disabled: bool,
98}
99
100impl SelectOption {
101    /// Create a new select option
102    #[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    /// Create a disabled option (useful for placeholder)
112    #[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/// Kind of form field
123#[derive(Debug, Clone)]
124pub enum FieldKind {
125    /// Standard input field
126    Input(InputType),
127    /// Textarea for multi-line text
128    Textarea {
129        /// Number of visible text lines
130        rows: Option<u32>,
131        /// Visible width in average character widths
132        cols: Option<u32>,
133    },
134    /// Select dropdown
135    Select {
136        /// Available options
137        options: Vec<SelectOption>,
138        /// Allow multiple selections
139        multiple: bool,
140    },
141    /// Checkbox
142    Checkbox {
143        /// Whether checkbox is checked
144        checked: bool,
145    },
146    /// Radio button group
147    Radio {
148        /// Available options
149        options: Vec<SelectOption>,
150    },
151}
152
153impl Default for FieldKind {
154    fn default() -> Self {
155        Self::Input(InputType::default())
156    }
157}
158
159/// A form field with all its attributes
160#[derive(Debug, Clone)]
161pub struct FormField {
162    /// Field name (used for form submission)
163    pub name: String,
164    /// Field kind (input, textarea, select, etc.)
165    pub kind: FieldKind,
166    /// Label text
167    pub label: Option<String>,
168    /// Placeholder text
169    pub placeholder: Option<String>,
170    /// Current value
171    pub value: Option<String>,
172    /// Field attribute flags (required, disabled, readonly, autofocus)
173    pub flags: FieldFlags,
174    /// Autocomplete attribute
175    pub autocomplete: Option<String>,
176    /// Minimum length for text inputs
177    pub min_length: Option<usize>,
178    /// Maximum length for text inputs
179    pub max_length: Option<usize>,
180    /// Minimum value for number inputs
181    pub min: Option<String>,
182    /// Maximum value for number inputs
183    pub max: Option<String>,
184    /// Step value for number inputs
185    pub step: Option<String>,
186    /// Pattern for validation (regex)
187    pub pattern: Option<String>,
188    /// CSS class(es)
189    pub class: Option<String>,
190    /// Element ID (defaults to name if not set)
191    pub id: Option<String>,
192    /// Help text shown below the field
193    pub help_text: Option<String>,
194    /// HTMX attributes
195    pub htmx: HtmxFieldAttrs,
196    /// Custom data attributes
197    pub data_attrs: Vec<(String, String)>,
198    /// Custom attributes
199    pub custom_attrs: Vec<(String, String)>,
200    /// File upload-specific attributes (only used for InputType::File)
201    pub file_attrs: FileFieldAttrs,
202}
203
204impl FormField {
205    /// Create a new input field
206    #[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    /// Create a new textarea field
212    #[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    /// Create a new select field
224    #[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    /// Create a new checkbox field
236    #[must_use]
237    pub fn checkbox(name: impl Into<String>) -> Self {
238        Self::new(name, FieldKind::Checkbox { checked: false })
239    }
240
241    /// Create a new radio button group
242    #[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    /// Get the effective ID (custom ID or field name)
273    #[must_use]
274    pub fn effective_id(&self) -> &str {
275        self.id.as_deref().unwrap_or(&self.name)
276    }
277
278    /// Check if this field is an input type
279    #[must_use]
280    pub const fn is_input(&self) -> bool {
281        matches!(self.kind, FieldKind::Input(_))
282    }
283
284    /// Check if this field is a textarea
285    #[must_use]
286    pub const fn is_textarea(&self) -> bool {
287        matches!(self.kind, FieldKind::Textarea { .. })
288    }
289
290    /// Check if this field is a select
291    #[must_use]
292    pub const fn is_select(&self) -> bool {
293        matches!(self.kind, FieldKind::Select { .. })
294    }
295
296    /// Check if this field is a checkbox
297    #[must_use]
298    pub const fn is_checkbox(&self) -> bool {
299        matches!(self.kind, FieldKind::Checkbox { .. })
300    }
301
302    /// Check if this field is a radio group
303    #[must_use]
304    pub const fn is_radio(&self) -> bool {
305        matches!(self.kind, FieldKind::Radio { .. })
306    }
307}
308
309/// HTMX-specific attributes for form fields
310#[derive(Debug, Clone, Default)]
311pub struct HtmxFieldAttrs {
312    /// hx-get URL
313    pub get: Option<String>,
314    /// hx-post URL
315    pub post: Option<String>,
316    /// hx-put URL
317    pub put: Option<String>,
318    /// hx-delete URL
319    pub delete: Option<String>,
320    /// hx-patch URL
321    pub patch: Option<String>,
322    /// hx-target selector
323    pub target: Option<String>,
324    /// hx-swap strategy
325    pub swap: Option<String>,
326    /// hx-trigger event
327    pub trigger: Option<String>,
328    /// hx-indicator selector
329    pub indicator: Option<String>,
330    /// hx-vals JSON
331    pub vals: Option<String>,
332    /// hx-validate
333    pub validate: bool,
334}
335
336impl HtmxFieldAttrs {
337    /// Check if any HTMX attributes are set
338    #[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/// File upload-specific attributes for file input fields
355#[derive(Debug, Clone, Default)]
356pub struct FileFieldAttrs {
357    /// Accept attribute (comma-separated MIME types or file extensions)
358    /// Example: "image/png,image/jpeg" or ".png,.jpg"
359    pub accept: Option<String>,
360    /// Allow multiple file selection
361    pub multiple: bool,
362    /// Maximum file size in MB (for client-side hint via data attribute)
363    pub max_size_mb: Option<u32>,
364    /// Show file preview (for images)
365    pub show_preview: bool,
366    /// Enable drag-and-drop zone styling
367    pub drag_drop: bool,
368    /// Upload progress tracking via SSE endpoint
369    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}