//! Shared rendering for form fields and their validation errors. //! //! Validation itself stays in each handler - only the repeated markup //! (label/legend, the `.error` div, `aria-invalid`/`aria-describedby` //! wiring) and the error summary live here. //! //! Every field renders its `.error` slot whether or not it has one, hidden //! until it does, so a form validated in the browser has somewhere to put a //! message without building the element itself. use maud::{html, Markup, PreEscaped}; /// A single text input, labeled, with an optional hint and error. pub fn text_field( id: &str, label: &str, value: &str, hint: Option, error: Option<&str>, ) -> Markup { let error_id = format!("{id}-error"); html! { div.form-item { label for=(id) { (label) } @if let Some(hint) = hint { div.hint { (hint) } } div.error id=(error_id) hidden[error.is_none()] { (error.unwrap_or_default()) } input type="text" id=(id) name=(id) value=(value) aria-invalid=[error.is_some().then_some("true")] aria-describedby=[error.is_some().then_some(error_id.as_str())]; } } } /// A multi-line text input, labeled, with an optional hint and error. pub fn textarea_field( id: &str, label: &str, value: &str, hint: Option, error: Option<&str>, ) -> Markup { let error_id = format!("{id}-error"); html! { div.form-item { label for=(id) { (label) } @if let Some(hint) = hint { div.hint { (hint) } } div.error id=(error_id) hidden[error.is_none()] { (error.unwrap_or_default()) } textarea id=(id) name=(id) aria-invalid=[error.is_some().then_some("true")] aria-describedby=[error.is_some().then_some(error_id.as_str())] { (value) } } } } /// A group of radio buttons under one legend, with a shared error. /// /// `options` pairs each radio's `value` with its label. pub fn radio_group( id: &str, legend: &str, options: &[(&str, L)], selected: Option<&str>, error: Option<&str>, ) -> Markup { let error_id = format!("{id}-error"); html! { fieldset.form-item.radio-group aria-invalid=[error.is_some().then_some("true")] aria-describedby=[error.is_some().then_some(error_id.as_str())] { legend { (legend) } @if let Some(error) = error { div.error id=(error_id) { (error) } } @for (value, label) in options { div.radio-item { input type="radio" id={ (id) "-" (value) } name=(id) value=(value) checked[selected == Some(*value)]; label for={ (id) "-" (value) } { (label) } } } } } } /// The messages to summarize at the top of a form: pairs of the id of the /// input each is about (`None` for a failure not tied to one field) and the /// message itself. pub type ErrorSummary<'a> = Vec<(Option<&'a str>, &'a str)>; /// A focusable box listing every error, each linking to its field, shown /// above the form's heading. Moves focus to itself on load, so screen /// readers announce it immediately and sighted users land on it first. /// /// Renders nothing if `errors` is empty. pub fn error_summary(errors: &ErrorSummary) -> Markup { html! { @if !errors.is_empty() { script defer { (PreEscaped("document.getElementById('error-summary').focus();")) } div.accent-red role="alert" tabindex="-1" id="error-summary" { h2 { "There's a problem" } ul.error-list { @for (field_id, message) in errors { li { @if let Some(field_id) = field_id { a href={ "#" (field_id) } { (message) } } @else { (message) } } } } } } } }