use rdom_core::{InputTypeState, NodeId};
use super::ValidityState;
use super::syntax::{is_valid_absolute_url, is_valid_email, parse_float, parse_non_negative};
use crate::TuiDom;
use crate::runtime::builtins::{input, select};
#[derive(Debug, Default)]
pub(super) struct RadioGroups {
missing: std::collections::HashMap<NodeId, bool>,
}
impl RadioGroups {
fn missing(&mut self, dom: &TuiDom, id: NodeId) -> bool {
if let Some(&m) = self.missing.get(&id) {
return m;
}
let group = dom.radio_group(id);
let m = group_missing(dom, &group);
for r in group {
self.missing.insert(r, m);
}
m
}
pub(super) fn clear(&mut self) {
self.missing.clear();
}
}
pub(super) fn compute(dom: &TuiDom, id: NodeId) -> ValidityState {
compute_with(dom, id, None)
}
pub(super) fn compute_in(dom: &TuiDom, id: NodeId, groups: &mut RadioGroups) -> ValidityState {
compute_with(dom, id, Some(groups))
}
fn compute_with(dom: &TuiDom, id: NodeId, groups: Option<&mut RadioGroups>) -> ValidityState {
let mut s = ValidityState {
custom_error: dom.node(id).ext().is_some_and(|e| {
e.form_state
.get()
.is_some_and(|f| !f.custom_validity.is_empty())
}),
..ValidityState::default()
};
match dom.node(id).tag_name() {
Some("input") => input_states(dom, id, &mut s, groups),
Some("textarea") => {
let value = dom.node(id).text_content();
s.value_missing = required(dom, id) && value.is_empty();
length_states(dom, id, &value, &mut s);
}
Some("select") => s.value_missing = required(dom, id) && select_missing(dom, id),
_ => {}
}
s
}
fn required(dom: &TuiDom, id: NodeId) -> bool {
dom.node(id).has_attribute("required")
}
fn input_states(dom: &TuiDom, id: NodeId, s: &mut ValidityState, groups: Option<&mut RadioGroups>) {
use InputTypeState as T;
let Some(state) = dom.input_type_state(id) else {
return;
};
match state {
T::Checkbox => {
s.value_missing = required(dom, id) && !dom.node(id).has_attribute("checked")
}
T::Radio => {
s.value_missing = match groups {
Some(groups) => groups.missing(dom, id),
None => group_missing(dom, &dom.radio_group(id)),
}
}
_ if !dom.required_applies(id) => {}
_ => {
let value = input::value(dom, id);
s.value_missing = required(dom, id) && value.is_empty();
if value.is_empty() {
return;
}
let multiple = dom.node(id).has_attribute("multiple");
match state {
T::Email => {
s.type_mismatch = !email_values(&value, multiple)
.into_iter()
.all(is_valid_email)
}
T::Url => s.type_mismatch = !is_valid_absolute_url(&value),
T::Number => number_states(dom, id, &value, s),
_ => {}
}
if matches!(
state,
T::Text | T::Search | T::Url | T::Tel | T::Email | T::Password
) {
let values: Vec<&str> = if state == T::Email {
email_values(&value, multiple)
} else {
vec![value.as_str()]
};
s.pattern_mismatch = super::pattern::mismatch(dom, id, &values);
length_states(dom, id, &value, s);
}
}
}
}
fn email_values(value: &str, multiple: bool) -> Vec<&str> {
if multiple {
value.split(',').map(str::trim_ascii).collect()
} else {
vec![value.trim_ascii()]
}
}
fn group_missing(dom: &TuiDom, group: &[NodeId]) -> bool {
group.iter().any(|&r| required(dom, r))
&& !group.iter().any(|&r| dom.node(r).has_attribute("checked"))
}
fn select_missing(dom: &TuiDom, id: NodeId) -> bool {
let selected = select::selected_options(dom, id);
let placeholder = placeholder_label_option(dom, id);
selected.iter().all(|&o| Some(o) == placeholder)
}
fn placeholder_label_option(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
if select::is_multi(dom, id) || select::display_size(dom, id) != 1 {
return None;
}
let first = *select::options(dom, id).first()?;
(select::option_value(dom, first).is_empty()
&& dom.node(first).parent_node().map(|p| p.id()) == Some(id))
.then_some(first)
}
fn length_states(dom: &TuiDom, id: NodeId, value: &str, s: &mut ValidityState) {
if !dom
.node(id)
.ext()
.is_some_and(|e| e.form_state.get().is_some_and(|f| f.value_user_edited))
{
return;
}
let len = value.encode_utf16().count();
let limit = |name| {
dom.node(id)
.get_attribute(name)
.and_then(parse_non_negative)
};
if let Some(max) = limit("maxlength") {
s.too_long = len > max;
}
if let Some(min) = limit("minlength") {
s.too_short = len > 0 && len < min;
}
}
fn number_states(dom: &TuiDom, id: NodeId, value: &str, s: &mut ValidityState) {
let Some(v) = parse_float(value) else {
s.bad_input = true;
return;
};
let bounds = NumberBounds::of(dom, id);
if let Some(min) = bounds.min {
s.range_underflow = v < min;
}
if let Some(max) = bounds.max {
s.range_overflow = v > max;
}
if let Some(step) = bounds.step {
s.step_mismatch = !on_step(v, bounds.base, step);
}
}
pub(super) struct NumberBounds {
pub(super) min: Option<f64>,
pub(super) max: Option<f64>,
pub(super) step: Option<f64>,
pub(super) base: f64,
}
impl NumberBounds {
pub(super) fn of(dom: &TuiDom, id: NodeId) -> Self {
let attr = |name| dom.node(id).get_attribute(name).and_then(parse_float);
let min = attr("min");
let step = match dom.node(id).get_attribute("step") {
Some(s) if s.eq_ignore_ascii_case("any") => None,
s => Some(s.and_then(parse_float).filter(|n| *n > 0.0).unwrap_or(1.0)),
};
let default_value = || {
use crate::accessors::TuiAccessors;
dom.node(id)
.default_value()
.as_deref()
.and_then(parse_float)
};
Self {
min,
max: attr("max"),
step,
base: min.or_else(default_value).unwrap_or(0.0),
}
}
}
pub(super) fn on_step(v: f64, base: f64, step: f64) -> bool {
let q = (v - base) / step;
(q - q.round()).abs() <= 1e-9 * q.abs().max(1.0)
}