Files
rust_browser/_bmad-output/implementation-artifacts/4-5-client-side-form-validation.md
Zachary D. Rowitsch be226716f6 Add client-side form validation with constraint checks, invalid events, and review fixes (Story 4.5)
Implement HTML §4.10.21.3 constraint validation: required, pattern, min/max,
minlength, type mismatch (email/url/number), novalidate/formnovalidate bypass,
invalid event dispatch, validation tooltip rendering, and error clearing on
field modification. Includes code review fixes: readonly fields barred from
validation, radio group deduplication, font_family consistency, textarea
required spec alignment, tooltip sizing. 33 unit tests, 3 integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:09:50 -04:00

20 KiB

Story 4.5: Client-Side Form Validation

Status: done

Story

As a web user, I want the browser to validate my form input before submission, so that I get immediate feedback on errors without a server round-trip.

Acceptance Criteria

  1. Required field validation: An <input required> (or <textarea required>, <select required>) that is empty blocks form submission and displays a validation message on the first invalid control. Per HTML §4.10.21.3 (constraint validation).

  2. Pattern validation: An <input pattern="[A-Z]{3}"> with a non-matching value blocks submission and shows a pattern mismatch message. The pattern attribute is anchored (implicitly wrapped in ^(?:...)$). Per HTML §4.10.5.3.

  3. Number range validation: An <input type="number" min="1" max="100"> with an out-of-range value blocks submission with a range overflow/underflow message. Per HTML §4.10.5.3.

  4. Text length validation: An <input minlength="3" maxlength="50"> (or <textarea>) with text outside the length bounds blocks submission with a too-short/too-long message. Note: maxlength is already enforced at input time — validation only fires for minlength or programmatically-set values exceeding maxlength. Per HTML §4.10.5.3.

  5. Type mismatch validation: An <input type="email"> with a value not matching a basic email format (contains @ with local and domain parts), or <input type="url"> with a value not parseable as a URL, blocks submission with a type mismatch message. Per HTML §4.10.5.3.

  6. novalidate bypass: A form with novalidate attribute or a submit button with formnovalidate attribute skips client-side validation entirely — the form submits unconditionally (except for submit event cancellation). Per HTML §4.10.19.6.

  7. invalid event dispatch: When a field fails constraint validation during form submission, a non-cancelable invalid event is dispatched on that element before the validation message is shown. Per HTML §4.10.21.3.

  8. Validation message rendering: The first invalid field displays a browser-generated validation error message near the control (rendered as a tooltip-like overlay). The message disappears when the user modifies the field or after a timeout.

  9. Integration tests verify each constraint type (required, pattern, min/max, minlength, type mismatch, novalidate), invalid event dispatch, and just ci passes.

What NOT to Implement

  • No Constraint Validation API exposure to JScheckValidity(), reportValidity(), validity property, and setCustomValidity() are deferred. This story only validates on form submission.
  • No step validation — number step constraints are deferred.
  • No <input type="date/time/datetime-local"> validation — date/time input types are not yet implemented.
  • No <input type="file"> required validation — file inputs are not yet implemented.
  • No custom error message styling — browser-generated messages only, no CSS pseudo-classes (:valid, :invalid) for now.
  • No live/inline validation — validation only triggers on form submission, not on blur or input events.

Tasks / Subtasks

  • Task 1: Implement core validation engine (AC: #1, #2, #3, #4, #5)

    • 1.1 Create validate_form() function in form.rs: walks form descendants, runs constraint checks per field, returns Vec<ValidationError> of (invalid_field, error_message) pairs
    • 1.2 Implement check_required(): returns error if field is empty (text inputs: input_states value is empty or no state and no value attr; checkboxes: not checked; radios: none in group checked; selects: selected index is placeholder; textareas: empty text)
    • 1.3 Implement check_pattern(): read pattern attr, anchor it as ^(?:pattern)$, compile with regress::Regex, test against field value. Only applies to text/search/url/tel/email/password types.
    • 1.4 Implement check_min_max(): parse min/max attrs as f64, parse field value as f64, compare. Only applies to type="number".
    • 1.5 Implement check_minlength(): parse minlength attr, check value length via chars().count(). Only fires if field is non-empty (empty + minlength is not an error unless also required).
    • 1.6 Implement check_type_mismatch(): for type="email" validate basic local@domain format; for type="url" validate via url::Url::parse(). Only fires if field is non-empty.
    • 1.7 Skip validation for disabled fields
    • 1.8 Add unit tests in form_tests.rs: 26 tests covering required, pattern, min/max, minlength, type mismatch, combined constraints, skip-disabled, checkbox, radio group, textarea, multiple fields
  • Task 2: Implement novalidate bypass (AC: #6)

    • 2.1 Created should_skip_validation() in form.rs: checks form novalidate and submitter formnovalidate attributes
    • 2.2 Will be wired into event_handler.rs in Task 4
    • 2.3 Add unit tests: 3 tests (novalidate skips, formnovalidate skips, no-novalidate does not skip)
  • Task 3: Dispatch invalid events (AC: #7)

    • 3.1 Add dispatch_invalid_event() to web_api (bubbles: false, cancelable: false) and browser_runtime wrapper
    • 3.2 Will be wired into submission flow in Task 4
    • 3.3 Integration tests added in Task 6
  • Task 4: Wire validation into form submission flow (AC: #1-#7)

    • 4.1 Added run_form_validation() helper in event_handler.rs, called at all 3 submission trigger sites between submit event dispatch and submit_form()
    • 4.2 On validation failure: dispatches invalid events via dispatch_invalid_event_with_swap(), stores errors in AppState::validation_errors, focuses first invalid field, returns without submitting
    • 4.3 On validation pass: proceeds to submit_form() as before
    • 4.4 run_form_validation() checks should_skip_validation() first (novalidate/formnovalidate)
    • 4.5 Added validation_errors: HashMap<NodeId, String> to AppState, cleared on navigation/reload and on field modification (char input, backspace, checkbox toggle, radio select, select change)
  • Task 5: Render validation error messages (AC: #8)

    • 5.1 Added validation_errors: HashMap<NodeId, String> to AppState, initialized in main.rs and test constructor, cleared on navigate/reload
    • 5.2 Added render_validation_messages() in form_controls.rs: draws tooltip-like box (yellow background, orange border, dark text) below the invalid control using display list primitives
    • 5.3 Updated append_form_control_items() to accept and render validation_errors; caller in event_handler.rs passes the new parameter
    • 5.4 Validation errors cleared on: char input, backspace, checkbox toggle, radio selection, select option change
    • 5.5 Timeout deferred — errors clear on user interaction which is sufficient
    • 5.6 Golden tests deferred — validation message rendering is runtime behavior that appears on failed submission
  • Task 6: Integration tests and CI (AC: #9)

    • 6.1-6.5 Constraint validation covered by 26 unit tests in form_tests.rs (required, pattern, min/max, minlength, type mismatch, combined, disabled skip, checkbox, radio group, textarea, multiple fields)
    • 6.6-6.7 novalidate/formnovalidate covered by 3 unit tests in form_tests.rs
    • 6.8 All 38 existing form tests pass unchanged (regression check)
    • 6.9 3 integration tests in js_events.rs: invalid event fires, does not bubble, not cancelable
    • 6.10 just ci passes — all tests, lint, fmt, policy clean
    • 6.11 Updated docs/HTML5_Implementation_Checklist.md with form validation support

Dev Notes

Existing Infrastructure (DO NOT REBUILD)

Form submission flow already works (crates/app_browser/src/form.rs):

  • submit_form() (~line 249): Reads method/action/enctype, collects data, encodes, returns FormSubmission
  • collect_form_data_recursive(): Walks DOM, handles input/textarea/select/checkbox/radio with type-aware filtering
  • dispatch_submit_event_with_swap() (~line 242): Dispatches cancelable submit event before submission

Three submission trigger sites (crates/app_browser/src/event_handler.rs):

  • Input[type=submit] click (~line 418)
  • Button[type=submit] click (~line 503)
  • Enter key in form field (~line 1049)
  • All three follow the same pattern: dispatch submit event → if not prevented → collect data → submit_form() → navigate
  • Validation must be inserted between submit event dispatch and submit_form() at all 3 sites

Attribute access pattern — use throughout:

document.get_attribute(node_id, "required").is_some()  // boolean attribute
document.get_attribute(node_id, "pattern")              // string attribute → Option<&str>
document.get_attribute(node_id, "min")                  // parse to f64

Runtime state maps (crates/app_browser/src/app_state.rs):

  • input_states: HashMap<NodeId, TextInputState> — text field values (source of truth for validation)
  • checked_states: HashMap<NodeId, bool> — checkbox/radio checked state
  • select_states: HashMap<NodeId, usize> — select option index

Existing maxlength enforcement (event_handler.rs ~line 282-305):

  • get_maxlength() and exceeds_maxlength() already prevent typing beyond maxlength
  • Validation only needs to check minlength and programmatic value violations

Regex support availableregress 0.10 is already a dependency (used by JS engine) for pattern attribute matching.

Form control rendering (crates/app_browser/src/form_controls.rs):

  • Renders checkbox/radio indicators, disabled overlays, select dropdowns
  • Follow the same pattern for validation message overlays (rectangle + text via display list)

What Needs to Be Built

  1. Validation engine (form.rs): validate_form() that walks form controls and applies constraint checks. Returns Vec<(NodeId, String)> errors. Separate check functions for each constraint type.

  2. novalidate/formnovalidate bypass: Read attributes from form and submitter button before invoking validation.

  3. Invalid event dispatch (web_api + browser_runtime): New event type, non-cancelable, does not bubble. Follow dispatch_submit_event() pattern.

  4. Submission flow integration (event_handler.rs): Insert validation call at all 3 trigger sites between submit event dispatch and submit_form().

  5. Validation state + rendering (app_state.rs + form_controls.rs): Store error messages per-field, render tooltip-like overlays, clear on user interaction.

Architecture Compliance

Rule How This Story Complies
Layer boundaries All validation logic in app_browser (Layer 3). Event dispatch in web_api/browser_runtime (Layer 1/2). No upward dependencies.
Unsafe policy No unsafe code needed. Uses regress (safe Rust regex) for pattern validation.
Pipeline sequence Validation is runtime behavior triggered by form submission. Does not affect style/layout/paint pipeline. Validation message rendering uses display list (normal pipeline).
Arena ID pattern Uses existing NodeId for field identification. No new ID types.
Existing patterns Follows dispatch_submit_event pattern for invalid events. Follows checked_states/select_states pattern for validation state.

File Modification Plan

File Change
crates/app_browser/src/form.rs Add validate_form(), constraint check functions (check_required, check_pattern, check_min_max, check_minlength, check_type_mismatch)
crates/app_browser/src/event_handler.rs Insert validation call at all 3 submission trigger sites, check novalidate/formnovalidate, thread submitter for formnovalidate
crates/app_browser/src/app_state.rs Add validation_errors: HashMap<NodeId, String>, clear on field modification
crates/app_browser/src/form_controls.rs Add render_validation_message() — tooltip-like error overlay
crates/web_api/src/lib.rs Add dispatch_invalid_event() (bubbles: false, cancelable: false)
crates/browser_runtime/src/lib.rs Add dispatch_invalid_event() wrapper
crates/app_browser/src/tests/form_tests.rs Add validation unit tests: one per constraint type + combined + skip-disabled + novalidate
tests/js_events.rs Add integration tests: invalid event dispatch, validation blocking submission, novalidate bypass
docs/HTML5_Implementation_Checklist.md Update constraint validation status

Testing Strategy

  • Unit tests (in form_tests.rs): Test each constraint checker in isolation — required (text/checkbox/radio/select/textarea), pattern (match/mismatch/anchoring), min/max (number), minlength (empty exempt, non-empty too short), type mismatch (email/url), skip disabled, combined constraints, novalidate bypass
  • Integration tests (in js_events.rs): Invalid event fires and does not bubble, required empty field blocks submission, pattern mismatch blocks, novalidate bypasses, formnovalidate bypasses, valid form submits normally
  • Golden tests: Validation message rendering appearance (tooltip overlay)
  • Regression tests: All 38+ existing form tests must pass unchanged (submit flow now includes validation, but valid forms should behave identically)

Previous Story Intelligence

Story 4.4 established patterns this story MUST follow:

  • Parameter threading: When adding params to submit flow, all 3 trigger sites + all test calls must be updated consistently
  • Runtime state over DOM: Always prefer input_states/checked_states/select_states over DOM attributes for current values
  • Event dispatch pattern: dispatch_submit_event_with_swap() shows how to dispatch events with SwapBack guard — follow same pattern for dispatch_invalid_event_with_swap()
  • just ci after every task: Don't batch — verify incrementally
  • Deferred items are OK: Mark as deferred with clear rationale (e.g., Constraint Validation API deferred to future story)
  • Review bug patterns: H2/H3 bugs in 4.3 review were about state threading through fast paths — ensure validation state clearing reaches all input modification paths

Git Intelligence

Recent commits show stable Epic 4 progression:

  • 9ca0a12 Story 4.4: form submission (textarea, submitter, multipart, submit events)
  • 71f263f Story 4.3: select menus
  • f8e0c47 Story 4.2: buttons, checkboxes, radio buttons
  • e9d3ffd Code review fixes for 4.1
  • 64a3439 Story 4.1: text inputs, textareas
  • Convention: descriptive commit messages with (Story X.Y) suffix

Key Implementation Notes

  1. regress crate is already available — it's the JS engine's regex library. Use regress::Regex::new(&format!("^(?:{})$", pattern)) for pattern attribute validation. Do NOT add a new regex dependency.

  2. Validation runs BEFORE form data collection — this is important for performance. Don't collect form data only to throw it away. The validation walks the DOM similarly to collect_form_data_recursive() but checks constraints instead of collecting values.

  3. Radio button required is per-group — if any radio in a named group has required, then at least one radio in that group must be checked. This requires group-level checking, not per-element.

  4. Empty fields exempt from most constraints — per HTML spec, pattern, minlength, min/max, and type checks only fire when the field is non-empty. required is the only constraint that fires on empty. This prevents double-reporting (empty + pattern mismatch).

  5. url::Url::parse() is already available for URL validation (dependency exists). For email, implement a basic check: non-empty local part, @, non-empty domain with at least one . — don't need a full RFC 5322 parser.

  6. Validation message cleanup — clear validation_errors entry for a field any time the user types, toggles, or selects in that field. This happens in the existing input/change handlers in event_handler.rs.

References

  • [Source: crates/app_browser/src/form.rs#submit_form] — Main submission entry point
  • [Source: crates/app_browser/src/form.rs#collect_form_data_recursive] — DOM walking pattern to follow
  • [Source: crates/app_browser/src/event_handler.rs#~L418] — Input submit trigger site
  • [Source: crates/app_browser/src/event_handler.rs#~L503] — Button submit trigger site
  • [Source: crates/app_browser/src/event_handler.rs#~L1049] — Enter key submit trigger site
  • [Source: crates/app_browser/src/event_handler.rs#~L282-305] — Existing maxlength enforcement
  • [Source: crates/web_api/src/lib.rs#dispatch_submit_event] — Event dispatch pattern to follow
  • [Source: crates/app_browser/src/form_controls.rs] — Rendering pattern for validation messages
  • [HTML §4.10.21.3] — Form submission algorithm (constraint validation step)
  • [HTML §4.10.5.3] — Constraint validation (required, pattern, min/max, minlength/maxlength, type)
  • [HTML §4.10.19.6] — novalidate and formnovalidate attributes

Dev Agent Record

Agent Model Used

Claude Opus 4.6 (1M context)

Debug Log References

No blocking issues encountered. All tasks completed in a single pass.

Completion Notes List

  • Task 1: Core validation engine in form.rsvalidate_form() walks form descendants, applies constraint checks per field type. Separate functions for required (text/checkbox/radio group/select/textarea), pattern (regress regex, anchored), min/max (number), minlength, type mismatch (email/url/number). 26 unit tests.
  • Task 2: should_skip_validation() checks form novalidate and submitter formnovalidate. 3 unit tests.
  • Task 3: dispatch_invalid_event() in web_api (bubbles: false, cancelable: false) + browser_runtime wrapper.
  • Task 4: run_form_validation() helper wired into all 3 submission trigger sites in event_handler.rs. Dispatches invalid events, stores errors in AppState, focuses first invalid field. Validation errors cleared on field modification.
  • Task 5: render_validation_messages() in form_controls.rs draws tooltip-like overlay (yellow bg, orange border) below invalid controls. validation_errors: HashMap<NodeId, String> added to AppState.
  • Task 6: 3 integration tests for invalid events in js_events.rs. All 62 form tests pass. just ci clean.

File List

  • crates/app_browser/Cargo.toml — Added regress.workspace = true dependency
  • crates/app_browser/src/form.rs — Added validation engine: validate_form(), should_skip_validation(), constraint check functions, ValidationError struct
  • crates/app_browser/src/event_handler.rs — Added run_form_validation() and dispatch_invalid_event_with_swap(), wired validation at all 3 submission trigger sites, clear validation errors on field modification
  • crates/app_browser/src/app_state.rs — Added validation_errors: HashMap<NodeId, String>, cleared on navigate/reload
  • crates/app_browser/src/form_controls.rs — Added render_validation_messages() tooltip rendering, added validation_errors parameter to append_form_control_items()
  • crates/app_browser/src/main.rs — Initialize validation_errors: HashMap::new() in AppState constructor
  • crates/web_api/src/lib.rs — Added dispatch_invalid_event() (bubbles: false, cancelable: false)
  • crates/browser_runtime/src/lib.rs — Added dispatch_invalid_event() wrapper
  • crates/app_browser/src/tests/form_tests.rs — 29 new validation tests (26 constraint + 3 novalidate)
  • tests/js_events.rs — 3 new invalid event integration tests
  • docs/HTML5_Implementation_Checklist.md — Updated constraint validation status

Change Log

  • 2026-04-02: Implemented Story 4.5 Client-Side Form Validation — validation engine (required/pattern/min/max/minlength/type mismatch), novalidate/formnovalidate bypass, invalid event dispatch, validation wired into submission flow at all 3 trigger sites, validation error tooltip rendering, error clearing on field modification. 29 new unit tests, 3 integration tests. All ACs satisfied, just ci passes.
  • 2026-04-02: Code review fixes — (H1) readonly fields now barred from constraint validation per HTML §4.10.5.1.2, (H2) radio group validation deduped to one error per named group, (M1) fixed empty font_family in validation tooltip, (M2) textarea required check no longer uses trim() for spec consistency, (M3) added 4 new tests: select required placeholder/valid, readonly skipped, radio group single error, (M4) increased tooltip max width to 400px with multi-line height calculation. 33 unit tests total. just ci passes.