use crate::{Emit, class, push_class};
use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, ThemeVariant, Tone};
use std::fmt::Write as _;
pub const FIELD_CLASSES: &[&str] = &[
"field",
"form-checkbox-label",
"form-checklist",
"form-editor-modes",
"form-editor-preview",
"form-error",
"form-group",
"form-hint",
"form-interval",
"form-label",
"form-note",
"form-option-detail",
"form-option-reason",
"form-radio-group",
"form-radio-label",
"form-unit",
];
pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Markup<'a>(pub &'a str);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Value<'a> {
#[default]
Absent,
Text(&'a str),
On(bool),
Between {
lower: &'a str,
upper: &'a str,
},
}
impl<'a> Value<'a> {
const fn as_text(&self) -> &'a str {
match self {
Self::Text(text) | Self::Between { lower: text, .. } => text,
Self::Absent | Self::On(_) => "",
}
}
}
impl<'a> Value<'a> {
const fn upper_text(&self) -> &'a str {
match self {
Self::Between { upper, .. } => upper,
Self::Absent | Self::Text(_) | Self::On(_) => "",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Filling<'a> {
pub value: Value<'a>,
pub trailing: Option<Markup<'a>>,
pub control_attrs: 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,
trailing: None,
control_attrs: None,
id_prefix: None,
}
}
fn id_for(&self, name: &str) -> String {
let mut id = String::new();
if let Some(prefix) = self.id_prefix {
escape_into(prefix, &mut id);
id.push('-');
}
escape_into(name, &mut id);
id
}
}
pub fn escape_into(text: &str, out: &mut String) {
let mut start = 0;
for (index, byte) in text.bytes().enumerate() {
let encoded = match byte {
b'&' => "&",
b'<' => "<",
b'>' => ">",
b'"' => """,
b'\'' => "'",
_ => continue,
};
out.push_str(&text[start..index]);
out.push_str(encoded);
start = index + 1;
}
out.push_str(&text[start..]);
}
#[must_use]
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
escape_into(text, &mut out);
out
}
const fn input_type(kind: FieldKind) -> &'static str {
match kind {
FieldKind::Secret => "password",
FieldKind::Number => "number",
FieldKind::Checkbox => "checkbox",
FieldKind::File => "file",
FieldKind::Hidden => "hidden",
FieldKind::Email => "email",
FieldKind::Url => "url",
FieldKind::Tel => "tel",
FieldKind::Date => "date",
FieldKind::DateTime => "datetime-local",
FieldKind::Radio => "radio",
FieldKind::Range => "range",
FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text",
_ => "text",
}
}
fn push_accept(out: &mut String, field: &Field<'_>) {
if field.accept.is_empty() {
return;
}
out.push_str(" accept=\"");
for (index, one) in field.accept.iter().enumerate() {
if index > 0 {
out.push(',');
}
escape_into(one.as_str(), out);
}
out.push('"');
}
fn push_bounds(out: &mut String, field: &Field<'_>) {
if let Some(min) = field.min {
out.push_str(" min=\"");
escape_into(min, out);
out.push('"');
}
if let Some(max) = field.max {
out.push_str(" max=\"");
escape_into(max, out);
out.push('"');
}
let step = if field.kind == FieldKind::Range {
field.curve.step()
} else {
field.step
};
if let Some(step) = step {
out.push_str(" step=\"");
escape_into(step, out);
out.push('"');
}
}
fn push_control_attributes(
out: &mut String,
field: &Field<'_>,
filling: &Filling<'_>,
id: &str,
name: &str,
) {
let _ = write!(out, " id=\"{id}\" name=\"");
escape_into(name, out);
out.push('"');
if field.required {
out.push_str(" required");
}
if let Some(limit) = field.max_length {
let _ = write!(out, " maxlength=\"{limit}\"");
}
push_bounds(out, field);
if field.as_instant && matches!(field.kind, FieldKind::DateTime) {
out.push_str(" data-instant=\"true\"");
}
if field.invalid() {
out.push_str(" aria-invalid=\"true\"");
}
push_described_by(out, field, id);
if let Some(Markup(attrs)) = filling.control_attrs {
out.push(' ');
out.push_str(attrs);
}
}
fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
let unit = unit_of(field).is_some();
if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit {
return;
}
let mut written = false;
out.push_str(" aria-describedby=\"");
if field.hint.is_some() {
let _ = write!(out, "{id}-hint");
written = true;
}
if unit {
if written {
out.push(' ');
}
let _ = write!(out, "{id}-unit");
written = true;
}
if field.note.is_some() {
if written {
out.push(' ');
}
let _ = write!(out, "{id}-note");
written = true;
}
if field.error.is_some() {
if written {
out.push(' ');
}
let _ = write!(out, "{id}-error");
}
out.push('"');
}
fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
field.unit.filter(|_| field.kind.measurable())
}
const fn is_group_control(kind: FieldKind) -> bool {
matches!(
kind,
FieldKind::Radio | FieldKind::Checklist | FieldKind::Interval
)
}
fn push_interval(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
let id = filling.id_for(field.name);
out.push_str("<div class=\"");
push_class(out, "form-interval", opts);
let _ = write!(out, "\" role=\"group\" aria-labelledby=\"{id}-label\"");
if field.invalid() {
out.push_str(" aria-invalid=\"true\"");
}
push_described_by(out, field, &id);
out.push('>');
let ends: [(&str, &str, &str); 2] = [
("lower", field.name, filling.value.as_text()),
(
"upper",
field.upper_name.unwrap_or(""),
filling.value.upper_text(),
),
];
for (end, name, value) in ends {
if name.is_empty() {
continue;
}
out.push_str("<input type=\"number\" class=\"");
push_class(out, "field", opts);
let _ = write!(out, "\" id=\"{id}-{end}\" name=\"");
escape_into(name, out);
let _ = write!(out, "\" aria-label=\"{end}\"");
if field.required {
out.push_str(" required");
}
push_bounds(out, field);
if let Some(text) = field.placeholder {
out.push_str(" placeholder=\"");
escape_into(text, out);
out.push('"');
}
out.push_str(" value=\"");
escape_into(value, out);
out.push_str("\">");
}
out.push_str("</div>");
}
fn push_choice_group(
out: &mut String,
field: &Field<'_>,
filling: &Filling<'_>,
opts: &Emit,
mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
let id = filling.id_for(field.name);
let value = filling.value.as_text();
let name = escape(field.name);
let several = field.kind.takes_several();
let (group_class, role, label_class, input) = if several {
("form-checklist", "group", "form-checkbox-label", "checkbox")
} else {
(
"form-radio-group",
"radiogroup",
"form-radio-label",
"radio",
)
};
out.push_str("<div class=\"");
push_class(out, group_class, opts);
let _ = write!(out, "\" role=\"{role}\" aria-labelledby=\"{id}-label\"");
if field.invalid() {
out.push_str(" aria-invalid=\"true\"");
}
push_described_by(out, field, &id);
out.push('>');
for (index, opt) in field.options.iter().enumerate() {
let at = out.len();
out.push_str("<label class=\"");
push_class(out, label_class, opts);
let _ = write!(
out,
"\"><input type=\"{input}\" id=\"{id}-{index}\" name=\"{name}\" value=\""
);
escape_into(opt.value, out);
out.push('"');
if opt.chosen || (!several && opt.value == value) {
out.push_str(" checked");
}
if field.required && !several {
out.push_str(" required");
}
if opt.unavailable.is_some() {
out.push_str(" disabled");
}
out.push_str("><span>");
escape_into(opt.label, out);
out.push_str("</span>");
if let Some(detail) = opt.detail {
out.push_str("<span class=\"");
push_class(out, "form-option-detail", opts);
out.push_str("\">");
escape_into(detail, out);
out.push_str("</span>");
}
if let Some(reason) = opt.unavailable {
out.push_str("<span class=\"");
push_class(out, "form-option-reason", opts);
out.push_str("\">");
escape_into(reason, out);
out.push_str("</span>");
}
out.push_str("</label>");
if let Some(placed) = placed.as_deref_mut() {
placed.push(at..out.len());
}
}
out.push_str("</div>");
}
fn push_options(
out: &mut String,
field: &Field<'_>,
options: &[Choice<'_>],
value: &str,
mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
if value.is_empty()
&& let Some(text) = field.placeholder
{
out.push_str("<option value=\"\" disabled selected>");
escape_into(text, out);
out.push_str("</option>");
}
if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
let escaped = escape(value);
let _ = write!(
out,
"<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
);
}
for opt in options {
let at = out.len();
out.push_str("<option value=\"");
escape_into(opt.value, out);
out.push('"');
if opt.chosen || opt.value == value {
out.push_str(" selected");
}
if opt.unavailable.is_some() {
out.push_str(" disabled");
}
out.push('>');
escape_into(opt.label, out);
if let Some(detail) = opt.detail {
out.push_str(": ");
escape_into(detail, out);
}
if let Some(reason) = opt.unavailable {
out.push_str(": ");
escape_into(reason, out);
}
out.push_str("</option>");
if let Some(placed) = placed.as_deref_mut() {
placed.push(at..out.len());
}
}
}
fn push_theme_options(out: &mut String, field: &Field<'_>, value: &str) {
if let Some(follow) = field.follows {
out.push_str("<option value=\"");
escape_into(follow.value, out);
out.push('"');
if follow.value == value {
out.push_str(" selected");
}
out.push('>');
escape_into(follow.label, out);
out.push_str("</option>");
}
let known = field.themes.iter().any(|theme| theme.id == value)
|| field.follows.is_some_and(|follow| follow.value == value);
if !value.is_empty() && !known {
let escaped = escape(value);
let _ = write!(
out,
"<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
);
}
let mut open: Option<ThemeVariant> = None;
for theme in field.themes {
if open != Some(theme.variant) {
if open.is_some() {
out.push_str("</optgroup>");
}
out.push_str("<optgroup label=\"");
escape_into(theme.variant.heading(), out);
out.push_str("\" data-variant=\"");
out.push_str(theme.variant.as_str());
out.push_str("\">");
open = Some(theme.variant);
}
out.push_str("<option value=\"");
escape_into(theme.id, out);
out.push_str("\" data-contrast=\"");
out.push_str(theme.contrast.as_str());
out.push('"');
if theme.id == value {
out.push_str(" selected");
}
out.push('>');
escape_into(theme.name, out);
out.push_str(" (");
out.push_str(theme.contrast.badge());
out.push(')');
out.push_str("</option>");
}
if open.is_some() {
out.push_str("</optgroup>");
}
}
fn push_control(
out: &mut String,
field: &Field<'_>,
filling: &Filling<'_>,
opts: &Emit,
placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
if matches!(field.kind, FieldKind::Radio | FieldKind::Checklist) {
push_choice_group(out, field, filling, opts, placed);
return;
}
if matches!(field.kind, FieldKind::Interval) {
push_interval(out, field, filling, opts);
return;
}
let id = filling.id_for(field.name);
let placeholder = |out: &mut String| {
if let Some(text) = field.placeholder {
out.push_str(" placeholder=\"");
escape_into(text, out);
out.push('"');
}
};
match field.kind {
kind if kind.multiline() => {
let rich = matches!(kind, FieldKind::Rich);
if rich {
push_editor_open(out, opts);
}
out.push_str("<textarea class=\"");
push_class(out, "field", opts);
out.push('"');
if rich {
out.push_str(" data-format=\"markdown\"");
}
push_control_attributes(out, field, filling, &id, field.name);
placeholder(out);
out.push('>');
escape_into(filling.value.as_text(), out);
out.push_str("</textarea>");
if rich {
push_editor_close(out, opts);
}
}
FieldKind::Select => {
out.push_str("<select class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, filling, &id, field.name);
out.push('>');
push_options(out, field, field.options, filling.value.as_text(), placed);
out.push_str("</select>");
}
FieldKind::Theme => {
out.push_str("<select class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, filling, &id, field.name);
out.push('>');
push_theme_options(out, field, filling.value.as_text());
out.push_str("</select>");
}
FieldKind::Checkbox => {
out.push_str("<label class=\"");
push_class(out, "form-checkbox-label", opts);
out.push_str("\"><input type=\"checkbox\"");
push_control_attributes(out, field, filling, &id, field.name);
if matches!(filling.value, Value::On(true)) {
out.push_str(" checked");
}
out.push_str("><span>");
escape_into(field.label, out);
out.push_str("</span></label>");
}
FieldKind::Secret => {
out.push_str("<input type=\"password\" class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, filling, &id, field.name);
placeholder(out);
out.push('>');
}
FieldKind::File => {
out.push_str("<input type=\"file\" class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, filling, &id, field.name);
push_accept(out, field);
if field.multiple {
out.push_str(" multiple");
}
out.push('>');
}
kind => {
let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, filling, &id, field.name);
placeholder(out);
out.push_str(" value=\"");
escape_into(filling.value.as_text(), out);
out.push_str("\">");
}
}
}
fn push_editor_open(out: &mut String, opts: &Emit) {
out.push_str("<div data-format=\"markdown\"><div class=\"");
push_class(out, "form-editor-modes", opts);
out.push_str("\">");
push_mode(out, "write", "Write", true, opts);
push_mode(out, "preview", "Preview", false, opts);
out.push_str("</div>");
}
fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
out.push_str("<button type=\"button\" class=\"");
push_class(out, crate::option_class(Selector::Segmented), opts);
if chosen {
out.push_str(" chosen");
}
let _ = write!(
out,
"\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
);
}
fn push_editor_close(out: &mut String, opts: &Emit) {
out.push_str("<div class=\"");
push_class(out, "form-editor-preview", opts);
out.push_str("\" data-editor-preview></div></div>");
}
pub(crate) fn editor_rules(opts: &Emit) -> String {
let mut css = String::new();
let modes = class("form-editor-modes", opts);
let preview = class("form-editor-preview", opts);
let field = class("field", opts);
let _ = writeln!(
css,
"[data-format=\"markdown\"] > .{modes} {{\n display: none;\n}}"
);
let _ = writeln!(
css,
"[data-format=\"markdown\"][data-ready] > .{modes} {{\n display: block;\n}}"
);
let _ = writeln!(
css,
"[data-format=\"markdown\"] > .{preview} {{\n display: none;\n}}"
);
let _ = writeln!(
css,
"[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
{{\n display: block;\n}}"
);
let _ = writeln!(
css,
"[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
{{\n display: none;\n}}"
);
let _ = write!(
css,
"[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
crate::depth_declarations(Depth::Well)
);
css
}
pub(crate) fn group_rules(opts: &Emit) -> String {
let group = class("form-group", opts);
let label = class("form-label", opts);
let hint = class("form-hint", opts);
let error = class("form-error", opts);
let radios = class("form-radio-group", opts);
let checklist = class("form-checklist", opts);
let radio = class("form-radio-label", opts);
let checkbox = class("form-checkbox-label", opts);
let reason = class("form-option-reason", opts);
let interval = class("form-interval", opts);
let danger = Tone::Danger.token();
let mut css = String::new();
let _ = writeln!(
css,
".{label} {{\n display: block;\n margin-block-end: var(--gap-bound);\n color: var(--content);\n font-weight: bold;\n}}"
);
let _ = writeln!(
css,
".{group}.has-error > .{label} {{\n color: var(--{danger});\n}}"
);
let _ = writeln!(
css,
".{hint} {{\n margin-block-start: var(--gap-bound);\n color: var(--content-secondary);\n font-size: var(--text-note);\n}}"
);
let _ = writeln!(
css,
".{error} {{\n margin-block-start: var(--gap-bound);\n color: var(--{danger});\n font-weight: bold;\n}}"
);
let _ = writeln!(css, ".{error}:not(.visible) {{\n display: none;\n}}");
let _ = writeln!(
css,
".{radios},\n.{checklist} {{\n display: flex;\n flex-direction: column;\n gap: var(--gap-peer);\n}}"
);
let _ = writeln!(
css,
".{radio},\n.{checkbox} {{\n display: flex;\n align-items: baseline;\n gap: var(--gap-bound);\n}}"
);
let _ = writeln!(css, ".{reason} {{\n display: block;\n}}");
let _ = writeln!(
css,
".{interval} {{\n display: flex;\n align-items: center;\n gap: var(--gap-bound);\n}}"
);
css
}
pub(crate) fn note_rules(opts: &Emit) -> String {
let note = class("form-note", opts);
let mut css = String::new();
let _ = writeln!(css, ".{note} {{\n color: var(--content);\n}}");
for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
let _ = writeln!(
css,
".{note}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}",
tone.token()
);
}
css
}
pub(crate) fn unit_rules(opts: &Emit) -> String {
let unit = class("form-unit", opts);
let mut css = String::new();
let _ = writeln!(css, ".{unit} {{\n color: var(--content-muted);\n}}");
css
}
pub(crate) fn option_detail_rules(opts: &Emit) -> String {
let detail = class("form-option-detail", opts);
let mut css = String::new();
let _ = writeln!(css, ".{detail} {{\n color: var(--content-muted);\n}}");
css
}
pub(crate) fn suggestion_rules(opts: &Emit) -> String {
let list = class("form-suggestions", opts);
let entry = class("form-suggestion", opts);
let detail = class("form-suggestion-detail", opts);
let mut css = String::new();
let _ = writeln!(css, ".{list}:empty {{\n display: none;\n}}");
css.push_str(&crate::depth_rule(&list, Depth::Overlay));
css.push_str(&crate::interactive_rules(&entry, Depth::Flat, opts));
let _ = writeln!(
css,
".{entry}[aria-selected=\"true\"] {{\n background: var(--hover-surface);\n}}"
);
let _ = writeln!(css, ".{detail} {{\n color: var(--content-muted);\n}}");
css
}
#[must_use]
pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let mut html = String::new();
field_html_into(field, filling, opts, &mut html);
html
}
pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
emit_field(field, filling, opts, out, None);
}
pub fn field_html_placed(
field: &Field<'_>,
filling: &Filling<'_>,
opts: &Emit,
out: &mut String,
placed: &mut Vec<core::ops::Range<usize>>,
) {
emit_field(field, filling, opts, out, Some(placed));
}
fn emit_field(
field: &Field<'_>,
filling: &Filling<'_>,
opts: &Emit,
out: &mut String,
placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
let id = filling.id_for(field.name);
if !field.kind.visible() {
out.push_str("<input type=\"hidden\" name=\"");
escape_into(field.name, out);
out.push_str("\" value=\"");
escape_into(filling.value.as_text(), out);
out.push_str("\">");
return;
}
out.push_str("<div class=\"");
push_class(out, "form-group", opts);
if field.invalid() {
out.push_str(" has-error");
}
if field.extended {
out.push_str("\" data-extended=\"true");
}
out.push_str("\">");
if !field.kind.labels_itself() {
out.push_str("<label class=\"");
push_class(out, "form-label", opts);
if is_group_control(field.kind) {
let _ = write!(out, "\" id=\"{id}-label\">");
} else {
let _ = write!(out, "\" for=\"{id}\">");
}
escape_into(field.label, out);
out.push_str("</label>");
}
push_control(out, field, filling, opts, placed);
if let Some(unit) = unit_of(field) {
out.push_str("<span class=\"");
push_class(out, "form-unit", opts);
let _ = write!(out, "\" id=\"{id}-unit\">");
escape_into(unit, out);
out.push_str("</span>");
}
if let Some(hint) = field.hint {
out.push_str("<div class=\"");
push_class(out, "form-hint", opts);
let _ = write!(out, "\" id=\"{id}-hint\">");
escape_into(hint, out);
out.push_str("</div>");
}
if let Some((tone, note)) = field.note {
out.push_str("<div class=\"");
push_class(out, "form-note", opts);
let assertive = matches!(tone, Tone::Warning | Tone::Danger);
let _ = write!(
out,
"\" id=\"{id}-note\" role=\"{}\"",
if assertive { "alert" } else { "status" }
);
if tone != Tone::Neutral {
let _ = write!(out, " data-tone=\"{}\"", tone.token());
}
out.push('>');
escape_into(note, out);
out.push_str("</div>");
}
if let Some(Markup(markup)) = filling.trailing {
out.push_str(markup);
}
if let Some(error) = field.error {
out.push_str("<div class=\"");
push_class(out, "form-error", opts);
let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
escape_into(error, out);
out.push_str("</div>");
}
out.push_str("</div>");
}
#[cfg(test)]
mod tests;