1use crate::{Emit, class};
37use makeover_layout::{Field, FieldKind};
38use std::fmt::Write as _;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Markup<'a>(pub &'a str);
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Choice<'a> {
53 pub value: &'a str,
55 pub label: &'a str,
57}
58
59impl<'a> Choice<'a> {
60 #[must_use]
62 pub const fn plain(value: &'a str) -> Self {
63 Self {
64 value,
65 label: value,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum Value<'a> {
78 #[default]
80 Absent,
81 Text(&'a str),
83 Chosen {
85 options: &'a [Choice<'a>],
87 value: &'a str,
89 },
90 On(bool),
92}
93
94impl<'a> Value<'a> {
95 const fn as_text(&self) -> &'a str {
97 match self {
98 Self::Text(text) | Self::Chosen { value: text, .. } => text,
99 Self::Absent | Self::On(_) => "",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, Default)]
106pub struct Filling<'a> {
107 pub value: Value<'a>,
109 pub placeholder: Option<&'a str>,
111 pub trailing: Option<Markup<'a>>,
113 pub id_prefix: Option<&'a str>,
125}
126
127impl<'a> Filling<'a> {
128 #[must_use]
130 pub const fn of(value: Value<'a>) -> Self {
131 Self {
132 value,
133 placeholder: None,
134 trailing: None,
135 id_prefix: None,
136 }
137 }
138
139 fn id_for(&self, name: &str) -> String {
141 match self.id_prefix {
142 Some(prefix) => format!("{}-{}", escape(prefix), escape(name)),
143 None => escape(name),
144 }
145 }
146}
147
148#[must_use]
154pub fn escape(text: &str) -> String {
155 let mut out = String::with_capacity(text.len());
156 for ch in text.chars() {
157 match ch {
158 '&' => out.push_str("&"),
159 '<' => out.push_str("<"),
160 '>' => out.push_str(">"),
161 '"' => out.push_str("""),
162 '\'' => out.push_str("'"),
163 other => out.push(other),
164 }
165 }
166 out
167}
168
169const fn input_type(kind: FieldKind) -> &'static str {
173 match kind {
174 FieldKind::Secret => "password",
175 FieldKind::Number => "number",
176 FieldKind::Checkbox => "checkbox",
177 FieldKind::Hidden => "hidden",
178 FieldKind::Email => "email",
183 FieldKind::Url => "url",
184 FieldKind::Tel => "tel",
185 FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
187 _ => "text",
191 }
192}
193
194fn control_attributes(field: &Field<'_>, id: &str, name: &str) -> String {
207 let mut attrs = format!(" id=\"{id}\" name=\"{}\"", escape(name));
208 if field.required {
209 attrs.push_str(" required");
210 }
211 if field.invalid() {
212 attrs.push_str(" aria-invalid=\"true\"");
213 }
214
215 let mut described = Vec::new();
220 if field.hint.is_some() {
221 described.push(format!("{id}-hint"));
222 }
223 if field.error.is_some() {
224 described.push(format!("{id}-error"));
225 }
226 if !described.is_empty() {
227 let _ = write!(attrs, " aria-describedby=\"{}\"", described.join(" "));
228 }
229 attrs
230}
231
232fn options_html(options: &[Choice<'_>], value: &str) -> String {
240 let mut html = String::new();
241 if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
242 let escaped = escape(value);
243 let _ = write!(
244 html,
245 "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
246 );
247 }
248 for opt in options {
249 let selected = if opt.value == value { " selected" } else { "" };
250 let _ = write!(
251 html,
252 "<option value=\"{}\"{selected}>{}</option>",
253 escape(opt.value),
254 escape(opt.label)
255 );
256 }
257 html
258}
259
260fn control_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
262 let id = filling.id_for(field.name);
263 let attrs = control_attributes(field, &id, field.name);
264 let field_class = class("field", opts);
265 let placeholder = filling.placeholder.map_or_else(String::new, |text| {
266 format!(" placeholder=\"{}\"", escape(text))
267 });
268
269 match field.kind {
270 FieldKind::Textarea => format!(
271 "<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
272 escape(filling.value.as_text())
273 ),
274 FieldKind::Select => {
275 let options = match filling.value {
276 Value::Chosen { options, value } => options_html(options, value),
277 _ => String::new(),
280 };
281 format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
282 }
283 FieldKind::Checkbox => {
284 let checked = if matches!(filling.value, Value::On(true)) {
285 " checked"
286 } else {
287 ""
288 };
289 format!(
290 "<label class=\"{}\"><input type=\"checkbox\"{attrs}{checked}><span>{}</span></label>",
291 class("form-checkbox-label", opts),
292 escape(field.label)
293 )
294 }
295 FieldKind::Secret => format!(
302 "<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>"
303 ),
304 kind => format!(
305 "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
306 input_type(kind),
307 escape(filling.value.as_text())
308 ),
309 }
310}
311
312#[must_use]
340pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
341 let id = filling.id_for(field.name);
342
343 if !field.kind.visible() {
344 return format!(
347 "<input type=\"hidden\" name=\"{}\" value=\"{}\">",
348 escape(field.name),
349 escape(filling.value.as_text())
350 );
351 }
352
353 let mut html = format!("<div class=\"{}", class("form-group", opts));
354 if field.invalid() {
355 html.push_str(" has-error");
356 }
357 if field.extended {
358 html.push_str("\" data-extended=\"true");
361 }
362 html.push_str("\">");
363
364 if !field.kind.labels_itself() {
368 let _ = write!(
369 html,
370 "<label class=\"{}\" for=\"{id}\">{}</label>",
371 class("form-label", opts),
372 escape(field.label)
373 );
374 }
375
376 html.push_str(&control_html(field, filling, opts));
377
378 if let Some(hint) = field.hint {
379 let _ = write!(
380 html,
381 "<div class=\"{}\" id=\"{id}-hint\">{}</div>",
382 class("form-hint", opts),
383 escape(hint)
384 );
385 }
386 if let Some(Markup(markup)) = filling.trailing {
387 html.push_str(markup);
388 }
389 if let Some(error) = field.error {
390 let _ = write!(
391 html,
392 "<div class=\"{} visible\" id=\"{id}-error\" role=\"alert\">{}</div>",
393 class("form-error", opts),
394 escape(error)
395 );
396 }
397
398 html.push_str("</div>");
399 html
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn field(kind: FieldKind) -> Field<'static> {
407 Field::new(kind, "title", "Title")
408 }
409
410 #[test]
411 fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
412 let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
414 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
415 assert!(!html.contains("\" onfocus"), "{html}");
419 assert!(
420 html.contains("value=\"x" onfocus=alert(1) autofocus="\""),
421 "{html}"
422 );
423 }
424
425 #[test]
426 fn a_label_cannot_open_a_tag() {
427 let mut f = field(FieldKind::Text);
428 f.label = "<script>alert(1)</script>";
429 let html = field_html(&f, &Filling::default(), &Emit::default());
430 assert!(!html.contains("<script>"), "{html}");
431 assert!(html.contains("<script>"), "{html}");
432 }
433
434 #[test]
435 fn every_escaped_sink_is_covered_by_the_one_escaper() {
436 assert_eq!(escape("&<>\"'"), "&<>"'");
437 assert!(escape("\"").contains("""));
440 }
441
442 #[test]
443 fn markup_is_the_only_way_past_the_escaping() {
444 let filling = Filling {
445 trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
446 ..Filling::default()
447 };
448 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
449 assert!(html.contains("<div class=\"recurrence-config\"></div>"), "{html}");
450 }
451
452 #[test]
453 fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
454 let mut f = field(FieldKind::Text);
455 f.error = Some("Required");
456 let opts = Emit::default();
457 let html = field_html(&f, &Filling::default(), &opts);
458 assert!(html.contains("aria-invalid=\"true\""), "{html}");
459 assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
461 assert!(html.contains("has-error"), "{html}");
464 }
465
466 #[test]
467 fn a_valid_field_claims_nothing_about_being_invalid() {
468 let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
469 assert!(!html.contains("aria-invalid"), "{html}");
470 assert!(!html.contains("has-error"), "{html}");
471 }
472
473 #[test]
474 fn the_hint_survives_an_error_arriving() {
475 let mut f = field(FieldKind::Text);
476 f.hint = Some("Keep it short");
477 f.error = Some("Required");
478 let html = field_html(&f, &Filling::default(), &Emit::default());
479 assert!(
480 html.contains("aria-describedby=\"title-hint title-error\""),
481 "{html}"
482 );
483 }
484
485 #[test]
486 fn a_secret_never_carries_its_value_into_the_markup() {
487 let filling = Filling::of(Value::Text("hunter2"));
488 let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
489 assert!(!html.contains("hunter2"), "{html}");
490 assert!(html.contains("type=\"password\""), "{html}");
491 }
492
493 #[test]
494 fn a_hidden_field_is_the_input_and_nothing_else() {
495 let filling = Filling::of(Value::Text("42"));
496 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
497 assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
498 }
499
500 #[test]
501 fn a_checkbox_labels_itself_and_takes_no_separate_label() {
502 let html = field_html(
503 &field(FieldKind::Checkbox),
504 &Filling::of(Value::On(true)),
505 &Emit::default(),
506 );
507 assert!(!html.contains("form-label"), "{html}");
508 assert!(html.contains("checked"), "{html}");
509 assert!(html.contains("<span>Title</span>"), "{html}");
510 }
511
512 #[test]
513 fn a_select_keeps_a_value_no_option_carries() {
514 let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
515 let filling = Filling::of(Value::Chosen {
516 options: &options,
517 value: "10",
518 });
519 let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
520 assert!(html.contains("data-unmatched=\"true\""), "{html}");
521 assert!(html.contains("<option value=\"10\" selected"), "{html}");
524 }
525
526 #[test]
527 fn a_select_marks_the_option_that_matches() {
528 let options = [Choice::plain("1"), Choice::plain("3")];
529 let filling = Filling::of(Value::Chosen {
530 options: &options,
531 value: "3",
532 });
533 let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
534 assert!(html.contains("<option value=\"3\" selected>3</option>"), "{html}");
535 assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
536 assert!(!html.contains("data-unmatched"), "{html}");
537 }
538
539 #[test]
540 fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
541 let filling = Filling::of(Value::Text("two\nlines"));
542 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
543 assert!(html.contains(">two\nlines</textarea>"), "{html}");
544 }
545
546 #[test]
547 fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
548 let opts = Emit {
549 class_prefix: "mk-",
550 ..Emit::default()
551 };
552 let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
553 assert!(html.contains("class=\"mk-form-group\""), "{html}");
554 assert!(html.contains("class=\"mk-field\""), "{html}");
555 }
556
557 #[test]
558 fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
559 let mut f = field(FieldKind::Text);
560 f.extended = true;
561 let html = field_html(&f, &Filling::default(), &Emit::default());
562 assert!(html.contains("data-extended=\"true\""), "{html}");
563 }
564
565 #[test]
569 fn the_id_prefix_scopes_the_id_and_never_the_name() {
570 let mut f = field(FieldKind::Text);
571 f.hint = Some("Keep it short");
572 f.error = Some("Required");
573 let filling = Filling {
574 id_prefix: Some("form-modal-task-edit"),
575 ..Filling::default()
576 };
577 let html = field_html(&f, &filling, &Emit::default());
578
579 assert!(html.contains(r#"id="form-modal-task-edit-title""#), "{html}");
580 assert!(html.contains(r#"name="title""#), "{html}");
581 assert!(!html.contains(r#"name="form-modal-task-edit-title""#), "{html}");
582
583 assert!(
586 html.contains(r#"for="form-modal-task-edit-title""#),
587 "{html}"
588 );
589 assert!(
590 html.contains(
591 r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
592 ),
593 "{html}"
594 );
595 assert!(
596 html.contains(r#"id="form-modal-task-edit-title-hint""#),
597 "{html}"
598 );
599 }
600
601 #[test]
602 fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
603 let filling = Filling {
604 value: Value::Text("42"),
605 id_prefix: Some("scoped"),
606 ..Filling::default()
607 };
608 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
609 assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
610 }
611
612 #[test]
616 fn the_typed_text_kinds_keep_their_input_type() {
617 for (kind, expected) in [
618 (FieldKind::Email, "email"),
619 (FieldKind::Url, "url"),
620 (FieldKind::Tel, "tel"),
621 ] {
622 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
623 assert!(
624 html.contains(&format!(r#"type="{expected}""#)),
625 "{kind:?} emitted {html}"
626 );
627 }
628 }
629
630 #[test]
631 fn no_prefix_leaves_the_id_as_the_name() {
632 let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
633 assert!(html.contains(r#"id="title" name="title""#), "{html}");
634 }
635}