use crate::i18n::{NumberFormat, Text};
use crate::model::FieldMeta;
use crate::render::{Render, escape};
use crate::value::{FieldKind, Value};
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Control {
Checkbox,
Switch,
Select,
Radio,
Segmented,
Listbox,
Password,
NewPassword,
OneTimeCode,
Percent,
PasswordMeter,
}
pub(crate) struct Slot<'a> {
pub m: &'static FieldMeta,
pub value: &'a Value,
pub common: &'a str,
pub id: &'a str,
pub label: &'a str,
pub control: Option<Control>,
}
fn ui(r: &Render, key: &str) -> String {
r.text(&Text::key(format!("zeyn.ui.{key}")))
}
pub(crate) fn render_bool(out: &mut String, s: &Slot) {
let checked = if *s.value == Value::Bool(true) { " checked" } else { "" };
let common = s.common;
if s.control == Some(Control::Switch) {
out.push_str(&format!(
r#"<span class="z-check z-switch-wrap"><input type="checkbox" role="switch" class="z-switch" {common}{checked}><span class="z-switch-track" aria-hidden="true"></span>{}</span>"#,
s.label
));
} else {
out.push_str(&format!(
r#"<span class="z-check"><input type="checkbox" class="z-checkbox" {common}{checked}>{}</span>"#,
s.label
));
}
}
pub(crate) fn render_choice(out: &mut String, r: &Render, s: &Slot) {
let FieldKind::Choice(options) = s.m.kind else { return };
let label_of = |key: &'static str, fallback: &'static str| r.text(&Text::key_or(key, fallback));
match s.control {
Some(Control::Radio | Control::Segmented) => {
let class = if s.control == Some(Control::Segmented) { "z-radios z-segmented" } else { "z-radios" };
let star = if s.m.is_required() { r#"<span class="z-required" aria-hidden="true">*</span>"# } else { "" };
out.push_str(&format!(
r#"<fieldset class="{class}" aria-describedby="{}-error"><legend>{}{star}</legend><div class="z-radios-list">"#,
s.id,
r.label(s.m)
));
let required = if s.m.is_required() { " required" } else { "" };
for (i, o) in options.iter().enumerate() {
let id = if i == 0 { s.id.to_string() } else { format!("{}-{i}", s.id) };
let checked = if *s.value == Value::Choice(o.value) { " checked" } else { "" };
out.push_str(&format!(
r#"<label class="z-radio" for="{id}"><input type="radio" id="{id}" name="{}" value="{}" data-testid="{}.field.{}.{}"{checked}{required}><span>{}</span></label>"#,
s.m.name,
o.value,
r.scope,
s.m.name,
o.value,
label_of(o.key, o.label)
));
}
out.push_str("</div></fieldset>");
}
_ => {
let listbox = s.control == Some(Control::Listbox);
out.push_str(s.label);
let (class, attr) = if listbox {
(
"z-listbox-field",
format!(r#" data-zeyn-listbox size="{}""#, options.len().clamp(2, 8) + usize::from(s.m.optional)),
)
} else {
("z-select", " data-zeyn-select".to_string())
};
out.push_str(&format!(
r#"<div class="{class}" data-text-none="{}" data-text-search="{}" data-text-no-results="{}"><select {}{attr}>"#,
ui(r, "select.none"),
ui(r, "select.search"),
ui(r, "lookup.no_results"),
s.common
));
if s.m.optional {
out.push_str(&format!(r#"<option value="">{}</option>"#, ui(r, "select.none")));
}
for o in options {
let selected = if *s.value == Value::Choice(o.value) { " selected" } else { "" };
out.push_str(&format!(
r#"<option value="{}"{selected}>{}</option>"#,
o.value,
label_of(o.key, o.label)
));
}
out.push_str("</select></div>");
}
}
}
pub(crate) fn render_number(out: &mut String, r: &Render, s: &Slot) {
let f = NumberFormat::for_locale(r.locale);
let money = s.m.kind == FieldKind::Money;
let group = s.m.kind != FieldKind::Id;
let scale = match s.m.kind {
FieldKind::Money => 2,
FieldKind::Decimal => s.m.decimal_limits().1,
_ => 0,
};
let percent = s.control == Some(Control::Percent);
let text = match s.value {
Value::Money(m) => m.format_plain(r.locale),
Value::Number(d) => d.format(r.locale),
Value::Int(n) if group => crate::value::Money::new(n.saturating_mul(100), Default::default())
.format_plain(r.locale)
.rsplit_once(f.decimal)
.map(|(a, _)| a.to_string())
.unwrap_or_default(),
Value::Int(n) => n.to_string(),
_ => String::new(),
};
let limits = [("min", s.m.min()), ("max", s.m.max())]
.iter()
.filter_map(|(k, v)| v.map(|n| format!(r#" data-{k}="{n}" aria-value{k}="{n}""#)))
.collect::<String>();
let _ = money;
out.push_str(s.label);
let (class, common) = if percent {
let unit = format!("{}-unit", s.id);
let common = s.common.replacen(r#"aria-describedby=""#, &format!(r#"aria-describedby="{unit} "#), 1);
(
if NumberFormat::percent_first(r.locale) {
"z-number z-percent z-affix-first"
} else {
"z-number z-percent"
},
common,
)
} else {
("z-number", s.common.to_string())
};
out.push_str(&format!(
r#"<div class="{class}" data-zeyn-number data-decimal="{}" data-group="{}" data-scale="{scale}" data-grouped="{group}"><input type="text" role="spinbutton" inputmode="{}" autocomplete="off" class="z-num" {common}{limits} value="{}">"#,
escape(&f.decimal.to_string()),
escape(&f.group.to_string()),
if scale > 0 { "decimal" } else { "numeric" },
escape(&text)
));
if percent {
out.push_str(&format!(
r#"<span class="z-affix" aria-hidden="true">%</span><span class="z-sr-only" id="{}-unit">{}</span>"#,
s.id,
ui(r, "number.percent")
));
}
if !s.m.readonly {
out.push_str(&format!(
r#"<span class="z-steppers"><button type="button" tabindex="-1" data-zeyn-step="1" aria-label="{}"><span aria-hidden="true">+</span></button><button type="button" tabindex="-1" data-zeyn-step="-1" aria-label="{}"><span aria-hidden="true">−</span></button></span>"#,
ui(r, "number.up"),
ui(r, "number.down")
));
}
out.push_str("</div>");
}
pub(crate) fn render_text(out: &mut String, r: &Render, s: &Slot) {
if let Some(pattern) = s.m.mask_pattern()
&& s.control.is_none()
{
crate::ui::inputs::render_mask(out, s, pattern);
return;
}
let text = match s.value {
Value::Text(t) => escape(t),
_ => String::new(),
};
let max = s.m.max_len();
let maxlength = max.map(|n| format!(r#" maxlength="{n}""#)).unwrap_or_default();
let minlength = s.m.min_len().map(|n| format!(r#" minlength="{n}""#)).unwrap_or_default();
out.push_str(s.label);
match max {
Some(n)
if n > 255
&& !matches!(s.control, Some(Control::Password | Control::NewPassword | Control::OneTimeCode)) =>
{
let id = s.id;
let common = s.common.replace(
&format!(r#"aria-describedby="{id}-error""#),
&format!(r#"aria-describedby="{id}-count {id}-error""#),
);
let count = match s.value {
Value::Text(t) => t.chars().count(),
_ => 0,
};
out.push_str(&format!(
r#"<textarea rows="3" class="z-textarea" data-zeyn-autosize {common}{minlength}{maxlength}>{text}</textarea><p class="z-counter" id="{id}-count" data-zeyn-counter="{n}" data-text="{}">{}</p>"#,
ui(r, "text.count"),
escape(&r.i18n.text(r.locale, &Text::key("zeyn.ui.text.count").arg("n", count.to_string()).arg("max", n.to_string())))
));
}
_ if matches!(s.control, Some(Control::Password | Control::NewPassword | Control::PasswordMeter)) => {
let auto = if s.control == Some(Control::Password) { "current-password" } else { "new-password" };
let meter = s.control == Some(Control::PasswordMeter);
let id = s.id;
let common = if meter {
s.common.replacen(r#"aria-describedby=""#, &format!(r#"aria-describedby="{id}-strength "#), 1)
} else {
s.common.to_string()
};
out.push_str(&format!(
r#"<div class="z-password" data-zeyn-password><input type="password" autocomplete="{auto}" spellcheck="false" {common}{minlength}{maxlength} value=""><button type="button" class="z-password-toggle" aria-pressed="false" aria-controls="{id}" aria-label="{}" data-testid="{}.field.{}.toggle" hidden><span class="z-eye" aria-hidden="true"></span></button></div>"#,
ui(r, "password.show"),
r.scope,
s.m.name
));
if meter {
let levels = ["weak", "fair", "good", "strong"].map(|k| ui(r, &format!("password.{k}")));
out.push_str(&format!(
r#"<div class="z-strength" data-zeyn-strength="{id}" data-levels="{}" hidden><span class="z-strength-bar" aria-hidden="true"><span></span><span></span><span></span><span></span></span><span class="z-strength-text" id="{id}-strength" aria-live="polite"></span></div>"#,
crate::render::attr(&levels)
));
}
}
_ if s.control == Some(Control::OneTimeCode) => {
out.push_str(&format!(r#"<input type="text" inputmode="text" autocomplete="one-time-code" spellcheck="false" {}{maxlength} value="">"#, s.common));
}
_ => {
let kind = if s.m.rules.contains(&crate::model::Rule::Email) { "email" } else { "text" };
out.push_str(&format!(r#"<input type="{kind}" {}{minlength}{maxlength} value="{text}">"#, s.common));
}
}
}
pub(crate) fn render_choices(out: &mut String, r: &Render, s: &Slot) {
let FieldKind::Choices(options) = s.m.kind else { return };
let chosen: Vec<&str> = match s.value {
Value::Text(t) => t.split(',').filter(|v| !v.is_empty()).collect(),
_ => Vec::new(),
};
let texts = serde_json::json!({
"none": ui(r, "select.none"),
"remove": ui(r, "lookup.remove"),
"selected": ui(r, "lookup.selected"),
"count": ui(r, "multi.count"),
"open": ui(r, "lookup.open"),
});
out.push_str(s.label);
out.push_str(&format!(
r#"<div class="z-multi" data-zeyn-multi data-texts="{}"><select multiple size="{}" {}>"#,
crate::render::attr(&texts),
options.len().clamp(2, 8),
s.common
));
for o in options {
let selected = if chosen.contains(&o.value) { " selected" } else { "" };
out.push_str(&format!(
r#"<option value="{}"{selected}>{}</option>"#,
o.value,
r.text(&Text::key_or(o.key, o.label))
));
}
out.push_str("</select></div>");
}
pub(crate) fn read_choices(
options: &[crate::value::ChoiceOption],
raw: &serde_json::Value,
) -> Option<serde_json::Value> {
let parts: Vec<String> = match raw {
serde_json::Value::Array(items) => {
items.iter().map(|v| v.as_str().map(str::to_string)).collect::<Option<_>>()?
}
serde_json::Value::String(s) => s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect(),
serde_json::Value::Null => Vec::new(),
_ => return None,
};
let mut values: Vec<&str> = Vec::with_capacity(parts.len().min(options.len()));
for p in &parts {
let option = options.iter().find(|o| o.value == p.trim())?;
if !values.contains(&option.value) {
values.push(option.value);
}
}
Some(serde_json::json!(values))
}
pub(crate) fn choices_text(value: &str, kind: &FieldKind, r: &Render) -> String {
let FieldKind::Choices(options) = kind else { return value.to_string() };
value
.split(',')
.filter(|v| !v.is_empty())
.map(|v| match options.iter().find(|o| o.value == v) {
Some(o) => r.i18n.text(r.locale, &Text::key_or(o.key, o.label)),
None => v.to_string(),
})
.collect::<Vec<_>>()
.join(", ")
}