acton_htmx/forms/
builder.rs

1//! Form builder API with fluent interface
2//!
3//! Provides a builder pattern for constructing HTML forms with
4//! HTMX integration and validation support.
5
6use super::error::ValidationErrors;
7use super::field::{FieldKind, FormField, InputType, SelectOption};
8use super::render::FormRenderer;
9
10/// Builder for constructing HTML forms
11///
12/// # Examples
13///
14/// ```rust
15/// use acton_htmx::forms::{FormBuilder, InputType};
16///
17/// let html = FormBuilder::new("/login", "POST")
18///     .id("login-form")
19///     .csrf_token("abc123")
20///     .field("email", InputType::Email)
21///         .label("Email Address")
22///         .required()
23///         .placeholder("you@example.com")
24///         .done()
25///     .field("password", InputType::Password)
26///         .label("Password")
27///         .required()
28///         .done()
29///     .submit("Sign In")
30///     .build();
31/// ```
32#[derive(Debug, Clone)]
33pub struct FormBuilder<'a> {
34    /// Form action URL
35    pub(crate) action: String,
36    /// HTTP method
37    pub(crate) method: String,
38    /// Form ID
39    pub(crate) id: Option<String>,
40    /// CSS classes
41    pub(crate) class: Option<String>,
42    /// CSRF token
43    pub(crate) csrf_token: Option<String>,
44    /// Enctype for file uploads
45    pub(crate) enctype: Option<String>,
46    /// Form fields
47    pub(crate) fields: Vec<FormField>,
48    /// Submit button text
49    pub(crate) submit_text: Option<String>,
50    /// Submit button class
51    pub(crate) submit_class: Option<String>,
52    /// Validation errors
53    pub(crate) errors: Option<&'a ValidationErrors>,
54    /// HTMX attributes
55    pub(crate) htmx: HtmxFormAttrs,
56    /// Custom attributes
57    pub(crate) custom_attrs: Vec<(String, String)>,
58    /// Whether to include HTMX validation
59    pub(crate) htmx_validate: bool,
60    /// Disable browser validation
61    pub(crate) novalidate: bool,
62}
63
64/// HTMX attributes for the form element
65#[derive(Debug, Clone, Default)]
66pub struct HtmxFormAttrs {
67    /// hx-get URL
68    pub get: Option<String>,
69    /// hx-post URL
70    pub post: Option<String>,
71    /// hx-put URL
72    pub put: Option<String>,
73    /// hx-delete URL
74    pub delete: Option<String>,
75    /// hx-patch URL
76    pub patch: Option<String>,
77    /// hx-target selector
78    pub target: Option<String>,
79    /// hx-swap strategy
80    pub swap: Option<String>,
81    /// hx-trigger event
82    pub trigger: Option<String>,
83    /// hx-indicator selector
84    pub indicator: Option<String>,
85    /// hx-push-url
86    pub push_url: Option<String>,
87    /// hx-confirm message
88    pub confirm: Option<String>,
89    /// hx-disabled-elt selector
90    pub disabled_elt: Option<String>,
91}
92
93impl<'a> FormBuilder<'a> {
94    /// Create a new form builder with action and method
95    #[must_use]
96    pub fn new(action: impl Into<String>, method: impl Into<String>) -> Self {
97        Self {
98            action: action.into(),
99            method: method.into(),
100            id: None,
101            class: None,
102            csrf_token: None,
103            enctype: None,
104            fields: Vec::new(),
105            submit_text: None,
106            submit_class: None,
107            errors: None,
108            htmx: HtmxFormAttrs::default(),
109            custom_attrs: Vec::new(),
110            htmx_validate: false,
111            novalidate: false,
112        }
113    }
114
115    /// Set the form ID
116    #[must_use]
117    pub fn id(mut self, id: impl Into<String>) -> Self {
118        self.id = Some(id.into());
119        self
120    }
121
122    /// Set the form CSS class
123    #[must_use]
124    pub fn class(mut self, class: impl Into<String>) -> Self {
125        self.class = Some(class.into());
126        self
127    }
128
129    /// Set the CSRF token
130    #[must_use]
131    pub fn csrf_token(mut self, token: impl Into<String>) -> Self {
132        self.csrf_token = Some(token.into());
133        self
134    }
135
136    /// Set the form enctype (for file uploads use "multipart/form-data")
137    #[must_use]
138    pub fn enctype(mut self, enctype: impl Into<String>) -> Self {
139        self.enctype = Some(enctype.into());
140        self
141    }
142
143    /// Enable multipart form data (for file uploads)
144    #[must_use]
145    pub fn multipart(mut self) -> Self {
146        self.enctype = Some("multipart/form-data".into());
147        self
148    }
149
150    /// Set validation errors to display
151    #[must_use]
152    pub const fn errors(mut self, errors: &'a ValidationErrors) -> Self {
153        self.errors = Some(errors);
154        self
155    }
156
157    /// Set the submit button text
158    #[must_use]
159    pub fn submit(mut self, text: impl Into<String>) -> Self {
160        self.submit_text = Some(text.into());
161        self
162    }
163
164    /// Set the submit button CSS class
165    #[must_use]
166    pub fn submit_class(mut self, class: impl Into<String>) -> Self {
167        self.submit_class = Some(class.into());
168        self
169    }
170
171    /// Disable browser validation (add novalidate attribute)
172    #[must_use]
173    pub const fn novalidate(mut self) -> Self {
174        self.novalidate = true;
175        self
176    }
177
178    /// Add a custom attribute
179    #[must_use]
180    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
181        self.custom_attrs.push((name.into(), value.into()));
182        self
183    }
184
185    // =========================================================================
186    // HTMX Attributes
187    // =========================================================================
188
189    /// Set hx-get attribute
190    #[must_use]
191    pub fn htmx_get(mut self, url: impl Into<String>) -> Self {
192        self.htmx.get = Some(url.into());
193        self
194    }
195
196    /// Set hx-post attribute
197    #[must_use]
198    pub fn htmx_post(mut self, url: impl Into<String>) -> Self {
199        self.htmx.post = Some(url.into());
200        self
201    }
202
203    /// Set hx-put attribute
204    #[must_use]
205    pub fn htmx_put(mut self, url: impl Into<String>) -> Self {
206        self.htmx.put = Some(url.into());
207        self
208    }
209
210    /// Set hx-delete attribute
211    #[must_use]
212    pub fn htmx_delete(mut self, url: impl Into<String>) -> Self {
213        self.htmx.delete = Some(url.into());
214        self
215    }
216
217    /// Set hx-patch attribute
218    #[must_use]
219    pub fn htmx_patch(mut self, url: impl Into<String>) -> Self {
220        self.htmx.patch = Some(url.into());
221        self
222    }
223
224    /// Set hx-target attribute
225    #[must_use]
226    pub fn htmx_target(mut self, selector: impl Into<String>) -> Self {
227        self.htmx.target = Some(selector.into());
228        self
229    }
230
231    /// Set hx-swap attribute
232    #[must_use]
233    pub fn htmx_swap(mut self, strategy: impl Into<String>) -> Self {
234        self.htmx.swap = Some(strategy.into());
235        self
236    }
237
238    /// Set hx-trigger attribute
239    #[must_use]
240    pub fn htmx_trigger(mut self, trigger: impl Into<String>) -> Self {
241        self.htmx.trigger = Some(trigger.into());
242        self
243    }
244
245    /// Set hx-indicator attribute
246    #[must_use]
247    pub fn htmx_indicator(mut self, selector: impl Into<String>) -> Self {
248        self.htmx.indicator = Some(selector.into());
249        self
250    }
251
252    /// Set hx-push-url attribute
253    #[must_use]
254    pub fn htmx_push_url(mut self, url: impl Into<String>) -> Self {
255        self.htmx.push_url = Some(url.into());
256        self
257    }
258
259    /// Set hx-confirm attribute
260    #[must_use]
261    pub fn htmx_confirm(mut self, message: impl Into<String>) -> Self {
262        self.htmx.confirm = Some(message.into());
263        self
264    }
265
266    /// Set hx-disabled-elt attribute
267    #[must_use]
268    pub fn htmx_disabled_elt(mut self, selector: impl Into<String>) -> Self {
269        self.htmx.disabled_elt = Some(selector.into());
270        self
271    }
272
273    /// Enable hx-validate
274    #[must_use]
275    pub const fn htmx_validate(mut self) -> Self {
276        self.htmx_validate = true;
277        self
278    }
279
280    // =========================================================================
281    // Field Builders
282    // =========================================================================
283
284    /// Add an input field and return a field builder
285    #[must_use]
286    pub fn field(self, name: impl Into<String>, input_type: InputType) -> FieldBuilder<'a> {
287        FieldBuilder::new(self, FormField::input(name, input_type))
288    }
289
290    /// Add a file upload field and return a file field builder
291    ///
292    /// This automatically sets the form enctype to multipart/form-data.
293    ///
294    /// # Examples
295    ///
296    /// ```rust
297    /// use acton_htmx::forms::FormBuilder;
298    ///
299    /// let form = FormBuilder::new("/upload", "POST")
300    ///     .file("avatar")
301    ///         .label("Profile Picture")
302    ///         .accept("image/png,image/jpeg")
303    ///         .max_size_mb(5)
304    ///         .required()
305    ///         .done()
306    ///     .build();
307    /// ```
308    #[must_use]
309    pub fn file(mut self, name: impl Into<String>) -> FileFieldBuilder<'a> {
310        // Automatically set multipart encoding
311        if self.enctype.is_none() {
312            self.enctype = Some("multipart/form-data".into());
313        }
314        FileFieldBuilder::new(self, FormField::input(name, InputType::File))
315    }
316
317    /// Add a textarea field and return a field builder
318    #[must_use]
319    pub fn textarea(self, name: impl Into<String>) -> TextareaBuilder<'a> {
320        TextareaBuilder::new(self, FormField::textarea(name))
321    }
322
323    /// Add a select field and return a select builder
324    #[must_use]
325    pub fn select(self, name: impl Into<String>) -> SelectBuilder<'a> {
326        SelectBuilder::new(self, FormField::select(name))
327    }
328
329    /// Add a checkbox field and return a checkbox builder
330    #[must_use]
331    pub fn checkbox(self, name: impl Into<String>) -> CheckboxBuilder<'a> {
332        CheckboxBuilder::new(self, FormField::checkbox(name))
333    }
334
335    /// Add a hidden field
336    #[must_use]
337    pub fn hidden(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
338        let mut field = FormField::input(name, InputType::Hidden);
339        field.value = Some(value.into());
340        self.fields.push(field);
341        self
342    }
343
344    /// Add a pre-built field
345    #[must_use]
346    pub fn add_field(mut self, field: FormField) -> Self {
347        self.fields.push(field);
348        self
349    }
350
351    /// Build the form HTML using programmatic rendering
352    ///
353    /// This uses the built-in programmatic renderer. For template-based
354    /// rendering that allows customization, use [`build_with_templates`].
355    #[must_use]
356    pub fn build(self) -> String {
357        FormRenderer::render(&self)
358    }
359
360    /// Build the form HTML using template-based rendering
361    ///
362    /// This uses minijinja templates from the XDG template directory,
363    /// allowing users to customize the HTML structure.
364    ///
365    /// # Errors
366    ///
367    /// Returns error if template rendering fails.
368    ///
369    /// # Example
370    ///
371    /// ```rust,ignore
372    /// use acton_htmx::forms::{FormBuilder, InputType};
373    /// use acton_htmx::template::framework::FrameworkTemplates;
374    ///
375    /// let templates = FrameworkTemplates::new()?;
376    /// let html = FormBuilder::new("/login", "POST")
377    ///     .field("email", InputType::Email)
378    ///         .label("Email")
379    ///         .done()
380    ///     .build_with_templates(&templates)?;
381    /// ```
382    pub fn build_with_templates(
383        self,
384        templates: &crate::template::framework::FrameworkTemplates,
385    ) -> Result<String, super::template_render::FormRenderError> {
386        let renderer = super::template_render::TemplateFormRenderer::new(templates);
387        renderer.render(&self)
388    }
389
390    /// Build the form HTML using template-based rendering with custom options
391    ///
392    /// # Errors
393    ///
394    /// Returns error if template rendering fails.
395    pub fn build_with_templates_and_options(
396        self,
397        templates: &crate::template::framework::FrameworkTemplates,
398        options: super::render::FormRenderOptions,
399    ) -> Result<String, super::template_render::FormRenderError> {
400        let renderer = super::template_render::TemplateFormRenderer::with_options(templates, options);
401        renderer.render(&self)
402    }
403}
404
405// =============================================================================
406// Field Builder
407// =============================================================================
408
409/// Builder for input fields
410pub struct FieldBuilder<'a> {
411    form: FormBuilder<'a>,
412    field: FormField,
413}
414
415impl<'a> FieldBuilder<'a> {
416    const fn new(form: FormBuilder<'a>, field: FormField) -> Self {
417        Self { form, field }
418    }
419
420    /// Set the field label
421    #[must_use]
422    pub fn label(mut self, label: impl Into<String>) -> Self {
423        self.field.label = Some(label.into());
424        self
425    }
426
427    /// Set placeholder text
428    #[must_use]
429    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
430        self.field.placeholder = Some(placeholder.into());
431        self
432    }
433
434    /// Set the current value
435    #[must_use]
436    pub fn value(mut self, value: impl Into<String>) -> Self {
437        self.field.value = Some(value.into());
438        self
439    }
440
441    /// Mark field as required
442    #[must_use]
443    pub const fn required(mut self) -> Self {
444        self.field.flags.required = true;
445        self
446    }
447
448    /// Mark field as disabled
449    #[must_use]
450    pub const fn disabled(mut self) -> Self {
451        self.field.flags.disabled = true;
452        self
453    }
454
455    /// Mark field as readonly
456    #[must_use]
457    pub const fn readonly(mut self) -> Self {
458        self.field.flags.readonly = true;
459        self
460    }
461
462    /// Enable autofocus
463    #[must_use]
464    pub const fn autofocus(mut self) -> Self {
465        self.field.flags.autofocus = true;
466        self
467    }
468
469    /// Set autocomplete attribute
470    #[must_use]
471    pub fn autocomplete(mut self, value: impl Into<String>) -> Self {
472        self.field.autocomplete = Some(value.into());
473        self
474    }
475
476    /// Set minimum length
477    #[must_use]
478    pub const fn min_length(mut self, len: usize) -> Self {
479        self.field.min_length = Some(len);
480        self
481    }
482
483    /// Set maximum length
484    #[must_use]
485    pub const fn max_length(mut self, len: usize) -> Self {
486        self.field.max_length = Some(len);
487        self
488    }
489
490    /// Set minimum value (for number inputs)
491    #[must_use]
492    pub fn min(mut self, value: impl Into<String>) -> Self {
493        self.field.min = Some(value.into());
494        self
495    }
496
497    /// Set maximum value (for number inputs)
498    #[must_use]
499    pub fn max(mut self, value: impl Into<String>) -> Self {
500        self.field.max = Some(value.into());
501        self
502    }
503
504    /// Set step value (for number inputs)
505    #[must_use]
506    pub fn step(mut self, value: impl Into<String>) -> Self {
507        self.field.step = Some(value.into());
508        self
509    }
510
511    /// Set validation pattern (regex)
512    #[must_use]
513    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
514        self.field.pattern = Some(pattern.into());
515        self
516    }
517
518    /// Set CSS class
519    #[must_use]
520    pub fn class(mut self, class: impl Into<String>) -> Self {
521        self.field.class = Some(class.into());
522        self
523    }
524
525    /// Set element ID (overrides default which is the field name)
526    #[must_use]
527    pub fn id(mut self, id: impl Into<String>) -> Self {
528        self.field.id = Some(id.into());
529        self
530    }
531
532    /// Set help text
533    #[must_use]
534    pub fn help(mut self, text: impl Into<String>) -> Self {
535        self.field.help_text = Some(text.into());
536        self
537    }
538
539    /// Add a data attribute
540    #[must_use]
541    pub fn data(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
542        self.field.data_attrs.push((name.into(), value.into()));
543        self
544    }
545
546    /// Add a custom attribute
547    #[must_use]
548    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
549        self.field.custom_attrs.push((name.into(), value.into()));
550        self
551    }
552
553    // HTMX attributes for the field
554    /// Set hx-get for this field
555    #[must_use]
556    pub fn htmx_get(mut self, url: impl Into<String>) -> Self {
557        self.field.htmx.get = Some(url.into());
558        self
559    }
560
561    /// Set hx-post for this field
562    #[must_use]
563    pub fn htmx_post(mut self, url: impl Into<String>) -> Self {
564        self.field.htmx.post = Some(url.into());
565        self
566    }
567
568    /// Set hx-target for this field
569    #[must_use]
570    pub fn htmx_target(mut self, selector: impl Into<String>) -> Self {
571        self.field.htmx.target = Some(selector.into());
572        self
573    }
574
575    /// Set hx-swap for this field
576    #[must_use]
577    pub fn htmx_swap(mut self, strategy: impl Into<String>) -> Self {
578        self.field.htmx.swap = Some(strategy.into());
579        self
580    }
581
582    /// Set hx-trigger for this field
583    #[must_use]
584    pub fn htmx_trigger(mut self, trigger: impl Into<String>) -> Self {
585        self.field.htmx.trigger = Some(trigger.into());
586        self
587    }
588
589    /// Finish building this field and return to form builder
590    #[must_use]
591    pub fn done(mut self) -> FormBuilder<'a> {
592        self.form.fields.push(self.field);
593        self.form
594    }
595}
596
597// =============================================================================
598// Textarea Builder
599// =============================================================================
600
601/// Builder for textarea fields
602pub struct TextareaBuilder<'a> {
603    form: FormBuilder<'a>,
604    field: FormField,
605}
606
607impl<'a> TextareaBuilder<'a> {
608    const fn new(form: FormBuilder<'a>, field: FormField) -> Self {
609        Self { form, field }
610    }
611
612    /// Set the field label
613    #[must_use]
614    pub fn label(mut self, label: impl Into<String>) -> Self {
615        self.field.label = Some(label.into());
616        self
617    }
618
619    /// Set placeholder text
620    #[must_use]
621    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
622        self.field.placeholder = Some(placeholder.into());
623        self
624    }
625
626    /// Set the current value
627    #[must_use]
628    pub fn value(mut self, value: impl Into<String>) -> Self {
629        self.field.value = Some(value.into());
630        self
631    }
632
633    /// Mark field as required
634    #[must_use]
635    pub const fn required(mut self) -> Self {
636        self.field.flags.required = true;
637        self
638    }
639
640    /// Mark field as disabled
641    #[must_use]
642    pub const fn disabled(mut self) -> Self {
643        self.field.flags.disabled = true;
644        self
645    }
646
647    /// Set number of rows
648    #[must_use]
649    pub const fn rows(mut self, rows: u32) -> Self {
650        if let FieldKind::Textarea {
651            rows: ref mut r, ..
652        } = self.field.kind
653        {
654            *r = Some(rows);
655        }
656        self
657    }
658
659    /// Set number of columns
660    #[must_use]
661    pub const fn cols(mut self, cols: u32) -> Self {
662        if let FieldKind::Textarea {
663            cols: ref mut c, ..
664        } = self.field.kind
665        {
666            *c = Some(cols);
667        }
668        self
669    }
670
671    /// Set CSS class
672    #[must_use]
673    pub fn class(mut self, class: impl Into<String>) -> Self {
674        self.field.class = Some(class.into());
675        self
676    }
677
678    /// Set element ID
679    #[must_use]
680    pub fn id(mut self, id: impl Into<String>) -> Self {
681        self.field.id = Some(id.into());
682        self
683    }
684
685    /// Set help text
686    #[must_use]
687    pub fn help(mut self, text: impl Into<String>) -> Self {
688        self.field.help_text = Some(text.into());
689        self
690    }
691
692    /// Finish building this field and return to form builder
693    #[must_use]
694    pub fn done(mut self) -> FormBuilder<'a> {
695        self.form.fields.push(self.field);
696        self.form
697    }
698}
699
700// =============================================================================
701// Select Builder
702// =============================================================================
703
704/// Builder for select fields
705pub struct SelectBuilder<'a> {
706    form: FormBuilder<'a>,
707    field: FormField,
708    selected_value: Option<String>,
709}
710
711impl<'a> SelectBuilder<'a> {
712    const fn new(form: FormBuilder<'a>, field: FormField) -> Self {
713        Self {
714            form,
715            field,
716            selected_value: None,
717        }
718    }
719
720    /// Set the field label
721    #[must_use]
722    pub fn label(mut self, label: impl Into<String>) -> Self {
723        self.field.label = Some(label.into());
724        self
725    }
726
727    /// Add an option
728    #[must_use]
729    pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
730        if let FieldKind::Select { ref mut options, .. } = self.field.kind {
731            options.push(SelectOption::new(value, label));
732        }
733        self
734    }
735
736    /// Add a disabled placeholder option
737    #[must_use]
738    pub fn placeholder_option(mut self, label: impl Into<String>) -> Self {
739        if let FieldKind::Select { ref mut options, .. } = self.field.kind {
740            options.insert(0, SelectOption::disabled("", label));
741        }
742        self
743    }
744
745    /// Set the selected value
746    #[must_use]
747    pub fn selected(mut self, value: impl Into<String>) -> Self {
748        self.selected_value = Some(value.into());
749        self
750    }
751
752    /// Mark field as required
753    #[must_use]
754    pub const fn required(mut self) -> Self {
755        self.field.flags.required = true;
756        self
757    }
758
759    /// Mark field as disabled
760    #[must_use]
761    pub const fn disabled(mut self) -> Self {
762        self.field.flags.disabled = true;
763        self
764    }
765
766    /// Allow multiple selections
767    #[must_use]
768    pub const fn multiple(mut self) -> Self {
769        if let FieldKind::Select {
770            ref mut multiple, ..
771        } = self.field.kind
772        {
773            *multiple = true;
774        }
775        self
776    }
777
778    /// Set CSS class
779    #[must_use]
780    pub fn class(mut self, class: impl Into<String>) -> Self {
781        self.field.class = Some(class.into());
782        self
783    }
784
785    /// Set element ID
786    #[must_use]
787    pub fn id(mut self, id: impl Into<String>) -> Self {
788        self.field.id = Some(id.into());
789        self
790    }
791
792    /// Finish building this field and return to form builder
793    #[must_use]
794    pub fn done(mut self) -> FormBuilder<'a> {
795        // Store selected value in the field's value
796        self.field.value = self.selected_value;
797        self.form.fields.push(self.field);
798        self.form
799    }
800}
801
802// =============================================================================
803// Checkbox Builder
804// =============================================================================
805
806/// Builder for checkbox fields
807pub struct CheckboxBuilder<'a> {
808    form: FormBuilder<'a>,
809    field: FormField,
810}
811
812impl<'a> CheckboxBuilder<'a> {
813    const fn new(form: FormBuilder<'a>, field: FormField) -> Self {
814        Self { form, field }
815    }
816
817    /// Set the field label
818    #[must_use]
819    pub fn label(mut self, label: impl Into<String>) -> Self {
820        self.field.label = Some(label.into());
821        self
822    }
823
824    /// Set the checkbox value (sent when checked)
825    #[must_use]
826    pub fn value(mut self, value: impl Into<String>) -> Self {
827        self.field.value = Some(value.into());
828        self
829    }
830
831    /// Set checkbox as checked
832    #[must_use]
833    pub const fn checked(mut self) -> Self {
834        if let FieldKind::Checkbox {
835            ref mut checked, ..
836        } = self.field.kind
837        {
838            *checked = true;
839        }
840        self
841    }
842
843    /// Mark field as required
844    #[must_use]
845    pub const fn required(mut self) -> Self {
846        self.field.flags.required = true;
847        self
848    }
849
850    /// Mark field as disabled
851    #[must_use]
852    pub const fn disabled(mut self) -> Self {
853        self.field.flags.disabled = true;
854        self
855    }
856
857    /// Set CSS class
858    #[must_use]
859    pub fn class(mut self, class: impl Into<String>) -> Self {
860        self.field.class = Some(class.into());
861        self
862    }
863
864    /// Set element ID
865    #[must_use]
866    pub fn id(mut self, id: impl Into<String>) -> Self {
867        self.field.id = Some(id.into());
868        self
869    }
870
871    /// Finish building this field and return to form builder
872    #[must_use]
873    pub fn done(mut self) -> FormBuilder<'a> {
874        self.form.fields.push(self.field);
875        self.form
876    }
877}
878
879// =============================================================================
880// File Field Builder
881// =============================================================================
882
883/// Builder for file upload fields
884///
885/// Provides file-specific configuration options like accept types,
886/// multiple file selection, size limits, and progress tracking.
887pub struct FileFieldBuilder<'a> {
888    form: FormBuilder<'a>,
889    field: FormField,
890}
891
892impl<'a> FileFieldBuilder<'a> {
893    const fn new(form: FormBuilder<'a>, field: FormField) -> Self {
894        Self { form, field }
895    }
896
897    /// Set the field label
898    #[must_use]
899    pub fn label(mut self, label: impl Into<String>) -> Self {
900        self.field.label = Some(label.into());
901        self
902    }
903
904    /// Set accepted file types (MIME types or file extensions)
905    ///
906    /// # Examples
907    ///
908    /// ```rust
909    /// use acton_htmx::forms::FormBuilder;
910    ///
911    /// // MIME types
912    /// let form = FormBuilder::new("/upload", "POST")
913    ///     .file("avatar")
914    ///         .accept("image/png,image/jpeg,image/gif")
915    ///         .done()
916    ///     .build();
917    ///
918    /// // File extensions
919    /// let form2 = FormBuilder::new("/upload", "POST")
920    ///     .file("document")
921    ///         .accept(".pdf,.doc,.docx")
922    ///         .done()
923    ///     .build();
924    /// ```
925    #[must_use]
926    pub fn accept(mut self, types: impl Into<String>) -> Self {
927        self.field.file_attrs.accept = Some(types.into());
928        self
929    }
930
931    /// Allow multiple file selection
932    #[must_use]
933    pub const fn multiple(mut self) -> Self {
934        self.field.file_attrs.multiple = true;
935        self
936    }
937
938    /// Set maximum file size in megabytes (client-side hint)
939    ///
940    /// Note: This adds a data attribute for client-side validation hints,
941    /// but server-side validation is still required.
942    #[must_use]
943    pub const fn max_size_mb(mut self, size_mb: u32) -> Self {
944        self.field.file_attrs.max_size_mb = Some(size_mb);
945        self
946    }
947
948    /// Enable image preview for uploaded files
949    #[must_use]
950    pub const fn show_preview(mut self) -> Self {
951        self.field.file_attrs.show_preview = true;
952        self
953    }
954
955    /// Enable drag-and-drop zone styling
956    #[must_use]
957    pub const fn drag_drop(mut self) -> Self {
958        self.field.file_attrs.drag_drop = true;
959        self
960    }
961
962    /// Set SSE endpoint for upload progress tracking
963    ///
964    /// # Examples
965    ///
966    /// ```rust
967    /// use acton_htmx::forms::FormBuilder;
968    ///
969    /// let form = FormBuilder::new("/upload", "POST")
970    ///     .file("large_file")
971    ///         .label("Large File Upload")
972    ///         .progress_endpoint("/upload/progress")
973    ///         .done()
974    ///     .build();
975    /// ```
976    #[must_use]
977    pub fn progress_endpoint(mut self, endpoint: impl Into<String>) -> Self {
978        self.field.file_attrs.progress_endpoint = Some(endpoint.into());
979        self
980    }
981
982    /// Mark field as required
983    #[must_use]
984    pub const fn required(mut self) -> Self {
985        self.field.flags.required = true;
986        self
987    }
988
989    /// Mark field as disabled
990    #[must_use]
991    pub const fn disabled(mut self) -> Self {
992        self.field.flags.disabled = true;
993        self
994    }
995
996    /// Set CSS class
997    #[must_use]
998    pub fn class(mut self, class: impl Into<String>) -> Self {
999        self.field.class = Some(class.into());
1000        self
1001    }
1002
1003    /// Set element ID
1004    #[must_use]
1005    pub fn id(mut self, id: impl Into<String>) -> Self {
1006        self.field.id = Some(id.into());
1007        self
1008    }
1009
1010    /// Set help text
1011    #[must_use]
1012    pub fn help(mut self, text: impl Into<String>) -> Self {
1013        self.field.help_text = Some(text.into());
1014        self
1015    }
1016
1017    /// Add a custom attribute
1018    #[must_use]
1019    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1020        self.field.custom_attrs.push((name.into(), value.into()));
1021        self
1022    }
1023
1024    /// Finish building this field and return to form builder
1025    #[must_use]
1026    pub fn done(mut self) -> FormBuilder<'a> {
1027        self.form.fields.push(self.field);
1028        self.form
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035
1036    #[test]
1037    fn test_form_builder_basic() {
1038        let form = FormBuilder::new("/test", "POST");
1039        assert_eq!(form.action, "/test");
1040        assert_eq!(form.method, "POST");
1041    }
1042
1043    #[test]
1044    fn test_form_builder_with_id() {
1045        let form = FormBuilder::new("/test", "POST").id("my-form");
1046        assert_eq!(form.id.as_deref(), Some("my-form"));
1047    }
1048
1049    #[test]
1050    fn test_form_builder_csrf() {
1051        let form = FormBuilder::new("/test", "POST").csrf_token("token123");
1052        assert_eq!(form.csrf_token.as_deref(), Some("token123"));
1053    }
1054
1055    #[test]
1056    fn test_field_builder() {
1057        let form = FormBuilder::new("/test", "POST")
1058            .field("email", InputType::Email)
1059            .label("Email")
1060            .required()
1061            .placeholder("test@example.com")
1062            .done();
1063
1064        assert_eq!(form.fields.len(), 1);
1065        let field = &form.fields[0];
1066        assert_eq!(field.name, "email");
1067        assert_eq!(field.label.as_deref(), Some("Email"));
1068        assert!(field.flags.required);
1069        assert_eq!(field.placeholder.as_deref(), Some("test@example.com"));
1070    }
1071
1072    #[test]
1073    fn test_textarea_builder() {
1074        let form = FormBuilder::new("/test", "POST")
1075            .textarea("content")
1076            .label("Content")
1077            .rows(10)
1078            .cols(50)
1079            .done();
1080
1081        assert_eq!(form.fields.len(), 1);
1082        let field = &form.fields[0];
1083        assert!(matches!(
1084            field.kind,
1085            FieldKind::Textarea {
1086                rows: Some(10),
1087                cols: Some(50)
1088            }
1089        ));
1090    }
1091
1092    #[test]
1093    fn test_select_builder() {
1094        let form = FormBuilder::new("/test", "POST")
1095            .select("country")
1096            .label("Country")
1097            .option("us", "United States")
1098            .option("ca", "Canada")
1099            .selected("us")
1100            .done();
1101
1102        assert_eq!(form.fields.len(), 1);
1103        let field = &form.fields[0];
1104        assert!(field.is_select());
1105        assert_eq!(field.value.as_deref(), Some("us"));
1106    }
1107
1108    #[test]
1109    fn test_checkbox_builder() {
1110        let form = FormBuilder::new("/test", "POST")
1111            .checkbox("terms")
1112            .label("I agree")
1113            .checked()
1114            .done();
1115
1116        assert_eq!(form.fields.len(), 1);
1117        let field = &form.fields[0];
1118        assert!(matches!(field.kind, FieldKind::Checkbox { checked: true }));
1119    }
1120
1121    #[test]
1122    fn test_hidden_field() {
1123        let form = FormBuilder::new("/test", "POST").hidden("user_id", "123");
1124
1125        assert_eq!(form.fields.len(), 1);
1126        let field = &form.fields[0];
1127        assert!(matches!(field.kind, FieldKind::Input(InputType::Hidden)));
1128        assert_eq!(field.value.as_deref(), Some("123"));
1129    }
1130
1131    #[test]
1132    fn test_htmx_form_attrs() {
1133        let form = FormBuilder::new("/test", "POST")
1134            .htmx_post("/api/test")
1135            .htmx_target("#result")
1136            .htmx_swap("innerHTML")
1137            .htmx_indicator("#spinner");
1138
1139        assert_eq!(form.htmx.post.as_deref(), Some("/api/test"));
1140        assert_eq!(form.htmx.target.as_deref(), Some("#result"));
1141        assert_eq!(form.htmx.swap.as_deref(), Some("innerHTML"));
1142        assert_eq!(form.htmx.indicator.as_deref(), Some("#spinner"));
1143    }
1144}