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, push_class};
44use makeover_layout::{Choice, Depth, Field, FieldKind, Selector};
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 let mut id = String::new();
125 if let Some(prefix) = self.id_prefix {
126 escape_into(prefix, &mut id);
127 id.push('-');
128 }
129 escape_into(name, &mut id);
130 id
131 }
132}
133
134/// Encode the five characters that let a value stop being a value, into a
135/// buffer the caller already has.
136///
137/// The form the emitters use. [`escape`] is this with a `String` allocated
138/// around it, and the allocation is the whole difference: a described screen
139/// escapes once per attribute and once per run of text, so a function that
140/// returns a `String` allocates a few thousand times to produce one page, where
141/// a template engine writes its escaped bytes straight into the output buffer.
142/// Measured 2026-08-14 against a real pane, that gap was 85% of a 42x rendering
143/// cost, and this is the half of the fix that lives in this crate.
144///
145/// Sound in element text and in a double-quoted attribute alike, which is the
146/// property `textContent`-based escaping cannot have. Both sinks are covered by
147/// one function so that no call site has to choose, here or downstream.
148///
149/// Copies in runs rather than per character. All five encoded characters are
150/// ASCII, so a byte scan cannot land inside a multi-byte character and the
151/// slice between two of them is always a valid `&str`. Text with nothing to
152/// encode — which is most text — is one `push_str` of the whole thing.
153pub fn escape_into(text: &str, out: &mut String) {
154 let mut start = 0;
155 for (index, byte) in text.bytes().enumerate() {
156 let encoded = match byte {
157 b'&' => "&",
158 b'<' => "<",
159 b'>' => ">",
160 b'"' => """,
161 b'\'' => "'",
162 _ => continue,
163 };
164 out.push_str(&text[start..index]);
165 out.push_str(encoded);
166 start = index + 1;
167 }
168 out.push_str(&text[start..]);
169}
170
171/// Encode the five characters that let a value stop being a value.
172///
173/// [`escape_into`] with a buffer of its own, for the callers that want a value
174/// rather than an append: a caller assembling an attribute out of several
175/// pieces, and everything outside this crate that took this function before the
176/// buffer-writing form existed. Emitting into a buffer you already hold is the
177/// cheaper path and the one this crate's own emitters take.
178#[must_use]
179pub fn escape(text: &str) -> String {
180 let mut out = String::with_capacity(text.len());
181 escape_into(text, &mut out);
182 out
183}
184
185/// The `type` an input takes for a kind.
186///
187/// [`FieldKind::Secret`] is `password`, which both apps already map by hand.
188const fn input_type(kind: FieldKind) -> &'static str {
189 match kind {
190 FieldKind::Secret => "password",
191 FieldKind::Number => "number",
192 FieldKind::Checkbox => "checkbox",
193 FieldKind::File => "file",
194 FieldKind::Hidden => "hidden",
195 // Not decoration. Each of these changes the keyboard a touch device
196 // offers and turns on the platform's own validation, which is why the
197 // description names them apart from text rather than letting the app
198 // pass an HTML type through.
199 FieldKind::Email => "email",
200 FieldKind::Url => "url",
201 FieldKind::Tel => "tel",
202 // The same argument, and it buys more here than anywhere else in this
203 // list: a native picker as well as the keyboard and the validation.
204 // Both submit the format `makeover-layout` names, `DATE_FORMAT` and
205 // `DATETIME_FORMAT`, so honouring it costs this renderer nothing.
206 FieldKind::Date => "date",
207 FieldKind::DateTime => "datetime-local",
208 FieldKind::Radio => "radio",
209 // The clearest case in this list that a kind is not decoration: a
210 // number and a range submit the same value and are different controls,
211 // and the browser is the one drawing the difference.
212 FieldKind::Range => "range",
213 // Select and Textarea are not inputs at all; they never reach here.
214 // Radio is one, but it is emitted once per option by `radio_html` and
215 // so does not reach here either.
216 FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text",
217 // A kind added to the description since this renderer was built. Text
218 // accepts any value the others would, so it degrades rather than
219 // dropping the field.
220 _ => "text",
221 }
222}
223
224/// The attributes every visible control carries, error state included.
225///
226/// `aria-invalid` is the whole reason the error state is readable at all: the
227/// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather
228/// than on a class, so a control rendered already-invalid without it is styled
229/// as if nothing were wrong. goingson's runtime validation path sets the
230/// attribute and its initial render does not, which is exactly the drift one
231/// emitter removes.
232/// `id` and `name` arrive separately because they are not the same fact. The
233/// name is what submits and is fixed by the description; the id has to be
234/// unique in the document and so carries [`Filling::id_prefix`] when a form
235/// appears more than once.
236/// The `accept` attribute, from the description's accept list.
237///
238/// makeover-layout 0.31.0. The list is comma-joined because that is the
239/// attribute's own format, and each entry writes itself: a family is its
240/// wildcard media type, a media type is itself, a suffix is itself with its
241/// leading dot. Nothing is normalised on the way through -- `.tar.gz` is two
242/// dots and the browser is fine with it.
243///
244/// An empty list emits no attribute at all, which is the browser's own "any
245/// file" and is what the description means by listing nothing. Emitting
246/// `accept=""` instead would be a filter that matches nothing on some browsers
247/// and everything on others.
248///
249/// It is a filter and not a guarantee, on the browser's side as much as here:
250/// the picker keeps an "All Files" escape and the user may take it. Whoever
251/// validated still validates.
252fn push_accept(out: &mut String, field: &Field<'_>) {
253 if field.accept.is_empty() {
254 return;
255 }
256 out.push_str(" accept=\"");
257 for (index, one) in field.accept.iter().enumerate() {
258 if index > 0 {
259 out.push(',');
260 }
261 escape_into(one.as_str(), out);
262 }
263 out.push('"');
264}
265
266fn push_control_attributes(out: &mut String, field: &Field<'_>, id: &str, name: &str) {
267 let _ = write!(out, " id=\"{id}\" name=\"");
268 escape_into(name, out);
269 out.push('"');
270 if field.required {
271 out.push_str(" required");
272 }
273 // makeover-layout 0.11.0's constraints. The description carries the rule and
274 // this emits the browser's idiom for it, which is the model `required` has
275 // been using since before the crate wrote down that it carried none.
276 // Enforcement is still whoever validated's, and arrives back as `error`.
277 if let Some(limit) = field.max_length {
278 let _ = write!(out, " maxlength=\"{limit}\"");
279 }
280 if let Some(min) = field.min {
281 out.push_str(" min=\"");
282 escape_into(min, out);
283 out.push('"');
284 }
285 if let Some(max) = field.max {
286 out.push_str(" max=\"");
287 escape_into(max, out);
288 out.push('"');
289 }
290 // The browser's own default is `step="1"`, which turns a 0-to-1 threshold
291 // into a two-position control. That is the granularity the description
292 // means when it says nothing, so this is emitted only when an app has said
293 // otherwise rather than defaulted here.
294 if let Some(step) = field.step {
295 out.push_str(" step=\"");
296 escape_into(step, out);
297 out.push('"');
298 }
299 if field.invalid() {
300 out.push_str(" aria-invalid=\"true\"");
301 }
302
303 push_described_by(out, field, id);
304}
305
306/// The `aria-describedby` naming whatever of the hint and the error exist.
307///
308/// Both associations, in the order they are useful: the standing help, then
309/// what is currently wrong. goingson's runtime path points describedby at the
310/// error alone and drops the hint association it never made in the first place;
311/// naming both here means the hint survives an error appearing.
312///
313/// Its own function because a radio group carries it on the group rather than
314/// on a control, and one reading of "what describes this field" is the point.
315fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
316 if field.hint.is_none() && field.error.is_none() {
317 return;
318 }
319 out.push_str(" aria-describedby=\"");
320 if field.hint.is_some() {
321 let _ = write!(out, "{id}-hint");
322 }
323 if field.error.is_some() {
324 if field.hint.is_some() {
325 out.push(' ');
326 }
327 let _ = write!(out, "{id}-error");
328 }
329 out.push('"');
330}
331
332/// Whether the field's control is a set of elements rather than one.
333///
334/// A DOM concern rather than a description one, which is why it is decided here
335/// and not in `makeover-layout`: `for` and `id` are an HTML association and
336/// egui has no counterpart to get wrong. A `<label for>` aimed at a radio group
337/// points at nothing, because no single element carries the group's id, so the
338/// association has to invert — the label takes an id and the group names itself
339/// with `aria-labelledby`.
340const fn is_group_control(kind: FieldKind) -> bool {
341 matches!(kind, FieldKind::Radio)
342}
343
344/// A radio group: the options as sibling inputs sharing one `name`.
345///
346/// The group carries the error state and the descriptions, and the inputs carry
347/// what submits. That split is [`Field::invalid`]'s reasoning applied one level
348/// down: marking a single input invalid would say the wrong thing, since what
349/// is wrong is the answer to the question and not one of the alternatives.
350///
351/// Ids are numbered rather than built from the option values, which can hold
352/// anything a `&str` can — spaces and quotes included — and would otherwise
353/// have to be slugged into something unique by a rule this crate would then own.
354///
355/// `required` lands on every input, which is how HTML says a group is
356/// compulsory: the constraint is satisfied when any one of them is checked.
357fn push_radio(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
358 let id = filling.id_for(field.name);
359 let value = filling.value.as_text();
360 let name = escape(field.name);
361
362 out.push_str("<div class=\"");
363 push_class(out, "form-radio-group", opts);
364 let _ = write!(out, "\" role=\"radiogroup\" aria-labelledby=\"{id}-label\"");
365 if field.invalid() {
366 out.push_str(" aria-invalid=\"true\"");
367 }
368 push_described_by(out, field, &id);
369 out.push('>');
370
371 // A group described with no options emits an empty group, for the reason
372 // `Field::options` gives: an app whose option list has not loaded has
373 // exactly that, and an empty group says so on screen rather than in a log.
374 for (index, opt) in field.options.iter().enumerate() {
375 out.push_str("<label class=\"");
376 push_class(out, "form-radio-label", opts);
377 let _ = write!(
378 out,
379 "\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" value=\""
380 );
381 escape_into(opt.value, out);
382 out.push('"');
383 if opt.value == value {
384 out.push_str(" checked");
385 }
386 if field.required {
387 out.push_str(" required");
388 }
389 // A radio group has room a `<select>` does not, so the reason gets its
390 // own element beside the label rather than being run into it. The class
391 // is what a stylesheet mutes; the text is there either way, which is
392 // the half that matters — the finding was a greyed control with its
393 // explanation behind a hover.
394 if let Some(reason) = opt.unavailable {
395 out.push_str(" disabled");
396 out.push_str("><span>");
397 escape_into(opt.label, out);
398 out.push_str("</span><span class=\"");
399 push_class(out, "form-option-reason", opts);
400 out.push_str("\">");
401 escape_into(reason, out);
402 out.push_str("</span></label>");
403 continue;
404 }
405 out.push_str("><span>");
406 escape_into(opt.label, out);
407 out.push_str("</span></label>");
408 }
409
410 out.push_str("</div>");
411}
412
413/// The options of a select: the unanswered instruction, an unmatched current
414/// value carried as its own, then the options themselves.
415///
416/// A select handed a value no option carries renders with nothing selected, the
417/// browser falls back to the first option, and the next save writes a value
418/// nobody chose. goingson hit exactly that with a backup-retention default of
419/// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
420/// here so the second app gets it without hitting the bug first.
421fn push_options(out: &mut String, field: &Field<'_>, options: &[Choice<'_>], value: &str) {
422 // The unanswered state, which HTML has no attribute for: `placeholder` is
423 // not a `<select>` attribute, and the idiom is an empty option that cannot
424 // be chosen back. `disabled` is what stops it being re-selected once the
425 // user has answered, and `selected` is what puts it in the closed control
426 // while the value is empty; together they read as an instruction rather
427 // than as an option.
428 //
429 // `required` keeps working through it rather than around it: the option's
430 // value is empty, so a required select with this showing is invalid, which
431 // is the true report on a question nobody has answered.
432 //
433 // Emitted only while the value is empty, so it does not sit in the open
434 // list once the field is answered. A non-empty value no option carries is a
435 // wrong answer rather than an absent one and takes the stray-option path
436 // below.
437 if value.is_empty()
438 && let Some(text) = field.placeholder
439 {
440 out.push_str("<option value=\"\" disabled selected>");
441 escape_into(text, out);
442 out.push_str("</option>");
443 }
444 if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
445 // The one place an escaped value is worth keeping: it is written twice,
446 // as the option's value and as its text.
447 let escaped = escape(value);
448 let _ = write!(
449 out,
450 "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
451 );
452 }
453 for opt in options {
454 out.push_str("<option value=\"");
455 escape_into(opt.value, out);
456 out.push('"');
457 if opt.value == value {
458 out.push_str(" selected");
459 }
460 // `disabled` is what the browser reads, and it says nothing about why.
461 // The reason goes in the option's own text, because a `<select>` gives
462 // its options no room for anything else: no title attribute the
463 // keyboard reaches, no second line, no element inside. So the row reads
464 // "Multi-sample: Drop a second sample onto the keyboard." and is the
465 // one place the precondition can be both attached to its option and
466 // read without a pointer.
467 if let Some(reason) = opt.unavailable {
468 out.push_str(" disabled");
469 out.push('>');
470 escape_into(opt.label, out);
471 out.push_str(": ");
472 escape_into(reason, out);
473 out.push_str("</option>");
474 continue;
475 }
476 out.push('>');
477 escape_into(opt.label, out);
478 out.push_str("</option>");
479 }
480}
481
482/// The control itself, without its label, hint or error.
483fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
484 // Emitted before anything else is computed: a radio group carries its
485 // descriptions on the group rather than on a control, so none of the
486 // attributes below belong to it.
487 if matches!(field.kind, FieldKind::Radio) {
488 push_radio(out, field, filling, opts);
489 return;
490 }
491
492 let id = filling.id_for(field.name);
493 let placeholder = |out: &mut String| {
494 if let Some(text) = field.placeholder {
495 out.push_str(" placeholder=\"");
496 escape_into(text, out);
497 out.push('"');
498 }
499 };
500
501 match field.kind {
502 // Both multi-line kinds are a `<textarea>`, and the markdown one says so
503 // in an attribute rather than in a class: what the value *is* is not a
504 // styling hook, and a progressive enhancement looking for editors to
505 // upgrade needs a selector that survives `Emit`'s class prefixing.
506 // Without the mark, a described editor is a plain box and the four
507 // hand-written MNW editors have nothing to convert onto.
508 //
509 // `data-format` and not `data-value`: this names the shape of the
510 // value, and `facet` already spends `data-facet-value` on carrying an
511 // actual one. Two attributes a letter apart meaning opposite things is
512 // how a renderer's own vocabulary starts drifting.
513 kind if kind.multiline() => {
514 let rich = matches!(kind, FieldKind::Rich);
515 if rich {
516 push_editor_open(out, opts);
517 }
518 out.push_str("<textarea class=\"");
519 push_class(out, "field", opts);
520 out.push('"');
521 if rich {
522 out.push_str(" data-format=\"markdown\"");
523 }
524 push_control_attributes(out, field, &id, field.name);
525 placeholder(out);
526 out.push('>');
527 escape_into(filling.value.as_text(), out);
528 out.push_str("</textarea>");
529 if rich {
530 push_editor_close(out, opts);
531 }
532 }
533 FieldKind::Select => {
534 out.push_str("<select class=\"");
535 push_class(out, "field", opts);
536 out.push('"');
537 push_control_attributes(out, field, &id, field.name);
538 out.push('>');
539 // A select described with no options emits an empty select, which
540 // says so on screen rather than in a log. That is the description's
541 // own position on `Field::options`, not a fallback invented here.
542 push_options(out, field, field.options, filling.value.as_text());
543 out.push_str("</select>");
544 }
545 FieldKind::Checkbox => {
546 out.push_str("<label class=\"");
547 push_class(out, "form-checkbox-label", opts);
548 out.push_str("\"><input type=\"checkbox\"");
549 push_control_attributes(out, field, &id, field.name);
550 if matches!(filling.value, Value::On(true)) {
551 out.push_str(" checked");
552 }
553 out.push_str("><span>");
554 escape_into(field.label, out);
555 out.push_str("</span></label>");
556 }
557 // A secret never carries its value into the markup. `FieldKind::secret`
558 // is documented as a value that must not be round-tripped through
559 // anything that might persist it, and the DOM is such a thing: it is
560 // read by every extension on the page and is the first thing a crash
561 // reporter serialises. Neither app pre-fills one today, so this costs
562 // nothing and closes the door before something does.
563 FieldKind::Secret => {
564 out.push_str("<input type=\"password\" class=\"");
565 push_class(out, "field", opts);
566 out.push('"');
567 push_control_attributes(out, field, &id, field.name);
568 placeholder(out);
569 out.push('>');
570 }
571 // A file input carries no value, and this is the browser's rule rather
572 // than a preference: setting one from markup is refused, because a page
573 // that could preselect a path could read a file the user never offered.
574 // Nothing upstream needs to know, which is why the exception is here.
575 FieldKind::File => {
576 out.push_str("<input type=\"file\" class=\"");
577 push_class(out, "field", opts);
578 out.push('"');
579 push_control_attributes(out, field, &id, field.name);
580 push_accept(out, field);
581 if field.multiple {
582 out.push_str(" multiple");
583 }
584 out.push('>');
585 }
586 kind => {
587 let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
588 push_class(out, "field", opts);
589 out.push('"');
590 push_control_attributes(out, field, &id, field.name);
591 placeholder(out);
592 out.push_str(" value=\"");
593 escape_into(filling.value.as_text(), out);
594 out.push_str("\">");
595 }
596 }
597}
598
599/// The chrome a markdown field gets and a plain textarea does not: the two
600/// modes, and the pane a preview lands in.
601///
602/// # Why this is the one field with markup around it
603///
604/// [`FieldKind::Rich`]'s own doc says the mark buys a renderer permission to
605/// offer a preview or a syntax pass, and that a renderer with neither draws a
606/// textarea. A renderer taking the permission and emitting the same box as
607/// [`FieldKind::Textarea`] leaves an app converting onto the member with less
608/// than it had written by hand: MNW's `partial-item-text-editor.js` has a
609/// Write/Preview pair and a pane behind it, and describing the field without
610/// this would delete both. So the pair is here, on `facet`'s argument one
611/// field down -- the markup it replaces is not markup an app is keeping.
612///
613/// # Nothing here renders markdown, and that is where the sanitising stays
614///
615/// The pane arrives empty and this crate never turns a value into markup.
616/// Converting markdown is the host's, which is where the sanitiser already is:
617/// MNW renders through `docengine` over ammonia and holds an allowlist beside
618/// it. A converter here would move that guarantee into a crate with no view of
619/// the host's content-security posture, and `Rich`'s doc is explicit that a
620/// host with its own sanitiser still owns it. What this emits is a hook, and
621/// whatever fills it fills it with markup it has already made safe.
622///
623/// # The direction the enhancement runs
624///
625/// [`crate::stylesheet`]'s rule for a showing region, and for its reason: a
626/// control rendered into a document with no script is a control that looks live
627/// and answers nothing. Nothing is hidden here and no control is shown until
628/// whatever binds the editor sets `data-ready` on the wrapper, so a reader with
629/// no script gets the textarea alone -- what 0.50.0 emitted -- and a reader with
630/// script gets the modes. A bound editor says which mode it is in with
631/// `data-mode`, and [`editor_rules`] reads that.
632fn push_editor_open(out: &mut String, opts: &Emit) {
633 // The mark sits on the wrapper as well as on the control, saying one thing
634 // about two: this control's value is markdown, and this editor edits
635 // markdown. The rules gate on the wrapper and they are attribute rules
636 // rather than class rules for `data-format`'s own reason -- the gate has to
637 // survive `Emit`'s class prefixing, because the enhancement selects on it
638 // too.
639 out.push_str("<div data-format=\"markdown\"><div class=\"");
640 push_class(out, "form-editor-modes", opts);
641 out.push_str("\">");
642 push_mode(out, "write", "Write", true, opts);
643 push_mode(out, "preview", "Preview", false, opts);
644 out.push_str("</div>");
645}
646
647/// One of the two modes, as a segment of the pair.
648///
649/// [`crate::option_class`] for [`Selector::Segmented`] rather than a name of
650/// its own: a Write/Preview pair is a segmented control, and spelling it as one
651/// gets it the depth, the focus ring and the chosen state every described
652/// selector gets, from rules that already exist. The words are written here for
653/// the reason `facet`'s exclude button writes its own: a description carrying
654/// them would be choosing them for the terminal as well.
655fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
656 out.push_str("<button type=\"button\" class=\"");
657 push_class(out, crate::option_class(Selector::Segmented), opts);
658 if chosen {
659 // The sheet keys the held-in segment on the class and a screen reader
660 // reads the attribute. Both, because they are two readings of one fact,
661 // which is the arrangement a facet value already has.
662 out.push_str(" chosen");
663 }
664 let _ = write!(
665 out,
666 "\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
667 );
668}
669
670/// The preview pane, and the wrapper closing over both halves.
671fn push_editor_close(out: &mut String, opts: &Emit) {
672 out.push_str("<div class=\"");
673 push_class(out, "form-editor-preview", opts);
674 // `data-editor-preview` and not an id: a form appears twice in a document
675 // often enough that `Filling::id_prefix` exists for it, and a binder holding
676 // the control can reach this without either of them being unique.
677 out.push_str("\" data-editor-preview></div></div>");
678}
679
680/// The rules the markdown editor's chrome needs.
681///
682/// The one place this module writes CSS. The class names [`field_html`] emits
683/// are goingson's and are deliberately unruled -- `.form-group`, `.form-label`,
684/// `.form-hint` and `.form-error` are the app's own, and phase A emits only what
685/// it can generate from the description -- but the two names here have no app
686/// counterpart to keep, because the chrome did not exist before the member did.
687///
688/// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
689/// off a plain textarea, and every rule that hides content is gated on
690/// `data-ready` as well, which is what keeps them out of a document with no
691/// script.
692pub(crate) fn editor_rules(opts: &Emit) -> String {
693 let mut css = String::new();
694 let modes = class("form-editor-modes", opts);
695 let preview = class("form-editor-preview", opts);
696 let field = class("field", opts);
697
698 // Hidden until something binds the editor, which is the whole argument in
699 // `push_editor_open`.
700 let _ = writeln!(
701 css,
702 "[data-format=\"markdown\"] > .{modes} {{\n display: none;\n}}"
703 );
704 // Block, and nothing about how the two segments sit in it. A button is
705 // inline already, so they make a row without this crate saying so, and
706 // saying so is where a gap would follow -- a magnitude, and
707 // `makeover-geometry`'s.
708 let _ = writeln!(
709 css,
710 "[data-format=\"markdown\"][data-ready] > .{modes} {{\n display: block;\n}}"
711 );
712
713 // The pane is empty until the host fills it, so it is out of flow in every
714 // state but the one where a bound editor is showing it. An empty box under
715 // the control is chrome claiming a preview nobody rendered.
716 let _ = writeln!(
717 css,
718 "[data-format=\"markdown\"] > .{preview} {{\n display: none;\n}}"
719 );
720 let _ = writeln!(
721 css,
722 "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
723 {{\n display: block;\n}}"
724 );
725 // One at a time. The source and the preview are the same content read two
726 // ways, and a field showing both answers its own question twice.
727 let _ = writeln!(
728 css,
729 "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
730 {{\n display: none;\n}}"
731 );
732
733 // The pane stands where the control stood, so it reads as the surface the
734 // control was: `.field` is a well, and this is the well it stands in for.
735 // Nothing about size -- how tall a preview is is the app's, the way the
736 // height of a track is.
737 let _ = write!(
738 css,
739 "[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
740 crate::depth_declarations(Depth::Well)
741 );
742
743 css
744}
745
746/// One field, as the group the app drops into its form.
747///
748/// The shape is goingson's, down to the class names, so adoption there deletes
749/// `renderFormField` rather than restyling anything. That is also why the class
750/// names are not emitted by [`crate::stylesheet`]: `.form-group`, `.form-label`,
751/// `.form-hint` and `.form-error` are the apps' own, and phase A deliberately
752/// emits only what it can generate from the description. Whether they should
753/// move into the description is the next question this raises, not one it
754/// answers.
755///
756/// A [`FieldKind::Hidden`] field is the input alone: no group, no label, and
757/// nothing drawn, which is what [`FieldKind::visible`] means.
758///
759/// The error marks the group as well as the control. That is
760/// [`Field::invalid`]'s own reasoning: a renderer with no descendant selectors
761/// cannot find the group from the message, so the group has to be told.
762///
763/// ```
764/// use makeover_layout::{Field, FieldKind};
765/// use makeover_webview::{Emit, form::{Filling, Value, field_html}};
766///
767/// let field = Field::new(FieldKind::Text, "title", "Title");
768/// let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
769///
770/// assert!(html.contains(r#"<label class="form-label" for="title">Title</label>"#));
771/// assert!(html.contains(r#"value="Ship it""#));
772/// ```
773#[must_use]
774pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
775 let mut html = String::new();
776 field_html_into(field, filling, opts, &mut html);
777 html
778}
779
780/// One field, written into a buffer the caller already has.
781///
782/// [`field_html`]'s streaming form, byte-identical to it. A form is a run of
783/// these, so a host building one should hold a single buffer and append each
784/// field into it rather than take a `String` per field and concatenate.
785pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
786 let id = filling.id_for(field.name);
787
788 if !field.kind.visible() {
789 // Name only, no id: a hidden field is never pointed at by a label or a
790 // description, so the one attribute it needs is the one that submits.
791 out.push_str("<input type=\"hidden\" name=\"");
792 escape_into(field.name, out);
793 out.push_str("\" value=\"");
794 escape_into(filling.value.as_text(), out);
795 out.push_str("\">");
796 return;
797 }
798
799 out.push_str("<div class=\"");
800 push_class(out, "form-group", opts);
801 if field.invalid() {
802 out.push_str(" has-error");
803 }
804 if field.extended {
805 // The disclosure that hides these is a property of the form, not of the
806 // field, so the field is marked and the app opens or closes the group.
807 out.push_str("\" data-extended=\"true");
808 }
809 out.push_str("\">");
810
811 // A checkbox labels itself, on the right of the box. Both apps special-case
812 // this inline today, which is the tell that it belongs in the description;
813 // `FieldKind::labels_itself` is where it went.
814 if !field.kind.labels_itself() {
815 out.push_str("<label class=\"");
816 push_class(out, "form-label", opts);
817 // A group control is named *by* its label rather than pointing at it,
818 // so the two carry opposite halves of the association. See
819 // `is_group_control`.
820 if is_group_control(field.kind) {
821 let _ = write!(out, "\" id=\"{id}-label\">");
822 } else {
823 let _ = write!(out, "\" for=\"{id}\">");
824 }
825 escape_into(field.label, out);
826 out.push_str("</label>");
827 }
828
829 push_control(out, field, filling, opts);
830
831 if let Some(hint) = field.hint {
832 out.push_str("<div class=\"");
833 push_class(out, "form-hint", opts);
834 let _ = write!(out, "\" id=\"{id}-hint\">");
835 escape_into(hint, out);
836 out.push_str("</div>");
837 }
838 if let Some(Markup(markup)) = filling.trailing {
839 out.push_str(markup);
840 }
841 if let Some(error) = field.error {
842 out.push_str("<div class=\"");
843 push_class(out, "form-error", opts);
844 let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
845 escape_into(error, out);
846 out.push_str("</div>");
847 }
848
849 out.push_str("</div>");
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use makeover_layout::{Accepted, Family};
856
857 fn field(kind: FieldKind) -> Field<'static> {
858 Field::new(kind, "title", "Title")
859 }
860
861 #[test]
862 fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
863 // The payload from goingson's own CHRONIC-XSS regression test.
864 let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
865 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
866 // The payload survives as text, which is the point: it is inert
867 // because the quote that would have closed the attribute is encoded,
868 // not because the words were filtered.
869 assert!(!html.contains("\" onfocus"), "{html}");
870 assert!(
871 html.contains("value=\"x" onfocus=alert(1) autofocus="\""),
872 "{html}"
873 );
874 }
875
876 #[test]
877 fn a_label_cannot_open_a_tag() {
878 let mut f = field(FieldKind::Text);
879 f.label = "<script>alert(1)</script>";
880 let html = field_html(&f, &Filling::default(), &Emit::default());
881 assert!(!html.contains("<script>"), "{html}");
882 assert!(html.contains("<script>"), "{html}");
883 }
884
885 #[test]
886 fn every_escaped_sink_is_covered_by_the_one_escaper() {
887 assert_eq!(escape("&<>\"'"), "&<>"'");
888 // The character `textContent` serialization leaves alone, which is why
889 // the app needs two escapers and this needs one.
890 assert!(escape("\"").contains("""));
891 }
892
893 /// The streaming escaper is the one the emitters call and [`escape`] is a
894 /// buffer around it, so the two cannot be allowed to drift. It copies in
895 /// runs between the encoded characters, which is where a multi-byte
896 /// character would break it if the scan were not restricted to ASCII.
897 #[test]
898 fn the_streaming_escaper_appends_what_the_returning_one_returns() {
899 for text in [
900 "",
901 "plain",
902 "&<>\"'",
903 "&&&",
904 "a & b",
905 "trailing&",
906 "&leading",
907 "é世 & <b>naïve</b> \u{1f600}",
908 ] {
909 let mut out = String::from("kept: ");
910 escape_into(text, &mut out);
911 assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
912 }
913 }
914
915 /// Same obligation one layer up: a form is a run of fields appended into one
916 /// buffer, and the two ways to get one have to agree byte for byte.
917 #[test]
918 fn a_streamed_field_is_the_field_the_other_form_returns() {
919 let kinds = [
920 FieldKind::Text,
921 FieldKind::Secret,
922 FieldKind::Number,
923 FieldKind::Checkbox,
924 FieldKind::Radio,
925 FieldKind::Select,
926 FieldKind::Textarea,
927 FieldKind::File,
928 FieldKind::Hidden,
929 ];
930 let choices = [Choice::plain("one"), Choice::plain("two")];
931 let opts = Emit {
932 class_prefix: "mk-",
933 ..Emit::default()
934 };
935 for kind in kinds {
936 let described = Field {
937 hint: Some("a hint"),
938 error: Some("wrong <here>"),
939 placeholder: Some("x\" y"),
940 options: &choices,
941 required: true,
942 max_length: Some(40),
943 min: Some("1"),
944 max: Some("9"),
945 extended: true,
946 ..Field::new(kind, "the & name", "The <label>")
947 };
948 let filling = Filling {
949 value: Value::Text("one"),
950 trailing: Some(Markup("<i>t</i>")),
951 id_prefix: Some("modal"),
952 };
953 let mut streamed = String::new();
954 field_html_into(&described, &filling, &opts, &mut streamed);
955 assert_eq!(
956 streamed,
957 field_html(&described, &filling, &opts),
958 "{kind:?}"
959 );
960
961 // And the bare field, where every optional half is absent.
962 let plain = Field::new(kind, "name", "Label");
963 let mut streamed = String::new();
964 field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
965 assert_eq!(
966 streamed,
967 field_html(&plain, &Filling::default(), &opts),
968 "{kind:?}"
969 );
970 }
971 }
972
973 #[test]
974 fn markup_is_the_only_way_past_the_escaping() {
975 let filling = Filling {
976 trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
977 ..Filling::default()
978 };
979 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
980 assert!(
981 html.contains("<div class=\"recurrence-config\"></div>"),
982 "{html}"
983 );
984 }
985
986 #[test]
987 fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
988 let mut f = field(FieldKind::Text);
989 f.error = Some("Required");
990 let opts = Emit::default();
991 let html = field_html(&f, &Filling::default(), &opts);
992 assert!(html.contains("aria-invalid=\"true\""), "{html}");
993 // The selector the CSS side emits for exactly this state.
994 assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
995 // And the group is marked too, which a renderer without descendant
996 // selectors depends on.
997 assert!(html.contains("has-error"), "{html}");
998 }
999
1000 #[test]
1001 fn a_valid_field_claims_nothing_about_being_invalid() {
1002 let html = field_html(
1003 &field(FieldKind::Text),
1004 &Filling::default(),
1005 &Emit::default(),
1006 );
1007 assert!(!html.contains("aria-invalid"), "{html}");
1008 assert!(!html.contains("has-error"), "{html}");
1009 }
1010
1011 #[test]
1012 fn the_hint_survives_an_error_arriving() {
1013 let mut f = field(FieldKind::Text);
1014 f.hint = Some("Keep it short");
1015 f.error = Some("Required");
1016 let html = field_html(&f, &Filling::default(), &Emit::default());
1017 assert!(
1018 html.contains("aria-describedby=\"title-hint title-error\""),
1019 "{html}"
1020 );
1021 }
1022
1023 #[test]
1024 fn a_secret_never_carries_its_value_into_the_markup() {
1025 let filling = Filling::of(Value::Text("hunter2"));
1026 let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
1027 assert!(!html.contains("hunter2"), "{html}");
1028 assert!(html.contains("type=\"password\""), "{html}");
1029 }
1030
1031 #[test]
1032 fn a_hidden_field_is_the_input_and_nothing_else() {
1033 let filling = Filling::of(Value::Text("42"));
1034 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
1035 assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
1036 }
1037
1038 #[test]
1039 fn a_checkbox_labels_itself_and_takes_no_separate_label() {
1040 let html = field_html(
1041 &field(FieldKind::Checkbox),
1042 &Filling::of(Value::On(true)),
1043 &Emit::default(),
1044 );
1045 assert!(!html.contains("form-label"), "{html}");
1046 assert!(html.contains("checked"), "{html}");
1047 assert!(html.contains("<span>Title</span>"), "{html}");
1048 }
1049
1050 #[test]
1051 fn a_select_keeps_a_value_no_option_carries() {
1052 let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
1053 let f = Field::select("title", "Title", &options);
1054 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1055 assert!(html.contains("data-unmatched=\"true\""), "{html}");
1056 // Selected, so the next save round-trips it rather than writing the
1057 // first option over the top of it.
1058 assert!(html.contains("<option value=\"10\" selected"), "{html}");
1059 }
1060
1061 #[test]
1062 fn a_select_with_no_options_emits_an_empty_select() {
1063 // The description says a select with no options is sayable, because an
1064 // app whose option list has not loaded has exactly that. Emitting the
1065 // empty select reports it on screen rather than in a log.
1066 let f = Field::select("title", "Title", &[]);
1067 let html = field_html(&f, &Filling::default(), &Emit::default());
1068 assert!(html.contains("<select"), "{html}");
1069 assert!(!html.contains("<option"), "{html}");
1070 }
1071
1072 #[test]
1073 fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
1074 let options = [Choice::new("sp404", "SP-404")];
1075 let f = Field {
1076 placeholder: Some("Select device..."),
1077 ..Field::select("device", "Conform for device", &options)
1078 };
1079 let html = field_html(&f, &Filling::default(), &Emit::default());
1080
1081 assert!(
1082 html.contains("<option value=\"\" disabled selected>Select device...</option>"),
1083 "{html}"
1084 );
1085 // First, so the closed control reads it rather than the first real
1086 // option.
1087 assert!(
1088 html.find("Select device...") < html.find("SP-404"),
1089 "{html}"
1090 );
1091 }
1092
1093 #[test]
1094 fn an_answered_select_drops_the_ghost_text() {
1095 // It is an instruction about an empty field, so it has nothing to say
1096 // once the field is answered, and leaving it in the list is one dead
1097 // row every time the control is opened afterwards.
1098 let options = [Choice::new("sp404", "SP-404")];
1099 let f = Field {
1100 placeholder: Some("Select device..."),
1101 ..Field::select("device", "Conform for device", &options)
1102 };
1103 let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
1104 assert!(!html.contains("Select device..."), "{html}");
1105 }
1106
1107 #[test]
1108 fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
1109 // The two paths through `push_options` meet here. An unmatched value is
1110 // an answer that is wrong and stays visible as itself; only the empty
1111 // value is unanswered.
1112 let options = [Choice::plain("1"), Choice::plain("7")];
1113 let f = Field {
1114 placeholder: Some("Pick one"),
1115 ..Field::select("retention", "Keep backups for", &options)
1116 };
1117 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1118 assert!(html.contains("data-unmatched=\"true\""), "{html}");
1119 assert!(!html.contains("Pick one"), "{html}");
1120 }
1121
1122 #[test]
1123 fn a_range_is_a_range_input_and_carries_its_extent() {
1124 let f = Field {
1125 step: Some("0.01"),
1126 ..Field::range("review", "Review above", "0", "1")
1127 };
1128 let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
1129 assert!(html.contains("type=\"range\""), "{html}");
1130 assert!(html.contains("min=\"0\""), "{html}");
1131 assert!(html.contains("max=\"1\""), "{html}");
1132 // Without it the browser steps by 1 and a 0-to-1 question becomes a
1133 // two-position control.
1134 assert!(html.contains("step=\"0.01\""), "{html}");
1135 }
1136
1137 #[test]
1138 fn a_number_with_bounds_is_still_typed_into() {
1139 // The distinction the kind exists for, at the renderer where getting it
1140 // wrong is most visible: goingson's `min="1"` duration must not come
1141 // back as a slider.
1142 let f = Field {
1143 min: Some("1"),
1144 ..Field::new(FieldKind::Number, "minutes", "Minutes")
1145 };
1146 let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
1147 assert!(html.contains("type=\"number\""), "{html}");
1148 assert!(!html.contains("type=\"range\""), "{html}");
1149 // And nothing invents a step for it.
1150 assert!(!html.contains("step="), "{html}");
1151 }
1152
1153 #[test]
1154 fn an_unavailable_option_is_disabled_and_says_why() {
1155 let options = [
1156 Choice::new("chromatic", "Chromatic"),
1157 Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1158 ];
1159 let f = Field::radio("mode", "Mode", &options);
1160 let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
1161
1162 assert!(html.contains(" disabled"), "{html}");
1163 assert!(html.contains("Drop a second sample."), "{html}");
1164 // The option is still offered: dropping it is what costs the user the
1165 // knowledge that the mode exists.
1166 assert!(html.contains("value=\"multi\""), "{html}");
1167 // And the reason is its own element, not run into the label.
1168 assert!(html.contains("form-option-reason"), "{html}");
1169 }
1170
1171 #[test]
1172 fn an_unavailable_select_option_carries_its_reason_in_its_text() {
1173 // A `<select>` gives an option no room for a second element, so the
1174 // reason has to be in the text or be unreadable without a pointer.
1175 let options = [Choice::new("multi", "Multi-sample").unless("Drop a second sample.")];
1176 let f = Field::select("mode", "Mode", &options);
1177 let html = field_html(&f, &Filling::default(), &Emit::default());
1178 assert!(
1179 html.contains(">Multi-sample: Drop a second sample.</option>"),
1180 "{html}"
1181 );
1182 assert!(html.contains("disabled"), "{html}");
1183 }
1184
1185 #[test]
1186 fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
1187 // The association inverts, and getting it wrong is silent: a
1188 // `<label for>` aimed at a group points at no element, so the group
1189 // simply has no accessible name and nothing reports that.
1190 let options = [Choice::plain("copy"), Choice::plain("reference")];
1191 let f = Field::radio("storage", "Storage style", &options);
1192 let html = field_html(&f, &Filling::of(Value::Text("copy")), &Emit::default());
1193
1194 assert!(html.contains("id=\"storage-label\""), "{html}");
1195 assert!(!html.contains("for=\"storage\""), "{html}");
1196 assert!(html.contains("role=\"radiogroup\""), "{html}");
1197 assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
1198 }
1199
1200 #[test]
1201 fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
1202 // One `name` is what makes them one answer rather than three; distinct
1203 // ids are what keep each `<label>` wrapping its own input.
1204 let options = [
1205 Choice::plain("copy"),
1206 Choice::plain("reference"),
1207 Choice::plain("link"),
1208 ];
1209 let f = Field::radio("storage", "Storage style", &options);
1210 let html = field_html(&f, &Filling::of(Value::Text("reference")), &Emit::default());
1211
1212 assert_eq!(html.matches("name=\"storage\"").count(), 3, "{html}");
1213 assert_eq!(html.matches(" checked").count(), 1, "{html}");
1214 assert!(
1215 html.contains("value=\"reference\" checked"),
1216 "the checked one is the one held: {html}"
1217 );
1218 for index in 0..3 {
1219 assert!(html.contains(&format!("id=\"storage-{index}\"")), "{html}");
1220 }
1221 }
1222
1223 #[test]
1224 fn a_radio_group_carries_the_error_rather_than_any_one_option() {
1225 // What is wrong is the answer, not one of the alternatives, so marking
1226 // a single input invalid would say something false. Same reading
1227 // `Field::invalid` gives one level up.
1228 let options = [Choice::plain("copy"), Choice::plain("reference")];
1229 let f = Field {
1230 error: Some("Pick one."),
1231 hint: Some("Cannot be changed later."),
1232 ..Field::radio("storage", "Storage style", &options)
1233 };
1234 let html = field_html(&f, &Filling::default(), &Emit::default());
1235
1236 assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
1237 assert!(
1238 html.contains("aria-describedby=\"storage-hint storage-error\""),
1239 "{html}"
1240 );
1241 // The group is the element that carries them, so they land before the
1242 // first option rather than on it.
1243 let group = html.find("role=\"radiogroup\"").expect("group");
1244 let first = html.find("type=\"radio\"").expect("an option");
1245 assert!(group < first, "{html}");
1246 }
1247
1248 #[test]
1249 fn a_compulsory_radio_group_marks_every_option() {
1250 // How HTML says a group is compulsory: the constraint reads as
1251 // satisfied when any one of them is checked.
1252 let options = [Choice::plain("copy"), Choice::plain("reference")];
1253 let f = Field {
1254 required: true,
1255 ..Field::radio("storage", "Storage style", &options)
1256 };
1257 let html = field_html(&f, &Filling::default(), &Emit::default());
1258 assert_eq!(html.matches(" required").count(), 2, "{html}");
1259 }
1260
1261 #[test]
1262 fn a_radio_option_cannot_break_out_of_its_attribute() {
1263 // Values are `&str` and carry whatever the app put in them. The ids are
1264 // numbered rather than derived from the value for the same reason.
1265 let hostile = [Choice::new(
1266 "x\" onclick=alert(1) data-x=\"",
1267 "<script>alert(1)</script>",
1268 )];
1269 let f = Field::radio("storage", "Storage style", &hostile);
1270 let html = field_html(&f, &Filling::default(), &Emit::default());
1271
1272 // The payload survives as text; what must not survive is the quote
1273 // that would end the attribute and let the rest of it become markup.
1274 assert!(html.contains("value=\"x" onclick=alert(1)"), "{html}");
1275 assert!(!html.contains("<script>"), "{html}");
1276 assert!(html.contains("id=\"storage-0\""), "{html}");
1277 }
1278
1279 #[test]
1280 fn a_radio_group_with_no_options_emits_an_empty_group() {
1281 // Same position the select takes, and the description's own.
1282 let f = Field::radio("storage", "Storage style", &[]);
1283 let html = field_html(&f, &Filling::default(), &Emit::default());
1284 assert!(html.contains("role=\"radiogroup\""), "{html}");
1285 assert!(!html.contains("type=\"radio\""), "{html}");
1286 }
1287
1288 #[test]
1289 fn a_placeholder_comes_off_the_description_and_is_escaped() {
1290 // It arrived in `Filling` until makeover-layout 0.8.0 and was never
1291 // covered here; it is a value in an attribute like any other.
1292 let f = Field {
1293 placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
1294 ..field(FieldKind::Text)
1295 };
1296 let html = field_html(&f, &Filling::default(), &Emit::default());
1297 assert!(html.contains("placeholder=\""), "{html}");
1298 assert!(!html.contains("\" onfocus"), "{html}");
1299 }
1300
1301 #[test]
1302 fn a_select_marks_the_option_that_matches() {
1303 let options = [Choice::plain("1"), Choice::plain("3")];
1304 let f = Field::select("title", "Title", &options);
1305 let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
1306 assert!(
1307 html.contains("<option value=\"3\" selected>3</option>"),
1308 "{html}"
1309 );
1310 assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
1311 assert!(!html.contains("data-unmatched"), "{html}");
1312 }
1313
1314 #[test]
1315 fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
1316 let filling = Filling::of(Value::Text("two\nlines"));
1317 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
1318 assert!(html.contains(">two\nlines</textarea>"), "{html}");
1319 }
1320
1321 #[test]
1322 fn a_markdown_field_is_a_textarea_that_says_what_its_value_is() {
1323 // The mark is the whole difference. Without it a described editor is a
1324 // plain box, and an enhancement looking for editors to upgrade has
1325 // nothing to find -- which is the state MNW's four hand-written section
1326 // editors would have had to keep living in.
1327 let filling = Filling::of(Value::Text("# Heading"));
1328 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
1329 assert!(html.contains("<textarea"), "{html}");
1330 assert!(html.contains(r#"data-format="markdown""#), "{html}");
1331 assert!(html.contains("># Heading</textarea>"), "{html}");
1332
1333 // A plain textarea claims nothing about its value, so the marker has to
1334 // be absent rather than present-and-different.
1335 let plain = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
1336 assert!(!plain.contains("data-format"), "{plain}");
1337
1338 // And it is not an input: the catch-all in `input_type` would have
1339 // degraded it to a single-line text box, which is the wrong shape for
1340 // markdown rather than a lossless fallback.
1341 assert!(!html.contains("<input"), "{html}");
1342 }
1343
1344 #[test]
1345 fn a_markdown_field_gets_the_preview_the_member_permits() {
1346 // The mark on its own is what 0.50.0 shipped, and nothing read it. What
1347 // a conversion needs is the pair MNW's `partial-item-text-editor.js`
1348 // already draws, so describing the field is not a way to lose it.
1349 let filling = Filling::of(Value::Text("# Heading"));
1350 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
1351 assert!(html.contains("data-editor-mode=\"write\""), "{html}");
1352 assert!(html.contains("data-editor-mode=\"preview\""), "{html}");
1353 assert!(html.contains("data-editor-preview"), "{html}");
1354 // Write is the mode a fresh editor is in, and the segment says so twice
1355 // because the sheet reads one and a screen reader reads the other.
1356 assert!(
1357 html.contains(
1358 "class=\"segment chosen\" data-editor-mode=\"write\" aria-pressed=\"true\""
1359 ),
1360 "{html}"
1361 );
1362 assert!(
1363 html.contains("data-editor-mode=\"preview\" aria-pressed=\"false\""),
1364 "{html}"
1365 );
1366 // The value is still the textarea's, and still text rather than an
1367 // attribute. The chrome sits around the control, not in place of it.
1368 assert!(html.contains("># Heading</textarea>"), "{html}");
1369 }
1370
1371 #[test]
1372 fn a_plain_textarea_gets_no_editor_chrome() {
1373 let filling = Filling::of(Value::Text("plain"));
1374 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
1375 assert!(!html.contains("data-editor-mode"), "{html}");
1376 assert!(!html.contains("data-editor-preview"), "{html}");
1377 assert!(!html.contains("segment"), "{html}");
1378 }
1379
1380 #[test]
1381 fn nothing_the_editor_emits_renders_the_value_as_markup() {
1382 // The whole of this crate's half of the sanitising question: the pane is
1383 // empty, so no value reaches markup through it, and the host's own
1384 // renderer keeps the guarantee it already has.
1385 let filling = Filling::of(Value::Text("<img src=x onerror=alert(1)>"));
1386 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
1387 assert!(html.contains("data-editor-preview></div>"), "{html}");
1388 assert!(!html.contains("<img"), "{html}");
1389 assert!(
1390 html.contains("<img src=x onerror=alert(1)>"),
1391 "{html}"
1392 );
1393 }
1394
1395 #[test]
1396 fn the_editor_rules_gate_on_the_attribute_and_on_a_binding() {
1397 let css = editor_rules(&Emit::default());
1398 // Behind the attribute, which is the reason the mark is an attribute:
1399 // a class-keyed gate would be prefixed away from the enhancement that
1400 // selects on it.
1401 for line in css.lines().filter(|line| line.contains('{')) {
1402 assert!(line.contains("[data-format=\"markdown\"]"), "{line}");
1403 }
1404 // Nothing is hidden and no control appears until something binds the
1405 // editor. A reader with no script gets the textarea alone.
1406 assert!(
1407 css.contains(
1408 "[data-format=\"markdown\"] > .form-editor-modes {\n display: none;\n}"
1409 )
1410 );
1411 assert!(css.contains(
1412 "[data-format=\"markdown\"][data-ready] > .form-editor-modes {\n display: block;\n}"
1413 ));
1414 assert!(css.contains(
1415 "[data-ready][data-mode=\"preview\"] > .form-editor-preview {\n display: block;\n}"
1416 ));
1417 assert!(
1418 css.contains("[data-ready][data-mode=\"preview\"] > .field {\n display: none;\n}")
1419 );
1420 // No magnitude, the line this crate holds everywhere else.
1421 assert!(!css.contains("px"), "{css}");
1422 assert!(!css.contains("rem"), "{css}");
1423 }
1424
1425 /// The prefix reaches the chrome as well, and the gate deliberately does
1426 /// not: an app assembling the sheet with its own prefix still has the
1427 /// selector an enhancement finds the editors by.
1428 #[test]
1429 fn the_editor_chrome_is_prefixed_and_its_gate_is_not() {
1430 let opts = Emit {
1431 class_prefix: "mk-",
1432 ..Emit::default()
1433 };
1434 let html = field_html(&field(FieldKind::Rich), &Filling::default(), &opts);
1435 assert!(html.contains("class=\"mk-form-editor-modes\""), "{html}");
1436 assert!(html.contains("class=\"mk-form-editor-preview\""), "{html}");
1437 assert!(html.contains("class=\"mk-segment chosen\""), "{html}");
1438 assert!(html.contains("data-format=\"markdown\""), "{html}");
1439
1440 let css = editor_rules(&opts);
1441 assert!(css.contains(".mk-form-editor-modes"), "{css}");
1442 assert!(css.contains("[data-format=\"markdown\"]"), "{css}");
1443 }
1444
1445 /// Every class the editor puts in markup is one the generated sheet rules,
1446 /// which is `FACET_CLASSES`' obligation without a list to keep: these two
1447 /// have rules, so the vocabulary seal picks them up from the sheet itself.
1448 #[test]
1449 fn the_editor_classes_are_in_the_vocabulary() {
1450 let opts = Emit::default();
1451 let names = crate::vocabulary::names(&opts);
1452 for name in ["form-editor-modes", "form-editor-preview", "segment"] {
1453 assert!(names.contains(name), "{name} is not in the vocabulary");
1454 }
1455 }
1456
1457 #[test]
1458 fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
1459 let opts = Emit {
1460 class_prefix: "mk-",
1461 ..Emit::default()
1462 };
1463 let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
1464 assert!(html.contains("class=\"mk-form-group\""), "{html}");
1465 assert!(html.contains("class=\"mk-field\""), "{html}");
1466 }
1467
1468 #[test]
1469 fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
1470 let mut f = field(FieldKind::Text);
1471 f.extended = true;
1472 let html = field_html(&f, &Filling::default(), &Emit::default());
1473 assert!(html.contains("data-extended=\"true\""), "{html}");
1474 }
1475
1476 /// The prefix scopes the id and leaves the name alone. Prefixing the name
1477 /// too would change what the form submits, which is the failure this pair
1478 /// of assertions exists to catch rather than describe.
1479 #[test]
1480 fn the_id_prefix_scopes_the_id_and_never_the_name() {
1481 let mut f = field(FieldKind::Text);
1482 f.hint = Some("Keep it short");
1483 f.error = Some("Required");
1484 let filling = Filling {
1485 id_prefix: Some("form-modal-task-edit"),
1486 ..Filling::default()
1487 };
1488 let html = field_html(&f, &filling, &Emit::default());
1489
1490 assert!(
1491 html.contains(r#"id="form-modal-task-edit-title""#),
1492 "{html}"
1493 );
1494 assert!(html.contains(r#"name="title""#), "{html}");
1495 assert!(
1496 !html.contains(r#"name="form-modal-task-edit-title""#),
1497 "{html}"
1498 );
1499
1500 // The label and both associations follow the id, or they point at
1501 // nothing once the same form is on screen twice.
1502 assert!(
1503 html.contains(r#"for="form-modal-task-edit-title""#),
1504 "{html}"
1505 );
1506 assert!(
1507 html.contains(
1508 r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
1509 ),
1510 "{html}"
1511 );
1512 assert!(
1513 html.contains(r#"id="form-modal-task-edit-title-hint""#),
1514 "{html}"
1515 );
1516 }
1517
1518 #[test]
1519 fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
1520 let filling = Filling {
1521 value: Value::Text("42"),
1522 id_prefix: Some("scoped"),
1523 ..Filling::default()
1524 };
1525 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
1526 assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
1527 }
1528
1529 /// These three exist so a touch keyboard and the platform's validation
1530 /// arrive with the field. Emitting text for any of them is the regression
1531 /// the variants were added to prevent, so the type is asserted directly.
1532 #[test]
1533 fn a_constraint_becomes_the_browsers_own_attribute() {
1534 // makeover-layout 0.11.0's model: the description carries the rule and
1535 // each renderer emits its host's idiom for it. Enforcement is still
1536 // whoever validated's, and arrives back as `error`.
1537 let html = field_html(
1538 &Field {
1539 max_length: Some(100),
1540 min: Some("1"),
1541 max: Some("240"),
1542 required: true,
1543 ..Field::new(FieldKind::Number, "minutes", "Minutes")
1544 },
1545 &Filling::default(),
1546 &Emit::default(),
1547 );
1548 assert!(html.contains(r#"maxlength="100""#));
1549 assert!(html.contains(r#"min="1""#));
1550 assert!(html.contains(r#"max="240""#));
1551 assert!(html.contains(" required"));
1552 }
1553
1554 #[test]
1555 fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() {
1556 // The bound is text because it is only a number for some of the kinds
1557 // that take one; goingson's own sites are a duration and a datetime.
1558 let html = field_html(
1559 &Field {
1560 min: Some("2026-08-09T14:30"),
1561 ..Field::new(FieldKind::Text, "starts", "Starts")
1562 },
1563 &Filling::default(),
1564 &Emit::default(),
1565 );
1566 assert!(html.contains(r#"min="2026-08-09T14:30""#));
1567 }
1568
1569 #[test]
1570 fn a_file_field_is_a_file_input() {
1571 // `844b5ae0`. A field that takes any file emits no `accept` at all,
1572 // which is the browser's own "any file". `accept=""` is a filter that
1573 // means nothing on one browser and everything on another.
1574 let html = field_html(
1575 &Field::new(FieldKind::File, "attachment", "Attachment"),
1576 &Filling::default(),
1577 &Emit::default(),
1578 );
1579 assert!(html.contains(r#"type="file""#));
1580 assert!(!html.contains("accept="));
1581 assert!(!html.contains("multiple"));
1582 // And it never carries a value: a file input's value is not settable
1583 // from markup, and the browser refuses one that tries.
1584 assert!(!html.contains("value="));
1585 }
1586
1587 #[test]
1588 fn an_accept_list_is_comma_joined_in_the_attributes_own_format() {
1589 // `f7261a5a`, makeover-layout 0.31.0. Each entry writes itself: a
1590 // family is its wildcard, a media type is itself, a suffix keeps its
1591 // leading dot and however many more it has.
1592 const MIXED: &[Accepted<'_>] = &[
1593 Accepted::Family(Family::Image),
1594 Accepted::Type("text/csv"),
1595 Accepted::Suffix(".tar.gz"),
1596 ];
1597 let html = field_html(
1598 &Field {
1599 multiple: true,
1600 ..Field::upload("drop", "Drop files", MIXED)
1601 },
1602 &Filling::default(),
1603 &Emit::default(),
1604 );
1605 assert!(
1606 html.contains(r#"accept="image/*,text/csv,.tar.gz""#),
1607 "{html}"
1608 );
1609 assert!(html.contains(" multiple"), "{html}");
1610 }
1611
1612 #[test]
1613 fn an_accept_entry_cannot_end_the_attribute_it_sits_in() {
1614 // The list reaches an attribute value, so it is escaped like every
1615 // other string that does. Nothing in the tree writes a quote into one;
1616 // that it cannot is the point.
1617 const HOSTILE: &[Accepted<'_>] = &[Accepted::Type(r#"image/x" onload="x"#)];
1618 let html = field_html(
1619 &Field::upload("cover", "Cover", HOSTILE),
1620 &Filling::default(),
1621 &Emit::default(),
1622 );
1623 assert!(!html.contains(r#"onload="x"#), "{html}");
1624 }
1625
1626 #[test]
1627 fn the_typed_text_kinds_keep_their_input_type() {
1628 for (kind, expected) in [
1629 (FieldKind::Email, "email"),
1630 (FieldKind::Url, "url"),
1631 (FieldKind::Tel, "tel"),
1632 (FieldKind::Date, "date"),
1633 (FieldKind::DateTime, "datetime-local"),
1634 ] {
1635 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
1636 assert!(
1637 html.contains(&format!(r#"type="{expected}""#)),
1638 "{kind:?} emitted {html}"
1639 );
1640 }
1641 }
1642
1643 #[test]
1644 fn a_temporal_field_is_a_native_control_and_not_a_hinted_text_box() {
1645 // The regression this closes: described as text with a hint reading
1646 // "YYYY-MM-DD", which loses the picker, the platform's validation and
1647 // the touch keyboard, and asks prose to do all three.
1648 for kind in [FieldKind::Date, FieldKind::DateTime] {
1649 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
1650 assert!(!html.contains(r#"type="text""#), "{kind:?} emitted {html}");
1651 }
1652 }
1653
1654 #[test]
1655 fn no_prefix_leaves_the_id_as_the_name() {
1656 let html = field_html(
1657 &field(FieldKind::Text),
1658 &Filling::default(),
1659 &Emit::default(),
1660 );
1661 assert!(html.contains(r#"id="title" name="title""#), "{html}");
1662 }
1663}