we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement HTML5 constraint validation (Phase 16)

Add the constraint validation API for form controls:

- ValidityState with all flags: valueMissing, typeMismatch, patternMismatch,
tooLong, tooShort, rangeUnderflow, rangeOverflow, stepMismatch, customError
- Constraint checking for all input types (text, email, url, number,
checkbox, radio), textarea, and select elements
- Built-in regex engine for pattern attribute matching
- CSS pseudo-classes: :valid, :invalid, :required, :optional,
:in-range, :out-of-range
- JS API: validity, validationMessage, willValidate properties;
checkValidity(), reportValidity(), setCustomValidity() methods
- Custom validity messages via setCustomValidity()
- Validation message generation for each constraint type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+2282
+5
crates/dom/src/lib.rs
··· 7 7 //! thousands of `<div>` elements share one string allocation instead of one each. 8 8 9 9 pub mod input_state; 10 + pub mod validation; 10 11 11 12 use std::collections::{HashMap, HashSet}; 12 13 use std::fmt; ··· 14 15 use we_memory::intern::Atom; 15 16 16 17 pub use input_state::{InputState, InputStateMap}; 18 + pub use validation::{CustomValidityMap, ValidityState}; 17 19 18 20 /// A handle to a node in the DOM tree. 19 21 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] ··· 106 108 select_highlighted: HashMap<NodeId, usize>, 107 109 /// Type-ahead search buffer per `<select>`: (accumulated chars, last keystroke time in ms). 108 110 select_type_ahead: HashMap<NodeId, (String, u64)>, 111 + /// Custom validity messages set via `setCustomValidity()`. 112 + pub custom_validity: CustomValidityMap, 109 113 } 110 114 111 115 impl fmt::Debug for Document { ··· 138 142 select_open: HashSet::new(), 139 143 select_highlighted: HashMap::new(), 140 144 select_type_ahead: HashMap::new(), 145 + custom_validity: CustomValidityMap::new(), 141 146 } 142 147 } 143 148
+1611
crates/dom/src/validation.rs
··· 1 + //! HTML5 constraint validation for form controls. 2 + //! 3 + //! Implements the ValidityState interface and constraint checking logic 4 + //! per the HTML specification. Each form control can be validated against 5 + //! its declared constraints (required, pattern, min/max, etc.). 6 + 7 + use std::collections::HashMap; 8 + 9 + use crate::{Document, NodeId}; 10 + 11 + /// The ValidityState for a form control, with boolean flags for each 12 + /// possible constraint violation. 13 + #[derive(Debug, Clone, Default, PartialEq, Eq)] 14 + pub struct ValidityState { 15 + /// The element's value is missing (required but empty). 16 + pub value_missing: bool, 17 + /// The element's value doesn't match the type constraint (email, url). 18 + pub type_mismatch: bool, 19 + /// The element's value doesn't match the pattern attribute. 20 + pub pattern_mismatch: bool, 21 + /// The element's value exceeds the maxlength attribute. 22 + pub too_long: bool, 23 + /// The element's value is shorter than the minlength attribute. 24 + pub too_short: bool, 25 + /// The element's value is less than the min attribute. 26 + pub range_underflow: bool, 27 + /// The element's value is greater than the max attribute. 28 + pub range_overflow: bool, 29 + /// The element's value doesn't match the step attribute. 30 + pub step_mismatch: bool, 31 + /// A custom validity message has been set via setCustomValidity(). 32 + pub custom_error: bool, 33 + } 34 + 35 + impl ValidityState { 36 + /// Returns true if all constraint flags are false (the element is valid). 37 + pub fn valid(&self) -> bool { 38 + !self.value_missing 39 + && !self.type_mismatch 40 + && !self.pattern_mismatch 41 + && !self.too_long 42 + && !self.too_short 43 + && !self.range_underflow 44 + && !self.range_overflow 45 + && !self.step_mismatch 46 + && !self.custom_error 47 + } 48 + } 49 + 50 + /// Stores custom validity messages set via setCustomValidity(). 51 + #[derive(Debug, Default)] 52 + pub struct CustomValidityMap { 53 + messages: HashMap<NodeId, String>, 54 + } 55 + 56 + impl CustomValidityMap { 57 + pub fn new() -> Self { 58 + Self { 59 + messages: HashMap::new(), 60 + } 61 + } 62 + 63 + /// Set a custom validity message for a node. An empty string clears it. 64 + pub fn set(&mut self, node: NodeId, message: &str) { 65 + if message.is_empty() { 66 + self.messages.remove(&node); 67 + } else { 68 + self.messages.insert(node, message.to_string()); 69 + } 70 + } 71 + 72 + /// Get the custom validity message, if any. 73 + pub fn get(&self, node: NodeId) -> Option<&str> { 74 + self.messages.get(&node).map(|s| s.as_str()) 75 + } 76 + 77 + /// Returns true if a non-empty custom validity message is set. 78 + pub fn has_custom(&self, node: NodeId) -> bool { 79 + self.messages 80 + .get(&node) 81 + .map(|s| !s.is_empty()) 82 + .unwrap_or(false) 83 + } 84 + } 85 + 86 + /// Returns true if the element participates in constraint validation. 87 + /// 88 + /// Elements that don't validate: disabled controls, hidden inputs, fieldsets, 89 + /// output elements, button elements that aren't submit buttons, and elements 90 + /// with a datalist ancestor. 91 + pub fn will_validate(doc: &Document, node: NodeId) -> bool { 92 + let tag = match doc.tag_name(node) { 93 + Some(t) => t, 94 + None => return false, 95 + }; 96 + 97 + match tag { 98 + "input" => { 99 + // Hidden inputs don't validate. 100 + let input_type = doc.get_attribute(node, "type").unwrap_or("text"); 101 + if input_type.eq_ignore_ascii_case("hidden") { 102 + return false; 103 + } 104 + // Disabled inputs don't validate. 105 + if doc.get_attribute(node, "disabled").is_some() { 106 + return false; 107 + } 108 + // Readonly inputs don't participate in constraint validation 109 + // for most constraints, but we still say willValidate is true 110 + // per spec (readonly only affects valueMissing for some types). 111 + true 112 + } 113 + "select" | "textarea" => doc.get_attribute(node, "disabled").is_none(), 114 + // Buttons only validate if type=submit, but we don't currently 115 + // apply constraints to buttons anyway. 116 + "button" | "fieldset" | "output" => false, 117 + _ => false, 118 + } 119 + } 120 + 121 + /// Compute the ValidityState for a form control. 122 + pub fn compute_validity(doc: &Document, node: NodeId, custom: &CustomValidityMap) -> ValidityState { 123 + let mut state = ValidityState::default(); 124 + 125 + if !will_validate(doc, node) { 126 + // Custom error still applies even if the element wouldn't normally validate, 127 + // but per spec, if willValidate is false the validity is all-valid. 128 + // However, customError is still tracked. 129 + if custom.has_custom(node) { 130 + state.custom_error = true; 131 + } 132 + return state; 133 + } 134 + 135 + let tag = match doc.tag_name(node) { 136 + Some(t) => t, 137 + None => return state, 138 + }; 139 + 140 + match tag { 141 + "input" => compute_input_validity(doc, node, custom, &mut state), 142 + "textarea" => compute_textarea_validity(doc, node, custom, &mut state), 143 + "select" => compute_select_validity(doc, node, custom, &mut state), 144 + _ => {} 145 + } 146 + 147 + // Custom validity always applies. 148 + if custom.has_custom(node) { 149 + state.custom_error = true; 150 + } 151 + 152 + state 153 + } 154 + 155 + /// Get the current value of a form control for validation purposes. 156 + /// Uses the InputState (edited value) if available, otherwise falls back 157 + /// to the DOM attribute. 158 + fn get_control_value(doc: &Document, node: NodeId) -> String { 159 + // Check InputState first (user-edited value). 160 + if let Some(input_state) = doc.input_states.get(node) { 161 + return input_state.text().to_string(); 162 + } 163 + // Fall back to DOM attribute. 164 + doc.get_attribute(node, "value").unwrap_or("").to_string() 165 + } 166 + 167 + fn compute_input_validity( 168 + doc: &Document, 169 + node: NodeId, 170 + _custom: &CustomValidityMap, 171 + state: &mut ValidityState, 172 + ) { 173 + let input_type = doc 174 + .get_attribute(node, "type") 175 + .unwrap_or("text") 176 + .to_ascii_lowercase(); 177 + 178 + match input_type.as_str() { 179 + "checkbox" => { 180 + // Required: checkbox must be checked. 181 + if doc.get_attribute(node, "required").is_some() 182 + && doc.get_attribute(node, "checked").is_none() 183 + { 184 + state.value_missing = true; 185 + } 186 + } 187 + "radio" => { 188 + // Required: at least one radio in the group must be checked. 189 + if doc.get_attribute(node, "required").is_some() { 190 + let members = doc.radio_group_members(node); 191 + let any_checked = members 192 + .iter() 193 + .any(|&m| doc.get_attribute(m, "checked").is_some()); 194 + if !any_checked { 195 + state.value_missing = true; 196 + } 197 + } 198 + } 199 + "number" => { 200 + let value = get_control_value(doc, node); 201 + 202 + // Required check. 203 + if doc.get_attribute(node, "required").is_some() && value.is_empty() { 204 + state.value_missing = true; 205 + } 206 + 207 + // Numeric validation only applies if value is non-empty. 208 + if !value.is_empty() { 209 + match value.trim().parse::<f64>() { 210 + Ok(num) if num.is_finite() => { 211 + validate_numeric_range(doc, node, num, state); 212 + } 213 + _ => { 214 + // Non-numeric value in a number input is a type mismatch 215 + // (badInput in the spec, but we map to typeMismatch). 216 + state.type_mismatch = true; 217 + } 218 + } 219 + } 220 + } 221 + "email" => { 222 + let value = get_control_value(doc, node); 223 + 224 + if doc.get_attribute(node, "required").is_some() && value.is_empty() { 225 + state.value_missing = true; 226 + } 227 + 228 + if !value.is_empty() && !is_valid_email(&value) { 229 + state.type_mismatch = true; 230 + } 231 + 232 + validate_text_constraints(doc, node, &value, state); 233 + } 234 + "url" => { 235 + let value = get_control_value(doc, node); 236 + 237 + if doc.get_attribute(node, "required").is_some() && value.is_empty() { 238 + state.value_missing = true; 239 + } 240 + 241 + if !value.is_empty() && !is_valid_url(&value) { 242 + state.type_mismatch = true; 243 + } 244 + 245 + validate_text_constraints(doc, node, &value, state); 246 + } 247 + // text, password, search, tel and similar text-like inputs 248 + _ => { 249 + let value = get_control_value(doc, node); 250 + 251 + // Required check. 252 + if doc.get_attribute(node, "required").is_some() && value.is_empty() { 253 + state.value_missing = true; 254 + } 255 + 256 + validate_text_constraints(doc, node, &value, state); 257 + } 258 + } 259 + } 260 + 261 + /// Validate text length and pattern constraints. 262 + fn validate_text_constraints(doc: &Document, node: NodeId, value: &str, state: &mut ValidityState) { 263 + if value.is_empty() { 264 + return; 265 + } 266 + 267 + let char_count = value.chars().count(); 268 + 269 + // minlength 270 + if let Some(min_str) = doc.get_attribute(node, "minlength") { 271 + if let Ok(min) = min_str.parse::<usize>() { 272 + if char_count < min { 273 + state.too_short = true; 274 + } 275 + } 276 + } 277 + 278 + // maxlength 279 + if let Some(max_str) = doc.get_attribute(node, "maxlength") { 280 + if let Ok(max) = max_str.parse::<usize>() { 281 + if char_count > max { 282 + state.too_long = true; 283 + } 284 + } 285 + } 286 + 287 + // pattern 288 + if let Some(pattern) = doc.get_attribute(node, "pattern") { 289 + if !matches_pattern(value, pattern) { 290 + state.pattern_mismatch = true; 291 + } 292 + } 293 + } 294 + 295 + /// Validate numeric range constraints (min, max, step). 296 + fn validate_numeric_range(doc: &Document, node: NodeId, num: f64, state: &mut ValidityState) { 297 + // min 298 + if let Some(min_str) = doc.get_attribute(node, "min") { 299 + if let Ok(min) = min_str.parse::<f64>() { 300 + if num < min { 301 + state.range_underflow = true; 302 + } 303 + } 304 + } 305 + 306 + // max 307 + if let Some(max_str) = doc.get_attribute(node, "max") { 308 + if let Ok(max) = max_str.parse::<f64>() { 309 + if num > max { 310 + state.range_overflow = true; 311 + } 312 + } 313 + } 314 + 315 + // step 316 + if let Some(step_str) = doc.get_attribute(node, "step") { 317 + if !step_str.eq_ignore_ascii_case("any") { 318 + if let Ok(step) = step_str.parse::<f64>() { 319 + if step > 0.0 { 320 + let base = doc 321 + .get_attribute(node, "min") 322 + .and_then(|s| s.parse::<f64>().ok()) 323 + .unwrap_or(0.0); 324 + let diff = num - base; 325 + // Check if diff is a multiple of step (within floating point tolerance). 326 + let remainder = (diff / step).round() * step - diff; 327 + if remainder.abs() > 1e-10 { 328 + state.step_mismatch = true; 329 + } 330 + } 331 + } 332 + } 333 + } 334 + } 335 + 336 + fn compute_textarea_validity( 337 + doc: &Document, 338 + node: NodeId, 339 + _custom: &CustomValidityMap, 340 + state: &mut ValidityState, 341 + ) { 342 + let value = get_control_value(doc, node); 343 + 344 + // Required check. 345 + if doc.get_attribute(node, "required").is_some() && value.is_empty() { 346 + state.value_missing = true; 347 + } 348 + 349 + if !value.is_empty() { 350 + let char_count = value.chars().count(); 351 + 352 + if let Some(min_str) = doc.get_attribute(node, "minlength") { 353 + if let Ok(min) = min_str.parse::<usize>() { 354 + if char_count < min { 355 + state.too_short = true; 356 + } 357 + } 358 + } 359 + 360 + if let Some(max_str) = doc.get_attribute(node, "maxlength") { 361 + if let Ok(max) = max_str.parse::<usize>() { 362 + if char_count > max { 363 + state.too_long = true; 364 + } 365 + } 366 + } 367 + } 368 + } 369 + 370 + fn compute_select_validity( 371 + doc: &Document, 372 + node: NodeId, 373 + _custom: &CustomValidityMap, 374 + state: &mut ValidityState, 375 + ) { 376 + // Required: a <select required> must have a selected option with a non-empty value. 377 + if doc.get_attribute(node, "required").is_some() { 378 + let options = doc.select_options(node); 379 + let has_valid_selection = options.iter().any(|opt| { 380 + if opt.is_group_label { 381 + return false; 382 + } 383 + opt.selected && !opt.value.is_empty() 384 + }); 385 + // Also check: if no option is explicitly selected, the first option's value 386 + // is used. If it's empty (placeholder), that's value_missing. 387 + if !has_valid_selection { 388 + // Check if first non-group option has a non-empty value and acts as default. 389 + let first_value = options 390 + .iter() 391 + .find(|o| !o.is_group_label) 392 + .map(|o| o.value.as_str()) 393 + .unwrap_or(""); 394 + let any_explicitly_selected = options.iter().any(|o| !o.is_group_label && o.selected); 395 + // value_missing unless: no explicit selection AND first option has a non-empty value 396 + state.value_missing = any_explicitly_selected || first_value.is_empty(); 397 + } 398 + } 399 + } 400 + 401 + /// Generate a human-readable validation message for the first failing constraint. 402 + pub fn validation_message(doc: &Document, node: NodeId, custom: &CustomValidityMap) -> String { 403 + if let Some(msg) = custom.get(node) { 404 + return msg.to_string(); 405 + } 406 + 407 + let state = compute_validity(doc, node, custom); 408 + if state.valid() { 409 + return String::new(); 410 + } 411 + 412 + if state.value_missing { 413 + return "Please fill out this field.".to_string(); 414 + } 415 + if state.type_mismatch { 416 + let input_type = doc.get_attribute(node, "type").unwrap_or("text"); 417 + return match input_type { 418 + "email" => "Please enter an email address.".to_string(), 419 + "url" => "Please enter a URL.".to_string(), 420 + _ => "Please enter a valid value.".to_string(), 421 + }; 422 + } 423 + if state.pattern_mismatch { 424 + if let Some(title) = doc.get_attribute(node, "title") { 425 + return format!("Please match the requested format: {title}"); 426 + } 427 + return "Please match the requested format.".to_string(); 428 + } 429 + if state.too_short { 430 + if let Some(minlen) = doc.get_attribute(node, "minlength") { 431 + return format!("Please use at least {minlen} characters."); 432 + } 433 + } 434 + if state.too_long { 435 + if let Some(maxlen) = doc.get_attribute(node, "maxlength") { 436 + return format!("Please shorten this text to {maxlen} characters or less."); 437 + } 438 + } 439 + if state.range_underflow { 440 + if let Some(min) = doc.get_attribute(node, "min") { 441 + return format!("Value must be {min} or more."); 442 + } 443 + } 444 + if state.range_overflow { 445 + if let Some(max) = doc.get_attribute(node, "max") { 446 + return format!("Value must be {max} or less."); 447 + } 448 + } 449 + if state.step_mismatch { 450 + return "Please enter a valid value.".to_string(); 451 + } 452 + 453 + String::new() 454 + } 455 + 456 + /// Simple pattern matching: the pattern attribute is anchored to the full value. 457 + /// We implement a basic regex subset (enough for common patterns). 458 + fn matches_pattern(value: &str, pattern: &str) -> bool { 459 + // Per spec, the pattern is implicitly anchored: ^(pattern)$ 460 + let anchored = format!("^(?:{pattern})$"); 461 + simple_regex_match(value, &anchored) 462 + } 463 + 464 + /// Basic email validation per the HTML spec (simplified). 465 + /// The spec says: a valid email is `local@domain` where local is non-empty 466 + /// and domain contains at least one dot-separated label. 467 + fn is_valid_email(value: &str) -> bool { 468 + let parts: Vec<&str> = value.splitn(2, '@').collect(); 469 + if parts.len() != 2 { 470 + return false; 471 + } 472 + let local = parts[0]; 473 + let domain = parts[1]; 474 + if local.is_empty() || domain.is_empty() { 475 + return false; 476 + } 477 + // Domain must have at least one dot (simplified check). 478 + // Per HTML spec, domain must match a valid domain format. 479 + // We just check it's non-empty and has some structure. 480 + if !domain.contains('.') { 481 + return false; 482 + } 483 + // Check no spaces. 484 + if value.contains(' ') { 485 + return false; 486 + } 487 + true 488 + } 489 + 490 + /// Basic URL validation per the HTML spec (simplified). 491 + /// Must have a scheme followed by :// and some content. 492 + fn is_valid_url(value: &str) -> bool { 493 + // Must contain a scheme (letters, then ://). 494 + if let Some(colon_pos) = value.find("://") { 495 + let scheme = &value[..colon_pos]; 496 + if scheme.is_empty() { 497 + return false; 498 + } 499 + // Scheme must start with a letter. 500 + if !scheme.chars().next().unwrap_or(' ').is_ascii_alphabetic() { 501 + return false; 502 + } 503 + // Scheme must be alphanumeric, +, -, . 504 + if !scheme 505 + .chars() 506 + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') 507 + { 508 + return false; 509 + } 510 + // Must have something after :// 511 + let rest = &value[colon_pos + 3..]; 512 + !rest.is_empty() 513 + } else { 514 + false 515 + } 516 + } 517 + 518 + /// A simple regex matcher supporting common HTML pattern constructs. 519 + /// 520 + /// Supports: literal chars, `.`, `*`, `+`, `?`, `^`, `$`, `[...]` char classes, 521 + /// `\d`, `\w`, `\s`, `(...)` groups, `{n,m}` quantifiers, `|` alternation. 522 + /// 523 + /// This is intentionally simplified — full regex is complex. We handle the 524 + /// most common patterns used in HTML form validation. 525 + fn simple_regex_match(input: &str, pattern: &str) -> bool { 526 + // We implement a basic NFA-style regex engine. 527 + let compiled = match compile_regex(pattern) { 528 + Some(c) => c, 529 + None => return false, // Invalid pattern — no match per spec. 530 + }; 531 + execute_regex(&compiled, input) 532 + } 533 + 534 + // ── Regex engine (simplified NFA) ───────────────────────────────── 535 + 536 + #[derive(Debug, Clone)] 537 + enum RegexNode { 538 + Literal(char), 539 + Dot, // matches any char except newline 540 + CharClass(Vec<CharRange>, bool), // (ranges, negated) 541 + Digit, // \d 542 + Word, // \w 543 + Whitespace, // \s 544 + Anchor(AnchorType), 545 + } 546 + 547 + #[derive(Debug, Clone)] 548 + enum AnchorType { 549 + Start, 550 + End, 551 + } 552 + 553 + #[derive(Debug, Clone)] 554 + struct CharRange { 555 + start: char, 556 + end: char, 557 + } 558 + 559 + #[derive(Debug, Clone)] 560 + enum Quantifier { 561 + One, 562 + ZeroOrMore, // * 563 + OneOrMore, // + 564 + ZeroOrOne, // ? 565 + Range(usize, Option<usize>), // {n}, {n,}, {n,m} 566 + } 567 + 568 + #[derive(Debug, Clone)] 569 + struct RegexPiece { 570 + node: RegexNode, 571 + quantifier: Quantifier, 572 + } 573 + 574 + #[derive(Debug, Clone)] 575 + enum RegexExpr { 576 + Sequence(Vec<RegexPart>), 577 + Alternation(Vec<RegexExpr>), 578 + } 579 + 580 + #[derive(Debug, Clone)] 581 + enum RegexPart { 582 + Piece(RegexPiece), 583 + Group(RegexExpr, Quantifier), 584 + } 585 + 586 + fn compile_regex(pattern: &str) -> Option<RegexExpr> { 587 + let chars: Vec<char> = pattern.chars().collect(); 588 + let (expr, pos) = parse_alternation(&chars, 0)?; 589 + if pos != chars.len() { 590 + return None; 591 + } 592 + Some(expr) 593 + } 594 + 595 + fn parse_alternation(chars: &[char], start: usize) -> Option<(RegexExpr, usize)> { 596 + let mut alternatives = Vec::new(); 597 + let (first, mut pos) = parse_sequence(chars, start)?; 598 + alternatives.push(first); 599 + 600 + while pos < chars.len() && chars[pos] == '|' { 601 + let (alt, new_pos) = parse_sequence(chars, pos + 1)?; 602 + alternatives.push(alt); 603 + pos = new_pos; 604 + } 605 + 606 + if alternatives.len() == 1 { 607 + Some((alternatives.remove(0), pos)) 608 + } else { 609 + Some((RegexExpr::Alternation(alternatives), pos)) 610 + } 611 + } 612 + 613 + fn parse_sequence(chars: &[char], start: usize) -> Option<(RegexExpr, usize)> { 614 + let mut parts = Vec::new(); 615 + let mut pos = start; 616 + 617 + while pos < chars.len() && chars[pos] != '|' && chars[pos] != ')' { 618 + let (part, new_pos) = parse_part(chars, pos)?; 619 + parts.push(part); 620 + pos = new_pos; 621 + } 622 + 623 + Some((RegexExpr::Sequence(parts), pos)) 624 + } 625 + 626 + fn parse_part(chars: &[char], start: usize) -> Option<(RegexPart, usize)> { 627 + let mut pos = start; 628 + 629 + if pos >= chars.len() { 630 + return None; 631 + } 632 + 633 + // Group: (...) 634 + if chars[pos] == '(' { 635 + pos += 1; 636 + // Handle non-capturing group (?:...) 637 + if pos + 1 < chars.len() && chars[pos] == '?' && chars[pos + 1] == ':' { 638 + pos += 2; 639 + } 640 + let (inner, new_pos) = parse_alternation(chars, pos)?; 641 + pos = new_pos; 642 + if pos >= chars.len() || chars[pos] != ')' { 643 + return None; 644 + } 645 + pos += 1; 646 + let (quant, new_pos) = parse_quantifier(chars, pos); 647 + return Some((RegexPart::Group(inner, quant), new_pos)); 648 + } 649 + 650 + // Atom 651 + let (node, new_pos) = parse_atom(chars, pos)?; 652 + pos = new_pos; 653 + 654 + let (quant, new_pos) = parse_quantifier(chars, pos); 655 + Some(( 656 + RegexPart::Piece(RegexPiece { 657 + node, 658 + quantifier: quant, 659 + }), 660 + new_pos, 661 + )) 662 + } 663 + 664 + fn parse_atom(chars: &[char], pos: usize) -> Option<(RegexNode, usize)> { 665 + if pos >= chars.len() { 666 + return None; 667 + } 668 + 669 + match chars[pos] { 670 + '^' => Some((RegexNode::Anchor(AnchorType::Start), pos + 1)), 671 + '$' => Some((RegexNode::Anchor(AnchorType::End), pos + 1)), 672 + '.' => Some((RegexNode::Dot, pos + 1)), 673 + '[' => parse_char_class(chars, pos), 674 + '\\' => parse_escape(chars, pos), 675 + c => Some((RegexNode::Literal(c), pos + 1)), 676 + } 677 + } 678 + 679 + fn parse_escape(chars: &[char], pos: usize) -> Option<(RegexNode, usize)> { 680 + if pos + 1 >= chars.len() { 681 + return None; 682 + } 683 + let c = chars[pos + 1]; 684 + match c { 685 + 'd' => Some((RegexNode::Digit, pos + 2)), 686 + 'w' => Some((RegexNode::Word, pos + 2)), 687 + 's' => Some((RegexNode::Whitespace, pos + 2)), 688 + 'n' => Some((RegexNode::Literal('\n'), pos + 2)), 689 + 't' => Some((RegexNode::Literal('\t'), pos + 2)), 690 + 'r' => Some((RegexNode::Literal('\r'), pos + 2)), 691 + _ => Some((RegexNode::Literal(c), pos + 2)), 692 + } 693 + } 694 + 695 + fn parse_char_class(chars: &[char], start: usize) -> Option<(RegexNode, usize)> { 696 + let mut pos = start + 1; // skip '[' 697 + let negated = pos < chars.len() && chars[pos] == '^'; 698 + if negated { 699 + pos += 1; 700 + } 701 + 702 + let mut ranges = Vec::new(); 703 + while pos < chars.len() && chars[pos] != ']' { 704 + let c = if chars[pos] == '\\' && pos + 1 < chars.len() { 705 + pos += 1; 706 + match chars[pos] { 707 + 'd' => { 708 + ranges.push(CharRange { 709 + start: '0', 710 + end: '9', 711 + }); 712 + pos += 1; 713 + continue; 714 + } 715 + 'w' => { 716 + ranges.push(CharRange { 717 + start: 'a', 718 + end: 'z', 719 + }); 720 + ranges.push(CharRange { 721 + start: 'A', 722 + end: 'Z', 723 + }); 724 + ranges.push(CharRange { 725 + start: '0', 726 + end: '9', 727 + }); 728 + ranges.push(CharRange { 729 + start: '_', 730 + end: '_', 731 + }); 732 + pos += 1; 733 + continue; 734 + } 735 + c => c, 736 + } 737 + } else { 738 + chars[pos] 739 + }; 740 + pos += 1; 741 + 742 + // Check for range: c-d 743 + if pos + 1 < chars.len() && chars[pos] == '-' && chars[pos + 1] != ']' { 744 + pos += 1; // skip '-' 745 + let end_c = if chars[pos] == '\\' && pos + 1 < chars.len() { 746 + pos += 1; 747 + chars[pos] 748 + } else { 749 + chars[pos] 750 + }; 751 + pos += 1; 752 + ranges.push(CharRange { 753 + start: c, 754 + end: end_c, 755 + }); 756 + } else { 757 + ranges.push(CharRange { start: c, end: c }); 758 + } 759 + } 760 + 761 + if pos >= chars.len() { 762 + return None; // unterminated char class 763 + } 764 + pos += 1; // skip ']' 765 + 766 + Some((RegexNode::CharClass(ranges, negated), pos)) 767 + } 768 + 769 + fn parse_quantifier(chars: &[char], pos: usize) -> (Quantifier, usize) { 770 + if pos >= chars.len() { 771 + return (Quantifier::One, pos); 772 + } 773 + match chars[pos] { 774 + '*' => (Quantifier::ZeroOrMore, pos + 1), 775 + '+' => (Quantifier::OneOrMore, pos + 1), 776 + '?' => (Quantifier::ZeroOrOne, pos + 1), 777 + '{' => { 778 + if let Some((quant, new_pos)) = parse_range_quantifier(chars, pos) { 779 + (quant, new_pos) 780 + } else { 781 + (Quantifier::One, pos) 782 + } 783 + } 784 + _ => (Quantifier::One, pos), 785 + } 786 + } 787 + 788 + fn parse_range_quantifier(chars: &[char], start: usize) -> Option<(Quantifier, usize)> { 789 + let mut pos = start + 1; // skip '{' 790 + let mut min_str = String::new(); 791 + while pos < chars.len() && chars[pos].is_ascii_digit() { 792 + min_str.push(chars[pos]); 793 + pos += 1; 794 + } 795 + if min_str.is_empty() { 796 + return None; 797 + } 798 + let min = min_str.parse::<usize>().ok()?; 799 + 800 + if pos >= chars.len() { 801 + return None; 802 + } 803 + 804 + if chars[pos] == '}' { 805 + // {n} — exact count 806 + return Some((Quantifier::Range(min, Some(min)), pos + 1)); 807 + } 808 + 809 + if chars[pos] != ',' { 810 + return None; 811 + } 812 + pos += 1; // skip ',' 813 + 814 + if pos >= chars.len() { 815 + return None; 816 + } 817 + 818 + if chars[pos] == '}' { 819 + // {n,} — at least n 820 + return Some((Quantifier::Range(min, None), pos + 1)); 821 + } 822 + 823 + let mut max_str = String::new(); 824 + while pos < chars.len() && chars[pos].is_ascii_digit() { 825 + max_str.push(chars[pos]); 826 + pos += 1; 827 + } 828 + let max = max_str.parse::<usize>().ok()?; 829 + 830 + if pos >= chars.len() || chars[pos] != '}' { 831 + return None; 832 + } 833 + Some((Quantifier::Range(min, Some(max)), pos + 1)) 834 + } 835 + 836 + // ── Regex execution (backtracking) ─────────────────────────────── 837 + 838 + fn execute_regex(expr: &RegexExpr, input: &str) -> bool { 839 + let chars: Vec<char> = input.chars().collect(); 840 + match_expr(expr, &chars, 0) == Some(chars.len()) 841 + } 842 + 843 + /// Try to match the expression at the given position. Returns the position 844 + /// after the match, or None if no match. 845 + fn match_expr(expr: &RegexExpr, chars: &[char], pos: usize) -> Option<usize> { 846 + match expr { 847 + RegexExpr::Sequence(parts) => match_sequence(parts, chars, pos, 0), 848 + RegexExpr::Alternation(alts) => { 849 + for alt in alts { 850 + if let Some(new_pos) = match_expr(alt, chars, pos) { 851 + return Some(new_pos); 852 + } 853 + } 854 + None 855 + } 856 + } 857 + } 858 + 859 + fn match_sequence(parts: &[RegexPart], chars: &[char], pos: usize, idx: usize) -> Option<usize> { 860 + if idx >= parts.len() { 861 + return Some(pos); 862 + } 863 + 864 + match &parts[idx] { 865 + RegexPart::Piece(piece) => match_piece(piece, parts, chars, pos, idx), 866 + RegexPart::Group(inner, quant) => { 867 + match_quantified_group(inner, quant, parts, chars, pos, idx) 868 + } 869 + } 870 + } 871 + 872 + fn match_piece( 873 + piece: &RegexPiece, 874 + parts: &[RegexPart], 875 + chars: &[char], 876 + pos: usize, 877 + idx: usize, 878 + ) -> Option<usize> { 879 + // Handle anchors specially. 880 + if let RegexNode::Anchor(anchor) = &piece.node { 881 + match anchor { 882 + AnchorType::Start => { 883 + if pos == 0 { 884 + return match_sequence(parts, chars, pos, idx + 1); 885 + } 886 + return None; 887 + } 888 + AnchorType::End => { 889 + if pos == chars.len() { 890 + return match_sequence(parts, chars, pos, idx + 1); 891 + } 892 + return None; 893 + } 894 + } 895 + } 896 + 897 + let (min, max) = quantifier_bounds(&piece.quantifier); 898 + 899 + // Try matching min..max times, greedily. 900 + let mut positions = Vec::new(); 901 + let mut cur = pos; 902 + 903 + for _ in 0..min { 904 + if let Some(new_pos) = match_node(&piece.node, chars, cur) { 905 + cur = new_pos; 906 + } else { 907 + return None; 908 + } 909 + } 910 + positions.push(cur); 911 + 912 + // Try more matches up to max. 913 + let remaining = max.unwrap_or(usize::MAX) - min; 914 + for _ in 0..remaining { 915 + if let Some(new_pos) = match_node(&piece.node, chars, cur) { 916 + cur = new_pos; 917 + positions.push(cur); 918 + } else { 919 + break; 920 + } 921 + } 922 + 923 + // Try greedy: from longest match to shortest. 924 + for &p in positions.iter().rev() { 925 + if let Some(result) = match_sequence(parts, chars, p, idx + 1) { 926 + return Some(result); 927 + } 928 + } 929 + None 930 + } 931 + 932 + fn match_quantified_group( 933 + inner: &RegexExpr, 934 + quant: &Quantifier, 935 + parts: &[RegexPart], 936 + chars: &[char], 937 + pos: usize, 938 + idx: usize, 939 + ) -> Option<usize> { 940 + let (min, max) = quantifier_bounds(quant); 941 + 942 + let mut positions = Vec::new(); 943 + let mut cur = pos; 944 + 945 + for _ in 0..min { 946 + if let Some(new_pos) = match_expr(inner, chars, cur) { 947 + if new_pos == cur { 948 + break; // Avoid infinite loop on zero-length match. 949 + } 950 + cur = new_pos; 951 + } else { 952 + return None; 953 + } 954 + } 955 + positions.push(cur); 956 + 957 + let remaining = max.unwrap_or(usize::MAX) - min; 958 + for _ in 0..remaining { 959 + if let Some(new_pos) = match_expr(inner, chars, cur) { 960 + if new_pos == cur { 961 + break; 962 + } 963 + cur = new_pos; 964 + positions.push(cur); 965 + } else { 966 + break; 967 + } 968 + } 969 + 970 + for &p in positions.iter().rev() { 971 + if let Some(result) = match_sequence(parts, chars, p, idx + 1) { 972 + return Some(result); 973 + } 974 + } 975 + None 976 + } 977 + 978 + fn quantifier_bounds(quant: &Quantifier) -> (usize, Option<usize>) { 979 + match quant { 980 + Quantifier::One => (1, Some(1)), 981 + Quantifier::ZeroOrMore => (0, None), 982 + Quantifier::OneOrMore => (1, None), 983 + Quantifier::ZeroOrOne => (0, Some(1)), 984 + Quantifier::Range(min, max) => (*min, *max), 985 + } 986 + } 987 + 988 + fn match_node(node: &RegexNode, chars: &[char], pos: usize) -> Option<usize> { 989 + if pos >= chars.len() { 990 + return None; 991 + } 992 + let c = chars[pos]; 993 + 994 + match node { 995 + RegexNode::Literal(expected) => { 996 + if c == *expected { 997 + Some(pos + 1) 998 + } else { 999 + None 1000 + } 1001 + } 1002 + RegexNode::Dot => { 1003 + if c != '\n' { 1004 + Some(pos + 1) 1005 + } else { 1006 + None 1007 + } 1008 + } 1009 + RegexNode::Digit => { 1010 + if c.is_ascii_digit() { 1011 + Some(pos + 1) 1012 + } else { 1013 + None 1014 + } 1015 + } 1016 + RegexNode::Word => { 1017 + if c.is_ascii_alphanumeric() || c == '_' { 1018 + Some(pos + 1) 1019 + } else { 1020 + None 1021 + } 1022 + } 1023 + RegexNode::Whitespace => { 1024 + if c.is_ascii_whitespace() { 1025 + Some(pos + 1) 1026 + } else { 1027 + None 1028 + } 1029 + } 1030 + RegexNode::CharClass(ranges, negated) => { 1031 + let in_class = ranges.iter().any(|r| c >= r.start && c <= r.end); 1032 + if in_class != *negated { 1033 + Some(pos + 1) 1034 + } else { 1035 + None 1036 + } 1037 + } 1038 + RegexNode::Anchor(_) => None, // Anchors don't consume characters. 1039 + } 1040 + } 1041 + 1042 + // ── Tests ──────────────────────────────────────────────────────── 1043 + 1044 + #[cfg(test)] 1045 + mod tests { 1046 + use super::*; 1047 + 1048 + // Helper: create a document with a single form containing an input. 1049 + fn make_input_doc(input_type: &str, attrs: &[(&str, &str)]) -> (Document, NodeId, NodeId) { 1050 + let mut doc = Document::new(); 1051 + let root = doc.root(); 1052 + let form = doc.create_element("form"); 1053 + doc.append_child(root, form); 1054 + let input = doc.create_element("input"); 1055 + doc.set_attribute(input, "type", input_type); 1056 + for &(name, value) in attrs { 1057 + doc.set_attribute(input, name, value); 1058 + } 1059 + doc.append_child(form, input); 1060 + (doc, form, input) 1061 + } 1062 + 1063 + #[test] 1064 + fn valid_state_default() { 1065 + let state = ValidityState::default(); 1066 + assert!(state.valid()); 1067 + } 1068 + 1069 + #[test] 1070 + fn will_validate_disabled() { 1071 + let (doc, _form, input) = make_input_doc("text", &[("disabled", "")]); 1072 + assert!(!will_validate(&doc, input)); 1073 + } 1074 + 1075 + #[test] 1076 + fn will_validate_hidden() { 1077 + let (doc, _form, input) = make_input_doc("hidden", &[]); 1078 + assert!(!will_validate(&doc, input)); 1079 + } 1080 + 1081 + #[test] 1082 + fn will_validate_normal_input() { 1083 + let (doc, _form, input) = make_input_doc("text", &[]); 1084 + assert!(will_validate(&doc, input)); 1085 + } 1086 + 1087 + // ── required ── 1088 + 1089 + #[test] 1090 + fn required_text_empty() { 1091 + let (doc, _form, input) = make_input_doc("text", &[("required", "")]); 1092 + let custom = CustomValidityMap::new(); 1093 + let state = compute_validity(&doc, input, &custom); 1094 + assert!(state.value_missing); 1095 + assert!(!state.valid()); 1096 + } 1097 + 1098 + #[test] 1099 + fn required_text_filled() { 1100 + let (doc, _form, input) = make_input_doc("text", &[("required", ""), ("value", "hello")]); 1101 + let custom = CustomValidityMap::new(); 1102 + let state = compute_validity(&doc, input, &custom); 1103 + assert!(!state.value_missing); 1104 + assert!(state.valid()); 1105 + } 1106 + 1107 + #[test] 1108 + fn required_checkbox_unchecked() { 1109 + let (doc, _form, input) = make_input_doc("checkbox", &[("required", "")]); 1110 + let custom = CustomValidityMap::new(); 1111 + let state = compute_validity(&doc, input, &custom); 1112 + assert!(state.value_missing); 1113 + } 1114 + 1115 + #[test] 1116 + fn required_checkbox_checked() { 1117 + let (doc, _form, input) = make_input_doc("checkbox", &[("required", ""), ("checked", "")]); 1118 + let custom = CustomValidityMap::new(); 1119 + let state = compute_validity(&doc, input, &custom); 1120 + assert!(!state.value_missing); 1121 + assert!(state.valid()); 1122 + } 1123 + 1124 + #[test] 1125 + fn required_radio_none_checked() { 1126 + let mut doc = Document::new(); 1127 + let root = doc.root(); 1128 + let form = doc.create_element("form"); 1129 + doc.append_child(root, form); 1130 + let r1 = doc.create_element("input"); 1131 + doc.set_attribute(r1, "type", "radio"); 1132 + doc.set_attribute(r1, "name", "choice"); 1133 + doc.set_attribute(r1, "required", ""); 1134 + doc.append_child(form, r1); 1135 + let r2 = doc.create_element("input"); 1136 + doc.set_attribute(r2, "type", "radio"); 1137 + doc.set_attribute(r2, "name", "choice"); 1138 + doc.append_child(form, r2); 1139 + 1140 + let custom = CustomValidityMap::new(); 1141 + let state = compute_validity(&doc, r1, &custom); 1142 + assert!(state.value_missing); 1143 + } 1144 + 1145 + #[test] 1146 + fn required_radio_one_checked() { 1147 + let mut doc = Document::new(); 1148 + let root = doc.root(); 1149 + let form = doc.create_element("form"); 1150 + doc.append_child(root, form); 1151 + let r1 = doc.create_element("input"); 1152 + doc.set_attribute(r1, "type", "radio"); 1153 + doc.set_attribute(r1, "name", "choice"); 1154 + doc.set_attribute(r1, "required", ""); 1155 + doc.append_child(form, r1); 1156 + let r2 = doc.create_element("input"); 1157 + doc.set_attribute(r2, "type", "radio"); 1158 + doc.set_attribute(r2, "name", "choice"); 1159 + doc.set_attribute(r2, "checked", ""); 1160 + doc.append_child(form, r2); 1161 + 1162 + let custom = CustomValidityMap::new(); 1163 + let state = compute_validity(&doc, r1, &custom); 1164 + assert!(!state.value_missing); 1165 + assert!(state.valid()); 1166 + } 1167 + 1168 + // ── pattern ── 1169 + 1170 + #[test] 1171 + fn pattern_match() { 1172 + let (doc, _form, input) = 1173 + make_input_doc("text", &[("pattern", "[a-z]+"), ("value", "hello")]); 1174 + let custom = CustomValidityMap::new(); 1175 + let state = compute_validity(&doc, input, &custom); 1176 + assert!(!state.pattern_mismatch); 1177 + assert!(state.valid()); 1178 + } 1179 + 1180 + #[test] 1181 + fn pattern_mismatch() { 1182 + let (doc, _form, input) = 1183 + make_input_doc("text", &[("pattern", "[a-z]+"), ("value", "Hello123")]); 1184 + let custom = CustomValidityMap::new(); 1185 + let state = compute_validity(&doc, input, &custom); 1186 + assert!(state.pattern_mismatch); 1187 + } 1188 + 1189 + #[test] 1190 + fn pattern_empty_value_no_mismatch() { 1191 + // Pattern doesn't apply to empty values (only required does). 1192 + let (doc, _form, input) = make_input_doc("text", &[("pattern", "[a-z]+")]); 1193 + let custom = CustomValidityMap::new(); 1194 + let state = compute_validity(&doc, input, &custom); 1195 + assert!(!state.pattern_mismatch); 1196 + assert!(state.valid()); 1197 + } 1198 + 1199 + // ── minlength / maxlength ── 1200 + 1201 + #[test] 1202 + fn minlength_too_short() { 1203 + let (doc, _form, input) = make_input_doc("text", &[("minlength", "5"), ("value", "hi")]); 1204 + let custom = CustomValidityMap::new(); 1205 + let state = compute_validity(&doc, input, &custom); 1206 + assert!(state.too_short); 1207 + } 1208 + 1209 + #[test] 1210 + fn minlength_ok() { 1211 + let (doc, _form, input) = make_input_doc("text", &[("minlength", "2"), ("value", "hi")]); 1212 + let custom = CustomValidityMap::new(); 1213 + let state = compute_validity(&doc, input, &custom); 1214 + assert!(!state.too_short); 1215 + assert!(state.valid()); 1216 + } 1217 + 1218 + #[test] 1219 + fn maxlength_too_long() { 1220 + let (doc, _form, input) = make_input_doc("text", &[("maxlength", "3"), ("value", "hello")]); 1221 + let custom = CustomValidityMap::new(); 1222 + let state = compute_validity(&doc, input, &custom); 1223 + assert!(state.too_long); 1224 + } 1225 + 1226 + #[test] 1227 + fn maxlength_ok() { 1228 + let (doc, _form, input) = 1229 + make_input_doc("text", &[("maxlength", "10"), ("value", "hello")]); 1230 + let custom = CustomValidityMap::new(); 1231 + let state = compute_validity(&doc, input, &custom); 1232 + assert!(!state.too_long); 1233 + assert!(state.valid()); 1234 + } 1235 + 1236 + // ── number: min / max / step ── 1237 + 1238 + #[test] 1239 + fn number_range_underflow() { 1240 + let (doc, _form, input) = make_input_doc("number", &[("min", "10"), ("value", "5")]); 1241 + let custom = CustomValidityMap::new(); 1242 + let state = compute_validity(&doc, input, &custom); 1243 + assert!(state.range_underflow); 1244 + } 1245 + 1246 + #[test] 1247 + fn number_range_overflow() { 1248 + let (doc, _form, input) = make_input_doc("number", &[("max", "10"), ("value", "15")]); 1249 + let custom = CustomValidityMap::new(); 1250 + let state = compute_validity(&doc, input, &custom); 1251 + assert!(state.range_overflow); 1252 + } 1253 + 1254 + #[test] 1255 + fn number_in_range() { 1256 + let (doc, _form, input) = 1257 + make_input_doc("number", &[("min", "1"), ("max", "10"), ("value", "5")]); 1258 + let custom = CustomValidityMap::new(); 1259 + let state = compute_validity(&doc, input, &custom); 1260 + assert!(!state.range_underflow); 1261 + assert!(!state.range_overflow); 1262 + assert!(state.valid()); 1263 + } 1264 + 1265 + #[test] 1266 + fn number_step_mismatch() { 1267 + let (doc, _form, input) = 1268 + make_input_doc("number", &[("min", "0"), ("step", "3"), ("value", "5")]); 1269 + let custom = CustomValidityMap::new(); 1270 + let state = compute_validity(&doc, input, &custom); 1271 + assert!(state.step_mismatch); 1272 + } 1273 + 1274 + #[test] 1275 + fn number_step_ok() { 1276 + let (doc, _form, input) = 1277 + make_input_doc("number", &[("min", "0"), ("step", "3"), ("value", "6")]); 1278 + let custom = CustomValidityMap::new(); 1279 + let state = compute_validity(&doc, input, &custom); 1280 + assert!(!state.step_mismatch); 1281 + assert!(state.valid()); 1282 + } 1283 + 1284 + #[test] 1285 + fn number_step_any() { 1286 + let (doc, _form, input) = make_input_doc( 1287 + "number", 1288 + &[("min", "0"), ("step", "any"), ("value", "3.14")], 1289 + ); 1290 + let custom = CustomValidityMap::new(); 1291 + let state = compute_validity(&doc, input, &custom); 1292 + assert!(!state.step_mismatch); 1293 + } 1294 + 1295 + #[test] 1296 + fn number_not_a_number() { 1297 + let (doc, _form, input) = make_input_doc("number", &[("value", "abc")]); 1298 + let custom = CustomValidityMap::new(); 1299 + let state = compute_validity(&doc, input, &custom); 1300 + assert!(state.type_mismatch); 1301 + } 1302 + 1303 + // ── email ── 1304 + 1305 + #[test] 1306 + fn email_valid() { 1307 + let (doc, _form, input) = make_input_doc("email", &[("value", "user@example.com")]); 1308 + let custom = CustomValidityMap::new(); 1309 + let state = compute_validity(&doc, input, &custom); 1310 + assert!(!state.type_mismatch); 1311 + assert!(state.valid()); 1312 + } 1313 + 1314 + #[test] 1315 + fn email_invalid() { 1316 + let (doc, _form, input) = make_input_doc("email", &[("value", "not-an-email")]); 1317 + let custom = CustomValidityMap::new(); 1318 + let state = compute_validity(&doc, input, &custom); 1319 + assert!(state.type_mismatch); 1320 + } 1321 + 1322 + // ── url ── 1323 + 1324 + #[test] 1325 + fn url_valid() { 1326 + let (doc, _form, input) = make_input_doc("url", &[("value", "https://example.com")]); 1327 + let custom = CustomValidityMap::new(); 1328 + let state = compute_validity(&doc, input, &custom); 1329 + assert!(!state.type_mismatch); 1330 + assert!(state.valid()); 1331 + } 1332 + 1333 + #[test] 1334 + fn url_invalid() { 1335 + let (doc, _form, input) = make_input_doc("url", &[("value", "not a url")]); 1336 + let custom = CustomValidityMap::new(); 1337 + let state = compute_validity(&doc, input, &custom); 1338 + assert!(state.type_mismatch); 1339 + } 1340 + 1341 + // ── textarea ── 1342 + 1343 + #[test] 1344 + fn textarea_required_empty() { 1345 + let mut doc = Document::new(); 1346 + let root = doc.root(); 1347 + let form = doc.create_element("form"); 1348 + doc.append_child(root, form); 1349 + let ta = doc.create_element("textarea"); 1350 + doc.set_attribute(ta, "required", ""); 1351 + doc.append_child(form, ta); 1352 + 1353 + let custom = CustomValidityMap::new(); 1354 + let state = compute_validity(&doc, ta, &custom); 1355 + assert!(state.value_missing); 1356 + } 1357 + 1358 + #[test] 1359 + fn textarea_required_filled() { 1360 + let mut doc = Document::new(); 1361 + let root = doc.root(); 1362 + let form = doc.create_element("form"); 1363 + doc.append_child(root, form); 1364 + let ta = doc.create_element("textarea"); 1365 + doc.set_attribute(ta, "required", ""); 1366 + doc.set_attribute(ta, "value", "some text"); 1367 + doc.append_child(form, ta); 1368 + 1369 + let custom = CustomValidityMap::new(); 1370 + let state = compute_validity(&doc, ta, &custom); 1371 + assert!(!state.value_missing); 1372 + assert!(state.valid()); 1373 + } 1374 + 1375 + // ── select ── 1376 + 1377 + #[test] 1378 + fn select_required_no_selection() { 1379 + let mut doc = Document::new(); 1380 + let root = doc.root(); 1381 + let form = doc.create_element("form"); 1382 + doc.append_child(root, form); 1383 + let sel = doc.create_element("select"); 1384 + doc.set_attribute(sel, "required", ""); 1385 + doc.append_child(form, sel); 1386 + // Placeholder option with empty value. 1387 + let opt = doc.create_element("option"); 1388 + doc.set_attribute(opt, "value", ""); 1389 + doc.append_child(sel, opt); 1390 + let text = doc.create_text("Choose..."); 1391 + doc.append_child(opt, text); 1392 + 1393 + let custom = CustomValidityMap::new(); 1394 + let state = compute_validity(&doc, sel, &custom); 1395 + assert!(state.value_missing); 1396 + } 1397 + 1398 + #[test] 1399 + fn select_required_valid_selection() { 1400 + let mut doc = Document::new(); 1401 + let root = doc.root(); 1402 + let form = doc.create_element("form"); 1403 + doc.append_child(root, form); 1404 + let sel = doc.create_element("select"); 1405 + doc.set_attribute(sel, "required", ""); 1406 + doc.append_child(form, sel); 1407 + let opt = doc.create_element("option"); 1408 + doc.set_attribute(opt, "value", "a"); 1409 + doc.set_attribute(opt, "selected", ""); 1410 + doc.append_child(sel, opt); 1411 + let text = doc.create_text("Option A"); 1412 + doc.append_child(opt, text); 1413 + 1414 + let custom = CustomValidityMap::new(); 1415 + let state = compute_validity(&doc, sel, &custom); 1416 + assert!(!state.value_missing); 1417 + assert!(state.valid()); 1418 + } 1419 + 1420 + // ── custom validity ── 1421 + 1422 + #[test] 1423 + fn custom_validity_set() { 1424 + let (doc, _form, input) = make_input_doc("text", &[("value", "hello")]); 1425 + let mut custom = CustomValidityMap::new(); 1426 + custom.set(input, "Custom error"); 1427 + let state = compute_validity(&doc, input, &custom); 1428 + assert!(state.custom_error); 1429 + assert!(!state.valid()); 1430 + } 1431 + 1432 + #[test] 1433 + fn custom_validity_cleared() { 1434 + let (doc, _form, input) = make_input_doc("text", &[("value", "hello")]); 1435 + let mut custom = CustomValidityMap::new(); 1436 + custom.set(input, "Custom error"); 1437 + custom.set(input, ""); 1438 + let state = compute_validity(&doc, input, &custom); 1439 + assert!(!state.custom_error); 1440 + assert!(state.valid()); 1441 + } 1442 + 1443 + // ── validation message ── 1444 + 1445 + #[test] 1446 + fn validation_message_required() { 1447 + let (doc, _form, input) = make_input_doc("text", &[("required", "")]); 1448 + let custom = CustomValidityMap::new(); 1449 + let msg = validation_message(&doc, input, &custom); 1450 + assert!(!msg.is_empty()); 1451 + } 1452 + 1453 + #[test] 1454 + fn validation_message_valid() { 1455 + let (doc, _form, input) = make_input_doc("text", &[("value", "hello")]); 1456 + let custom = CustomValidityMap::new(); 1457 + let msg = validation_message(&doc, input, &custom); 1458 + assert!(msg.is_empty()); 1459 + } 1460 + 1461 + #[test] 1462 + fn validation_message_custom() { 1463 + let (doc, _form, input) = make_input_doc("text", &[("value", "hello")]); 1464 + let mut custom = CustomValidityMap::new(); 1465 + custom.set(input, "My error"); 1466 + let msg = validation_message(&doc, input, &custom); 1467 + assert_eq!(msg, "My error"); 1468 + } 1469 + 1470 + // ── regex engine tests ── 1471 + 1472 + #[test] 1473 + fn regex_literal() { 1474 + assert!(simple_regex_match("abc", "^abc$")); 1475 + assert!(!simple_regex_match("abcd", "^abc$")); 1476 + } 1477 + 1478 + #[test] 1479 + fn regex_dot() { 1480 + assert!(simple_regex_match("abc", "^a.c$")); 1481 + assert!(!simple_regex_match("ac", "^a.c$")); 1482 + } 1483 + 1484 + #[test] 1485 + fn regex_star() { 1486 + assert!(simple_regex_match("aaa", "^a*$")); 1487 + assert!(simple_regex_match("", "^a*$")); 1488 + assert!(!simple_regex_match("b", "^a*$")); 1489 + } 1490 + 1491 + #[test] 1492 + fn regex_plus() { 1493 + assert!(simple_regex_match("aaa", "^a+$")); 1494 + assert!(!simple_regex_match("", "^a+$")); 1495 + } 1496 + 1497 + #[test] 1498 + fn regex_question() { 1499 + assert!(simple_regex_match("a", "^a?$")); 1500 + assert!(simple_regex_match("", "^a?$")); 1501 + assert!(!simple_regex_match("aa", "^a?$")); 1502 + } 1503 + 1504 + #[test] 1505 + fn regex_char_class() { 1506 + assert!(simple_regex_match("b", "^[abc]$")); 1507 + assert!(!simple_regex_match("d", "^[abc]$")); 1508 + } 1509 + 1510 + #[test] 1511 + fn regex_char_range() { 1512 + assert!(simple_regex_match("m", "^[a-z]$")); 1513 + assert!(!simple_regex_match("M", "^[a-z]$")); 1514 + } 1515 + 1516 + #[test] 1517 + fn regex_negated_class() { 1518 + assert!(!simple_regex_match("a", "^[^abc]$")); 1519 + assert!(simple_regex_match("d", "^[^abc]$")); 1520 + } 1521 + 1522 + #[test] 1523 + fn regex_digit_shorthand() { 1524 + assert!(simple_regex_match("5", "^\\d$")); 1525 + assert!(!simple_regex_match("a", "^\\d$")); 1526 + } 1527 + 1528 + #[test] 1529 + fn regex_word_shorthand() { 1530 + assert!(simple_regex_match("a", "^\\w$")); 1531 + assert!(simple_regex_match("5", "^\\w$")); 1532 + assert!(simple_regex_match("_", "^\\w$")); 1533 + assert!(!simple_regex_match("!", "^\\w$")); 1534 + } 1535 + 1536 + #[test] 1537 + fn regex_alternation() { 1538 + assert!(simple_regex_match("cat", "^(?:cat|dog)$")); 1539 + assert!(simple_regex_match("dog", "^(?:cat|dog)$")); 1540 + assert!(!simple_regex_match("fish", "^(?:cat|dog)$")); 1541 + } 1542 + 1543 + #[test] 1544 + fn regex_group_quantifier() { 1545 + assert!(simple_regex_match("abab", "^(?:ab)+$")); 1546 + assert!(!simple_regex_match("abc", "^(?:ab)+$")); 1547 + } 1548 + 1549 + #[test] 1550 + fn regex_range_quantifier() { 1551 + assert!(simple_regex_match("aaa", "^a{3}$")); 1552 + assert!(!simple_regex_match("aa", "^a{3}$")); 1553 + assert!(simple_regex_match("aaaa", "^a{2,4}$")); 1554 + assert!(!simple_regex_match("a", "^a{2,4}$")); 1555 + } 1556 + 1557 + #[test] 1558 + fn regex_complex_pattern() { 1559 + // Common email-like pattern. 1560 + assert!(simple_regex_match( 1561 + "test@example.com", 1562 + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" 1563 + )); 1564 + } 1565 + 1566 + #[test] 1567 + fn pattern_anchoring() { 1568 + // Pattern attribute is implicitly anchored. 1569 + assert!(matches_pattern("hello", "[a-z]+")); 1570 + assert!(!matches_pattern("Hello", "[a-z]+")); 1571 + } 1572 + 1573 + // ── will_validate tests ── 1574 + 1575 + #[test] 1576 + fn will_validate_select() { 1577 + let mut doc = Document::new(); 1578 + let root = doc.root(); 1579 + let sel = doc.create_element("select"); 1580 + doc.append_child(root, sel); 1581 + assert!(will_validate(&doc, sel)); 1582 + } 1583 + 1584 + #[test] 1585 + fn will_validate_disabled_select() { 1586 + let mut doc = Document::new(); 1587 + let root = doc.root(); 1588 + let sel = doc.create_element("select"); 1589 + doc.set_attribute(sel, "disabled", ""); 1590 + doc.append_child(root, sel); 1591 + assert!(!will_validate(&doc, sel)); 1592 + } 1593 + 1594 + #[test] 1595 + fn will_validate_textarea() { 1596 + let mut doc = Document::new(); 1597 + let root = doc.root(); 1598 + let ta = doc.create_element("textarea"); 1599 + doc.append_child(root, ta); 1600 + assert!(will_validate(&doc, ta)); 1601 + } 1602 + 1603 + #[test] 1604 + fn will_validate_button() { 1605 + let mut doc = Document::new(); 1606 + let root = doc.root(); 1607 + let btn = doc.create_element("button"); 1608 + doc.append_child(root, btn); 1609 + assert!(!will_validate(&doc, btn)); 1610 + } 1611 + }
+443
crates/js/src/dom_bridge.rs
··· 10 10 use crate::vm::*; 11 11 use std::rc::Rc; 12 12 use we_css::parser::Parser as CssParser; 13 + use we_dom::validation::{compute_validity, validation_message, will_validate}; 13 14 use we_dom::{Document, NodeData, NodeId}; 14 15 use we_html::parse_html; 15 16 use we_style::matching::matches_selector_list; ··· 634 635 set_builtin_prop(gc, shapes, wrapper, name, Value::Function(func)); 635 636 } 636 637 } 638 + 639 + // Constraint validation methods for form controls. 640 + if matches!( 641 + doc.tag_name(node_id), 642 + Some("input" | "select" | "textarea" | "form") 643 + ) { 644 + let validation_methods: &[NativeMethod] = &[ 645 + ("checkValidity", element_check_validity), 646 + ("reportValidity", element_report_validity), 647 + ("setCustomValidity", element_set_custom_validity), 648 + ]; 649 + for &(name, callback) in validation_methods { 650 + let func = make_native(gc, name, callback); 651 + set_builtin_prop(gc, shapes, wrapper, name, Value::Function(func)); 652 + } 653 + } 637 654 } 638 655 639 656 // ── Helper: extract NodeId from a wrapper ─────────────────────────── ··· 974 991 Ok(Value::Undefined) 975 992 } 976 993 994 + // ── Constraint validation methods ─────────────────────────────────── 995 + 996 + fn element_check_validity(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 997 + let bridge = ctx 998 + .dom_bridge 999 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 1000 + let node_id = match &ctx.this { 1001 + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), 1002 + _ => None, 1003 + } 1004 + .ok_or_else(|| RuntimeError::type_error("checkValidity called on non-element"))?; 1005 + 1006 + let doc = bridge.document.borrow(); 1007 + let tag = doc.tag_name(node_id).unwrap_or(""); 1008 + 1009 + if tag == "form" { 1010 + // Form.checkValidity(): validate all controls. 1011 + let controls = doc.form_elements(node_id); 1012 + let mut all_valid = true; 1013 + for &ctrl in &controls { 1014 + if !will_validate(&doc, ctrl) { 1015 + continue; 1016 + } 1017 + let state = compute_validity(&doc, ctrl, &doc.custom_validity); 1018 + if !state.valid() { 1019 + all_valid = false; 1020 + // Fire `invalid` event on the control. 1021 + // (We just return false; full event dispatch would require VM access.) 1022 + } 1023 + } 1024 + Ok(Value::Boolean(all_valid)) 1025 + } else { 1026 + // Element.checkValidity(): validate this control. 1027 + if !will_validate(&doc, node_id) { 1028 + return Ok(Value::Boolean(true)); 1029 + } 1030 + let state = compute_validity(&doc, node_id, &doc.custom_validity); 1031 + let valid = state.valid(); 1032 + if !valid { 1033 + // Per spec, fire an `invalid` event on the element. 1034 + // We return the marker for the VM to dispatch. 1035 + drop(doc); 1036 + let _ = &args; // suppress unused warning 1037 + let event_ref = create_event_object(ctx.gc, ctx.shapes, "invalid", false, true); 1038 + let mut marker = ObjectData::new(); 1039 + marker.insert_property( 1040 + "__event_dispatch__".to_string(), 1041 + Property::builtin(Value::Boolean(true)), 1042 + ctx.shapes, 1043 + ); 1044 + marker.insert_property( 1045 + "__target_id__".to_string(), 1046 + Property::builtin(Value::Number(node_id.index() as f64)), 1047 + ctx.shapes, 1048 + ); 1049 + marker.insert_property( 1050 + "__event_ref__".to_string(), 1051 + Property::builtin(Value::Object(event_ref)), 1052 + ctx.shapes, 1053 + ); 1054 + marker.insert_property( 1055 + "__check_validity_result__".to_string(), 1056 + Property::builtin(Value::Boolean(false)), 1057 + ctx.shapes, 1058 + ); 1059 + return Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(marker)))); 1060 + } 1061 + Ok(Value::Boolean(true)) 1062 + } 1063 + } 1064 + 1065 + fn element_report_validity( 1066 + _args: &[Value], 1067 + ctx: &mut NativeContext, 1068 + ) -> Result<Value, RuntimeError> { 1069 + // reportValidity is the same as checkValidity but would also show UI. 1070 + // Since we don't have validation UI yet, delegate to checkValidity. 1071 + element_check_validity(_args, ctx) 1072 + } 1073 + 1074 + fn element_set_custom_validity( 1075 + args: &[Value], 1076 + ctx: &mut NativeContext, 1077 + ) -> Result<Value, RuntimeError> { 1078 + let bridge = ctx 1079 + .dom_bridge 1080 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 1081 + let node_id = match &ctx.this { 1082 + Value::Object(r) => get_node_id(ctx.gc, ctx.shapes, *r), 1083 + _ => None, 1084 + } 1085 + .ok_or_else(|| RuntimeError::type_error("setCustomValidity called on non-element"))?; 1086 + 1087 + let message = args 1088 + .first() 1089 + .map(|v| v.to_js_string(ctx.gc)) 1090 + .unwrap_or_default(); 1091 + 1092 + bridge 1093 + .document 1094 + .borrow_mut() 1095 + .custom_validity 1096 + .set(node_id, &message); 1097 + 1098 + Ok(Value::Undefined) 1099 + } 1100 + 977 1101 // ── HTML serialization ────────────────────────────────────────────── 978 1102 979 1103 /// Serialize the children of a node to HTML (for innerHTML getter). ··· 1440 1564 } 1441 1565 } 1442 1566 1567 + // ── Constraint validation properties ── 1568 + "willValidate" => Some(Value::Boolean(will_validate(&doc, node_id))), 1569 + "validity" => { 1570 + let state = compute_validity(&doc, node_id, &doc.custom_validity); 1571 + drop(doc); 1572 + Some(create_validity_state_object(gc, shapes, &state)) 1573 + } 1574 + "validationMessage" => { 1575 + let msg = validation_message(&doc, node_id, &doc.custom_validity); 1576 + Some(Value::String(msg)) 1577 + } 1578 + "required" => { 1579 + if !matches!(doc.tag_name(node_id), Some("input" | "select" | "textarea")) { 1580 + return None; 1581 + } 1582 + Some(Value::Boolean( 1583 + doc.get_attribute(node_id, "required").is_some(), 1584 + )) 1585 + } 1586 + "name" => { 1587 + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { 1588 + return None; 1589 + } 1590 + Some(Value::String( 1591 + doc.get_attribute(node_id, "name").unwrap_or("").to_string(), 1592 + )) 1593 + } 1594 + 1443 1595 _ => None, 1444 1596 } 1597 + } 1598 + 1599 + /// Create a JS object representing a ValidityState. 1600 + fn create_validity_state_object( 1601 + gc: &mut Gc<HeapObject>, 1602 + shapes: &mut ShapeTable, 1603 + state: &we_dom::ValidityState, 1604 + ) -> Value { 1605 + let mut data = ObjectData::new(); 1606 + data.insert_property( 1607 + "valueMissing".to_string(), 1608 + Property::data(Value::Boolean(state.value_missing)), 1609 + shapes, 1610 + ); 1611 + data.insert_property( 1612 + "typeMismatch".to_string(), 1613 + Property::data(Value::Boolean(state.type_mismatch)), 1614 + shapes, 1615 + ); 1616 + data.insert_property( 1617 + "patternMismatch".to_string(), 1618 + Property::data(Value::Boolean(state.pattern_mismatch)), 1619 + shapes, 1620 + ); 1621 + data.insert_property( 1622 + "tooLong".to_string(), 1623 + Property::data(Value::Boolean(state.too_long)), 1624 + shapes, 1625 + ); 1626 + data.insert_property( 1627 + "tooShort".to_string(), 1628 + Property::data(Value::Boolean(state.too_short)), 1629 + shapes, 1630 + ); 1631 + data.insert_property( 1632 + "rangeUnderflow".to_string(), 1633 + Property::data(Value::Boolean(state.range_underflow)), 1634 + shapes, 1635 + ); 1636 + data.insert_property( 1637 + "rangeOverflow".to_string(), 1638 + Property::data(Value::Boolean(state.range_overflow)), 1639 + shapes, 1640 + ); 1641 + data.insert_property( 1642 + "stepMismatch".to_string(), 1643 + Property::data(Value::Boolean(state.step_mismatch)), 1644 + shapes, 1645 + ); 1646 + data.insert_property( 1647 + "customError".to_string(), 1648 + Property::data(Value::Boolean(state.custom_error)), 1649 + shapes, 1650 + ); 1651 + data.insert_property( 1652 + "valid".to_string(), 1653 + Property::data(Value::Boolean(state.valid())), 1654 + shapes, 1655 + ); 1656 + Value::Object(gc.alloc(HeapObject::Object(data))) 1445 1657 } 1446 1658 1447 1659 // ── Document-level dynamic properties (e.g. document.cookie) ────── ··· 1707 1919 doc.remove_attribute(node_id, "disabled"); 1708 1920 } 1709 1921 } 1922 + true 1923 + } 1924 + "required" => { 1925 + let mut doc = bridge.document.borrow_mut(); 1926 + if !matches!(doc.tag_name(node_id), Some("input" | "select" | "textarea")) { 1927 + return false; 1928 + } 1929 + match val { 1930 + Value::Boolean(true) => { 1931 + doc.set_attribute(node_id, "required", ""); 1932 + } 1933 + _ => { 1934 + doc.remove_attribute(node_id, "required"); 1935 + } 1936 + } 1937 + true 1938 + } 1939 + "name" => { 1940 + let mut doc = bridge.document.borrow_mut(); 1941 + let name_val = val.to_js_string(gc); 1942 + doc.set_attribute(node_id, "name", &name_val); 1710 1943 true 1711 1944 } 1712 1945 _ => false, ··· 4841 5074 match result { 4842 5075 Value::Boolean(b) => assert!(b), 4843 5076 v => panic!("expected true, got {v:?}"), 5077 + } 5078 + } 5079 + 5080 + // --- Constraint validation JS API tests --- 5081 + 5082 + #[test] 5083 + fn test_will_validate_property() { 5084 + let result = eval_with_doc( 5085 + r#"<html><body><input id="i" type="text"></body></html>"#, 5086 + r#"document.getElementById("i").willValidate"#, 5087 + ) 5088 + .unwrap(); 5089 + match result { 5090 + Value::Boolean(b) => assert!(b), 5091 + v => panic!("expected true, got {v:?}"), 5092 + } 5093 + } 5094 + 5095 + #[test] 5096 + fn test_will_validate_hidden() { 5097 + let result = eval_with_doc( 5098 + r#"<html><body><input id="i" type="hidden"></body></html>"#, 5099 + r#"document.getElementById("i").willValidate"#, 5100 + ) 5101 + .unwrap(); 5102 + match result { 5103 + Value::Boolean(b) => assert!(!b), 5104 + v => panic!("expected false, got {v:?}"), 5105 + } 5106 + } 5107 + 5108 + #[test] 5109 + fn test_will_validate_disabled() { 5110 + let result = eval_with_doc( 5111 + r#"<html><body><input id="i" type="text" disabled></body></html>"#, 5112 + r#"document.getElementById("i").willValidate"#, 5113 + ) 5114 + .unwrap(); 5115 + match result { 5116 + Value::Boolean(b) => assert!(!b), 5117 + v => panic!("expected false, got {v:?}"), 5118 + } 5119 + } 5120 + 5121 + #[test] 5122 + fn test_validity_valid_no_constraints() { 5123 + let result = eval_with_doc( 5124 + r#"<html><body><input id="i" type="text"></body></html>"#, 5125 + r#"document.getElementById("i").validity.valid"#, 5126 + ) 5127 + .unwrap(); 5128 + match result { 5129 + Value::Boolean(b) => assert!(b), 5130 + v => panic!("expected true, got {v:?}"), 5131 + } 5132 + } 5133 + 5134 + #[test] 5135 + fn test_validity_value_missing() { 5136 + let result = eval_with_doc( 5137 + r#"<html><body><input id="i" type="text" required></body></html>"#, 5138 + r#"document.getElementById("i").validity.valueMissing"#, 5139 + ) 5140 + .unwrap(); 5141 + match result { 5142 + Value::Boolean(b) => assert!(b), 5143 + v => panic!("expected true, got {v:?}"), 5144 + } 5145 + } 5146 + 5147 + #[test] 5148 + fn test_validity_not_missing_when_filled() { 5149 + let result = eval_with_doc( 5150 + r#"<html><body><input id="i" type="text" required value="hello"></body></html>"#, 5151 + r#"document.getElementById("i").validity.valid"#, 5152 + ) 5153 + .unwrap(); 5154 + match result { 5155 + Value::Boolean(b) => assert!(b), 5156 + v => panic!("expected true, got {v:?}"), 5157 + } 5158 + } 5159 + 5160 + #[test] 5161 + fn test_validity_range_overflow() { 5162 + let result = eval_with_doc( 5163 + r#"<html><body><input id="i" type="number" max="10" value="20"></body></html>"#, 5164 + r#"document.getElementById("i").validity.rangeOverflow"#, 5165 + ) 5166 + .unwrap(); 5167 + match result { 5168 + Value::Boolean(b) => assert!(b), 5169 + v => panic!("expected true, got {v:?}"), 5170 + } 5171 + } 5172 + 5173 + #[test] 5174 + fn test_validity_range_underflow() { 5175 + let result = eval_with_doc( 5176 + r#"<html><body><input id="i" type="number" min="10" value="5"></body></html>"#, 5177 + r#"document.getElementById("i").validity.rangeUnderflow"#, 5178 + ) 5179 + .unwrap(); 5180 + match result { 5181 + Value::Boolean(b) => assert!(b), 5182 + v => panic!("expected true, got {v:?}"), 5183 + } 5184 + } 5185 + 5186 + #[test] 5187 + fn test_validation_message_required() { 5188 + let result = eval_with_doc( 5189 + r#"<html><body><input id="i" type="text" required></body></html>"#, 5190 + r#"document.getElementById("i").validationMessage"#, 5191 + ) 5192 + .unwrap(); 5193 + match result { 5194 + Value::String(s) => assert!(!s.is_empty()), 5195 + v => panic!("expected non-empty string, got {v:?}"), 5196 + } 5197 + } 5198 + 5199 + #[test] 5200 + fn test_validation_message_valid() { 5201 + let result = eval_with_doc( 5202 + r#"<html><body><input id="i" type="text" value="hi"></body></html>"#, 5203 + r#"document.getElementById("i").validationMessage"#, 5204 + ) 5205 + .unwrap(); 5206 + match result { 5207 + Value::String(s) => assert!(s.is_empty()), 5208 + v => panic!("expected empty string, got {v:?}"), 5209 + } 5210 + } 5211 + 5212 + #[test] 5213 + fn test_check_validity_valid() { 5214 + let result = eval_with_doc( 5215 + r#"<html><body><input id="i" type="text" value="hi"></body></html>"#, 5216 + r#"document.getElementById("i").checkValidity()"#, 5217 + ) 5218 + .unwrap(); 5219 + match result { 5220 + Value::Boolean(b) => assert!(b), 5221 + v => panic!("expected true, got {v:?}"), 5222 + } 5223 + } 5224 + 5225 + #[test] 5226 + fn test_required_property_get() { 5227 + let result = eval_with_doc( 5228 + r#"<html><body><input id="i" type="text" required></body></html>"#, 5229 + r#"document.getElementById("i").required"#, 5230 + ) 5231 + .unwrap(); 5232 + match result { 5233 + Value::Boolean(b) => assert!(b), 5234 + v => panic!("expected true, got {v:?}"), 5235 + } 5236 + } 5237 + 5238 + #[test] 5239 + fn test_required_property_not_set() { 5240 + let result = eval_with_doc( 5241 + r#"<html><body><input id="i" type="text"></body></html>"#, 5242 + r#"document.getElementById("i").required"#, 5243 + ) 5244 + .unwrap(); 5245 + match result { 5246 + Value::Boolean(b) => assert!(!b), 5247 + v => panic!("expected false, got {v:?}"), 5248 + } 5249 + } 5250 + 5251 + #[test] 5252 + fn test_name_property() { 5253 + let result = eval_with_doc( 5254 + r#"<html><body><input id="i" type="text" name="username"></body></html>"#, 5255 + r#"document.getElementById("i").name"#, 5256 + ) 5257 + .unwrap(); 5258 + match result { 5259 + Value::String(s) => assert_eq!(s, "username"), 5260 + v => panic!("expected 'username', got {v:?}"), 5261 + } 5262 + } 5263 + 5264 + #[test] 5265 + fn test_validity_type_mismatch_email() { 5266 + let result = eval_with_doc( 5267 + r#"<html><body><input id="i" type="email" value="notanemail"></body></html>"#, 5268 + r#"document.getElementById("i").validity.typeMismatch"#, 5269 + ) 5270 + .unwrap(); 5271 + match result { 5272 + Value::Boolean(b) => assert!(b), 5273 + v => panic!("expected true, got {v:?}"), 5274 + } 5275 + } 5276 + 5277 + #[test] 5278 + fn test_validity_email_valid() { 5279 + let result = eval_with_doc( 5280 + r#"<html><body><input id="i" type="email" value="user@example.com"></body></html>"#, 5281 + r#"document.getElementById("i").validity.typeMismatch"#, 5282 + ) 5283 + .unwrap(); 5284 + match result { 5285 + Value::Boolean(b) => assert!(!b), 5286 + v => panic!("expected false, got {v:?}"), 4844 5287 } 4845 5288 } 4846 5289 }
+223
crates/style/src/matching.rs
··· 215 215 ) && doc.get_attribute(node, "disabled").is_none() 216 216 } 217 217 "checked" => doc.get_attribute(node, "checked").is_some(), 218 + "required" => { 219 + matches!(doc.tag_name(node), Some("input" | "select" | "textarea")) 220 + && doc.get_attribute(node, "required").is_some() 221 + } 222 + "optional" => { 223 + matches!(doc.tag_name(node), Some("input" | "select" | "textarea")) 224 + && doc.get_attribute(node, "required").is_none() 225 + } 226 + "valid" => { 227 + use we_dom::validation::{compute_validity, will_validate}; 228 + if !will_validate(doc, node) { 229 + return false; 230 + } 231 + let state = compute_validity(doc, node, &doc.custom_validity); 232 + state.valid() 233 + } 234 + "invalid" => { 235 + use we_dom::validation::{compute_validity, will_validate}; 236 + if !will_validate(doc, node) { 237 + return false; 238 + } 239 + let state = compute_validity(doc, node, &doc.custom_validity); 240 + !state.valid() 241 + } 242 + "in-range" => { 243 + // Matches number inputs whose value is within min/max bounds. 244 + if doc.tag_name(node) != Some("input") { 245 + return false; 246 + } 247 + let input_type = doc.get_attribute(node, "type").unwrap_or("text"); 248 + if !input_type.eq_ignore_ascii_case("number") { 249 + return false; 250 + } 251 + // Must have at least one of min or max to be applicable. 252 + if doc.get_attribute(node, "min").is_none() && doc.get_attribute(node, "max").is_none() 253 + { 254 + return false; 255 + } 256 + use we_dom::validation::compute_validity; 257 + let state = compute_validity(doc, node, &doc.custom_validity); 258 + !state.range_underflow && !state.range_overflow 259 + } 260 + "out-of-range" => { 261 + if doc.tag_name(node) != Some("input") { 262 + return false; 263 + } 264 + let input_type = doc.get_attribute(node, "type").unwrap_or("text"); 265 + if !input_type.eq_ignore_ascii_case("number") { 266 + return false; 267 + } 268 + if doc.get_attribute(node, "min").is_none() && doc.get_attribute(node, "max").is_none() 269 + { 270 + return false; 271 + } 272 + use we_dom::validation::compute_validity; 273 + let state = compute_validity(doc, node, &doc.custom_validity); 274 + state.range_underflow || state.range_overflow 275 + } 218 276 _ => false, 219 277 } 220 278 } ··· 992 1050 993 1051 doc.set_active_element(Some(input), false); 994 1052 assert!(matches_selector(&doc, input, &sel)); 1053 + } 1054 + 1055 + // ----------------------------------------------------------------------- 1056 + // Pseudo-class: :required / :optional 1057 + // ----------------------------------------------------------------------- 1058 + 1059 + #[test] 1060 + fn required_pseudo_class() { 1061 + let mut doc = Document::new(); 1062 + let root = doc.root(); 1063 + let input = doc.create_element("input"); 1064 + doc.set_attribute(input, "required", ""); 1065 + doc.append_child(root, input); 1066 + 1067 + let sel = parse_first_selector(":required {}"); 1068 + assert!(matches_selector(&doc, input, &sel)); 1069 + 1070 + let optional_sel = parse_first_selector(":optional {}"); 1071 + assert!(!matches_selector(&doc, input, &optional_sel)); 1072 + } 1073 + 1074 + #[test] 1075 + fn optional_pseudo_class() { 1076 + let mut doc = Document::new(); 1077 + let root = doc.root(); 1078 + let input = doc.create_element("input"); 1079 + doc.append_child(root, input); 1080 + 1081 + let sel = parse_first_selector(":optional {}"); 1082 + assert!(matches_selector(&doc, input, &sel)); 1083 + 1084 + let req_sel = parse_first_selector(":required {}"); 1085 + assert!(!matches_selector(&doc, input, &req_sel)); 1086 + } 1087 + 1088 + #[test] 1089 + fn required_on_non_form_element() { 1090 + let mut doc = Document::new(); 1091 + let root = doc.root(); 1092 + let div = doc.create_element("div"); 1093 + doc.set_attribute(div, "required", ""); 1094 + doc.append_child(root, div); 1095 + 1096 + let sel = parse_first_selector(":required {}"); 1097 + assert!(!matches_selector(&doc, div, &sel)); 1098 + } 1099 + 1100 + // ----------------------------------------------------------------------- 1101 + // Pseudo-class: :valid / :invalid 1102 + // ----------------------------------------------------------------------- 1103 + 1104 + #[test] 1105 + fn valid_pseudo_class_no_constraints() { 1106 + let mut doc = Document::new(); 1107 + let root = doc.root(); 1108 + let input = doc.create_element("input"); 1109 + doc.append_child(root, input); 1110 + 1111 + let sel = parse_first_selector(":valid {}"); 1112 + assert!(matches_selector(&doc, input, &sel)); 1113 + 1114 + let invalid_sel = parse_first_selector(":invalid {}"); 1115 + assert!(!matches_selector(&doc, input, &invalid_sel)); 1116 + } 1117 + 1118 + #[test] 1119 + fn invalid_pseudo_class_required_empty() { 1120 + let mut doc = Document::new(); 1121 + let root = doc.root(); 1122 + let input = doc.create_element("input"); 1123 + doc.set_attribute(input, "required", ""); 1124 + doc.append_child(root, input); 1125 + 1126 + let invalid_sel = parse_first_selector(":invalid {}"); 1127 + assert!(matches_selector(&doc, input, &invalid_sel)); 1128 + 1129 + let valid_sel = parse_first_selector(":valid {}"); 1130 + assert!(!matches_selector(&doc, input, &valid_sel)); 1131 + } 1132 + 1133 + #[test] 1134 + fn valid_pseudo_class_required_filled() { 1135 + let mut doc = Document::new(); 1136 + let root = doc.root(); 1137 + let input = doc.create_element("input"); 1138 + doc.set_attribute(input, "required", ""); 1139 + doc.set_attribute(input, "value", "hello"); 1140 + doc.append_child(root, input); 1141 + 1142 + let valid_sel = parse_first_selector(":valid {}"); 1143 + assert!(matches_selector(&doc, input, &valid_sel)); 1144 + 1145 + let invalid_sel = parse_first_selector(":invalid {}"); 1146 + assert!(!matches_selector(&doc, input, &invalid_sel)); 1147 + } 1148 + 1149 + // ----------------------------------------------------------------------- 1150 + // Pseudo-class: :in-range / :out-of-range 1151 + // ----------------------------------------------------------------------- 1152 + 1153 + #[test] 1154 + fn in_range_pseudo_class() { 1155 + let mut doc = Document::new(); 1156 + let root = doc.root(); 1157 + let input = doc.create_element("input"); 1158 + doc.set_attribute(input, "type", "number"); 1159 + doc.set_attribute(input, "min", "1"); 1160 + doc.set_attribute(input, "max", "10"); 1161 + doc.set_attribute(input, "value", "5"); 1162 + doc.append_child(root, input); 1163 + 1164 + let sel = parse_first_selector(":in-range {}"); 1165 + assert!(matches_selector(&doc, input, &sel)); 1166 + 1167 + let out_sel = parse_first_selector(":out-of-range {}"); 1168 + assert!(!matches_selector(&doc, input, &out_sel)); 1169 + } 1170 + 1171 + #[test] 1172 + fn out_of_range_pseudo_class() { 1173 + let mut doc = Document::new(); 1174 + let root = doc.root(); 1175 + let input = doc.create_element("input"); 1176 + doc.set_attribute(input, "type", "number"); 1177 + doc.set_attribute(input, "min", "1"); 1178 + doc.set_attribute(input, "max", "10"); 1179 + doc.set_attribute(input, "value", "15"); 1180 + doc.append_child(root, input); 1181 + 1182 + let out_sel = parse_first_selector(":out-of-range {}"); 1183 + assert!(matches_selector(&doc, input, &out_sel)); 1184 + 1185 + let in_sel = parse_first_selector(":in-range {}"); 1186 + assert!(!matches_selector(&doc, input, &in_sel)); 1187 + } 1188 + 1189 + #[test] 1190 + fn in_range_not_applicable_without_min_max() { 1191 + let mut doc = Document::new(); 1192 + let root = doc.root(); 1193 + let input = doc.create_element("input"); 1194 + doc.set_attribute(input, "type", "number"); 1195 + doc.set_attribute(input, "value", "5"); 1196 + doc.append_child(root, input); 1197 + 1198 + // Without min/max, :in-range and :out-of-range should not match. 1199 + let in_sel = parse_first_selector(":in-range {}"); 1200 + assert!(!matches_selector(&doc, input, &in_sel)); 1201 + 1202 + let out_sel = parse_first_selector(":out-of-range {}"); 1203 + assert!(!matches_selector(&doc, input, &out_sel)); 1204 + } 1205 + 1206 + #[test] 1207 + fn in_range_not_applicable_to_text() { 1208 + let mut doc = Document::new(); 1209 + let root = doc.root(); 1210 + let input = doc.create_element("input"); 1211 + doc.set_attribute(input, "type", "text"); 1212 + doc.set_attribute(input, "min", "1"); 1213 + doc.set_attribute(input, "max", "10"); 1214 + doc.append_child(root, input); 1215 + 1216 + let in_sel = parse_first_selector(":in-range {}"); 1217 + assert!(!matches_selector(&doc, input, &in_sel)); 995 1218 } 996 1219 }