Skip to main content

max / makeover-webview

Render a field description to form markup Phase B, the forms half. `form::field_html` takes a makeover-layout `Field` and emits the group both webview apps build by hand: label, control, hint, error, in goingson's shape and class names, so adoption there deletes `renderFormField` rather than restyling anything. Strings, not nodes. Both apps interpolate a rendered field into a larger string-built form -- goingson's fifteen call sites do it inside template literals bound for innerHTML -- so returning nodes would rewrite the surrounding templates too. That is a migration, and phase B is meant to be an adoption. Strings mean escaping, and the crate owns it. One escaper covers both sinks here: goingson carries four and has to choose correctly at 543 call sites only because its escapeHtml is built on textContent serialization, which will not encode a double quote. Rust has no such constraint, so the four-way choice does not move into the crate, it disappears. The single hole is `Markup`, a newtype a caller has to name, which exists because goingson has two live callers passing a recurrence-config block built elsewhere. Three things the emitter does that neither app does at initial render: - aria-invalid="true" on an invalid control. The generated stylesheet keys its danger ring on that attribute rather than on a class, and goingson sets it from its runtime validation path but not from its renderer, so a field rendered already-invalid was styled as though nothing were wrong. - aria-describedby naming the hint as well as the error, so standing help survives an error arriving. goingson's runtime path overwrites the association with the error alone. - A secret never carries its value into the markup, on FieldKind::confidential. Neither app pre-fills one today, so this costs nothing and shuts the door first. Carried over from goingson rather than left for the second app to rediscover: a select handed a value no option carries keeps it as its own selected option. Without that the browser falls back to the first option and the next save writes a value nobody chose, which goingson hit with a retention default of 10 against a 1/3/7/14/0 list. WHAT THE DESCRIPTION DOES NOT CARRY: the current value, a select's options and the placeholder all arrive renderer-side in `Filling`. The first two are genuinely renderer state. The placeholder is user-facing text and belongs beside label and hint in makeover-layout; it sits here because that crate is published and adding a field is a breaking change. Filed separately. The emitted class names -- form-group, form-label, form-hint, form-error, form-checkbox-label -- are the apps' own and are NOT emitted by the stylesheet half, which only generates what the description can derive. Whether they belong in the description is the next question this raises rather than one it answers. Version unchanged at 0.5.1: this adds a public module, so releasing it is a minor bump, and bumps are asked for rather than taken. 44 tests pass (16 new), doctest passes, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 03:11 UTC
Signed with PGP, not checked
Commit: 96bea5f0f9d93fc077fd55b5b9b2adf4ddfbc227
Parent: 8ca1160
3 files changed, +555 insertions, -9 deletions
M README.md +36 -4
@@ -15,9 +15,9 @@
15 15 the wrong renderer to derive a vocabulary from: it can express anything, so it
16 16 never pushes back.
17 17
18 - ## Phase A: the stylesheet only
18 + ## Phase A: the stylesheet
19 19
20 - No markup, deliberately. GoingsOn has 145 `innerHTML` sites and Balanced
20 + No markup here, deliberately. GoingsOn has 145 `innerHTML` sites and Balanced
21 21 Breakfast 175 `createElement` sites, so moving markup is a migration while
22 22 adopting a generated stylesheet is a deletion. The apps keep every line of their
23 23 markup and gain the classes.
@@ -41,6 +41,38 @@
41 41 carries a pressed state that an immediate-mode renderer resolves per call site,
42 42 eighteen of them in audiofiles.
43 43
44 + ## Phase B: form markup
45 +
46 + `form::field_html` renders a `makeover_layout::Field` to the group both apps
47 + build by hand today: label, control, hint, error, in goingson's shape and class
48 + names, so adopting it deletes `renderFormField` rather than restyling anything.
49 +
50 + ```rust
51 + use makeover_layout::{Field, FieldKind};
52 + use makeover_webview::{Emit, form::{Filling, Value, field_html}};
53 +
54 + let field = Field::new(FieldKind::Text, "title", "Title");
55 + let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
56 + ```
57 +
58 + It emits strings because both apps interpolate fields into larger string-built
59 + forms, and it escapes them itself. One escaper covers both sinks: goingson needs
60 + four and a correct choice at 543 call sites only because `textContent`
61 + serialization will not encode a double quote, which is not a constraint Rust
62 + has. The single hole is `form::Markup`, which a caller has to name.
63 +
64 + Three things the emitter does that neither app does at initial render:
65 +
66 + - `aria-invalid="true"` on an invalid control, which is what the generated
67 + stylesheet keys its danger ring on. goingson sets it from its runtime
68 + validation path and not from its renderer, so a field rendered already-invalid
69 + is styled as though nothing were wrong.
70 + - `aria-describedby` naming the hint as well as the error, so standing help
71 + survives an error appearing.
72 + - A secret never carries its value into the markup, on `FieldKind::confidential`.
73 +
74 + Rows and tables are the other half of phase B and are not written yet.
75 +
44 76 ## Substitution, three ways
45 77
46 78 `Fill::Well` has no colour on makeover before 2.3.0, and the three renderers
@@ -55,8 +87,8 @@
55 87
56 88 ## Status
57 89
58 - On crates.io at 0.1.0. Consumers are GoingsOn and Balanced Breakfast, both
59 - through makeover-build.
90 + On crates.io at 0.5.1, which is the CSS half only; the form markup is unreleased.
91 + Consumers are GoingsOn and Balanced Breakfast, both through makeover-build.
60 92
61 93 `cargo run --example dump` prints the stylesheet.
62 94
M src/lib.rs +19 -5
@@ -15,16 +15,28 @@
15 15 //! always the wrong renderer to derive a vocabulary from: it can express
16 16 //! anything, so it never pushes back.
17 17 //!
18 - //! # Phase A: the stylesheet only
18 + //! # Phase A: the stylesheet
19 19 //!
20 - //! This emits component CSS and no markup, deliberately. GoingsOn has 145
21 - //! `innerHTML` sites and Balanced Breakfast 175 `createElement` sites; moving
22 - //! markup is a migration, while adopting a generated stylesheet is a deletion.
23 - //! The apps keep every line of their markup and gain the classes.
20 + //! This module emits component CSS and no markup, deliberately. GoingsOn has
21 + //! 145 `innerHTML` sites and Balanced Breakfast 175 `createElement` sites;
22 + //! moving markup is a migration, while adopting a generated stylesheet is a
23 + //! deletion. The apps keep every line of their markup and gain the classes.
24 24 //!
25 25 //! The bevel properties are byte-identical to what both apps already
26 26 //! hand-write, which is asserted below.
27 27 //!
28 + //! # Phase B: the markup, one description at a time
29 + //!
30 + //! [`form`] renders [`makeover_layout::Field`], which is the half of phase B
31 + //! whose description is settled. It emits strings, because both apps
32 + //! interpolate their fields into larger string-built forms and returning nodes
33 + //! would rewrite those too. It owns its own escaping, on the reasoning in that
34 + //! module: a Rust encoder can cover element text and attribute values with one
35 + //! function, where the apps need four and have to choose correctly at every
36 + //! call site.
37 + //!
38 + //! Rows and tables are the other half and are not here yet.
39 + //!
28 40 //! # What phase A settled, and what it costs
29 41 //!
30 42 //! Decided 2026-07-29 against goingson's `styles.css` rather than against a
@@ -68,6 +80,8 @@
68 80
69 81 #![forbid(unsafe_code)]
70 82
83 + pub mod form;
84 +
71 85 use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone};
72 86 use std::fmt::Write as _;
73 87
A src/form.rs +500
@@ -1,0 +1,525 @@
1 + //! Phase B, the forms half: [`makeover_layout::Field`] rendered to HTML.
2 + //!
3 + //! # Why this emits strings
4 + //!
5 + //! Both webview apps build their markup as strings and hand it to `innerHTML`:
6 + //! goingson's `renderFormField` returns a template literal that fifteen call
7 + //! sites interpolate into larger literals, and Balanced Breakfast's builds
8 + //! nodes but appends them into the same string-built forms. Returning nodes
9 + //! would rewrite the surrounding templates as well, which makes it a migration
10 + //! rather than an adoption. So: strings, and the escaping comes with them.
11 + //!
12 + //! # Why one escaper is enough here
13 + //!
14 + //! goingson carries four escapers and 543 call sites that must pick between
15 + //! them, because `escapeHtml` is built on `textContent` serialization and
16 + //! **`textContent` refuses to encode `"`**. That is what makes it unsound in an
17 + //! attribute, and it is the whole reason the choice exists. Its `escape.js`
18 + //! records the finding as the CHRONIC-XSS seal, and its test suite has a gate
19 + //! keeping the unsafe one off the namespace.
20 + //!
21 + //! [`escape`] here is not built on that, so it encodes the quote along with
22 + //! everything else, which makes one function sound in both sinks. The four-way
23 + //! choice does not move into Rust: it disappears. Nothing in this module hands
24 + //! an unescaped value to the output except through [`Markup`], which a caller
25 + //! has to name.
26 + //!
27 + //! # What the description does not carry
28 + //!
29 + //! [`Field`] describes the field and not its contents, so three things arrive
30 + //! from the renderer side in [`Filling`]: the current value, the options of a
31 + //! select, and the placeholder. The first two are genuinely renderer state. The
32 + //! third is user-facing text and belongs with `label` and `hint` in
33 + //! makeover-layout; it lives here because that crate is published and adding a
34 + //! field to `Field` is a breaking change, not because this is its home.
35 +
36 + use crate::{Emit, class};
37 + use makeover_layout::{Field, FieldKind};
38 + use std::fmt::Write as _;
39 +
40 + /// A string that is already markup, and is emitted without escaping.
41 + ///
42 + /// The one hole in the escaping, and it has to be named to be used. goingson
43 + /// has two live callers that need it, both passing a recurrence-config block
44 + /// built elsewhere, and both would otherwise have their markup rendered as
45 + /// visible angle brackets. A caller constructing this is stating that the
46 + /// contents are trusted; nothing here can check that for them.
47 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48 + pub struct Markup<'a>(pub &'a str);
49 +
50 + /// One option of a select.
51 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 + pub struct Choice<'a> {
53 + /// What is submitted.
54 + pub value: &'a str,
55 + /// What is read.
56 + pub label: &'a str,
57 + }
58 +
59 + impl<'a> Choice<'a> {
60 + /// An option whose submitted value is also its label.
61 + #[must_use]
62 + pub const fn plain(value: &'a str) -> Self {
63 + Self {
64 + value,
65 + label: value,
66 + }
67 + }
68 + }
69 +
70 + /// What the field currently holds.
71 + ///
72 + /// An enum rather than a bag of optional fields, on the same reasoning
73 + /// [`makeover_layout::Depth`] is one: a select with no options and a checkbox
74 + /// with a string value are both unsayable here, where a struct would let them
75 + /// be said and then have to cope.
76 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77 + pub enum Value<'a> {
78 + /// Nothing yet.
79 + #[default]
80 + Absent,
81 + /// The value of anything that takes typed text.
82 + Text(&'a str),
83 + /// The options of a select, and which of them is current.
84 + Chosen {
85 + /// Every option, in the order they are offered.
86 + options: &'a [Choice<'a>],
87 + /// The current value. Matched against each option's `value`.
88 + value: &'a str,
89 + },
90 + /// A checkbox, on or off.
91 + On(bool),
92 + }
93 +
94 + impl<'a> Value<'a> {
95 + /// The value as text, for the kinds that submit one.
96 + const fn as_text(&self) -> &'a str {
97 + match self {
98 + Self::Text(text) | Self::Chosen { value: text, .. } => text,
99 + Self::Absent | Self::On(_) => "",
100 + }
101 + }
102 + }
103 +
104 + /// Everything about the field that the description does not carry.
105 + #[derive(Debug, Clone, Copy, Default)]
106 + pub struct Filling<'a> {
107 + /// What the field holds now.
108 + pub value: Value<'a>,
109 + /// Ghost text shown while the field is empty.
110 + pub placeholder: Option<&'a str>,
111 + /// Markup appended inside the group, after the hint. Not escaped.
112 + pub trailing: Option<Markup<'a>>,
113 + }
114 +
115 + impl<'a> Filling<'a> {
116 + /// A filling that carries a value and nothing else.
117 + #[must_use]
118 + pub const fn of(value: Value<'a>) -> Self {
119 + Self {
120 + value,
121 + placeholder: None,
122 + trailing: None,
123 + }
124 + }
125 + }
126 +
127 + /// Encode the five characters that let a value stop being a value.
128 + ///
129 + /// Sound in element text and in a double-quoted attribute alike, which is the
130 + /// property `textContent`-based escaping cannot have. Both sinks are covered by
131 + /// one function so that no call site has to choose, here or downstream.
132 + #[must_use]
133 + pub fn escape(text: &str) -> String {
134 + let mut out = String::with_capacity(text.len());
135 + for ch in text.chars() {
136 + match ch {
137 + '&' => out.push_str("&amp;"),
138 + '<' => out.push_str("&lt;"),
139 + '>' => out.push_str("&gt;"),
140 + '"' => out.push_str("&quot;"),
141 + '\'' => out.push_str("&#39;"),
142 + other => out.push(other),
143 + }
144 + }
145 + out
146 + }
147 +
148 + /// The `type` an input takes for a kind.
149 + ///
150 + /// [`FieldKind::Secret`] is `password`, which both apps already map by hand.
151 + const fn input_type(kind: FieldKind) -> &'static str {
152 + match kind {
153 + FieldKind::Secret => "password",
154 + FieldKind::Number => "number",
155 + FieldKind::Checkbox => "checkbox",
156 + FieldKind::Hidden => "hidden",
157 + // Select and Textarea are not inputs at all; they never reach here.
158 + FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
159 + }
160 + }
161 +
162 + /// The attributes every visible control carries, error state included.
163 + ///
164 + /// `aria-invalid` is the whole reason the error state is readable at all: the
165 + /// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather
166 + /// than on a class, so a control rendered already-invalid without it is styled
167 + /// as if nothing were wrong. goingson's runtime validation path sets the
168 + /// attribute and its initial render does not, which is exactly the drift one
169 + /// emitter removes.
170 + fn control_attributes(field: &Field<'_>, id: &str) -> String {
171 + let mut attrs = format!(" id=\"{0}\" name=\"{0}\"", escape(id));
172 + if field.required {
173 + attrs.push_str(" required");
174 + }
175 + if field.invalid() {
176 + attrs.push_str(" aria-invalid=\"true\"");
177 + }
178 +
179 + // Both associations, in the order they are useful: the standing help, then
180 + // what is currently wrong. goingson's runtime path points describedby at
181 + // the error alone and drops the hint association it never made in the first
182 + // place; naming both here means the hint survives an error appearing.
183 + let mut described = Vec::new();
184 + if field.hint.is_some() {
185 + described.push(format!("{}-hint", escape(id)));
186 + }
187 + if field.error.is_some() {
188 + described.push(format!("{}-error", escape(id)));
189 + }
190 + if !described.is_empty() {
191 + let _ = write!(attrs, " aria-describedby=\"{}\"", described.join(" "));
192 + }
193 + attrs
194 + }
195 +
196 + /// The options of a select, with an unmatched current value carried as its own.
197 + ///
198 + /// A select handed a value no option carries renders with nothing selected, the
199 + /// browser falls back to the first option, and the next save writes a value
200 + /// nobody chose. goingson hit exactly that with a backup-retention default of
201 + /// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
202 + /// here so the second app gets it without hitting the bug first.
203 + fn options_html(options: &[Choice<'_>], value: &str) -> String {
204 + let mut html = String::new();
205 + if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
206 + let escaped = escape(value);
207 + let _ = write!(
208 + html,
209 + "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
210 + );
211 + }
212 + for opt in options {
213 + let selected = if opt.value == value { " selected" } else { "" };
214 + let _ = write!(
215 + html,
216 + "<option value=\"{}\"{selected}>{}</option>",
217 + escape(opt.value),
218 + escape(opt.label)
219 + );
220 + }
221 + html
222 + }
223 +
224 + /// The control itself, without its label, hint or error.
225 + fn control_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
226 + let id = field.name;
227 + let attrs = control_attributes(field, id);
228 + let field_class = class("field", opts);
229 + let placeholder = filling.placeholder.map_or_else(String::new, |text| {
230 + format!(" placeholder=\"{}\"", escape(text))
231 + });
232 +
233 + match field.kind {
234 + FieldKind::Textarea => format!(
235 + "<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
236 + escape(filling.value.as_text())
237 + ),
238 + FieldKind::Select => {
239 + let options = match filling.value {
240 + Value::Chosen { options, value } => options_html(options, value),
241 + // Described as a select and filled as something else. Emitting
242 + // an empty select says so on screen rather than in a log.
243 + _ => String::new(),
244 + };
245 + format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
246 + }
247 + FieldKind::Checkbox => {
248 + let checked = if matches!(filling.value, Value::On(true)) {
249 + " checked"
250 + } else {
251 + ""
252 + };
253 + format!(
254 + "<label class=\"{}\"><input type=\"checkbox\"{attrs}{checked}><span>{}</span></label>",
255 + class("form-checkbox-label", opts),
256 + escape(field.label)
257 + )
258 + }
259 + // A secret never carries its value into the markup. `FieldKind::secret`
260 + // is documented as a value that must not be round-tripped through
261 + // anything that might persist it, and the DOM is such a thing: it is
262 + // read by every extension on the page and is the first thing a crash
263 + // reporter serialises. Neither app pre-fills one today, so this costs
264 + // nothing and closes the door before something does.
265 + FieldKind::Secret => format!(
266 + "<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>"
267 + ),
268 + kind => format!(
269 + "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
270 + input_type(kind),
271 + escape(filling.value.as_text())
272 + ),
273 + }
274 + }
275 +
276 + /// One field, as the group the app drops into its form.
277 + ///
278 + /// The shape is goingson's, down to the class names, so adoption there deletes
279 + /// `renderFormField` rather than restyling anything. That is also why the class
280 + /// names are not emitted by [`crate::stylesheet`]: `.form-group`, `.form-label`,
281 + /// `.form-hint` and `.form-error` are the apps' own, and phase A deliberately
282 + /// emits only what it can generate from the description. Whether they should
283 + /// move into the description is the next question this raises, not one it
284 + /// answers.
285 + ///
286 + /// A [`FieldKind::Hidden`] field is the input alone: no group, no label, and
287 + /// nothing drawn, which is what [`FieldKind::visible`] means.
288 + ///
289 + /// The error marks the group as well as the control. That is
290 + /// [`Field::invalid`]'s own reasoning: a renderer with no descendant selectors
291 + /// cannot find the group from the message, so the group has to be told.
292 + ///
293 + /// ```
294 + /// use makeover_layout::{Field, FieldKind};
295 + /// use makeover_webview::{Emit, form::{Filling, Value, field_html}};
296 + ///
297 + /// let field = Field::new(FieldKind::Text, "title", "Title");
298 + /// let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
299 + ///
300 + /// assert!(html.contains(r#"<label class="form-label" for="title">Title</label>"#));
301 + /// assert!(html.contains(r#"value="Ship it""#));
302 + /// ```
303 + #[must_use]
304 + pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
305 + let id = escape(field.name);
306 +
307 + if !field.kind.visible() {
308 + return format!(
309 + "<input type=\"hidden\" name=\"{id}\" value=\"{}\">",
310 + escape(filling.value.as_text())
311 + );
312 + }
313 +
314 + let mut html = format!("<div class=\"{}", class("form-group", opts));
315 + if field.invalid() {
316 + html.push_str(" has-error");
317 + }
318 + if field.extended {
319 + // The disclosure that hides these is a property of the form, not of the
320 + // field, so the field is marked and the app opens or closes the group.
321 + html.push_str("\" data-extended=\"true");
322 + }
323 + html.push_str("\">");
324 +
325 + // A checkbox labels itself, on the right of the box. Both apps special-case
326 + // this inline today, which is the tell that it belongs in the description;
327 + // `FieldKind::labels_itself` is where it went.
328 + if !field.kind.labels_itself() {
329 + let _ = write!(
330 + html,
331 + "<label class=\"{}\" for=\"{id}\">{}</label>",
332 + class("form-label", opts),
333 + escape(field.label)
334 + );
335 + }
336 +
337 + html.push_str(&control_html(field, filling, opts));
338 +
339 + if let Some(hint) = field.hint {
340 + let _ = write!(
341 + html,
342 + "<div class=\"{}\" id=\"{id}-hint\">{}</div>",
343 + class("form-hint", opts),
344 + escape(hint)
345 + );
346 + }
347 + if let Some(Markup(markup)) = filling.trailing {
348 + html.push_str(markup);
349 + }
350 + if let Some(error) = field.error {
351 + let _ = write!(
352 + html,
353 + "<div class=\"{} visible\" id=\"{id}-error\" role=\"alert\">{}</div>",
354 + class("form-error", opts),
355 + escape(error)
356 + );
357 + }
358 +
359 + html.push_str("</div>");
360 + html
361 + }
362 +
363 + #[cfg(test)]
364 + mod tests {
365 + use super::*;
366 +
367 + fn field(kind: FieldKind) -> Field<'static> {
368 + Field::new(kind, "title", "Title")
369 + }
370 +
371 + #[test]
372 + fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
373 + // The payload from goingson's own CHRONIC-XSS regression test.
374 + let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
375 + let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
376 + // The payload survives as text, which is the point: it is inert
377 + // because the quote that would have closed the attribute is encoded,
378 + // not because the words were filtered.
379 + assert!(!html.contains("\" onfocus"), "{html}");
380 + assert!(
381 + html.contains("value=\"x&quot; onfocus=alert(1) autofocus=&quot;\""),
382 + "{html}"
383 + );
384 + }
385 +
386 + #[test]
387 + fn a_label_cannot_open_a_tag() {
388 + let mut f = field(FieldKind::Text);
389 + f.label = "<script>alert(1)</script>";
390 + let html = field_html(&f, &Filling::default(), &Emit::default());
391 + assert!(!html.contains("<script>"), "{html}");
392 + assert!(html.contains("&lt;script&gt;"), "{html}");
393 + }
394 +
395 + #[test]
396 + fn every_escaped_sink_is_covered_by_the_one_escaper() {
397 + assert_eq!(escape("&<>\"'"), "&amp;&lt;&gt;&quot;&#39;");
398 + // The character `textContent` serialization leaves alone, which is why
399 + // the app needs two escapers and this needs one.
400 + assert!(escape("\"").contains("&quot;"));
401 + }
402 +
403 + #[test]
404 + fn markup_is_the_only_way_past_the_escaping() {
405 + let filling = Filling {
406 + trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
407 + ..Filling::default()
408 + };
409 + let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
410 + assert!(html.contains("<div class=\"recurrence-config\"></div>"), "{html}");
411 + }
412 +
413 + #[test]
414 + fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
415 + let mut f = field(FieldKind::Text);
416 + f.error = Some("Required");
417 + let opts = Emit::default();
418 + let html = field_html(&f, &Filling::default(), &opts);
419 + assert!(html.contains("aria-invalid=\"true\""), "{html}");
420 + // The selector the CSS side emits for exactly this state.
421 + assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
422 + // And the group is marked too, which a renderer without descendant
423 + // selectors depends on.
424 + assert!(html.contains("has-error"), "{html}");
425 + }
426 +
427 + #[test]
428 + fn a_valid_field_claims_nothing_about_being_invalid() {
429 + let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
430 + assert!(!html.contains("aria-invalid"), "{html}");
431 + assert!(!html.contains("has-error"), "{html}");
432 + }
433 +
434 + #[test]
435 + fn the_hint_survives_an_error_arriving() {
436 + let mut f = field(FieldKind::Text);
437 + f.hint = Some("Keep it short");
438 + f.error = Some("Required");
439 + let html = field_html(&f, &Filling::default(), &Emit::default());
440 + assert!(
441 + html.contains("aria-describedby=\"title-hint title-error\""),
442 + "{html}"
443 + );
444 + }
445 +
446 + #[test]
447 + fn a_secret_never_carries_its_value_into_the_markup() {
448 + let filling = Filling::of(Value::Text("hunter2"));
449 + let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
450 + assert!(!html.contains("hunter2"), "{html}");
451 + assert!(html.contains("type=\"password\""), "{html}");
452 + }
453 +
454 + #[test]
455 + fn a_hidden_field_is_the_input_and_nothing_else() {
456 + let filling = Filling::of(Value::Text("42"));
457 + let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
458 + assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
459 + }
460 +
461 + #[test]
462 + fn a_checkbox_labels_itself_and_takes_no_separate_label() {
463 + let html = field_html(
464 + &field(FieldKind::Checkbox),
465 + &Filling::of(Value::On(true)),
466 + &Emit::default(),
467 + );
468 + assert!(!html.contains("form-label"), "{html}");
469 + assert!(html.contains("checked"), "{html}");
470 + assert!(html.contains("<span>Title</span>"), "{html}");
471 + }
472 +
473 + #[test]
474 + fn a_select_keeps_a_value_no_option_carries() {
475 + let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
476 + let filling = Filling::of(Value::Chosen {
477 + options: &options,
478 + value: "10",
479 + });
480 + let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
481 + assert!(html.contains("data-unmatched=\"true\""), "{html}");
482 + // Selected, so the next save round-trips it rather than writing the
483 + // first option over the top of it.
484 + assert!(html.contains("<option value=\"10\" selected"), "{html}");
485 + }
486 +
487 + #[test]
488 + fn a_select_marks_the_option_that_matches() {
489 + let options = [Choice::plain("1"), Choice::plain("3")];
490 + let filling = Filling::of(Value::Chosen {
491 + options: &options,
492 + value: "3",
493 + });
494 + let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
495 + assert!(html.contains("<option value=\"3\" selected>3</option>"), "{html}");
496 + assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
497 + assert!(!html.contains("data-unmatched"), "{html}");
498 + }
499 +
500 + #[test]
Lines truncated