use crate::{Emit, class};
use makeover_layout::{Field, FieldKind};
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Markup<'a>(pub &'a str);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Choice<'a> {
pub value: &'a str,
pub label: &'a str,
}
impl<'a> Choice<'a> {
#[must_use]
pub const fn plain(value: &'a str) -> Self {
Self {
value,
label: value,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Value<'a> {
#[default]
Absent,
Text(&'a str),
Chosen {
options: &'a [Choice<'a>],
value: &'a str,
},
On(bool),
}
impl<'a> Value<'a> {
const fn as_text(&self) -> &'a str {
match self {
Self::Text(text) | Self::Chosen { value: text, .. } => text,
Self::Absent | Self::On(_) => "",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Filling<'a> {
pub value: Value<'a>,
pub placeholder: Option<&'a str>,
pub trailing: Option<Markup<'a>>,
pub id_prefix: Option<&'a str>,
}
impl<'a> Filling<'a> {
#[must_use]
pub const fn of(value: Value<'a>) -> Self {
Self {
value,
placeholder: None,
trailing: None,
id_prefix: None,
}
}
fn id_for(&self, name: &str) -> String {
match self.id_prefix {
Some(prefix) => format!("{}-{}", escape(prefix), escape(name)),
None => escape(name),
}
}
}
#[must_use]
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
const fn input_type(kind: FieldKind) -> &'static str {
match kind {
FieldKind::Secret => "password",
FieldKind::Number => "number",
FieldKind::Checkbox => "checkbox",
FieldKind::Hidden => "hidden",
FieldKind::Email => "email",
FieldKind::Url => "url",
FieldKind::Tel => "tel",
FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
_ => "text",
}
}
fn control_attributes(field: &Field<'_>, id: &str, name: &str) -> String {
let mut attrs = format!(" id=\"{id}\" name=\"{}\"", escape(name));
if field.required {
attrs.push_str(" required");
}
if field.invalid() {
attrs.push_str(" aria-invalid=\"true\"");
}
let mut described = Vec::new();
if field.hint.is_some() {
described.push(format!("{id}-hint"));
}
if field.error.is_some() {
described.push(format!("{id}-error"));
}
if !described.is_empty() {
let _ = write!(attrs, " aria-describedby=\"{}\"", described.join(" "));
}
attrs
}
fn options_html(options: &[Choice<'_>], value: &str) -> String {
let mut html = String::new();
if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
let escaped = escape(value);
let _ = write!(
html,
"<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
);
}
for opt in options {
let selected = if opt.value == value { " selected" } else { "" };
let _ = write!(
html,
"<option value=\"{}\"{selected}>{}</option>",
escape(opt.value),
escape(opt.label)
);
}
html
}
fn control_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let id = filling.id_for(field.name);
let attrs = control_attributes(field, &id, field.name);
let field_class = class("field", opts);
let placeholder = filling.placeholder.map_or_else(String::new, |text| {
format!(" placeholder=\"{}\"", escape(text))
});
match field.kind {
FieldKind::Textarea => format!(
"<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
escape(filling.value.as_text())
),
FieldKind::Select => {
let options = match filling.value {
Value::Chosen { options, value } => options_html(options, value),
_ => String::new(),
};
format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
}
FieldKind::Checkbox => {
let checked = if matches!(filling.value, Value::On(true)) {
" checked"
} else {
""
};
format!(
"<label class=\"{}\"><input type=\"checkbox\"{attrs}{checked}><span>{}</span></label>",
class("form-checkbox-label", opts),
escape(field.label)
)
}
FieldKind::Secret => format!(
"<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>"
),
kind => format!(
"<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
input_type(kind),
escape(filling.value.as_text())
),
}
}
#[must_use]
pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let id = filling.id_for(field.name);
if !field.kind.visible() {
return format!(
"<input type=\"hidden\" name=\"{}\" value=\"{}\">",
escape(field.name),
escape(filling.value.as_text())
);
}
let mut html = format!("<div class=\"{}", class("form-group", opts));
if field.invalid() {
html.push_str(" has-error");
}
if field.extended {
html.push_str("\" data-extended=\"true");
}
html.push_str("\">");
if !field.kind.labels_itself() {
let _ = write!(
html,
"<label class=\"{}\" for=\"{id}\">{}</label>",
class("form-label", opts),
escape(field.label)
);
}
html.push_str(&control_html(field, filling, opts));
if let Some(hint) = field.hint {
let _ = write!(
html,
"<div class=\"{}\" id=\"{id}-hint\">{}</div>",
class("form-hint", opts),
escape(hint)
);
}
if let Some(Markup(markup)) = filling.trailing {
html.push_str(markup);
}
if let Some(error) = field.error {
let _ = write!(
html,
"<div class=\"{} visible\" id=\"{id}-error\" role=\"alert\">{}</div>",
class("form-error", opts),
escape(error)
);
}
html.push_str("</div>");
html
}
#[cfg(test)]
mod tests {
use super::*;
fn field(kind: FieldKind) -> Field<'static> {
Field::new(kind, "title", "Title")
}
#[test]
fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
assert!(!html.contains("\" onfocus"), "{html}");
assert!(
html.contains("value=\"x" onfocus=alert(1) autofocus="\""),
"{html}"
);
}
#[test]
fn a_label_cannot_open_a_tag() {
let mut f = field(FieldKind::Text);
f.label = "<script>alert(1)</script>";
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
#[test]
fn every_escaped_sink_is_covered_by_the_one_escaper() {
assert_eq!(escape("&<>\"'"), "&<>"'");
assert!(escape("\"").contains("""));
}
#[test]
fn markup_is_the_only_way_past_the_escaping() {
let filling = Filling {
trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
..Filling::default()
};
let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
assert!(html.contains("<div class=\"recurrence-config\"></div>"), "{html}");
}
#[test]
fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
let mut f = field(FieldKind::Text);
f.error = Some("Required");
let opts = Emit::default();
let html = field_html(&f, &Filling::default(), &opts);
assert!(html.contains("aria-invalid=\"true\""), "{html}");
assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
assert!(html.contains("has-error"), "{html}");
}
#[test]
fn a_valid_field_claims_nothing_about_being_invalid() {
let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
assert!(!html.contains("aria-invalid"), "{html}");
assert!(!html.contains("has-error"), "{html}");
}
#[test]
fn the_hint_survives_an_error_arriving() {
let mut f = field(FieldKind::Text);
f.hint = Some("Keep it short");
f.error = Some("Required");
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(
html.contains("aria-describedby=\"title-hint title-error\""),
"{html}"
);
}
#[test]
fn a_secret_never_carries_its_value_into_the_markup() {
let filling = Filling::of(Value::Text("hunter2"));
let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
assert!(!html.contains("hunter2"), "{html}");
assert!(html.contains("type=\"password\""), "{html}");
}
#[test]
fn a_hidden_field_is_the_input_and_nothing_else() {
let filling = Filling::of(Value::Text("42"));
let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
}
#[test]
fn a_checkbox_labels_itself_and_takes_no_separate_label() {
let html = field_html(
&field(FieldKind::Checkbox),
&Filling::of(Value::On(true)),
&Emit::default(),
);
assert!(!html.contains("form-label"), "{html}");
assert!(html.contains("checked"), "{html}");
assert!(html.contains("<span>Title</span>"), "{html}");
}
#[test]
fn a_select_keeps_a_value_no_option_carries() {
let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
let filling = Filling::of(Value::Chosen {
options: &options,
value: "10",
});
let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
assert!(html.contains("data-unmatched=\"true\""), "{html}");
assert!(html.contains("<option value=\"10\" selected"), "{html}");
}
#[test]
fn a_select_marks_the_option_that_matches() {
let options = [Choice::plain("1"), Choice::plain("3")];
let filling = Filling::of(Value::Chosen {
options: &options,
value: "3",
});
let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
assert!(html.contains("<option value=\"3\" selected>3</option>"), "{html}");
assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
assert!(!html.contains("data-unmatched"), "{html}");
}
#[test]
fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
let filling = Filling::of(Value::Text("two\nlines"));
let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
assert!(html.contains(">two\nlines</textarea>"), "{html}");
}
#[test]
fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
assert!(html.contains("class=\"mk-form-group\""), "{html}");
assert!(html.contains("class=\"mk-field\""), "{html}");
}
#[test]
fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
let mut f = field(FieldKind::Text);
f.extended = true;
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(html.contains("data-extended=\"true\""), "{html}");
}
#[test]
fn the_id_prefix_scopes_the_id_and_never_the_name() {
let mut f = field(FieldKind::Text);
f.hint = Some("Keep it short");
f.error = Some("Required");
let filling = Filling {
id_prefix: Some("form-modal-task-edit"),
..Filling::default()
};
let html = field_html(&f, &filling, &Emit::default());
assert!(html.contains(r#"id="form-modal-task-edit-title""#), "{html}");
assert!(html.contains(r#"name="title""#), "{html}");
assert!(!html.contains(r#"name="form-modal-task-edit-title""#), "{html}");
assert!(
html.contains(r#"for="form-modal-task-edit-title""#),
"{html}"
);
assert!(
html.contains(
r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
),
"{html}"
);
assert!(
html.contains(r#"id="form-modal-task-edit-title-hint""#),
"{html}"
);
}
#[test]
fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
let filling = Filling {
value: Value::Text("42"),
id_prefix: Some("scoped"),
..Filling::default()
};
let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
}
#[test]
fn the_typed_text_kinds_keep_their_input_type() {
for (kind, expected) in [
(FieldKind::Email, "email"),
(FieldKind::Url, "url"),
(FieldKind::Tel, "tel"),
] {
let html = field_html(&field(kind), &Filling::default(), &Emit::default());
assert!(
html.contains(&format!(r#"type="{expected}""#)),
"{kind:?} emitted {html}"
);
}
}
#[test]
fn no_prefix_leaves_the_id_as_the_name() {
let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
assert!(html.contains(r#"id="title" name="title""#), "{html}");
}
}