makeover_webview/form.rs
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//! One thing: the **current value**, which arrives in [`Filling`].
30//!
31//! It used to be three. Writing this emitter is what found them, and the other
32//! two turned out not to be renderer state at all — the placeholder is
33//! user-facing text that sits with `label` and `hint`, and a select's options
34//! are needed by every renderer, which is how each of them ends up inventing a
35//! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0,
36//! `Choice` included, and this crate reads them off [`Field`] now.
37//!
38//! The value stays, and it is not a leftover. A webview reads it back out of
39//! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal
40//! keeps an edit buffer; a description carrying it would have to carry a way to
41//! write it back, at which point it is a form model.
42
43use crate::{Emit, class};
44use makeover_layout::{Choice, Field, FieldKind};
45use std::fmt::Write as _;
46
47/// A string that is already markup, and is emitted without escaping.
48///
49/// The one hole in the escaping, and it has to be named to be used. goingson
50/// has two live callers that need it, both passing a recurrence-config block
51/// built elsewhere, and both would otherwise have their markup rendered as
52/// visible angle brackets. A caller constructing this is stating that the
53/// contents are trusted; nothing here can check that for them.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Markup<'a>(pub &'a str);
56
57/// What the field currently holds.
58///
59/// An enum rather than a bag of optional fields, on the same reasoning
60/// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable
61/// here, where a struct would let it be said and then have to cope.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum Value<'a> {
64 /// Nothing yet.
65 #[default]
66 Absent,
67 /// The value of anything that takes typed text, a select included: what a
68 /// select holds is the `value` of one of [`Field::options`]'s
69 /// [`Choice`]s.
70 ///
71 /// It carried the options too until makeover-layout 0.8.0 moved them onto
72 /// the field, which collapsed a `Chosen { options, value }` variant into
73 /// this one. `makeover-immediate` arrived at the same single-variant shape
74 /// on its own, from the other direction.
75 Text(&'a str),
76 /// A checkbox, on or off.
77 On(bool),
78}
79
80impl<'a> Value<'a> {
81 /// The value as text, for the kinds that submit one.
82 const fn as_text(&self) -> &'a str {
83 match self {
84 Self::Text(text) => text,
85 Self::Absent | Self::On(_) => "",
86 }
87 }
88}
89
90/// Everything about the field that the description does not carry.
91#[derive(Debug, Clone, Copy, Default)]
92pub struct Filling<'a> {
93 /// What the field holds now.
94 pub value: Value<'a>,
95 /// Markup appended inside the group, after the hint. Not escaped.
96 pub trailing: Option<Markup<'a>>,
97 /// Scopes the `id` attributes to one instance of the form.
98 ///
99 /// The field's `name` is what the value submits under and is the same
100 /// wherever the form appears; its `id` has to be unique in the document,
101 /// and those two facts stop agreeing the moment a form appears twice.
102 /// goingson hits this directly: its new-task and edit-task modals are the
103 /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep
104 /// `label for` and `aria-describedby` pointing at the right control.
105 ///
106 /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to
107 /// `name`, which would change what the form submits.
108 pub id_prefix: Option<&'a str>,
109}
110
111impl<'a> Filling<'a> {
112 /// A filling that carries a value and nothing else.
113 #[must_use]
114 pub const fn of(value: Value<'a>) -> Self {
115 Self {
116 value,
117 trailing: None,
118 id_prefix: None,
119 }
120 }
121
122 /// The document-unique id for a field of this name.
123 fn id_for(&self, name: &str) -> String {
124 match self.id_prefix {
125 Some(prefix) => format!("{}-{}", escape(prefix), escape(name)),
126 None => escape(name),
127 }
128 }
129}
130
131/// Encode the five characters that let a value stop being a value.
132///
133/// Sound in element text and in a double-quoted attribute alike, which is the
134/// property `textContent`-based escaping cannot have. Both sinks are covered by
135/// one function so that no call site has to choose, here or downstream.
136#[must_use]
137pub fn escape(text: &str) -> String {
138 let mut out = String::with_capacity(text.len());
139 for ch in text.chars() {
140 match ch {
141 '&' => out.push_str("&"),
142 '<' => out.push_str("<"),
143 '>' => out.push_str(">"),
144 '"' => out.push_str("""),
145 '\'' => out.push_str("'"),
146 other => out.push(other),
147 }
148 }
149 out
150}
151
152/// The `type` an input takes for a kind.
153///
154/// [`FieldKind::Secret`] is `password`, which both apps already map by hand.
155const fn input_type(kind: FieldKind) -> &'static str {
156 match kind {
157 FieldKind::Secret => "password",
158 FieldKind::Number => "number",
159 FieldKind::Checkbox => "checkbox",
160 FieldKind::Hidden => "hidden",
161 // Not decoration. Each of these changes the keyboard a touch device
162 // offers and turns on the platform's own validation, which is why the
163 // description names them apart from text rather than letting the app
164 // pass an HTML type through.
165 FieldKind::Email => "email",
166 FieldKind::Url => "url",
167 FieldKind::Tel => "tel",
168 // Select and Textarea are not inputs at all; they never reach here.
169 FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
170 // A kind added to the description since this renderer was built. Text
171 // accepts any value the others would, so it degrades rather than
172 // dropping the field.
173 _ => "text",
174 }
175}
176
177/// The attributes every visible control carries, error state included.
178///
179/// `aria-invalid` is the whole reason the error state is readable at all: the
180/// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather
181/// than on a class, so a control rendered already-invalid without it is styled
182/// as if nothing were wrong. goingson's runtime validation path sets the
183/// attribute and its initial render does not, which is exactly the drift one
184/// emitter removes.
185/// `id` and `name` arrive separately because they are not the same fact. The
186/// name is what submits and is fixed by the description; the id has to be
187/// unique in the document and so carries [`Filling::id_prefix`] when a form
188/// appears more than once.
189fn control_attributes(field: &Field<'_>, id: &str, name: &str) -> String {
190 let mut attrs = format!(" id=\"{id}\" name=\"{}\"", escape(name));
191 if field.required {
192 attrs.push_str(" required");
193 }
194 if field.invalid() {
195 attrs.push_str(" aria-invalid=\"true\"");
196 }
197
198 // Both associations, in the order they are useful: the standing help, then
199 // what is currently wrong. goingson's runtime path points describedby at
200 // the error alone and drops the hint association it never made in the first
201 // place; naming both here means the hint survives an error appearing.
202 let mut described = Vec::new();
203 if field.hint.is_some() {
204 described.push(format!("{id}-hint"));
205 }
206 if field.error.is_some() {
207 described.push(format!("{id}-error"));
208 }
209 if !described.is_empty() {
210 let _ = write!(attrs, " aria-describedby=\"{}\"", described.join(" "));
211 }
212 attrs
213}
214
215/// The options of a select, with an unmatched current value carried as its own.
216///
217/// A select handed a value no option carries renders with nothing selected, the
218/// browser falls back to the first option, and the next save writes a value
219/// nobody chose. goingson hit exactly that with a backup-retention default of
220/// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
221/// here so the second app gets it without hitting the bug first.
222fn options_html(options: &[Choice<'_>], value: &str) -> String {
223 let mut html = String::new();
224 if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
225 let escaped = escape(value);
226 let _ = write!(
227 html,
228 "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
229 );
230 }
231 for opt in options {
232 let selected = if opt.value == value { " selected" } else { "" };
233 let _ = write!(
234 html,
235 "<option value=\"{}\"{selected}>{}</option>",
236 escape(opt.value),
237 escape(opt.label)
238 );
239 }
240 html
241}
242
243/// The control itself, without its label, hint or error.
244fn control_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
245 let id = filling.id_for(field.name);
246 let attrs = control_attributes(field, &id, field.name);
247 let field_class = class("field", opts);
248 let placeholder = field.placeholder.map_or_else(String::new, |text| {
249 format!(" placeholder=\"{}\"", escape(text))
250 });
251
252 match field.kind {
253 FieldKind::Textarea => format!(
254 "<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
255 escape(filling.value.as_text())
256 ),
257 FieldKind::Select => {
258 // A select described with no options emits an empty select, which
259 // says so on screen rather than in a log. That is the description's
260 // own position on `Field::options`, not a fallback invented here.
261 let options = options_html(field.options, filling.value.as_text());
262 format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
263 }
264 FieldKind::Checkbox => {
265 let checked = if matches!(filling.value, Value::On(true)) {
266 " checked"
267 } else {
268 ""
269 };
270 format!(
271 "<label class=\"{}\"><input type=\"checkbox\"{attrs}{checked}><span>{}</span></label>",
272 class("form-checkbox-label", opts),
273 escape(field.label)
274 )
275 }
276 // A secret never carries its value into the markup. `FieldKind::secret`
277 // is documented as a value that must not be round-tripped through
278 // anything that might persist it, and the DOM is such a thing: it is
279 // read by every extension on the page and is the first thing a crash
280 // reporter serialises. Neither app pre-fills one today, so this costs
281 // nothing and closes the door before something does.
282 FieldKind::Secret => {
283 format!("<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>")
284 }
285 kind => format!(
286 "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
287 input_type(kind),
288 escape(filling.value.as_text())
289 ),
290 }
291}
292
293/// One field, as the group the app drops into its form.
294///
295/// The shape is goingson's, down to the class names, so adoption there deletes
296/// `renderFormField` rather than restyling anything. That is also why the class
297/// names are not emitted by [`crate::stylesheet`]: `.form-group`, `.form-label`,
298/// `.form-hint` and `.form-error` are the apps' own, and phase A deliberately
299/// emits only what it can generate from the description. Whether they should
300/// move into the description is the next question this raises, not one it
301/// answers.
302///
303/// A [`FieldKind::Hidden`] field is the input alone: no group, no label, and
304/// nothing drawn, which is what [`FieldKind::visible`] means.
305///
306/// The error marks the group as well as the control. That is
307/// [`Field::invalid`]'s own reasoning: a renderer with no descendant selectors
308/// cannot find the group from the message, so the group has to be told.
309///
310/// ```
311/// use makeover_layout::{Field, FieldKind};
312/// use makeover_webview::{Emit, form::{Filling, Value, field_html}};
313///
314/// let field = Field::new(FieldKind::Text, "title", "Title");
315/// let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
316///
317/// assert!(html.contains(r#"<label class="form-label" for="title">Title</label>"#));
318/// assert!(html.contains(r#"value="Ship it""#));
319/// ```
320#[must_use]
321pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
322 let id = filling.id_for(field.name);
323
324 if !field.kind.visible() {
325 // Name only, no id: a hidden field is never pointed at by a label or a
326 // description, so the one attribute it needs is the one that submits.
327 return format!(
328 "<input type=\"hidden\" name=\"{}\" value=\"{}\">",
329 escape(field.name),
330 escape(filling.value.as_text())
331 );
332 }
333
334 let mut html = format!("<div class=\"{}", class("form-group", opts));
335 if field.invalid() {
336 html.push_str(" has-error");
337 }
338 if field.extended {
339 // The disclosure that hides these is a property of the form, not of the
340 // field, so the field is marked and the app opens or closes the group.
341 html.push_str("\" data-extended=\"true");
342 }
343 html.push_str("\">");
344
345 // A checkbox labels itself, on the right of the box. Both apps special-case
346 // this inline today, which is the tell that it belongs in the description;
347 // `FieldKind::labels_itself` is where it went.
348 if !field.kind.labels_itself() {
349 let _ = write!(
350 html,
351 "<label class=\"{}\" for=\"{id}\">{}</label>",
352 class("form-label", opts),
353 escape(field.label)
354 );
355 }
356
357 html.push_str(&control_html(field, filling, opts));
358
359 if let Some(hint) = field.hint {
360 let _ = write!(
361 html,
362 "<div class=\"{}\" id=\"{id}-hint\">{}</div>",
363 class("form-hint", opts),
364 escape(hint)
365 );
366 }
367 if let Some(Markup(markup)) = filling.trailing {
368 html.push_str(markup);
369 }
370 if let Some(error) = field.error {
371 let _ = write!(
372 html,
373 "<div class=\"{} visible\" id=\"{id}-error\" role=\"alert\">{}</div>",
374 class("form-error", opts),
375 escape(error)
376 );
377 }
378
379 html.push_str("</div>");
380 html
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 fn field(kind: FieldKind) -> Field<'static> {
388 Field::new(kind, "title", "Title")
389 }
390
391 #[test]
392 fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
393 // The payload from goingson's own CHRONIC-XSS regression test.
394 let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
395 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
396 // The payload survives as text, which is the point: it is inert
397 // because the quote that would have closed the attribute is encoded,
398 // not because the words were filtered.
399 assert!(!html.contains("\" onfocus"), "{html}");
400 assert!(
401 html.contains("value=\"x" onfocus=alert(1) autofocus="\""),
402 "{html}"
403 );
404 }
405
406 #[test]
407 fn a_label_cannot_open_a_tag() {
408 let mut f = field(FieldKind::Text);
409 f.label = "<script>alert(1)</script>";
410 let html = field_html(&f, &Filling::default(), &Emit::default());
411 assert!(!html.contains("<script>"), "{html}");
412 assert!(html.contains("<script>"), "{html}");
413 }
414
415 #[test]
416 fn every_escaped_sink_is_covered_by_the_one_escaper() {
417 assert_eq!(escape("&<>\"'"), "&<>"'");
418 // The character `textContent` serialization leaves alone, which is why
419 // the app needs two escapers and this needs one.
420 assert!(escape("\"").contains("""));
421 }
422
423 #[test]
424 fn markup_is_the_only_way_past_the_escaping() {
425 let filling = Filling {
426 trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
427 ..Filling::default()
428 };
429 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
430 assert!(
431 html.contains("<div class=\"recurrence-config\"></div>"),
432 "{html}"
433 );
434 }
435
436 #[test]
437 fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
438 let mut f = field(FieldKind::Text);
439 f.error = Some("Required");
440 let opts = Emit::default();
441 let html = field_html(&f, &Filling::default(), &opts);
442 assert!(html.contains("aria-invalid=\"true\""), "{html}");
443 // The selector the CSS side emits for exactly this state.
444 assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
445 // And the group is marked too, which a renderer without descendant
446 // selectors depends on.
447 assert!(html.contains("has-error"), "{html}");
448 }
449
450 #[test]
451 fn a_valid_field_claims_nothing_about_being_invalid() {
452 let html = field_html(
453 &field(FieldKind::Text),
454 &Filling::default(),
455 &Emit::default(),
456 );
457 assert!(!html.contains("aria-invalid"), "{html}");
458 assert!(!html.contains("has-error"), "{html}");
459 }
460
461 #[test]
462 fn the_hint_survives_an_error_arriving() {
463 let mut f = field(FieldKind::Text);
464 f.hint = Some("Keep it short");
465 f.error = Some("Required");
466 let html = field_html(&f, &Filling::default(), &Emit::default());
467 assert!(
468 html.contains("aria-describedby=\"title-hint title-error\""),
469 "{html}"
470 );
471 }
472
473 #[test]
474 fn a_secret_never_carries_its_value_into_the_markup() {
475 let filling = Filling::of(Value::Text("hunter2"));
476 let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
477 assert!(!html.contains("hunter2"), "{html}");
478 assert!(html.contains("type=\"password\""), "{html}");
479 }
480
481 #[test]
482 fn a_hidden_field_is_the_input_and_nothing_else() {
483 let filling = Filling::of(Value::Text("42"));
484 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
485 assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
486 }
487
488 #[test]
489 fn a_checkbox_labels_itself_and_takes_no_separate_label() {
490 let html = field_html(
491 &field(FieldKind::Checkbox),
492 &Filling::of(Value::On(true)),
493 &Emit::default(),
494 );
495 assert!(!html.contains("form-label"), "{html}");
496 assert!(html.contains("checked"), "{html}");
497 assert!(html.contains("<span>Title</span>"), "{html}");
498 }
499
500 #[test]
501 fn a_select_keeps_a_value_no_option_carries() {
502 let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
503 let f = Field::select("title", "Title", &options);
504 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
505 assert!(html.contains("data-unmatched=\"true\""), "{html}");
506 // Selected, so the next save round-trips it rather than writing the
507 // first option over the top of it.
508 assert!(html.contains("<option value=\"10\" selected"), "{html}");
509 }
510
511 #[test]
512 fn a_select_with_no_options_emits_an_empty_select() {
513 // The description says a select with no options is sayable, because an
514 // app whose option list has not loaded has exactly that. Emitting the
515 // empty select reports it on screen rather than in a log.
516 let f = Field::select("title", "Title", &[]);
517 let html = field_html(&f, &Filling::default(), &Emit::default());
518 assert!(html.contains("<select"), "{html}");
519 assert!(!html.contains("<option"), "{html}");
520 }
521
522 #[test]
523 fn a_placeholder_comes_off_the_description_and_is_escaped() {
524 // It arrived in `Filling` until makeover-layout 0.8.0 and was never
525 // covered here; it is a value in an attribute like any other.
526 let f = Field {
527 placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
528 ..field(FieldKind::Text)
529 };
530 let html = field_html(&f, &Filling::default(), &Emit::default());
531 assert!(html.contains("placeholder=\""), "{html}");
532 assert!(!html.contains("\" onfocus"), "{html}");
533 }
534
535 #[test]
536 fn a_select_marks_the_option_that_matches() {
537 let options = [Choice::plain("1"), Choice::plain("3")];
538 let f = Field::select("title", "Title", &options);
539 let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
540 assert!(
541 html.contains("<option value=\"3\" selected>3</option>"),
542 "{html}"
543 );
544 assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
545 assert!(!html.contains("data-unmatched"), "{html}");
546 }
547
548 #[test]
549 fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
550 let filling = Filling::of(Value::Text("two\nlines"));
551 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
552 assert!(html.contains(">two\nlines</textarea>"), "{html}");
553 }
554
555 #[test]
556 fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
557 let opts = Emit {
558 class_prefix: "mk-",
559 ..Emit::default()
560 };
561 let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
562 assert!(html.contains("class=\"mk-form-group\""), "{html}");
563 assert!(html.contains("class=\"mk-field\""), "{html}");
564 }
565
566 #[test]
567 fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
568 let mut f = field(FieldKind::Text);
569 f.extended = true;
570 let html = field_html(&f, &Filling::default(), &Emit::default());
571 assert!(html.contains("data-extended=\"true\""), "{html}");
572 }
573
574 /// The prefix scopes the id and leaves the name alone. Prefixing the name
575 /// too would change what the form submits, which is the failure this pair
576 /// of assertions exists to catch rather than describe.
577 #[test]
578 fn the_id_prefix_scopes_the_id_and_never_the_name() {
579 let mut f = field(FieldKind::Text);
580 f.hint = Some("Keep it short");
581 f.error = Some("Required");
582 let filling = Filling {
583 id_prefix: Some("form-modal-task-edit"),
584 ..Filling::default()
585 };
586 let html = field_html(&f, &filling, &Emit::default());
587
588 assert!(
589 html.contains(r#"id="form-modal-task-edit-title""#),
590 "{html}"
591 );
592 assert!(html.contains(r#"name="title""#), "{html}");
593 assert!(
594 !html.contains(r#"name="form-modal-task-edit-title""#),
595 "{html}"
596 );
597
598 // The label and both associations follow the id, or they point at
599 // nothing once the same form is on screen twice.
600 assert!(
601 html.contains(r#"for="form-modal-task-edit-title""#),
602 "{html}"
603 );
604 assert!(
605 html.contains(
606 r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
607 ),
608 "{html}"
609 );
610 assert!(
611 html.contains(r#"id="form-modal-task-edit-title-hint""#),
612 "{html}"
613 );
614 }
615
616 #[test]
617 fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
618 let filling = Filling {
619 value: Value::Text("42"),
620 id_prefix: Some("scoped"),
621 ..Filling::default()
622 };
623 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
624 assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
625 }
626
627 /// These three exist so a touch keyboard and the platform's validation
628 /// arrive with the field. Emitting text for any of them is the regression
629 /// the variants were added to prevent, so the type is asserted directly.
630 #[test]
631 fn the_typed_text_kinds_keep_their_input_type() {
632 for (kind, expected) in [
633 (FieldKind::Email, "email"),
634 (FieldKind::Url, "url"),
635 (FieldKind::Tel, "tel"),
636 ] {
637 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
638 assert!(
639 html.contains(&format!(r#"type="{expected}""#)),
640 "{kind:?} emitted {html}"
641 );
642 }
643 }
644
645 #[test]
646 fn no_prefix_leaves_the_id_as_the_name() {
647 let html = field_html(
648 &field(FieldKind::Text),
649 &Filling::default(),
650 &Emit::default(),
651 );
652 assert!(html.contains(r#"id="title" name="title""#), "{html}");
653 }
654}