//! Phase B, the forms half: [`makeover_layout::Field`] rendered to HTML. //! //! # Why this emits strings //! //! Both webview apps build their markup as strings and hand it to `innerHTML`: //! goingson's `renderFormField` returns a template literal that fifteen call //! sites interpolate into larger literals, and Balanced Breakfast's builds //! nodes but appends them into the same string-built forms. Returning nodes //! would rewrite the surrounding templates as well, which makes it a migration //! rather than an adoption. So: strings, and the escaping comes with them. //! //! # Why one escaper is enough here //! //! goingson carries four escapers and 543 call sites that must pick between //! them, because `escapeHtml` is built on `textContent` serialization and //! **`textContent` refuses to encode `"`**. That is what makes it unsound in an //! attribute, and it is the whole reason the choice exists. Its `escape.js` //! records the finding as the CHRONIC-XSS seal, and its test suite has a gate //! keeping the unsafe one off the namespace. //! //! [`escape`] here is not built on that, so it encodes the quote along with //! everything else, which makes one function sound in both sinks. The four-way //! choice does not move into Rust: it disappears. Nothing in this module hands //! an unescaped value to the output except through [`Markup`], which a caller //! has to name. //! //! # What the description does not carry //! //! One thing: the **current value**, which arrives in [`Filling`]. //! //! It used to be three. Writing this emitter is what found them, and the other //! two turned out not to be renderer state at all — the placeholder is //! user-facing text that sits with `label` and `hint`, and a select's options //! are needed by every renderer, which is how each of them ends up inventing a //! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0, //! `Choice` included, and this crate reads them off [`Field`] now. //! //! The value stays, and it is not a leftover. A webview reads it back out of //! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal //! keeps an edit buffer; a description carrying it would have to carry a way to //! write it back, at which point it is a form model. use crate::{Emit, class}; use makeover_layout::{Choice, Field, FieldKind}; use std::fmt::Write as _; /// A string that is already markup, and is emitted without escaping. /// /// The one hole in the escaping, and it has to be named to be used. goingson /// has two live callers that need it, both passing a recurrence-config block /// built elsewhere, and both would otherwise have their markup rendered as /// visible angle brackets. A caller constructing this is stating that the /// contents are trusted; nothing here can check that for them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Markup<'a>(pub &'a str); /// What the field currently holds. /// /// An enum rather than a bag of optional fields, on the same reasoning /// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable /// here, where a struct would let it be said and then have to cope. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Value<'a> { /// Nothing yet. #[default] Absent, /// The value of anything that takes typed text, a select included: what a /// select holds is the `value` of one of [`Field::options`]'s /// [`Choice`]s. /// /// It carried the options too until makeover-layout 0.8.0 moved them onto /// the field, which collapsed a `Chosen { options, value }` variant into /// this one. `makeover-immediate` arrived at the same single-variant shape /// on its own, from the other direction. Text(&'a str), /// A checkbox, on or off. On(bool), } impl<'a> Value<'a> { /// The value as text, for the kinds that submit one. const fn as_text(&self) -> &'a str { match self { Self::Text(text) => text, Self::Absent | Self::On(_) => "", } } } /// Everything about the field that the description does not carry. #[derive(Debug, Clone, Copy, Default)] pub struct Filling<'a> { /// What the field holds now. pub value: Value<'a>, /// Markup appended inside the group, after the hint. Not escaped. pub trailing: Option>, /// Scopes the `id` attributes to one instance of the form. /// /// The field's `name` is what the value submits under and is the same /// wherever the form appears; its `id` has to be unique in the document, /// and those two facts stop agreeing the moment a form appears twice. /// goingson hits this directly: its new-task and edit-task modals are the /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep /// `label for` and `aria-describedby` pointing at the right control. /// /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to /// `name`, which would change what the form submits. pub id_prefix: Option<&'a str>, } impl<'a> Filling<'a> { /// A filling that carries a value and nothing else. #[must_use] pub const fn of(value: Value<'a>) -> Self { Self { value, trailing: None, id_prefix: None, } } /// The document-unique id for a field of this name. fn id_for(&self, name: &str) -> String { match self.id_prefix { Some(prefix) => format!("{}-{}", escape(prefix), escape(name)), None => escape(name), } } } /// Encode the five characters that let a value stop being a value. /// /// Sound in element text and in a double-quoted attribute alike, which is the /// property `textContent`-based escaping cannot have. Both sinks are covered by /// one function so that no call site has to choose, here or downstream. #[must_use] pub fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for ch in text.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), other => out.push(other), } } out } /// The `type` an input takes for a kind. /// /// [`FieldKind::Secret`] is `password`, which both apps already map by hand. const fn input_type(kind: FieldKind) -> &'static str { match kind { FieldKind::Secret => "password", FieldKind::Number => "number", FieldKind::Checkbox => "checkbox", FieldKind::File => "file", FieldKind::Hidden => "hidden", // Not decoration. Each of these changes the keyboard a touch device // offers and turns on the platform's own validation, which is why the // description names them apart from text rather than letting the app // pass an HTML type through. FieldKind::Email => "email", FieldKind::Url => "url", FieldKind::Tel => "tel", // The same argument, and it buys more here than anywhere else in this // list: a native picker as well as the keyboard and the validation. // Both submit the format `makeover-layout` names, `DATE_FORMAT` and // `DATETIME_FORMAT`, so honouring it costs this renderer nothing. FieldKind::Date => "date", FieldKind::DateTime => "datetime-local", FieldKind::Radio => "radio", // Select and Textarea are not inputs at all; they never reach here. // Radio is one, but it is emitted once per option by `radio_html` and // so does not reach here either. FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text", // A kind added to the description since this renderer was built. Text // accepts any value the others would, so it degrades rather than // dropping the field. _ => "text", } } /// The attributes every visible control carries, error state included. /// /// `aria-invalid` is the whole reason the error state is readable at all: the /// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather /// than on a class, so a control rendered already-invalid without it is styled /// as if nothing were wrong. goingson's runtime validation path sets the /// attribute and its initial render does not, which is exactly the drift one /// emitter removes. /// `id` and `name` arrive separately because they are not the same fact. The /// name is what submits and is fixed by the description; the id has to be /// unique in the document and so carries [`Filling::id_prefix`] when a form /// appears more than once. fn control_attributes(field: &Field<'_>, id: &str, name: &str) -> String { let mut attrs = format!(" id=\"{id}\" name=\"{}\"", escape(name)); if field.required { attrs.push_str(" required"); } // makeover-layout 0.11.0's constraints. The description carries the rule and // this emits the browser's idiom for it, which is the model `required` has // been using since before the crate wrote down that it carried none. // Enforcement is still whoever validated's, and arrives back as `error`. if let Some(limit) = field.max_length { let _ = write!(attrs, " maxlength=\"{limit}\""); } if let Some(min) = field.min { let _ = write!(attrs, " min=\"{}\"", escape(min)); } if let Some(max) = field.max { let _ = write!(attrs, " max=\"{}\"", escape(max)); } if field.invalid() { attrs.push_str(" aria-invalid=\"true\""); } attrs.push_str(&described_by(field, id)); attrs } /// The `aria-describedby` naming whatever of the hint and the error exist. /// /// Both associations, in the order they are useful: the standing help, then /// what is currently wrong. goingson's runtime path points describedby at the /// error alone and drops the hint association it never made in the first place; /// naming both here means the hint survives an error appearing. /// /// Its own function because a radio group carries it on the group rather than /// on a control, and one reading of "what describes this field" is the point. fn described_by(field: &Field<'_>, id: &str) -> String { let mut described = Vec::new(); if field.hint.is_some() { described.push(format!("{id}-hint")); } if field.error.is_some() { described.push(format!("{id}-error")); } if described.is_empty() { return String::new(); } format!(" aria-describedby=\"{}\"", described.join(" ")) } /// Whether the field's control is a set of elements rather than one. /// /// A DOM concern rather than a description one, which is why it is decided here /// and not in `makeover-layout`: `for` and `id` are an HTML association and /// egui has no counterpart to get wrong. A `