Skip to main content

reinhardt_pages/form/
rendering.rs

1//! Form Widgets and HTML Rendering
2//!
3//! This module provides HTML rendering utilities for form fields.
4//! Widgets handle rendering of form fields as HTML, supporting
5//! various CSS frameworks like Bootstrap and Tailwind.
6//!
7//! ## Server-side only
8//!
9//! This module is only available on server-side (non-WASM) targets.
10//! For client-side (WASM) rendering, use DOM APIs directly through
11//! the `FormComponent` in this crate.
12
13use reinhardt_core::security::xss::validate_html_attr_name;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17/// Widget type enumeration
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub enum WidgetType {
20	/// Text input field
21	TextInput,
22	/// Password input field
23	PasswordInput,
24	/// Email input field
25	EmailInput,
26	/// Number input field
27	NumberInput,
28	/// Date input field
29	DateInput,
30	/// Time input field
31	TimeInput,
32	/// DateTime input field
33	DateTimeInput,
34	/// Textarea (multiline text)
35	Textarea,
36	/// Checkbox input
37	Checkbox,
38	/// Select dropdown
39	Select,
40	/// Multiple select dropdown
41	SelectMultiple,
42	/// Radio button
43	Radio,
44	/// Radio button group
45	RadioSelect,
46	/// Checkbox group for multiple selection
47	CheckboxSelectMultiple,
48	/// File input
49	FileInput,
50	/// Hidden input
51	HiddenInput,
52	/// Split date/time inputs
53	SplitDateTime,
54	/// Date select with year/month/day dropdowns
55	SelectDate,
56}
57
58/// Base widget trait
59pub trait Widget: Send + Sync {
60	/// Get the widget type
61	fn widget_type(&self) -> WidgetType;
62
63	/// Render the widget as HTML
64	fn render(&self, name: &str, value: Option<&str>, attrs: &HashMap<String, String>) -> String;
65
66	/// Render the widget with choices (for select widgets)
67	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		// Default implementation for non-select widgets
75		self.render(name, value, attrs)
76	}
77}
78
79/// Text input widget
80#[derive(Debug, Clone)]
81pub struct TextInput {
82	input_type: String,
83}
84
85impl TextInput {
86	/// Create a new text input widget
87	pub fn new() -> Self {
88		Self {
89			input_type: "text".to_string(),
90		}
91	}
92
93	/// Create a password input widget
94	pub fn password() -> Self {
95		Self {
96			input_type: "password".to_string(),
97		}
98	}
99
100	/// Create an email input widget
101	pub fn email() -> Self {
102		Self {
103			input_type: "email".to_string(),
104		}
105	}
106
107	/// Create a number input widget
108	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/// Date input widget
154#[derive(Debug, Clone)]
155pub struct DateInput;
156
157impl DateInput {
158	/// Create a new date input widget
159	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/// Checkbox input widget
199#[derive(Debug, Clone)]
200pub struct CheckboxInput;
201
202impl CheckboxInput {
203	/// Create a new checkbox input widget
204	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/// Select widget
244#[derive(Debug, Clone)]
245pub struct Select;
246
247impl Select {
248	/// Create a new select widget
249	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/// Select multiple widget
310#[derive(Debug, Clone)]
311pub struct SelectMultiple;
312
313impl SelectMultiple {
314	/// Create a new multiple select widget
315	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/// Radio select widget
378#[derive(Debug, Clone)]
379pub struct RadioSelect;
380
381impl RadioSelect {
382	/// Create a new radio select widget
383	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/// Checkbox select multiple widget
449#[derive(Debug, Clone)]
450pub struct CheckboxSelectMultiple;
451
452impl CheckboxSelectMultiple {
453	/// Create a new checkbox select multiple widget
454	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/// File input widget
522#[derive(Debug, Clone)]
523pub struct FileInput;
524
525impl FileInput {
526	/// Create a new file input widget
527	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/// Split date time widget (separate inputs for date and time)
563#[derive(Debug, Clone)]
564pub struct SplitDateTimeWidget;
565
566impl SplitDateTimeWidget {
567	/// Create a new split date/time widget
568	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		// Date input
591		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		// Time input
608		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/// Select date widget (separate selects for year, month, day)
629#[derive(Debug, Clone)]
630pub struct SelectDateWidget {
631	years: Vec<i32>,
632}
633
634impl SelectDateWidget {
635	/// Create a new select date widget with default year range
636	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			// 365.25 days/year * 86400 secs/day = 31_557_600 secs/year
644			1970 + (secs / 31_557_600) as i32
645		};
646		let years = (current_year - 100..=current_year + 10).collect();
647		Self { years }
648	}
649
650	/// Create with custom year range
651	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		// Year select
683		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		// Month select
706		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		// Day select
729		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
755/// HTML escape utility
756pub fn html_escape(s: &str) -> String {
757	s.replace('&', "&amp;")
758		.replace('<', "&lt;")
759		.replace('>', "&gt;")
760		.replace('"', "&quot;")
761		.replace('\'', "&#x27;")
762}
763
764/// CSS Framework rendering styles
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub enum CssFramework {
767	/// Bootstrap 5 CSS framework
768	Bootstrap5,
769	/// Tailwind CSS framework
770	TailwindCSS,
771	/// No CSS framework (plain HTML)
772	None,
773}
774
775/// Widget attribute builder for data-* and ARIA attributes
776#[derive(Debug, Clone, Default)]
777pub struct WidgetAttrs {
778	attrs: HashMap<String, String>,
779}
780
781impl WidgetAttrs {
782	/// Create a new empty attribute builder
783	pub fn new() -> Self {
784		Self {
785			attrs: HashMap::new(),
786		}
787	}
788
789	/// Add a custom attribute
790	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	/// Add a data-* attribute
796	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	/// Add an ARIA attribute
803	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	/// Add a CSS class
810	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	/// Add an ID attribute
822	pub fn id(mut self, value: impl Into<String>) -> Self {
823		self.attrs.insert("id".to_string(), value.into());
824		self
825	}
826
827	/// Add a placeholder attribute
828	pub fn placeholder(mut self, value: impl Into<String>) -> Self {
829		self.attrs.insert("placeholder".to_string(), value.into());
830		self
831	}
832
833	/// Add a required attribute
834	pub fn required(mut self) -> Self {
835		self.attrs
836			.insert("required".to_string(), "required".to_string());
837		self
838	}
839
840	/// Add a disabled attribute
841	pub fn disabled(mut self) -> Self {
842		self.attrs
843			.insert("disabled".to_string(), "disabled".to_string());
844		self
845	}
846
847	/// Add a readonly attribute
848	pub fn readonly(mut self) -> Self {
849		self.attrs
850			.insert("readonly".to_string(), "readonly".to_string());
851		self
852	}
853
854	/// Build the attributes map
855	pub fn build(self) -> HashMap<String, String> {
856		self.attrs
857	}
858}
859
860/// Bootstrap 5 widget renderer
861pub struct BootstrapRenderer;
862
863impl BootstrapRenderer {
864	/// Get Bootstrap 5 CSS classes for form controls
865	pub fn form_control_class() -> &'static str {
866		"form-control"
867	}
868
869	/// Get Bootstrap 5 CSS classes for form check (checkbox/radio)
870	pub fn form_check_class() -> &'static str {
871		"form-check"
872	}
873
874	/// Get Bootstrap 5 CSS classes for form check input
875	pub fn form_check_input_class() -> &'static str {
876		"form-check-input"
877	}
878
879	/// Get Bootstrap 5 CSS classes for form select
880	pub fn form_select_class() -> &'static str {
881		"form-select"
882	}
883
884	/// Render a text input with Bootstrap 5 classes
885	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	/// Render a select with Bootstrap 5 classes
895	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	/// Render a checkbox with Bootstrap 5 classes
906	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
926/// Tailwind CSS widget renderer
927pub struct TailwindRenderer;
928
929impl TailwindRenderer {
930	/// Get Tailwind CSS classes for form controls
931	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	/// Get Tailwind CSS classes for form check (checkbox/radio)
936	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	/// Get Tailwind CSS classes for form select
941	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	/// Render a text input with Tailwind CSS classes
946	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	/// Render a select with Tailwind CSS classes
956	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	/// Render a checkbox with Tailwind CSS classes
967	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>"), "&lt;script&gt;");
1035		assert_eq!(html_escape("A & B"), "A &amp; B");
1036		assert_eq!(html_escape(r#"He said "hi""#), "He said &quot;hi&quot;");
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	// ============================================================================
1109	// XSS Prevention Tests (Issue #594)
1110	// ============================================================================
1111
1112	#[test]
1113	fn test_text_input_escapes_name() {
1114		let widget = TextInput::new();
1115		// Malicious name that could break out of the name attribute
1116		let xss_name = "field\"><script>alert('xss')</script>";
1117		let html = widget.render(xss_name, Some("value"), &HashMap::new());
1118
1119		// Should NOT contain raw script tag
1120		assert!(!html.contains("<script>"));
1121		// Should contain escaped version
1122		assert!(html.contains("&lt;script&gt;"));
1123		assert!(html.contains("&quot;"));
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("&lt;script&gt;"));
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("&lt;script&gt;"));
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("&lt;script&gt;"));
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("&lt;script&gt;"));
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("&lt;script&gt;"));
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("&lt;script&gt;"));
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("&lt;script&gt;"));
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		// Normal names should work correctly
1204		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		// Arrange
1211		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		// Act - catch_unwind to handle debug_assert! in debug builds
1217		let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1218			widget.render("field", None, &attrs)
1219		}));
1220
1221		// Assert - in debug builds, debug_assert! fires; in release, invalid key is skipped
1222		if let Ok(html) = result {
1223			assert!(html.contains(r#"class="form-control""#));
1224			assert!(!html.contains("onclick"));
1225		}
1226		// If panicked (debug build), the debug_assert! did its job
1227	}
1228
1229	#[test]
1230	fn test_attr_key_injection_with_equals_is_skipped() {
1231		// Arrange
1232		let widget = TextInput::new();
1233		let mut attrs = HashMap::new();
1234		attrs.insert("onclick=alert(1)".to_string(), "x".to_string());
1235
1236		// Act - catch_unwind to handle debug_assert! in debug builds
1237		let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1238			widget.render("field", None, &attrs)
1239		}));
1240
1241		// Assert - in debug builds, debug_assert! fires; in release, invalid key is skipped
1242		if let Ok(html) = result {
1243			assert!(!html.contains("onclick"));
1244		}
1245		// If panicked (debug build), the debug_assert! did its job
1246	}
1247
1248	#[test]
1249	fn test_valid_attr_keys_preserved() {
1250		// Arrange
1251		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		// Act
1258		let html = widget.render("field", None, &attrs);
1259
1260		// Assert
1261		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}