1use reinhardt_core::security::xss::validate_html_attr_name;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub enum WidgetType {
20 TextInput,
22 PasswordInput,
24 EmailInput,
26 NumberInput,
28 DateInput,
30 TimeInput,
32 DateTimeInput,
34 Textarea,
36 Checkbox,
38 Select,
40 SelectMultiple,
42 Radio,
44 RadioSelect,
46 CheckboxSelectMultiple,
48 FileInput,
50 HiddenInput,
52 SplitDateTime,
54 SelectDate,
56}
57
58pub trait Widget: Send + Sync {
60 fn widget_type(&self) -> WidgetType;
62
63 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String;
65
66 fn render_with_choices(
68 &self,
69 name: &str,
70 value: Option<&str>,
71 attrs: &HashMap<String, String>,
72 _choices: &[(String, String)],
73 ) -> String {
74 self.render(name, value, attrs)
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct TextInput {
82 input_type: String,
83}
84
85impl TextInput {
86 pub fn new() -> Self {
88 Self {
89 input_type: "text".to_string(),
90 }
91 }
92
93 pub fn password() -> Self {
95 Self {
96 input_type: "password".to_string(),
97 }
98 }
99
100 pub fn email() -> Self {
102 Self {
103 input_type: "email".to_string(),
104 }
105 }
106
107 pub fn number() -> Self {
109 Self {
110 input_type: "number".to_string(),
111 }
112 }
113}
114
115impl Default for TextInput {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl Widget for TextInput {
122 fn widget_type(&self) -> WidgetType {
123 WidgetType::TextInput
124 }
125
126 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
127 let mut html = format!(
128 r#"<input type="{}" name="{}""#,
129 self.input_type,
130 html_escape(name)
131 );
132
133 if let Some(v) = value {
134 html.push_str(&format!(r#" value="{}""#, html_escape(v)));
135 }
136
137 for (key, val) in attrs {
138 debug_assert!(
139 validate_html_attr_name(key),
140 "Invalid HTML attribute name: {key}"
141 );
142 if !validate_html_attr_name(key) {
143 continue;
144 }
145 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
146 }
147
148 html.push_str(" />");
149 html
150 }
151}
152
153#[derive(Debug, Clone)]
155pub struct DateInput;
156
157impl DateInput {
158 pub fn new() -> Self {
160 Self
161 }
162}
163
164impl Default for DateInput {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl Widget for DateInput {
171 fn widget_type(&self) -> WidgetType {
172 WidgetType::DateInput
173 }
174
175 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
176 let mut html = format!(r#"<input type="date" name="{}""#, html_escape(name));
177
178 if let Some(v) = value {
179 html.push_str(&format!(r#" value="{}""#, html_escape(v)));
180 }
181
182 for (key, val) in attrs {
183 debug_assert!(
184 validate_html_attr_name(key),
185 "Invalid HTML attribute name: {key}"
186 );
187 if !validate_html_attr_name(key) {
188 continue;
189 }
190 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
191 }
192
193 html.push_str(" />");
194 html
195 }
196}
197
198#[derive(Debug, Clone)]
200pub struct CheckboxInput;
201
202impl CheckboxInput {
203 pub fn new() -> Self {
205 Self
206 }
207}
208
209impl Default for CheckboxInput {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215impl Widget for CheckboxInput {
216 fn widget_type(&self) -> WidgetType {
217 WidgetType::Checkbox
218 }
219
220 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
221 let mut html = format!(r#"<input type="checkbox" name="{}""#, html_escape(name));
222
223 if value == Some("true") || value == Some("1") || value == Some("on") {
224 html.push_str(" checked");
225 }
226
227 for (key, val) in attrs {
228 debug_assert!(
229 validate_html_attr_name(key),
230 "Invalid HTML attribute name: {key}"
231 );
232 if !validate_html_attr_name(key) {
233 continue;
234 }
235 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
236 }
237
238 html.push_str(" />");
239 html
240 }
241}
242
243#[derive(Debug, Clone)]
245pub struct Select;
246
247impl Select {
248 pub fn new() -> Self {
250 Self
251 }
252}
253
254impl Default for Select {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260impl Widget for Select {
261 fn widget_type(&self) -> WidgetType {
262 WidgetType::Select
263 }
264
265 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
266 self.render_with_choices(name, value, attrs, &[])
267 }
268
269 fn render_with_choices(
270 &self,
271 name: &str,
272 value: Option<&str>,
273 attrs: &HashMap<String, String>,
274 choices: &[(String, String)],
275 ) -> String {
276 let mut html = format!(r#"<select name="{}""#, html_escape(name));
277
278 for (key, val) in attrs {
279 debug_assert!(
280 validate_html_attr_name(key),
281 "Invalid HTML attribute name: {key}"
282 );
283 if !validate_html_attr_name(key) {
284 continue;
285 }
286 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
287 }
288
289 html.push('>');
290
291 for (choice_value, choice_label) in choices {
292 html.push_str("<option");
293 html.push_str(&format!(r#" value="{}""#, html_escape(choice_value)));
294
295 if Some(choice_value.as_str()) == value {
296 html.push_str(" selected");
297 }
298
299 html.push('>');
300 html.push_str(&html_escape(choice_label));
301 html.push_str("</option>");
302 }
303
304 html.push_str("</select>");
305 html
306 }
307}
308
309#[derive(Debug, Clone)]
311pub struct SelectMultiple;
312
313impl SelectMultiple {
314 pub fn new() -> Self {
316 Self
317 }
318}
319
320impl Default for SelectMultiple {
321 fn default() -> Self {
322 Self::new()
323 }
324}
325
326impl Widget for SelectMultiple {
327 fn widget_type(&self) -> WidgetType {
328 WidgetType::SelectMultiple
329 }
330
331 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
332 self.render_with_choices(name, value, attrs, &[])
333 }
334
335 fn render_with_choices(
336 &self,
337 name: &str,
338 value: Option<&str>,
339 attrs: &HashMap<String, String>,
340 choices: &[(String, String)],
341 ) -> String {
342 let selected_values: Vec<&str> = value.map(|v| v.split(',').collect()).unwrap_or_default();
343
344 let mut html = format!(r#"<select name="{}" multiple"#, html_escape(name));
345
346 for (key, val) in attrs {
347 debug_assert!(
348 validate_html_attr_name(key),
349 "Invalid HTML attribute name: {key}"
350 );
351 if !validate_html_attr_name(key) {
352 continue;
353 }
354 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
355 }
356
357 html.push('>');
358
359 for (choice_value, choice_label) in choices {
360 html.push_str("<option");
361 html.push_str(&format!(r#" value="{}""#, html_escape(choice_value)));
362
363 if selected_values.contains(&choice_value.as_str()) {
364 html.push_str(" selected");
365 }
366
367 html.push('>');
368 html.push_str(&html_escape(choice_label));
369 html.push_str("</option>");
370 }
371
372 html.push_str("</select>");
373 html
374 }
375}
376
377#[derive(Debug, Clone)]
379pub struct RadioSelect;
380
381impl RadioSelect {
382 pub fn new() -> Self {
384 Self
385 }
386}
387
388impl Default for RadioSelect {
389 fn default() -> Self {
390 Self::new()
391 }
392}
393
394impl Widget for RadioSelect {
395 fn widget_type(&self) -> WidgetType {
396 WidgetType::RadioSelect
397 }
398
399 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
400 self.render_with_choices(name, value, attrs, &[])
401 }
402
403 fn render_with_choices(
404 &self,
405 name: &str,
406 value: Option<&str>,
407 attrs: &HashMap<String, String>,
408 choices: &[(String, String)],
409 ) -> String {
410 let mut html = String::new();
411 let escaped_name = html_escape(name);
412
413 for (i, (choice_value, choice_label)) in choices.iter().enumerate() {
414 let input_id = format!("{}_{}", escaped_name, i);
415
416 html.push_str(&format!(
417 r#"<label for="{}"><input type="radio" name="{}" id="{}" value="{}""#,
418 input_id,
419 escaped_name,
420 input_id,
421 html_escape(choice_value)
422 ));
423
424 if Some(choice_value.as_str()) == value {
425 html.push_str(" checked");
426 }
427
428 for (key, val) in attrs {
429 debug_assert!(
430 validate_html_attr_name(key),
431 "Invalid HTML attribute name: {key}"
432 );
433 if !validate_html_attr_name(key) {
434 continue;
435 }
436 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
437 }
438
439 html.push_str(" /> ");
440 html.push_str(&html_escape(choice_label));
441 html.push_str("</label>");
442 }
443
444 html
445 }
446}
447
448#[derive(Debug, Clone)]
450pub struct CheckboxSelectMultiple;
451
452impl CheckboxSelectMultiple {
453 pub fn new() -> Self {
455 Self
456 }
457}
458
459impl Default for CheckboxSelectMultiple {
460 fn default() -> Self {
461 Self::new()
462 }
463}
464
465impl Widget for CheckboxSelectMultiple {
466 fn widget_type(&self) -> WidgetType {
467 WidgetType::CheckboxSelectMultiple
468 }
469
470 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
471 self.render_with_choices(name, value, attrs, &[])
472 }
473
474 fn render_with_choices(
475 &self,
476 name: &str,
477 value: Option<&str>,
478 attrs: &HashMap<String, String>,
479 choices: &[(String, String)],
480 ) -> String {
481 let selected_values: Vec<&str> = value.map(|v| v.split(',').collect()).unwrap_or_default();
482
483 let mut html = String::new();
484 let escaped_name = html_escape(name);
485
486 for (i, (choice_value, choice_label)) in choices.iter().enumerate() {
487 let input_id = format!("{}_{}", escaped_name, i);
488
489 html.push_str(&format!(
490 r#"<label for="{}"><input type="checkbox" name="{}" id="{}" value="{}""#,
491 input_id,
492 escaped_name,
493 input_id,
494 html_escape(choice_value)
495 ));
496
497 if selected_values.contains(&choice_value.as_str()) {
498 html.push_str(" checked");
499 }
500
501 for (key, val) in attrs {
502 debug_assert!(
503 validate_html_attr_name(key),
504 "Invalid HTML attribute name: {key}"
505 );
506 if !validate_html_attr_name(key) {
507 continue;
508 }
509 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
510 }
511
512 html.push_str(" /> ");
513 html.push_str(&html_escape(choice_label));
514 html.push_str("</label>");
515 }
516
517 html
518 }
519}
520
521#[derive(Debug, Clone)]
523pub struct FileInput;
524
525impl FileInput {
526 pub fn new() -> Self {
528 Self
529 }
530}
531
532impl Default for FileInput {
533 fn default() -> Self {
534 Self::new()
535 }
536}
537
538impl Widget for FileInput {
539 fn widget_type(&self) -> WidgetType {
540 WidgetType::FileInput
541 }
542
543 fn render(&self, name: &str, _value: Option<&str>, attrs: &HashMap<String, String>) -> String {
544 let mut html = format!(r#"<input type="file" name="{}""#, html_escape(name));
545
546 for (key, val) in attrs {
547 debug_assert!(
548 validate_html_attr_name(key),
549 "Invalid HTML attribute name: {key}"
550 );
551 if !validate_html_attr_name(key) {
552 continue;
553 }
554 html.push_str(&format!(r#" {}="{}""#, key, html_escape(val)));
555 }
556
557 html.push_str(" />");
558 html
559 }
560}
561
562#[derive(Debug, Clone)]
564pub struct SplitDateTimeWidget;
565
566impl SplitDateTimeWidget {
567 pub fn new() -> Self {
569 Self
570 }
571}
572
573impl Default for SplitDateTimeWidget {
574 fn default() -> Self {
575 Self::new()
576 }
577}
578
579impl Widget for SplitDateTimeWidget {
580 fn widget_type(&self) -> WidgetType {
581 WidgetType::SplitDateTime
582 }
583
584 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
585 let (date_value, time_value) = value.and_then(|v| v.split_once('T')).unwrap_or(("", ""));
586
587 let mut html = String::new();
588 let escaped_name = html_escape(name);
589
590 html.push_str(&format!(
592 r#"<input type="date" name="{}_0" value="{}""#,
593 escaped_name,
594 html_escape(date_value)
595 ));
596 for (key, val) in attrs {
597 if let Some(date_attr) = key.strip_prefix("date_") {
598 if !validate_html_attr_name(date_attr) {
599 debug_assert!(false, "Invalid HTML attribute name: {date_attr}");
600 continue;
601 }
602 html.push_str(&format!(r#" {}="{}""#, date_attr, html_escape(val)));
603 }
604 }
605 html.push_str(" /> ");
606
607 html.push_str(&format!(
609 r#"<input type="time" name="{}_1" value="{}""#,
610 escaped_name,
611 html_escape(time_value)
612 ));
613 for (key, val) in attrs {
614 if let Some(time_attr) = key.strip_prefix("time_") {
615 if !validate_html_attr_name(time_attr) {
616 debug_assert!(false, "Invalid HTML attribute name: {time_attr}");
617 continue;
618 }
619 html.push_str(&format!(r#" {}="{}""#, time_attr, html_escape(val)));
620 }
621 }
622 html.push_str(" />");
623
624 html
625 }
626}
627
628#[derive(Debug, Clone)]
630pub struct SelectDateWidget {
631 years: Vec<i32>,
632}
633
634impl SelectDateWidget {
635 pub fn new() -> Self {
637 let current_year = {
638 use std::time::{SystemTime, UNIX_EPOCH};
639 let secs = SystemTime::now()
640 .duration_since(UNIX_EPOCH)
641 .unwrap_or_default()
642 .as_secs();
643 1970 + (secs / 31_557_600) as i32
645 };
646 let years = (current_year - 100..=current_year + 10).collect();
647 Self { years }
648 }
649
650 pub fn with_years(years: Vec<i32>) -> Self {
652 Self { years }
653 }
654}
655
656impl Default for SelectDateWidget {
657 fn default() -> Self {
658 Self::new()
659 }
660}
661
662impl Widget for SelectDateWidget {
663 fn widget_type(&self) -> WidgetType {
664 WidgetType::SelectDate
665 }
666
667 fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String {
668 let (year, month, day) = value
669 .and_then(|v| {
670 let parts: Vec<&str> = v.split('-').collect();
671 if parts.len() == 3 {
672 Some((parts[0], parts[1], parts[2]))
673 } else {
674 None
675 }
676 })
677 .unwrap_or(("", "", ""));
678
679 let mut html = String::new();
680 let escaped_name = html_escape(name);
681
682 html.push_str(&format!(r#"<select name="{}_year""#, escaped_name));
684 for (key, val) in attrs {
685 if let Some(year_attr) = key.strip_prefix("year_") {
686 if !validate_html_attr_name(year_attr) {
687 debug_assert!(false, "Invalid HTML attribute name: {year_attr}");
688 continue;
689 }
690 html.push_str(&format!(r#" {}="{}""#, year_attr, html_escape(val)));
691 }
692 }
693 html.push('>');
694 for y in &self.years {
695 html.push_str(&format!(r#"<option value="{}""#, y));
696 if year == y.to_string() {
697 html.push_str(" selected");
698 }
699 html.push('>');
700 html.push_str(&y.to_string());
701 html.push_str("</option>");
702 }
703 html.push_str("</select> ");
704
705 html.push_str(&format!(r#"<select name="{}_month""#, escaped_name));
707 for (key, val) in attrs {
708 if let Some(month_attr) = key.strip_prefix("month_") {
709 if !validate_html_attr_name(month_attr) {
710 debug_assert!(false, "Invalid HTML attribute name: {month_attr}");
711 continue;
712 }
713 html.push_str(&format!(r#" {}="{}""#, month_attr, html_escape(val)));
714 }
715 }
716 html.push('>');
717 for m in 1..=12 {
718 html.push_str(&format!(r#"<option value="{:02}""#, m));
719 if month == format!("{:02}", m) {
720 html.push_str(" selected");
721 }
722 html.push('>');
723 html.push_str(&format!("{:02}", m));
724 html.push_str("</option>");
725 }
726 html.push_str("</select> ");
727
728 html.push_str(&format!(r#"<select name="{}_day""#, escaped_name));
730 for (key, val) in attrs {
731 if let Some(day_attr) = key.strip_prefix("day_") {
732 if !validate_html_attr_name(day_attr) {
733 debug_assert!(false, "Invalid HTML attribute name: {day_attr}");
734 continue;
735 }
736 html.push_str(&format!(r#" {}="{}""#, day_attr, html_escape(val)));
737 }
738 }
739 html.push('>');
740 for d in 1..=31 {
741 html.push_str(&format!(r#"<option value="{:02}""#, d));
742 if day == format!("{:02}", d) {
743 html.push_str(" selected");
744 }
745 html.push('>');
746 html.push_str(&format!("{:02}", d));
747 html.push_str("</option>");
748 }
749 html.push_str("</select>");
750
751 html
752 }
753}
754
755pub fn html_escape(s: &str) -> String {
757 s.replace('&', "&")
758 .replace('<', "<")
759 .replace('>', ">")
760 .replace('"', """)
761 .replace('\'', "'")
762}
763
764#[derive(Debug, Clone, PartialEq, Eq)]
766pub enum CssFramework {
767 Bootstrap5,
769 TailwindCSS,
771 None,
773}
774
775#[derive(Debug, Clone, Default)]
777pub struct WidgetAttrs {
778 attrs: HashMap<String, String>,
779}
780
781impl WidgetAttrs {
782 pub fn new() -> Self {
784 Self {
785 attrs: HashMap::new(),
786 }
787 }
788
789 pub fn attr(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
791 self.attrs.insert(key.into(), value.into());
792 self
793 }
794
795 pub fn data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
797 self.attrs
798 .insert(format!("data-{}", key.into()), value.into());
799 self
800 }
801
802 pub fn aria(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
804 self.attrs
805 .insert(format!("aria-{}", key.into()), value.into());
806 self
807 }
808
809 pub fn class(mut self, value: impl Into<String>) -> Self {
811 let class_value = value.into();
812 if let Some(existing) = self.attrs.get_mut("class") {
813 existing.push(' ');
814 existing.push_str(&class_value);
815 } else {
816 self.attrs.insert("class".to_string(), class_value);
817 }
818 self
819 }
820
821 pub fn id(mut self, value: impl Into<String>) -> Self {
823 self.attrs.insert("id".to_string(), value.into());
824 self
825 }
826
827 pub fn placeholder(mut self, value: impl Into<String>) -> Self {
829 self.attrs.insert("placeholder".to_string(), value.into());
830 self
831 }
832
833 pub fn required(mut self) -> Self {
835 self.attrs
836 .insert("required".to_string(), "required".to_string());
837 self
838 }
839
840 pub fn disabled(mut self) -> Self {
842 self.attrs
843 .insert("disabled".to_string(), "disabled".to_string());
844 self
845 }
846
847 pub fn readonly(mut self) -> Self {
849 self.attrs
850 .insert("readonly".to_string(), "readonly".to_string());
851 self
852 }
853
854 pub fn build(self) -> HashMap<String, String> {
856 self.attrs
857 }
858}
859
860pub struct BootstrapRenderer;
862
863impl BootstrapRenderer {
864 pub fn form_control_class() -> &'static str {
866 "form-control"
867 }
868
869 pub fn form_check_class() -> &'static str {
871 "form-check"
872 }
873
874 pub fn form_check_input_class() -> &'static str {
876 "form-check-input"
877 }
878
879 pub fn form_select_class() -> &'static str {
881 "form-select"
882 }
883
884 pub fn render_text_input(
886 name: &str,
887 value: Option<&str>,
888 mut attrs: HashMap<String, String>,
889 ) -> String {
890 Self::add_class(&mut attrs, Self::form_control_class());
891 TextInput::new().render(name, value, &attrs)
892 }
893
894 pub fn render_select(
896 name: &str,
897 value: Option<&str>,
898 mut attrs: HashMap<String, String>,
899 choices: &[(String, String)],
900 ) -> String {
901 Self::add_class(&mut attrs, Self::form_select_class());
902 Select::new().render_with_choices(name, value, &attrs, choices)
903 }
904
905 pub fn render_checkbox(
907 name: &str,
908 value: Option<&str>,
909 mut attrs: HashMap<String, String>,
910 ) -> String {
911 Self::add_class(&mut attrs, Self::form_check_input_class());
912 let input_html = CheckboxInput::new().render(name, value, &attrs);
913 format!(r#"<div class="form-check">{}</div>"#, input_html)
914 }
915
916 fn add_class(attrs: &mut HashMap<String, String>, class: &str) {
917 if let Some(existing) = attrs.get_mut("class") {
918 existing.push(' ');
919 existing.push_str(class);
920 } else {
921 attrs.insert("class".to_string(), class.to_string());
922 }
923 }
924}
925
926pub struct TailwindRenderer;
928
929impl TailwindRenderer {
930 pub fn form_control_class() -> &'static str {
932 "block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
933 }
934
935 pub fn form_check_class() -> &'static str {
937 "h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
938 }
939
940 pub fn form_select_class() -> &'static str {
942 "block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
943 }
944
945 pub fn render_text_input(
947 name: &str,
948 value: Option<&str>,
949 mut attrs: HashMap<String, String>,
950 ) -> String {
951 Self::add_class(&mut attrs, Self::form_control_class());
952 TextInput::new().render(name, value, &attrs)
953 }
954
955 pub fn render_select(
957 name: &str,
958 value: Option<&str>,
959 mut attrs: HashMap<String, String>,
960 choices: &[(String, String)],
961 ) -> String {
962 Self::add_class(&mut attrs, Self::form_select_class());
963 Select::new().render_with_choices(name, value, &attrs, choices)
964 }
965
966 pub fn render_checkbox(
968 name: &str,
969 value: Option<&str>,
970 mut attrs: HashMap<String, String>,
971 ) -> String {
972 Self::add_class(&mut attrs, Self::form_check_class());
973 CheckboxInput::new().render(name, value, &attrs)
974 }
975
976 fn add_class(attrs: &mut HashMap<String, String>, class: &str) {
977 if let Some(existing) = attrs.get_mut("class") {
978 existing.push(' ');
979 existing.push_str(class);
980 } else {
981 attrs.insert("class".to_string(), class.to_string());
982 }
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[test]
991 fn test_text_input_render() {
992 let widget = TextInput::new();
993 let html = widget.render("username", Some("john"), &HashMap::new());
994 assert!(html.contains(r#"type="text""#));
995 assert!(html.contains(r#"name="username""#));
996 assert!(html.contains(r#"value="john""#));
997 }
998
999 #[test]
1000 fn test_select_render() {
1001 let widget = Select::new();
1002 let choices = vec![
1003 ("1".to_string(), "Option 1".to_string()),
1004 ("2".to_string(), "Option 2".to_string()),
1005 ];
1006 let html = widget.render_with_choices("choice", Some("2"), &HashMap::new(), &choices);
1007 assert!(html.contains(r#"<select name="choice""#));
1008 assert!(html.contains(r#"value="1""#));
1009 assert!(html.contains(r#"value="2" selected"#));
1010 }
1011
1012 #[test]
1013 fn test_checkbox_input_render() {
1014 let widget = CheckboxInput::new();
1015 let html = widget.render("agree", Some("true"), &HashMap::new());
1016 assert!(html.contains(r#"type="checkbox""#));
1017 assert!(html.contains("checked"));
1018 }
1019
1020 #[test]
1021 fn test_radio_select_render() {
1022 let widget = RadioSelect::new();
1023 let choices = vec![
1024 ("male".to_string(), "Male".to_string()),
1025 ("female".to_string(), "Female".to_string()),
1026 ];
1027 let html = widget.render_with_choices("gender", Some("female"), &HashMap::new(), &choices);
1028 assert!(html.contains(r#"type="radio""#));
1029 assert!(html.contains(r#"value="female" checked"#));
1030 }
1031
1032 #[test]
1033 fn test_forms_widgets_html_escape() {
1034 assert_eq!(html_escape("<script>"), "<script>");
1035 assert_eq!(html_escape("A & B"), "A & B");
1036 assert_eq!(html_escape(r#"He said "hi""#), "He said "hi"");
1037 }
1038
1039 #[test]
1040 fn test_widget_attrs_builder() {
1041 let attrs = WidgetAttrs::new()
1042 .class("form-control")
1043 .data("id", "123")
1044 .aria("label", "Username")
1045 .placeholder("Enter username")
1046 .required()
1047 .build();
1048
1049 assert_eq!(attrs.get("class"), Some(&"form-control".to_string()));
1050 assert_eq!(attrs.get("data-id"), Some(&"123".to_string()));
1051 assert_eq!(attrs.get("aria-label"), Some(&"Username".to_string()));
1052 assert_eq!(
1053 attrs.get("placeholder"),
1054 Some(&"Enter username".to_string())
1055 );
1056 assert_eq!(attrs.get("required"), Some(&"required".to_string()));
1057 }
1058
1059 #[test]
1060 fn test_widget_attrs_multiple_classes() {
1061 let attrs = WidgetAttrs::new()
1062 .class("form-control")
1063 .class("is-valid")
1064 .build();
1065
1066 assert_eq!(
1067 attrs.get("class"),
1068 Some(&"form-control is-valid".to_string())
1069 );
1070 }
1071
1072 #[test]
1073 fn test_bootstrap_renderer() {
1074 let html = BootstrapRenderer::render_text_input("username", Some("john"), HashMap::new());
1075 assert!(html.contains("form-control"));
1076 assert!(html.contains(r#"name="username""#));
1077 assert!(html.contains(r#"value="john""#));
1078 }
1079
1080 #[test]
1081 fn test_tailwind_renderer() {
1082 let html = TailwindRenderer::render_text_input("email", None, HashMap::new());
1083 assert!(html.contains("rounded-md"));
1084 assert!(html.contains("border-gray-300"));
1085 assert!(html.contains(r#"name="email""#));
1086 }
1087
1088 #[test]
1089 fn test_split_datetime_widget() {
1090 let widget = SplitDateTimeWidget::new();
1091 let html = widget.render("created_at", Some("2025-10-10T14:30:00"), &HashMap::new());
1092 assert!(html.contains(r#"type="date""#));
1093 assert!(html.contains(r#"type="time""#));
1094 assert!(html.contains(r#"value="2025-10-10""#));
1095 assert!(html.contains(r#"value="14:30:00""#));
1096 }
1097
1098 #[test]
1099 fn test_select_date_widget() {
1100 let widget = SelectDateWidget::new();
1101 let html = widget.render("birthday", Some("1990-05-15"), &HashMap::new());
1102 assert!(html.contains(r#"<select name="birthday_year""#));
1103 assert!(html.contains(r#"<select name="birthday_month""#));
1104 assert!(html.contains(r#"<select name="birthday_day""#));
1105 assert!(html.contains(r#"<option value="1990" selected"#));
1106 }
1107
1108 #[test]
1113 fn test_text_input_escapes_name() {
1114 let widget = TextInput::new();
1115 let xss_name = "field\"><script>alert('xss')</script>";
1117 let html = widget.render(xss_name, Some("value"), &HashMap::new());
1118
1119 assert!(!html.contains("<script>"));
1121 assert!(html.contains("<script>"));
1123 assert!(html.contains("""));
1124 }
1125
1126 #[test]
1127 fn test_date_input_escapes_name() {
1128 let widget = DateInput::new();
1129 let xss_name = "date\"><script>alert('xss')</script>";
1130 let html = widget.render(xss_name, None, &HashMap::new());
1131
1132 assert!(!html.contains("<script>"));
1133 assert!(html.contains("<script>"));
1134 }
1135
1136 #[test]
1137 fn test_checkbox_input_escapes_name() {
1138 let widget = CheckboxInput::new();
1139 let xss_name = "agree\"><script>alert('xss')</script>";
1140 let html = widget.render(xss_name, Some("true"), &HashMap::new());
1141
1142 assert!(!html.contains("<script>"));
1143 assert!(html.contains("<script>"));
1144 }
1145
1146 #[test]
1147 fn test_select_escapes_name() {
1148 let widget = Select::new();
1149 let choices = vec![("1".to_string(), "Option 1".to_string())];
1150 let xss_name = "choice\"><script>alert('xss')</script>";
1151 let html = widget.render_with_choices(xss_name, None, &HashMap::new(), &choices);
1152
1153 assert!(!html.contains("<script>"));
1154 assert!(html.contains("<script>"));
1155 }
1156
1157 #[test]
1158 fn test_radio_select_escapes_name() {
1159 let widget = RadioSelect::new();
1160 let choices = vec![("male".to_string(), "Male".to_string())];
1161 let xss_name = "gender\"><script>alert('xss')</script>";
1162 let html = widget.render_with_choices(xss_name, None, &HashMap::new(), &choices);
1163
1164 assert!(!html.contains("<script>"));
1165 assert!(html.contains("<script>"));
1166 }
1167
1168 #[test]
1169 fn test_file_input_escapes_name() {
1170 let widget = FileInput::new();
1171 let xss_name = "upload\"><script>alert('xss')</script>";
1172 let html = widget.render(xss_name, None, &HashMap::new());
1173
1174 assert!(!html.contains("<script>"));
1175 assert!(html.contains("<script>"));
1176 }
1177
1178 #[test]
1179 fn test_split_datetime_escapes_name() {
1180 let widget = SplitDateTimeWidget::new();
1181 let xss_name = "date\"><script>alert('xss')</script>";
1182 let html = widget.render(xss_name, Some("2025-10-10T14:30:00"), &HashMap::new());
1183
1184 assert!(!html.contains("<script>"));
1185 assert!(html.contains("<script>"));
1186 }
1187
1188 #[test]
1189 fn test_select_date_escapes_name() {
1190 let widget = SelectDateWidget::new();
1191 let xss_name = "birthday\"><script>alert('xss')</script>";
1192 let html = widget.render(xss_name, Some("1990-05-15"), &HashMap::new());
1193
1194 assert!(!html.contains("<script>"));
1195 assert!(html.contains("<script>"));
1196 }
1197
1198 #[test]
1199 fn test_normal_names_preserved() {
1200 let widget = TextInput::new();
1201 let html = widget.render("username", Some("john"), &HashMap::new());
1202
1203 assert!(html.contains(r#"name="username""#));
1205 assert!(html.contains(r#"value="john""#));
1206 }
1207
1208 #[test]
1209 fn test_attr_key_injection_with_space_is_skipped() {
1210 let widget = TextInput::new();
1212 let mut attrs = HashMap::new();
1213 attrs.insert("class".to_string(), "form-control".to_string());
1214 attrs.insert("onclick=alert(1) x".to_string(), "y".to_string());
1215
1216 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1218 widget.render("field", None, &attrs)
1219 }));
1220
1221 if let Ok(html) = result {
1223 assert!(html.contains(r#"class="form-control""#));
1224 assert!(!html.contains("onclick"));
1225 }
1226 }
1228
1229 #[test]
1230 fn test_attr_key_injection_with_equals_is_skipped() {
1231 let widget = TextInput::new();
1233 let mut attrs = HashMap::new();
1234 attrs.insert("onclick=alert(1)".to_string(), "x".to_string());
1235
1236 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1238 widget.render("field", None, &attrs)
1239 }));
1240
1241 if let Ok(html) = result {
1243 assert!(!html.contains("onclick"));
1244 }
1245 }
1247
1248 #[test]
1249 fn test_valid_attr_keys_preserved() {
1250 let widget = TextInput::new();
1252 let mut attrs = HashMap::new();
1253 attrs.insert("class".to_string(), "form-control".to_string());
1254 attrs.insert("data-value".to_string(), "123".to_string());
1255 attrs.insert("aria-label".to_string(), "Name".to_string());
1256
1257 let html = widget.render("field", None, &attrs);
1259
1260 assert!(html.contains(r#"class="form-control""#));
1262 assert!(html.contains(r#"data-value="123""#));
1263 assert!(html.contains(r#"aria-label="Name""#));
1264 }
1265}