Skip to main content

max / makeover-webview

110.0 KB · 2607 lines History Blame Raw
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
43 use crate::{Emit, class, push_class};
44 use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, ThemeVariant, Tone};
45 use std::fmt::Write as _;
46
47 /// Every class this module can put in markup.
48 ///
49 /// [`crate::facet::FACET_CLASSES`]' obligation, and the module where it was
50 /// missing longest. Most of these carry no rule and never will: `.form-group`,
51 /// `.form-label`, `.form-hint` and `.form-error` are the apps' own names, kept
52 /// so adoption deletes goingson's `renderFormField` rather than restyling
53 /// anything, and phase A emits only what it can generate from the description.
54 /// A class with no rule is invisible to [`crate::vocabulary::vocabulary`],
55 /// which reads the generated sheet, so the unruled half of a renderer's
56 /// vocabulary can only be written down.
57 ///
58 /// What went wrong without it: an app checking its stylesheet against
59 /// [`crate::vocabulary::names`] concluded that its live `.form-group` and
60 /// `.form-label` rules matched nothing and were safe to delete. quasi-webview
61 /// carried them in a `MAKEOVER_UNLISTED` constant of its own until 0.59.0
62 /// rather than let that happen.
63 pub const FIELD_CLASSES: &[&str] = &[
64 "field",
65 "form-checkbox-label",
66 "form-editor-modes",
67 "form-editor-preview",
68 "form-error",
69 "form-group",
70 "form-hint",
71 "form-interval",
72 "form-label",
73 "form-note",
74 "form-option-reason",
75 "form-radio-group",
76 "form-radio-label",
77 "form-unit",
78 ];
79
80 // `form-suggestions`, `form-suggestion` and `form-suggestion-detail` are
81 // deliberately absent: [`suggestion_rules`] writes their look and
82 // `quasi-webview` writes their markup, because a suggestion source is a route
83 // and no description layer carries one. They reach the vocabulary through the
84 // generated sheet, which is where a name this crate rules but does not emit
85 // belongs.
86
87 /// The state classes a field carries, which take no prefix.
88 ///
89 /// `chosen` and `latched`'s convention, stated in
90 /// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component
91 /// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix
92 /// moves the thing and not its state.
93 ///
94 /// `has-error` marks the group and `visible` marks the message, which is
95 /// [`makeover_layout::Field::invalid`]'s own reasoning: a renderer with no
96 /// descendant selectors cannot find the group from the message, so both are
97 /// told.
98 pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"];
99
100 /// A string that is already markup, and is emitted without escaping.
101 ///
102 /// The one hole in the escaping, and it has to be named to be used. goingson
103 /// has two live callers that need it, both passing a recurrence-config block
104 /// built elsewhere, and both would otherwise have their markup rendered as
105 /// visible angle brackets. A caller constructing this is stating that the
106 /// contents are trusted; nothing here can check that for them.
107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub struct Markup<'a>(pub &'a str);
109
110 /// What the field currently holds.
111 ///
112 /// An enum rather than a bag of optional fields, on the same reasoning
113 /// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable
114 /// here, where a struct would let it be said and then have to cope.
115 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116 pub enum Value<'a> {
117 /// Nothing yet.
118 #[default]
119 Absent,
120 /// The value of anything that takes typed text, a select included: what a
121 /// select holds is the `value` of one of [`Field::options`]'s
122 /// [`Choice`]s.
123 ///
124 /// It carried the options too until makeover-layout 0.8.0 moved them onto
125 /// the field, which collapsed a `Chosen { options, value }` variant into
126 /// this one. `makeover-immediate` arrived at the same single-variant shape
127 /// on its own, from the other direction.
128 Text(&'a str),
129 /// A checkbox, on or off.
130 On(bool),
131 /// Both ends of a [`FieldKind::Interval`], lower first.
132 ///
133 /// Two values rather than one string with a separator, for
134 /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
135 /// interval submits under two names, so it comes back as two values, and a
136 /// delimiter this crate owned could appear inside either of them.
137 ///
138 /// Either end may be empty while the other stands. "Over 120 BPM" is a
139 /// lower end and no upper one, and it is an answer rather than a
140 /// half-filled form.
141 ///
142 /// Added 0.56.0 with makeover-layout 0.34.0.
143 Between {
144 /// What the lower box holds now.
145 lower: &'a str,
146 /// What the upper box holds now.
147 upper: &'a str,
148 },
149 }
150
151 impl<'a> Value<'a> {
152 /// The value as text, for the kinds that submit one.
153 const fn as_text(&self) -> &'a str {
154 match self {
155 Self::Text(text) | Self::Between { lower: text, .. } => text,
156 Self::Absent | Self::On(_) => "",
157 }
158 }
159 }
160
161 impl<'a> Value<'a> {
162 /// The upper end, for the one variant that has one.
163 const fn upper_text(&self) -> &'a str {
164 match self {
165 Self::Between { upper, .. } => upper,
166 Self::Absent | Self::Text(_) | Self::On(_) => "",
167 }
168 }
169 }
170
171 /// Everything about the field that the description does not carry.
172 #[derive(Debug, Clone, Copy, Default)]
173 pub struct Filling<'a> {
174 /// What the field holds now.
175 pub value: Value<'a>,
176 /// Markup appended inside the group, after the hint. Not escaped.
177 pub trailing: Option<Markup<'a>>,
178 /// Attributes written onto the control element itself. Not escaped.
179 ///
180 /// [`trailing`](Self::trailing)'s argument at attribute scale: a host knows
181 /// facts about the control that no description layer carries, and until
182 /// this existed the only way to attach one was to stop calling this emitter
183 /// and write a second one. quasi's suggestion source is the first caller —
184 /// a field that owns a list of candidates is a `role="combobox"` pointing
185 /// at the list it owns, and neither half is anything
186 /// [`makeover_layout::Field`] can say.
187 ///
188 /// Written verbatim, so a caller supplies `attr="value"` pairs with no
189 /// leading space and does its own escaping. It is [`Markup`]'s hole in the
190 /// same wall, named the same way so a caller has to state that the contents
191 /// are trusted.
192 ///
193 /// A [`FieldKind::Radio`] drops them, and that is deliberate rather than an
194 /// oversight: a radio group is a set of sibling inputs with no one control
195 /// element, so there is nowhere honest to put an attribute meant for the
196 /// control. The group carries the descriptions for the same reason.
197 pub control_attrs: Option<Markup<'a>>,
198 /// Scopes the `id` attributes to one instance of the form.
199 ///
200 /// The field's `name` is what the value submits under and is the same
201 /// wherever the form appears; its `id` has to be unique in the document,
202 /// and those two facts stop agreeing the moment a form appears twice.
203 /// goingson hits this directly: its new-task and edit-task modals are the
204 /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep
205 /// `label for` and `aria-describedby` pointing at the right control.
206 ///
207 /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to
208 /// `name`, which would change what the form submits.
209 pub id_prefix: Option<&'a str>,
210 }
211
212 impl<'a> Filling<'a> {
213 /// A filling that carries a value and nothing else.
214 #[must_use]
215 pub const fn of(value: Value<'a>) -> Self {
216 Self {
217 value,
218 trailing: None,
219 control_attrs: None,
220 id_prefix: None,
221 }
222 }
223
224 /// The document-unique id for a field of this name.
225 fn id_for(&self, name: &str) -> String {
226 let mut id = String::new();
227 if let Some(prefix) = self.id_prefix {
228 escape_into(prefix, &mut id);
229 id.push('-');
230 }
231 escape_into(name, &mut id);
232 id
233 }
234 }
235
236 /// Encode the five characters that let a value stop being a value, into a
237 /// buffer the caller already has.
238 ///
239 /// The form the emitters use. [`escape`] is this with a `String` allocated
240 /// around it, and the allocation is the whole difference: a described screen
241 /// escapes once per attribute and once per run of text, so a function that
242 /// returns a `String` allocates a few thousand times to produce one page, where
243 /// a template engine writes its escaped bytes straight into the output buffer.
244 /// Measured 2026-08-14 against a real pane, that gap was 85% of a 42x rendering
245 /// cost, and this is the half of the fix that lives in this crate.
246 ///
247 /// Sound in element text and in a double-quoted attribute alike, which is the
248 /// property `textContent`-based escaping cannot have. Both sinks are covered by
249 /// one function so that no call site has to choose, here or downstream.
250 ///
251 /// Copies in runs rather than per character. All five encoded characters are
252 /// ASCII, so a byte scan cannot land inside a multi-byte character and the
253 /// slice between two of them is always a valid `&str`. Text with nothing to
254 /// encode — which is most text — is one `push_str` of the whole thing.
255 pub fn escape_into(text: &str, out: &mut String) {
256 let mut start = 0;
257 for (index, byte) in text.bytes().enumerate() {
258 let encoded = match byte {
259 b'&' => "&amp;",
260 b'<' => "&lt;",
261 b'>' => "&gt;",
262 b'"' => "&quot;",
263 b'\'' => "&#39;",
264 _ => continue,
265 };
266 out.push_str(&text[start..index]);
267 out.push_str(encoded);
268 start = index + 1;
269 }
270 out.push_str(&text[start..]);
271 }
272
273 /// Encode the five characters that let a value stop being a value.
274 ///
275 /// [`escape_into`] with a buffer of its own, for the callers that want a value
276 /// rather than an append: a caller assembling an attribute out of several
277 /// pieces, and everything outside this crate that took this function before the
278 /// buffer-writing form existed. Emitting into a buffer you already hold is the
279 /// cheaper path and the one this crate's own emitters take.
280 #[must_use]
281 pub fn escape(text: &str) -> String {
282 let mut out = String::with_capacity(text.len());
283 escape_into(text, &mut out);
284 out
285 }
286
287 /// The `type` an input takes for a kind.
288 ///
289 /// [`FieldKind::Secret`] is `password`, which both apps already map by hand.
290 const fn input_type(kind: FieldKind) -> &'static str {
291 match kind {
292 FieldKind::Secret => "password",
293 FieldKind::Number => "number",
294 FieldKind::Checkbox => "checkbox",
295 FieldKind::File => "file",
296 FieldKind::Hidden => "hidden",
297 // Not decoration. Each of these changes the keyboard a touch device
298 // offers and turns on the platform's own validation, which is why the
299 // description names them apart from text rather than letting the app
300 // pass an HTML type through.
301 FieldKind::Email => "email",
302 FieldKind::Url => "url",
303 FieldKind::Tel => "tel",
304 // The same argument, and it buys more here than anywhere else in this
305 // list: a native picker as well as the keyboard and the validation.
306 // Both submit the format `makeover-layout` names, `DATE_FORMAT` and
307 // `DATETIME_FORMAT`, so honouring it costs this renderer nothing.
308 FieldKind::Date => "date",
309 FieldKind::DateTime => "datetime-local",
310 FieldKind::Radio => "radio",
311 // The clearest case in this list that a kind is not decoration: a
312 // number and a range submit the same value and are different controls,
313 // and the browser is the one drawing the difference.
314 FieldKind::Range => "range",
315 // Select and Textarea are not inputs at all; they never reach here.
316 // Radio is one, but it is emitted once per option by `radio_html` and
317 // so does not reach here either.
318 FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text",
319 // A kind added to the description since this renderer was built. Text
320 // accepts any value the others would, so it degrades rather than
321 // dropping the field.
322 _ => "text",
323 }
324 }
325
326 /// The attributes every visible control carries, error state included.
327 ///
328 /// `aria-invalid` is the whole reason the error state is readable at all: the
329 /// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather
330 /// than on a class, so a control rendered already-invalid without it is styled
331 /// as if nothing were wrong. goingson's runtime validation path sets the
332 /// attribute and its initial render does not, which is exactly the drift one
333 /// emitter removes.
334 /// `id` and `name` arrive separately because they are not the same fact. The
335 /// name is what submits and is fixed by the description; the id has to be
336 /// unique in the document and so carries [`Filling::id_prefix`] when a form
337 /// appears more than once.
338 /// The `accept` attribute, from the description's accept list.
339 ///
340 /// makeover-layout 0.31.0. The list is comma-joined because that is the
341 /// attribute's own format, and each entry writes itself: a family is its
342 /// wildcard media type, a media type is itself, a suffix is itself with its
343 /// leading dot. Nothing is normalised on the way through -- `.tar.gz` is two
344 /// dots and the browser is fine with it.
345 ///
346 /// An empty list emits no attribute at all, which is the browser's own "any
347 /// file" and is what the description means by listing nothing. Emitting
348 /// `accept=""` instead would be a filter that matches nothing on some browsers
349 /// and everything on others.
350 ///
351 /// It is a filter and not a guarantee, on the browser's side as much as here:
352 /// the picker keeps an "All Files" escape and the user may take it. Whoever
353 /// validated still validates.
354 fn push_accept(out: &mut String, field: &Field<'_>) {
355 if field.accept.is_empty() {
356 return;
357 }
358 out.push_str(" accept=\"");
359 for (index, one) in field.accept.iter().enumerate() {
360 if index > 0 {
361 out.push(',');
362 }
363 escape_into(one.as_str(), out);
364 }
365 out.push('"');
366 }
367
368 /// The extent and the granularity, as the browser spells them.
369 ///
370 /// Its own function because an interval writes them onto both of its ends: they
371 /// describe the axis rather than either end of it, which is what
372 /// [`FieldKind::Interval`] says and what the six audiofiles filter axes are.
373 fn push_bounds(out: &mut String, field: &Field<'_>) {
374 if let Some(min) = field.min {
375 out.push_str(" min=\"");
376 escape_into(min, out);
377 out.push('"');
378 }
379 if let Some(max) = field.max {
380 out.push_str(" max=\"");
381 escape_into(max, out);
382 out.push('"');
383 }
384 // The browser's own default is `step="1"`, which turns a 0-to-1 threshold
385 // into a two-position control. That is the granularity the description
386 // means when it says nothing, so this is emitted only when an app has said
387 // otherwise rather than defaulted here.
388 //
389 // A range takes its granularity from its curve as of makeover-layout
390 // 0.32.0, and every other kind keeps `Field::step`. See the crate header on
391 // what this renderer can and cannot do with a curve.
392 let step = if field.kind == FieldKind::Range {
393 field.curve.step()
394 } else {
395 field.step
396 };
397 if let Some(step) = step {
398 out.push_str(" step=\"");
399 escape_into(step, out);
400 out.push('"');
401 }
402 }
403
404 fn push_control_attributes(
405 out: &mut String,
406 field: &Field<'_>,
407 filling: &Filling<'_>,
408 id: &str,
409 name: &str,
410 ) {
411 let _ = write!(out, " id=\"{id}\" name=\"");
412 escape_into(name, out);
413 out.push('"');
414 if field.required {
415 out.push_str(" required");
416 }
417 // makeover-layout 0.11.0's constraints. The description carries the rule and
418 // this emits the browser's idiom for it, which is the model `required` has
419 // been using since before the crate wrote down that it carried none.
420 // Enforcement is still whoever validated's, and arrives back as `error`.
421 if let Some(limit) = field.max_length {
422 let _ = write!(out, " maxlength=\"{limit}\"");
423 }
424 push_bounds(out, field);
425 // The description asks for the wall-clock value to be submitted as the
426 // moment it names, and in a browser that conversion is script's: `<input
427 // type="datetime-local">` submits what the user typed and nothing in HTML
428 // turns it into an instant. So this emits the mark and quasi-webview's
429 // `instant.js` does the converting -- the same division as `data-clock`,
430 // where the markup says what to do and the shipped script is what a browser
431 // knows that a description cannot.
432 //
433 // Only DateTime. A date and a time are each half a moment and cannot name
434 // one on their own, so the flag is ignored there rather than emitting a
435 // mark nothing can honour.
436 if field.as_instant && matches!(field.kind, FieldKind::DateTime) {
437 out.push_str(" data-instant=\"true\"");
438 }
439 if field.invalid() {
440 out.push_str(" aria-invalid=\"true\"");
441 }
442
443 push_described_by(out, field, id);
444
445 // Last, so that a host attaching a fact of its own can see everything this
446 // emitter decided and cannot be overwritten by it. Duplicate attributes are
447 // the caller's to avoid: HTML takes the first of a repeated pair, so an
448 // attribute spelled here as well as there keeps this crate's answer.
449 if let Some(Markup(attrs)) = filling.control_attrs {
450 out.push(' ');
451 out.push_str(attrs);
452 }
453 }
454
455 /// The `aria-describedby` naming whatever of the hint and the error exist.
456 ///
457 /// Both associations, in the order they are useful: the standing help, then
458 /// what is currently wrong. goingson's runtime path points describedby at the
459 /// error alone and drops the hint association it never made in the first place;
460 /// naming both here means the hint survives an error appearing.
461 ///
462 /// Its own function because a radio group carries it on the group rather than
463 /// on a control, and one reading of "what describes this field" is the point.
464 fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
465 let unit = unit_of(field).is_some();
466 if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit {
467 return;
468 }
469 let mut written = false;
470 out.push_str(" aria-describedby=\"");
471 if field.hint.is_some() {
472 let _ = write!(out, "{id}-hint");
473 written = true;
474 }
475 // The unit before the error and after the hint, which is the order they are
476 // useful in: what the number is measured in is standing context like the
477 // hint, and what is wrong with it now comes last.
478 if unit {
479 if written {
480 out.push(' ');
481 }
482 let _ = write!(out, "{id}-unit");
483 written = true;
484 }
485 // The note after the unit and before the error, matching the order the
486 // three are drawn in and the order they are useful in: what the answer
487 // costs is context, and what is wrong with it now still comes last.
488 if field.note.is_some() {
489 if written {
490 out.push(' ');
491 }
492 let _ = write!(out, "{id}-note");
493 written = true;
494 }
495 if field.error.is_some() {
496 if written {
497 out.push(' ');
498 }
499 let _ = write!(out, "{id}-error");
500 }
501 out.push('"');
502 }
503
504 /// The unit to draw beside this field's value, if there is one to draw.
505 ///
506 /// Two conditions rather than one: the field has to carry a unit and its kind
507 /// has to be one that means anything by it. `FieldKind::measurable` is the
508 /// description answering the second, so this renderer keeps no list of its own
509 /// of which kinds are quantities.
510 fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
511 field.unit.filter(|_| field.kind.measurable())
512 }
513
514 /// Whether the field's control is a set of elements rather than one.
515 ///
516 /// A DOM concern rather than a description one, which is why it is decided here
517 /// and not in `makeover-layout`: `for` and `id` are an HTML association and
518 /// egui has no counterpart to get wrong. A `<label for>` aimed at a radio group
519 /// points at nothing, because no single element carries the group's id, so the
520 /// association has to invert — the label takes an id and the group names itself
521 /// with `aria-labelledby`.
522 const fn is_group_control(kind: FieldKind) -> bool {
523 matches!(kind, FieldKind::Radio | FieldKind::Interval)
524 }
525
526 /// An interval: two number boxes inside one labelled group.
527 ///
528 /// The markup MNW's discover sidebar writes by hand -- a `role="group"` with
529 /// `aria-labelledby` pointing at the question, holding `min_price` and
530 /// `max_price` -- which is HTML saying by hand exactly what
531 /// [`FieldKind::Interval`] now says in the description. So this emits what that
532 /// page already proved is right, rather than inventing a shape.
533 ///
534 /// The group carries the error state and the descriptions, for
535 /// [`push_radio`]'s reason: what is wrong is the answer, and marking one box
536 /// invalid would name the wrong half of a fault that belongs to both ends.
537 ///
538 /// # Both boxes take the same extent
539 ///
540 /// [`Field::min`], [`Field::max`] and [`Field::step`] describe the axis rather
541 /// than either end, so [`push_bounds`] writes them onto both. The crossing rule
542 /// is not emitted, because the description does not carry it and the browser
543 /// has no attribute for it: an upper end below the lower one is a refusal
544 /// whoever validated hands back as [`Field::error`], which lands on the group.
545 ///
546 /// # Which end is which, in words
547 ///
548 /// `aria-label`, because the description states direction structurally -- the
549 /// lower end's name is [`Field::name`] and the upper one's is
550 /// [`Field::upper_name`] -- and never in words. Words for the ends are the
551 /// host's, the same way a slider's readout is, and a page with visible Min and
552 /// Max captions supplies them through [`Filling::trailing`] rather than having
553 /// this crate own two strings of English.
554 fn push_interval(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
555 let id = filling.id_for(field.name);
556
557 out.push_str("<div class=\"");
558 push_class(out, "form-interval", opts);
559 let _ = write!(out, "\" role=\"group\" aria-labelledby=\"{id}-label\"");
560 if field.invalid() {
561 out.push_str(" aria-invalid=\"true\"");
562 }
563 push_described_by(out, field, &id);
564 out.push('>');
565
566 // An interval with no upper name has one end that can be submitted, which
567 // is what the description said and is drawn honestly rather than repaired:
568 // `Field::interval` is what makes it unsayable, and inventing a name here
569 // would submit a parameter no handler is reading.
570 let ends: [(&str, &str, &str); 2] = [
571 ("lower", field.name, filling.value.as_text()),
572 (
573 "upper",
574 field.upper_name.unwrap_or(""),
575 filling.value.upper_text(),
576 ),
577 ];
578 for (end, name, value) in ends {
579 if name.is_empty() {
580 continue;
581 }
582 out.push_str("<input type=\"number\" class=\"");
583 push_class(out, "field", opts);
584 let _ = write!(out, "\" id=\"{id}-{end}\" name=\"");
585 escape_into(name, out);
586 let _ = write!(out, "\" aria-label=\"{end}\"");
587 if field.required {
588 out.push_str(" required");
589 }
590 push_bounds(out, field);
591 if let Some(text) = field.placeholder {
592 out.push_str(" placeholder=\"");
593 escape_into(text, out);
594 out.push('"');
595 }
596 out.push_str(" value=\"");
597 escape_into(value, out);
598 out.push_str("\">");
599 }
600
601 out.push_str("</div>");
602 }
603
604 /// A radio group: the options as sibling inputs sharing one `name`.
605 ///
606 /// The group carries the error state and the descriptions, and the inputs carry
607 /// what submits. That split is [`Field::invalid`]'s reasoning applied one level
608 /// down: marking a single input invalid would say the wrong thing, since what
609 /// is wrong is the answer to the question and not one of the alternatives.
610 ///
611 /// Ids are numbered rather than built from the option values, which can hold
612 /// anything a `&str` can — spaces and quotes included — and would otherwise
613 /// have to be slugged into something unique by a rule this crate would then own.
614 ///
615 /// `required` lands on every input, which is how HTML says a group is
616 /// compulsory: the constraint is satisfied when any one of them is checked.
617 fn push_radio(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
618 let id = filling.id_for(field.name);
619 let value = filling.value.as_text();
620 let name = escape(field.name);
621
622 out.push_str("<div class=\"");
623 push_class(out, "form-radio-group", opts);
624 let _ = write!(out, "\" role=\"radiogroup\" aria-labelledby=\"{id}-label\"");
625 if field.invalid() {
626 out.push_str(" aria-invalid=\"true\"");
627 }
628 push_described_by(out, field, &id);
629 out.push('>');
630
631 // A group described with no options emits an empty group, for the reason
632 // `Field::options` gives: an app whose option list has not loaded has
633 // exactly that, and an empty group says so on screen rather than in a log.
634 for (index, opt) in field.options.iter().enumerate() {
635 out.push_str("<label class=\"");
636 push_class(out, "form-radio-label", opts);
637 let _ = write!(
638 out,
639 "\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" value=\""
640 );
641 escape_into(opt.value, out);
642 out.push('"');
643 if opt.value == value {
644 out.push_str(" checked");
645 }
646 if field.required {
647 out.push_str(" required");
648 }
649 // A radio group has room a `<select>` does not, so the reason gets its
650 // own element beside the label rather than being run into it. The class
651 // is what a stylesheet mutes; the text is there either way, which is
652 // the half that matters — the finding was a greyed control with its
653 // explanation behind a hover.
654 if let Some(reason) = opt.unavailable {
655 out.push_str(" disabled");
656 out.push_str("><span>");
657 escape_into(opt.label, out);
658 out.push_str("</span><span class=\"");
659 push_class(out, "form-option-reason", opts);
660 out.push_str("\">");
661 escape_into(reason, out);
662 out.push_str("</span></label>");
663 continue;
664 }
665 out.push_str("><span>");
666 escape_into(opt.label, out);
667 out.push_str("</span></label>");
668 }
669
670 out.push_str("</div>");
671 }
672
673 /// The options of a select: the unanswered instruction, an unmatched current
674 /// value carried as its own, then the options themselves.
675 ///
676 /// A select handed a value no option carries renders with nothing selected, the
677 /// browser falls back to the first option, and the next save writes a value
678 /// nobody chose. goingson hit exactly that with a backup-retention default of
679 /// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
680 /// here so the second app gets it without hitting the bug first.
681 fn push_options(out: &mut String, field: &Field<'_>, options: &[Choice<'_>], value: &str) {
682 // The unanswered state, which HTML has no attribute for: `placeholder` is
683 // not a `<select>` attribute, and the idiom is an empty option that cannot
684 // be chosen back. `disabled` is what stops it being re-selected once the
685 // user has answered, and `selected` is what puts it in the closed control
686 // while the value is empty; together they read as an instruction rather
687 // than as an option.
688 //
689 // `required` keeps working through it rather than around it: the option's
690 // value is empty, so a required select with this showing is invalid, which
691 // is the true report on a question nobody has answered.
692 //
693 // Emitted only while the value is empty, so it does not sit in the open
694 // list once the field is answered. A non-empty value no option carries is a
695 // wrong answer rather than an absent one and takes the stray-option path
696 // below.
697 if value.is_empty()
698 && let Some(text) = field.placeholder
699 {
700 out.push_str("<option value=\"\" disabled selected>");
701 escape_into(text, out);
702 out.push_str("</option>");
703 }
704 if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
705 // The one place an escaped value is worth keeping: it is written twice,
706 // as the option's value and as its text.
707 let escaped = escape(value);
708 let _ = write!(
709 out,
710 "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
711 );
712 }
713 for opt in options {
714 out.push_str("<option value=\"");
715 escape_into(opt.value, out);
716 out.push('"');
717 if opt.value == value {
718 out.push_str(" selected");
719 }
720 // `disabled` is what the browser reads, and it says nothing about why.
721 // The reason goes in the option's own text, because a `<select>` gives
722 // its options no room for anything else: no title attribute the
723 // keyboard reaches, no second line, no element inside. So the row reads
724 // "Multi-sample: Drop a second sample onto the keyboard." and is the
725 // one place the precondition can be both attached to its option and
726 // read without a pointer.
727 if let Some(reason) = opt.unavailable {
728 out.push_str(" disabled");
729 out.push('>');
730 escape_into(opt.label, out);
731 out.push_str(": ");
732 escape_into(reason, out);
733 out.push_str("</option>");
734 continue;
735 }
736 out.push('>');
737 escape_into(opt.label, out);
738 out.push_str("</option>");
739 }
740 }
741
742 /// The themes, as one `<optgroup>` per variant with a contrast mark per row.
743 ///
744 /// # The grouping comes out of the order, not out of a group list
745 ///
746 /// [`makeover_layout::Field::themes`] arrives sorted by variant and then by
747 /// measured contrast, and the run of one variant is the group. So this walks
748 /// the list once and opens a new `<optgroup>` whenever the variant changes,
749 /// which is the whole of the grouping logic and cannot disagree with the order
750 /// the way a separately-carried group list could.
751 ///
752 /// A theme whose variant equals its predecessor's never opens a group, so a
753 /// list that arrived unsorted would emit repeated groups rather than silently
754 /// merging distant rows. That is the honest report on a description that broke
755 /// its own contract, and it is visible on screen rather than in a log.
756 ///
757 /// # The follow row is not in a group
758 ///
759 /// It names no theme and sits in no variant, so it is emitted first and bare.
760 /// Grouping it under a heading would be inventing a fourth variant for one row.
761 ///
762 /// # The badge is text, because a `<select>` has nowhere else to put it
763 ///
764 /// A `<select>`'s options take no elements, no second line and no title the
765 /// keyboard reaches, which is [`push_options`]' finding about
766 /// [`Choice::unavailable`] met a second time. So the tier rides in the option's
767 /// own text, in brackets after the name, and it is
768 /// [`makeover_layout::Contrast::badge`]'s spelling rather than one invented
769 /// here — three renderers picking their own is one picker reading three ways.
770 fn push_theme_options(out: &mut String, field: &Field<'_>, value: &str) {
771 if let Some(follow) = field.follows {
772 out.push_str("<option value=\"");
773 escape_into(follow.value, out);
774 out.push('"');
775 if follow.value == value {
776 out.push_str(" selected");
777 }
778 out.push('>');
779 escape_into(follow.label, out);
780 out.push_str("</option>");
781 }
782
783 // A stored id naming a theme that is no longer installed. `push_options`'
784 // reasoning applies unchanged: a value no row carries is a wrong answer
785 // rather than an absent one, and dropping it would silently show the user
786 // a different theme than the one their config names.
787 let known = field.themes.iter().any(|theme| theme.id == value)
788 || field.follows.is_some_and(|follow| follow.value == value);
789 if !value.is_empty() && !known {
790 let escaped = escape(value);
791 let _ = write!(
792 out,
793 "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
794 );
795 }
796
797 let mut open: Option<ThemeVariant> = None;
798 for theme in field.themes {
799 if open != Some(theme.variant) {
800 if open.is_some() {
801 out.push_str("</optgroup>");
802 }
803 out.push_str("<optgroup label=\"");
804 escape_into(theme.variant.heading(), out);
805 out.push_str("\" data-variant=\"");
806 out.push_str(theme.variant.as_str());
807 out.push_str("\">");
808 open = Some(theme.variant);
809 }
810
811 out.push_str("<option value=\"");
812 escape_into(theme.id, out);
813 out.push_str("\" data-contrast=\"");
814 out.push_str(theme.contrast.as_str());
815 out.push('"');
816 if theme.id == value {
817 out.push_str(" selected");
818 }
819 out.push('>');
820 escape_into(theme.name, out);
821 out.push_str(" (");
822 out.push_str(theme.contrast.badge());
823 out.push(')');
824 out.push_str("</option>");
825 }
826 if open.is_some() {
827 out.push_str("</optgroup>");
828 }
829 }
830
831 /// The control itself, without its label, hint or error.
832 fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
833 // Emitted before anything else is computed: a radio group carries its
834 // descriptions on the group rather than on a control, so none of the
835 // attributes below belong to it.
836 if matches!(field.kind, FieldKind::Radio) {
837 push_radio(out, field, filling, opts);
838 return;
839 }
840 // The same split one kind along: an interval is two inputs and one
841 // question, so the group carries the error and the descriptions and the
842 // boxes carry what submits.
843 if matches!(field.kind, FieldKind::Interval) {
844 push_interval(out, field, filling, opts);
845 return;
846 }
847
848 let id = filling.id_for(field.name);
849 let placeholder = |out: &mut String| {
850 if let Some(text) = field.placeholder {
851 out.push_str(" placeholder=\"");
852 escape_into(text, out);
853 out.push('"');
854 }
855 };
856
857 match field.kind {
858 // Both multi-line kinds are a `<textarea>`, and the markdown one says so
859 // in an attribute rather than in a class: what the value *is* is not a
860 // styling hook, and a progressive enhancement looking for editors to
861 // upgrade needs a selector that survives `Emit`'s class prefixing.
862 // Without the mark, a described editor is a plain box and the four
863 // hand-written MNW editors have nothing to convert onto.
864 //
865 // `data-format` and not `data-value`: this names the shape of the
866 // value, and `facet` already spends `data-facet-value` on carrying an
867 // actual one. Two attributes a letter apart meaning opposite things is
868 // how a renderer's own vocabulary starts drifting.
869 kind if kind.multiline() => {
870 let rich = matches!(kind, FieldKind::Rich);
871 if rich {
872 push_editor_open(out, opts);
873 }
874 out.push_str("<textarea class=\"");
875 push_class(out, "field", opts);
876 out.push('"');
877 if rich {
878 out.push_str(" data-format=\"markdown\"");
879 }
880 push_control_attributes(out, field, filling, &id, field.name);
881 placeholder(out);
882 out.push('>');
883 escape_into(filling.value.as_text(), out);
884 out.push_str("</textarea>");
885 if rich {
886 push_editor_close(out, opts);
887 }
888 }
889 FieldKind::Select => {
890 out.push_str("<select class=\"");
891 push_class(out, "field", opts);
892 out.push('"');
893 push_control_attributes(out, field, filling, &id, field.name);
894 out.push('>');
895 // A select described with no options emits an empty select, which
896 // says so on screen rather than in a log. That is the description's
897 // own position on `Field::options`, not a fallback invented here.
898 push_options(out, field, field.options, filling.value.as_text());
899 out.push_str("</select>");
900 }
901 // The one place this renderer emits `<optgroup>`, and it emits it
902 // because the description finally says there is a group. The measured
903 // history is the argument: `optgroup` appears at one live site in the
904 // whole tree, and the two apps that had grouped theme pickers lost the
905 // grouping the moment they were described, because `Choice` is a value
906 // and a label and a group is neither.
907 FieldKind::Theme => {
908 out.push_str("<select class=\"");
909 push_class(out, "field", opts);
910 out.push('"');
911 push_control_attributes(out, field, filling, &id, field.name);
912 out.push('>');
913 push_theme_options(out, field, filling.value.as_text());
914 out.push_str("</select>");
915 }
916 FieldKind::Checkbox => {
917 out.push_str("<label class=\"");
918 push_class(out, "form-checkbox-label", opts);
919 out.push_str("\"><input type=\"checkbox\"");
920 push_control_attributes(out, field, filling, &id, field.name);
921 if matches!(filling.value, Value::On(true)) {
922 out.push_str(" checked");
923 }
924 out.push_str("><span>");
925 escape_into(field.label, out);
926 out.push_str("</span></label>");
927 }
928 // A secret never carries its value into the markup. `FieldKind::secret`
929 // is documented as a value that must not be round-tripped through
930 // anything that might persist it, and the DOM is such a thing: it is
931 // read by every extension on the page and is the first thing a crash
932 // reporter serialises. Neither app pre-fills one today, so this costs
933 // nothing and closes the door before something does.
934 FieldKind::Secret => {
935 out.push_str("<input type=\"password\" class=\"");
936 push_class(out, "field", opts);
937 out.push('"');
938 push_control_attributes(out, field, filling, &id, field.name);
939 placeholder(out);
940 out.push('>');
941 }
942 // A file input carries no value, and this is the browser's rule rather
943 // than a preference: setting one from markup is refused, because a page
944 // that could preselect a path could read a file the user never offered.
945 // Nothing upstream needs to know, which is why the exception is here.
946 FieldKind::File => {
947 out.push_str("<input type=\"file\" class=\"");
948 push_class(out, "field", opts);
949 out.push('"');
950 push_control_attributes(out, field, filling, &id, field.name);
951 push_accept(out, field);
952 if field.multiple {
953 out.push_str(" multiple");
954 }
955 out.push('>');
956 }
957 kind => {
958 let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
959 push_class(out, "field", opts);
960 out.push('"');
961 push_control_attributes(out, field, filling, &id, field.name);
962 placeholder(out);
963 out.push_str(" value=\"");
964 escape_into(filling.value.as_text(), out);
965 out.push_str("\">");
966 }
967 }
968 }
969
970 /// The chrome a markdown field gets and a plain textarea does not: the two
971 /// modes, and the pane a preview lands in.
972 ///
973 /// # Why this is the one field with markup around it
974 ///
975 /// [`FieldKind::Rich`]'s own doc says the mark buys a renderer permission to
976 /// offer a preview or a syntax pass, and that a renderer with neither draws a
977 /// textarea. A renderer taking the permission and emitting the same box as
978 /// [`FieldKind::Textarea`] leaves an app converting onto the member with less
979 /// than it had written by hand: MNW's `partial-item-text-editor.js` has a
980 /// Write/Preview pair and a pane behind it, and describing the field without
981 /// this would delete both. So the pair is here, on `facet`'s argument one
982 /// field down -- the markup it replaces is not markup an app is keeping.
983 ///
984 /// # Nothing here renders markdown, and that is where the sanitising stays
985 ///
986 /// The pane arrives empty and this crate never turns a value into markup.
987 /// Converting markdown is the host's, which is where the sanitiser already is:
988 /// MNW renders through `docengine` over ammonia and holds an allowlist beside
989 /// it. A converter here would move that guarantee into a crate with no view of
990 /// the host's content-security posture, and `Rich`'s doc is explicit that a
991 /// host with its own sanitiser still owns it. What this emits is a hook, and
992 /// whatever fills it fills it with markup it has already made safe.
993 ///
994 /// # The direction the enhancement runs
995 ///
996 /// [`crate::stylesheet`]'s rule for a showing region, and for its reason: a
997 /// control rendered into a document with no script is a control that looks live
998 /// and answers nothing. Nothing is hidden here and no control is shown until
999 /// whatever binds the editor sets `data-ready` on the wrapper, so a reader with
1000 /// no script gets the textarea alone -- what 0.50.0 emitted -- and a reader with
1001 /// script gets the modes. A bound editor says which mode it is in with
1002 /// `data-mode`, and [`editor_rules`] reads that.
1003 fn push_editor_open(out: &mut String, opts: &Emit) {
1004 // The mark sits on the wrapper as well as on the control, saying one thing
1005 // about two: this control's value is markdown, and this editor edits
1006 // markdown. The rules gate on the wrapper and they are attribute rules
1007 // rather than class rules for `data-format`'s own reason -- the gate has to
1008 // survive `Emit`'s class prefixing, because the enhancement selects on it
1009 // too.
1010 out.push_str("<div data-format=\"markdown\"><div class=\"");
1011 push_class(out, "form-editor-modes", opts);
1012 out.push_str("\">");
1013 push_mode(out, "write", "Write", true, opts);
1014 push_mode(out, "preview", "Preview", false, opts);
1015 out.push_str("</div>");
1016 }
1017
1018 /// One of the two modes, as a segment of the pair.
1019 ///
1020 /// [`crate::option_class`] for [`Selector::Segmented`] rather than a name of
1021 /// its own: a Write/Preview pair is a segmented control, and spelling it as one
1022 /// gets it the depth, the focus ring and the chosen state every described
1023 /// selector gets, from rules that already exist. The words are written here for
1024 /// the reason `facet`'s exclude button writes its own: a description carrying
1025 /// them would be choosing them for the terminal as well.
1026 fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
1027 out.push_str("<button type=\"button\" class=\"");
1028 push_class(out, crate::option_class(Selector::Segmented), opts);
1029 if chosen {
1030 // The sheet keys the held-in segment on the class and a screen reader
1031 // reads the attribute. Both, because they are two readings of one fact,
1032 // which is the arrangement a facet value already has.
1033 out.push_str(" chosen");
1034 }
1035 let _ = write!(
1036 out,
1037 "\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
1038 );
1039 }
1040
1041 /// The preview pane, and the wrapper closing over both halves.
1042 fn push_editor_close(out: &mut String, opts: &Emit) {
1043 out.push_str("<div class=\"");
1044 push_class(out, "form-editor-preview", opts);
1045 // `data-editor-preview` and not an id: a form appears twice in a document
1046 // often enough that `Filling::id_prefix` exists for it, and a binder holding
1047 // the control can reach this without either of them being unique.
1048 out.push_str("\" data-editor-preview></div></div>");
1049 }
1050
1051 /// The rules the markdown editor's chrome needs.
1052 ///
1053 /// The one place this module writes CSS. The class names [`field_html`] emits
1054 /// are goingson's and are deliberately unruled -- `.form-group`, `.form-label`,
1055 /// `.form-hint` and `.form-error` are the app's own, and phase A emits only what
1056 /// it can generate from the description -- but the two names here have no app
1057 /// counterpart to keep, because the chrome did not exist before the member did.
1058 ///
1059 /// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
1060 /// off a plain textarea, and every rule that hides content is gated on
1061 /// `data-ready` as well, which is what keeps them out of a document with no
1062 /// script.
1063 pub(crate) fn editor_rules(opts: &Emit) -> String {
1064 let mut css = String::new();
1065 let modes = class("form-editor-modes", opts);
1066 let preview = class("form-editor-preview", opts);
1067 let field = class("field", opts);
1068
1069 // Hidden until something binds the editor, which is the whole argument in
1070 // `push_editor_open`.
1071 let _ = writeln!(
1072 css,
1073 "[data-format=\"markdown\"] > .{modes} {{\n display: none;\n}}"
1074 );
1075 // Block, and nothing about how the two segments sit in it. A button is
1076 // inline already, so they make a row without this crate saying so, and
1077 // saying so is where a gap would follow -- a magnitude, and
1078 // `makeover-geometry`'s.
1079 let _ = writeln!(
1080 css,
1081 "[data-format=\"markdown\"][data-ready] > .{modes} {{\n display: block;\n}}"
1082 );
1083
1084 // The pane is empty until the host fills it, so it is out of flow in every
1085 // state but the one where a bound editor is showing it. An empty box under
1086 // the control is chrome claiming a preview nobody rendered.
1087 let _ = writeln!(
1088 css,
1089 "[data-format=\"markdown\"] > .{preview} {{\n display: none;\n}}"
1090 );
1091 let _ = writeln!(
1092 css,
1093 "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
1094 {{\n display: block;\n}}"
1095 );
1096 // One at a time. The source and the preview are the same content read two
1097 // ways, and a field showing both answers its own question twice.
1098 let _ = writeln!(
1099 css,
1100 "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
1101 {{\n display: none;\n}}"
1102 );
1103
1104 // The pane stands where the control stood, so it reads as the surface the
1105 // control was: `.field` is a well, and this is the well it stands in for.
1106 // Nothing about size -- how tall a preview is is the app's, the way the
1107 // height of a track is.
1108 let _ = write!(
1109 css,
1110 "[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
1111 crate::depth_declarations(Depth::Well)
1112 );
1113
1114 css
1115 }
1116
1117 /// The rule a field's unit needs.
1118 ///
1119 /// [`suggestion_rules`]' precedent and its argument: `.form-group`,
1120 /// `.form-label`, `.form-hint` and `.form-error` are the apps' own names and
1121 /// stay unruled here, and this one has no app counterpart to keep because
1122 /// nothing emitted it before `Field::unit` existed.
1123 ///
1124 /// One declaration, and it is the whole look. A unit is a fact about the number
1125 /// beside it rather than a second thing to read, so it takes the muted content
1126 /// intent -- the same reading `.figure-caption` and `.track-tick` take, and for
1127 /// the same reason.
1128 ///
1129 /// Nothing about placement or spacing. Where the span sits relative to the
1130 /// control is the app's layout, exactly as `.form-hint`'s is, and a margin
1131 /// asserted here would be this crate deciding a magnitude that belongs to
1132 /// `makeover-geometry`.
1133 /// The rules a field's note needs.
1134 ///
1135 /// [`unit_rules`]' precedent and its argument: `.form-hint` and `.form-error`
1136 /// are the apps' own names and stay unruled here, and this one has no app
1137 /// counterpart to keep because nothing emitted it before [`Field::note`]
1138 /// existed.
1139 ///
1140 /// Colour only, and the tones are the four a badge carries. The bare class is
1141 /// `content` rather than `content-muted`: a note is a consequence the user is
1142 /// meant to read before answering, so muting it by default would be this crate
1143 /// deciding it does not matter.
1144 pub(crate) fn note_rules(opts: &Emit) -> String {
1145 let note = class("form-note", opts);
1146 let mut css = String::new();
1147 let _ = writeln!(css, ".{note} {{\n color: var(--content);\n}}");
1148 for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
1149 let _ = writeln!(
1150 css,
1151 ".{note}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}",
1152 tone.token()
1153 );
1154 }
1155 css
1156 }
1157
1158 pub(crate) fn unit_rules(opts: &Emit) -> String {
1159 let unit = class("form-unit", opts);
1160 let mut css = String::new();
1161 let _ = writeln!(css, ".{unit} {{\n color: var(--content-muted);\n}}");
1162 css
1163 }
1164
1165 /// The rules a field's suggestion list needs.
1166 ///
1167 /// [`editor_rules`]' precedent and its argument: the class names this module's
1168 /// markup emits are the apps' own and stay unruled, and these three have no app
1169 /// counterpart to keep because the list did not exist before the member did.
1170 /// The markup is `quasi-webview`'s rather than this crate's — a suggestion
1171 /// source is a route, which no description layer carries — and the look is
1172 /// still this crate's, because a renderer inventing how a list of candidates
1173 /// reads is the drift the vocabulary check exists to catch.
1174 ///
1175 /// # In flow, and not floating
1176 ///
1177 /// An absolutely positioned list needs a positioned ancestor, and the only
1178 /// candidate is `.form-group`, which is the app's class and deliberately
1179 /// unruled here. So the list stands under the control and moves what is below
1180 /// it. An app that wants it over the form positions the group itself, which is
1181 /// one declaration and is the app's call about its own layout.
1182 ///
1183 /// `:empty` is what takes it away, so a route that answers with no candidates
1184 /// leaves no box behind. It is a content question rather than a whitespace one
1185 /// only because the emitter writes no whitespace inside the container, which is
1186 /// stated in `quasi-webview`'s own test.
1187 ///
1188 /// # Nothing about size
1189 ///
1190 /// No height, no scroll ceiling, no padding. How tall a list of candidates gets
1191 /// to be before it scrolls is a magnitude, and magnitudes are
1192 /// `makeover-geometry`'s, exactly as the preview pane's height is.
1193 pub(crate) fn suggestion_rules(opts: &Emit) -> String {
1194 let list = class("form-suggestions", opts);
1195 let entry = class("form-suggestion", opts);
1196 let detail = class("form-suggestion-detail", opts);
1197 let mut css = String::new();
1198
1199 let _ = writeln!(css, ".{list}:empty {{\n display: none;\n}}");
1200 // Over what it covers, which is what a list of candidates is even in flow:
1201 // it is answering the box above it and goes away when the answer is taken.
1202 css.push_str(&crate::depth_rule(&list, Depth::Overlay));
1203 // An entry answers a click, so it gets every state one implies.
1204 css.push_str(&crate::interactive_rules(&entry, Depth::Flat, opts));
1205 // The keyboard's highlight and the pointer's are the same surface. They are
1206 // the same fact told two ways, and a list where arrowing and hovering look
1207 // different is a list that has two current entries.
1208 //
1209 // Keyed on `aria-selected` rather than on a class, for the reason
1210 // `aria-invalid` carries the error state: it is what a screen reader hears,
1211 // so a look keyed on it cannot drift from what is announced. A `.current`
1212 // class would also be a name apps already spell for their own reasons --
1213 // the MNW server has one -- and unlayered app CSS beats this layer in
1214 // silence.
1215 let _ = writeln!(
1216 css,
1217 ".{entry}[aria-selected=\"true\"] {{\n background: var(--hover-surface);\n}}"
1218 );
1219 // The second line, muted rather than disabled. `1fcf2e9b` replaced the
1220 // unavailable reason this rule used to draw: a candidate carries no
1221 // `unavailable`, and what sits beside the label now is what tells one row
1222 // from another that reads the same. Disabled would say the row cannot be
1223 // picked, which is the opposite of what the detail is for.
1224 let _ = writeln!(css, ".{detail} {{\n color: var(--content-muted);\n}}");
1225
1226 css
1227 }
1228
1229 /// One field, as the group the app drops into its form.
1230 ///
1231 /// The shape is goingson's, down to the class names, so adoption there deletes
1232 /// `renderFormField` rather than restyling anything. That is also why the class
1233 /// names are not emitted by [`crate::stylesheet`]: `.form-group`, `.form-label`,
1234 /// `.form-hint` and `.form-error` are the apps' own, and phase A deliberately
1235 /// emits only what it can generate from the description. Whether they should
1236 /// move into the description is the next question this raises, not one it
1237 /// answers.
1238 ///
1239 /// A [`FieldKind::Hidden`] field is the input alone: no group, no label, and
1240 /// nothing drawn, which is what [`FieldKind::visible`] means.
1241 ///
1242 /// The error marks the group as well as the control. That is
1243 /// [`Field::invalid`]'s own reasoning: a renderer with no descendant selectors
1244 /// cannot find the group from the message, so the group has to be told.
1245 ///
1246 /// ```
1247 /// use makeover_layout::{Field, FieldKind};
1248 /// use makeover_webview::{Emit, form::{Filling, Value, field_html}};
1249 ///
1250 /// let field = Field::new(FieldKind::Text, "title", "Title");
1251 /// let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
1252 ///
1253 /// assert!(html.contains(r#"<label class="form-label" for="title">Title</label>"#));
1254 /// assert!(html.contains(r#"value="Ship it""#));
1255 /// ```
1256 #[must_use]
1257 pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
1258 let mut html = String::new();
1259 field_html_into(field, filling, opts, &mut html);
1260 html
1261 }
1262
1263 /// One field, written into a buffer the caller already has.
1264 ///
1265 /// [`field_html`]'s streaming form, byte-identical to it. A form is a run of
1266 /// these, so a host building one should hold a single buffer and append each
1267 /// field into it rather than take a `String` per field and concatenate.
1268 pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
1269 let id = filling.id_for(field.name);
1270
1271 if !field.kind.visible() {
1272 // Name only, no id: a hidden field is never pointed at by a label or a
1273 // description, so the one attribute it needs is the one that submits.
1274 out.push_str("<input type=\"hidden\" name=\"");
1275 escape_into(field.name, out);
1276 out.push_str("\" value=\"");
1277 escape_into(filling.value.as_text(), out);
1278 out.push_str("\">");
1279 return;
1280 }
1281
1282 out.push_str("<div class=\"");
1283 push_class(out, "form-group", opts);
1284 if field.invalid() {
1285 out.push_str(" has-error");
1286 }
1287 if field.extended {
1288 // The disclosure that hides these is a property of the form, not of the
1289 // field, so the field is marked and the app opens or closes the group.
1290 out.push_str("\" data-extended=\"true");
1291 }
1292 out.push_str("\">");
1293
1294 // A checkbox labels itself, on the right of the box. Both apps special-case
1295 // this inline today, which is the tell that it belongs in the description;
1296 // `FieldKind::labels_itself` is where it went.
1297 if !field.kind.labels_itself() {
1298 out.push_str("<label class=\"");
1299 push_class(out, "form-label", opts);
1300 // A group control is named *by* its label rather than pointing at it,
1301 // so the two carry opposite halves of the association. See
1302 // `is_group_control`.
1303 if is_group_control(field.kind) {
1304 let _ = write!(out, "\" id=\"{id}-label\">");
1305 } else {
1306 let _ = write!(out, "\" for=\"{id}\">");
1307 }
1308 escape_into(field.label, out);
1309 out.push_str("</label>");
1310 }
1311
1312 push_control(out, field, filling, opts);
1313
1314 // Adjacent text, because HTML has no unit attribute and inventing one would
1315 // be markup nothing reads. Pointed at by `aria-describedby` so it is not
1316 // decoration a screen reader skips: the number and what it is measured in
1317 // are one fact, and reading the first without the second is reading it
1318 // wrong.
1319 if let Some(unit) = unit_of(field) {
1320 out.push_str("<span class=\"");
1321 push_class(out, "form-unit", opts);
1322 let _ = write!(out, "\" id=\"{id}-unit\">");
1323 escape_into(unit, out);
1324 out.push_str("</span>");
1325 }
1326
1327 if let Some(hint) = field.hint {
1328 out.push_str("<div class=\"");
1329 push_class(out, "form-hint", opts);
1330 let _ = write!(out, "\" id=\"{id}-hint\">");
1331 escape_into(hint, out);
1332 out.push_str("</div>");
1333 }
1334 // A consequence of the answer, between the standing help and the failure.
1335 // The tone rides on `data-tone` -- the same attribute every other toned
1336 // thing in this crate takes -- and it also picks the live region: Warning
1337 // and Danger are assertive, which is quasi-webview's own reading at
1338 // `node.rs:1403` and is honoured here rather than restated differently.
1339 if let Some((tone, note)) = field.note {
1340 out.push_str("<div class=\"");
1341 push_class(out, "form-note", opts);
1342 let assertive = matches!(tone, Tone::Warning | Tone::Danger);
1343 let _ = write!(
1344 out,
1345 "\" id=\"{id}-note\" role=\"{}\"",
1346 if assertive { "alert" } else { "status" }
1347 );
1348 // Neutral is the bare class rather than a variant, matching every
1349 // other toned component here: it is the absence of a status.
1350 if tone != Tone::Neutral {
1351 let _ = write!(out, " data-tone=\"{}\"", tone.token());
1352 }
1353 out.push('>');
1354 escape_into(note, out);
1355 out.push_str("</div>");
1356 }
1357 if let Some(Markup(markup)) = filling.trailing {
1358 out.push_str(markup);
1359 }
1360 if let Some(error) = field.error {
1361 out.push_str("<div class=\"");
1362 push_class(out, "form-error", opts);
1363 let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
1364 escape_into(error, out);
1365 out.push_str("</div>");
1366 }
1367
1368 out.push_str("</div>");
1369 }
1370
1371 #[cfg(test)]
1372 mod tests {
1373 use super::*;
1374 use makeover_layout::{Accepted, Curve, Family};
1375
1376 fn field(kind: FieldKind) -> Field<'static> {
1377 Field::new(kind, "title", "Title")
1378 }
1379
1380 #[test]
1381 fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
1382 // The payload from goingson's own CHRONIC-XSS regression test.
1383 let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
1384 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1385 // The payload survives as text, which is the point: it is inert
1386 // because the quote that would have closed the attribute is encoded,
1387 // not because the words were filtered.
1388 assert!(!html.contains("\" onfocus"), "{html}");
1389 assert!(
1390 html.contains("value=\"x&quot; onfocus=alert(1) autofocus=&quot;\""),
1391 "{html}"
1392 );
1393 }
1394
1395 /// The seam quasi's suggestion source needs: a host's own attributes land
1396 /// on the control, unescaped, and after everything this crate decided.
1397 #[test]
1398 fn a_host_can_write_its_own_attributes_onto_the_control() {
1399 let mut filling = Filling::of(Value::Text("ru"));
1400 filling.control_attrs = Some(Markup(
1401 r#"role="combobox" aria-expanded="false" aria-controls="title-suggestions""#,
1402 ));
1403 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1404 assert!(html.contains(r#"role="combobox""#), "{html}");
1405 assert!(
1406 html.contains(r#"aria-controls="title-suggestions""#),
1407 "{html}"
1408 );
1409 // After the id, which is what "last" buys: a host can read what this
1410 // emitter wrote and cannot be overwritten by it.
1411 let id = html.find(r#"id="title""#).expect("id");
1412 let role = html.find(r#"role="combobox""#).expect("role");
1413 assert!(id < role, "{html}");
1414 }
1415
1416 /// A radio group has no one control element, so there is nowhere honest to
1417 /// put an attribute meant for the control. Documented on the member.
1418 #[test]
1419 fn a_radio_group_drops_control_attributes() {
1420 let mut f = field(FieldKind::Radio);
1421 let options = [Choice::new("a", "A")];
1422 f.options = &options;
1423 let filling = Filling {
1424 control_attrs: Some(Markup(r#"data-host="1""#)),
1425 ..Filling::default()
1426 };
1427 let html = field_html(&f, &filling, &Emit::default());
1428 assert!(!html.contains("data-host"), "{html}");
1429 }
1430
1431 #[test]
1432 fn a_label_cannot_open_a_tag() {
1433 let mut f = field(FieldKind::Text);
1434 f.label = "<script>alert(1)</script>";
1435 let html = field_html(&f, &Filling::default(), &Emit::default());
1436 assert!(!html.contains("<script>"), "{html}");
1437 assert!(html.contains("&lt;script&gt;"), "{html}");
1438 }
1439
1440 #[test]
1441 fn every_escaped_sink_is_covered_by_the_one_escaper() {
1442 assert_eq!(escape("&<>\"'"), "&amp;&lt;&gt;&quot;&#39;");
1443 // The character `textContent` serialization leaves alone, which is why
1444 // the app needs two escapers and this needs one.
1445 assert!(escape("\"").contains("&quot;"));
1446 }
1447
1448 /// The streaming escaper is the one the emitters call and [`escape`] is a
1449 /// buffer around it, so the two cannot be allowed to drift. It copies in
1450 /// runs between the encoded characters, which is where a multi-byte
1451 /// character would break it if the scan were not restricted to ASCII.
1452 #[test]
1453 fn the_streaming_escaper_appends_what_the_returning_one_returns() {
1454 for text in [
1455 "",
1456 "plain",
1457 "&<>\"'",
1458 "&&&",
1459 "a & b",
1460 "trailing&",
1461 "&leading",
1462 "é世 & <b>naïve</b> \u{1f600}",
1463 ] {
1464 let mut out = String::from("kept: ");
1465 escape_into(text, &mut out);
1466 assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
1467 }
1468 }
1469
1470 /// Same obligation one layer up: a form is a run of fields appended into one
1471 /// buffer, and the two ways to get one have to agree byte for byte.
1472 #[test]
1473 fn a_streamed_field_is_the_field_the_other_form_returns() {
1474 let kinds = [
1475 FieldKind::Text,
1476 FieldKind::Secret,
1477 FieldKind::Number,
1478 FieldKind::Checkbox,
1479 FieldKind::Radio,
1480 FieldKind::Select,
1481 FieldKind::Textarea,
1482 FieldKind::File,
1483 FieldKind::Hidden,
1484 ];
1485 let choices = [Choice::plain("one"), Choice::plain("two")];
1486 let opts = Emit {
1487 class_prefix: "mk-",
1488 ..Emit::default()
1489 };
1490 for kind in kinds {
1491 let described = Field {
1492 hint: Some("a hint"),
1493 error: Some("wrong <here>"),
1494 placeholder: Some("x\" y"),
1495 options: &choices,
1496 required: true,
1497 max_length: Some(40),
1498 min: Some("1"),
1499 max: Some("9"),
1500 extended: true,
1501 ..Field::new(kind, "the & name", "The <label>")
1502 };
1503 let filling = Filling {
1504 value: Value::Text("one"),
1505 trailing: Some(Markup("<i>t</i>")),
1506 control_attrs: Some(Markup(r#"data-host="1""#)),
1507 id_prefix: Some("modal"),
1508 };
1509 let mut streamed = String::new();
1510 field_html_into(&described, &filling, &opts, &mut streamed);
1511 assert_eq!(
1512 streamed,
1513 field_html(&described, &filling, &opts),
1514 "{kind:?}"
1515 );
1516
1517 // And the bare field, where every optional half is absent.
1518 let plain = Field::new(kind, "name", "Label");
1519 let mut streamed = String::new();
1520 field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
1521 assert_eq!(
1522 streamed,
1523 field_html(&plain, &Filling::default(), &opts),
1524 "{kind:?}"
1525 );
1526 }
1527 }
1528
1529 #[test]
1530 fn markup_is_the_only_way_past_the_escaping() {
1531 let filling = Filling {
1532 trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
1533 ..Filling::default()
1534 };
1535 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1536 assert!(
1537 html.contains("<div class=\"recurrence-config\"></div>"),
1538 "{html}"
1539 );
1540 }
1541
1542 #[test]
1543 fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
1544 let mut f = field(FieldKind::Text);
1545 f.error = Some("Required");
1546 let opts = Emit::default();
1547 let html = field_html(&f, &Filling::default(), &opts);
1548 assert!(html.contains("aria-invalid=\"true\""), "{html}");
1549 // The selector the CSS side emits for exactly this state.
1550 assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
1551 // And the group is marked too, which a renderer without descendant
1552 // selectors depends on.
1553 assert!(html.contains("has-error"), "{html}");
1554 }
1555
1556 #[test]
1557 fn a_valid_field_claims_nothing_about_being_invalid() {
1558 let html = field_html(
1559 &field(FieldKind::Text),
1560 &Filling::default(),
1561 &Emit::default(),
1562 );
1563 assert!(!html.contains("aria-invalid"), "{html}");
1564 assert!(!html.contains("has-error"), "{html}");
1565 }
1566
1567 #[test]
1568 fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() {
1569 let mut f = field(FieldKind::Text);
1570 f.hint = Some("Keep it short");
1571 f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF"));
1572 f.error = Some("Required");
1573 let html = field_html(&f, &Filling::default(), &Emit::default());
1574
1575 // All three associated, in the order they are drawn.
1576 assert!(
1577 html.contains(r#"aria-describedby="title-hint title-note title-error""#),
1578 "{html}"
1579 );
1580 assert!(
1581 html.contains(r#"id="title-note" role="alert" data-tone="warning""#),
1582 "{html}"
1583 );
1584 // And in that order in the document, so the reading order matches.
1585 let hint = html.find("title-hint").unwrap();
1586 let note = html.rfind("title-note").unwrap();
1587 let err = html.rfind("title-error").unwrap();
1588 assert!(hint < note && note < err, "{html}");
1589 }
1590
1591 #[test]
1592 fn a_quiet_note_is_polite_and_wears_no_tone_attribute() {
1593 // Neutral is the bare class, matching every other toned component
1594 // here, and only Warning and Danger interrupt.
1595 let mut f = field(FieldKind::Text);
1596 f.note = Some((Tone::Info, "This is what that setting implies"));
1597 let html = field_html(&f, &Filling::default(), &Emit::default());
1598 assert!(html.contains(r#"role="status" data-tone="info""#), "{html}");
1599
1600 f.note = Some((Tone::Neutral, "An ordinary fact"));
1601 let html = field_html(&f, &Filling::default(), &Emit::default());
1602 assert!(html.contains(r#"id="title-note" role="status">"#), "{html}");
1603 assert!(!html.contains("data-tone"), "{html}");
1604 }
1605
1606 #[test]
1607 fn a_note_does_not_mark_the_group_invalid() {
1608 // `Field::invalid` stays `error.is_some()`, and the renderer's
1609 // `has-error` follows it rather than any message being present.
1610 let mut f = field(FieldKind::Text);
1611 f.note = Some((Tone::Danger, "This cannot be undone"));
1612 let html = field_html(&f, &Filling::default(), &Emit::default());
1613 assert!(!html.contains("has-error"), "{html}");
1614 assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
1615 }
1616
1617 #[test]
1618 fn the_hint_survives_an_error_arriving() {
1619 let mut f = field(FieldKind::Text);
1620 f.hint = Some("Keep it short");
1621 f.error = Some("Required");
1622 let html = field_html(&f, &Filling::default(), &Emit::default());
1623 assert!(
1624 html.contains("aria-describedby=\"title-hint title-error\""),
1625 "{html}"
1626 );
1627 }
1628
1629 #[test]
1630 fn a_secret_never_carries_its_value_into_the_markup() {
1631 let filling = Filling::of(Value::Text("hunter2"));
1632 let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
1633 assert!(!html.contains("hunter2"), "{html}");
1634 assert!(html.contains("type=\"password\""), "{html}");
1635 }
1636
1637 #[test]
1638 fn a_hidden_field_is_the_input_and_nothing_else() {
1639 let filling = Filling::of(Value::Text("42"));
1640 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
1641 assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
1642 }
1643
1644 #[test]
1645 fn a_checkbox_labels_itself_and_takes_no_separate_label() {
1646 let html = field_html(
1647 &field(FieldKind::Checkbox),
1648 &Filling::of(Value::On(true)),
1649 &Emit::default(),
1650 );
1651 assert!(!html.contains("form-label"), "{html}");
1652 assert!(html.contains("checked"), "{html}");
1653 assert!(html.contains("<span>Title</span>"), "{html}");
1654 }
1655
1656 #[test]
1657 fn a_select_keeps_a_value_no_option_carries() {
1658 let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
1659 let f = Field::select("title", "Title", &options);
1660 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1661 assert!(html.contains("data-unmatched=\"true\""), "{html}");
1662 // Selected, so the next save round-trips it rather than writing the
1663 // first option over the top of it.
1664 assert!(html.contains("<option value=\"10\" selected"), "{html}");
1665 }
1666
1667 #[test]
1668 fn a_select_with_no_options_emits_an_empty_select() {
1669 // The description says a select with no options is sayable, because an
1670 // app whose option list has not loaded has exactly that. Emitting the
1671 // empty select reports it on screen rather than in a log.
1672 let f = Field::select("title", "Title", &[]);
1673 let html = field_html(&f, &Filling::default(), &Emit::default());
1674 assert!(html.contains("<select"), "{html}");
1675 assert!(!html.contains("<option"), "{html}");
1676 }
1677
1678 #[test]
1679 fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
1680 let options = [Choice::new("sp404", "SP-404")];
1681 let f = Field {
1682 placeholder: Some("Select device..."),
1683 ..Field::select("device", "Conform for device", &options)
1684 };
1685 let html = field_html(&f, &Filling::default(), &Emit::default());
1686
1687 assert!(
1688 html.contains("<option value=\"\" disabled selected>Select device...</option>"),
1689 "{html}"
1690 );
1691 // First, so the closed control reads it rather than the first real
1692 // option.
1693 assert!(
1694 html.find("Select device...") < html.find("SP-404"),
1695 "{html}"
1696 );
1697 }
1698
1699 #[test]
1700 fn an_answered_select_drops_the_ghost_text() {
1701 // It is an instruction about an empty field, so it has nothing to say
1702 // once the field is answered, and leaving it in the list is one dead
1703 // row every time the control is opened afterwards.
1704 let options = [Choice::new("sp404", "SP-404")];
1705 let f = Field {
1706 placeholder: Some("Select device..."),
1707 ..Field::select("device", "Conform for device", &options)
1708 };
1709 let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
1710 assert!(!html.contains("Select device..."), "{html}");
1711 }
1712
1713 #[test]
1714 fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
1715 // The two paths through `push_options` meet here. An unmatched value is
1716 // an answer that is wrong and stays visible as itself; only the empty
1717 // value is unanswered.
1718 let options = [Choice::plain("1"), Choice::plain("7")];
1719 let f = Field {
1720 placeholder: Some("Pick one"),
1721 ..Field::select("retention", "Keep backups for", &options)
1722 };
1723 let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1724 assert!(html.contains("data-unmatched=\"true\""), "{html}");
1725 assert!(!html.contains("Pick one"), "{html}");
1726 }
1727
1728 #[test]
1729 fn a_range_is_a_range_input_and_carries_its_extent() {
1730 let f = Field {
1731 curve: Curve::Linear { step: Some("0.01") },
1732 ..Field::range("review", "Review above", "0", "1")
1733 };
1734 let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
1735 assert!(html.contains("type=\"range\""), "{html}");
1736 assert!(html.contains("min=\"0\""), "{html}");
1737 assert!(html.contains("max=\"1\""), "{html}");
1738 // Without it the browser steps by 1 and a 0-to-1 question becomes a
1739 // two-position control.
1740 assert!(html.contains("step=\"0.01\""), "{html}");
1741 }
1742
1743 #[test]
1744 fn a_range_reads_its_granularity_off_the_curve_and_not_off_field_step() {
1745 // The 0.32.0 narrowing, at the renderer. `Field::step` on a range is a
1746 // site that has not been moved over, and emitting it would make the
1747 // control step by a number the curve never agreed to.
1748 let f = Field {
1749 step: Some("99"),
1750 ..Field::range("review", "Review above", "0", "1")
1751 };
1752 let html = field_html(&f, &Filling::of(Value::Text("0.5")), &Emit::default());
1753 assert!(!html.contains("step="), "{html}");
1754 }
1755
1756 #[test]
1757 fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
1758 // Not decoration: the number and what it is measured in are one fact,
1759 // so the association is what makes this worth emitting at all.
1760 let f = Field {
1761 unit: Some("dBFS"),
1762 ..Field::range("threshold", "Threshold", "-96", "-20")
1763 };
1764 let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
1765 assert!(html.contains(r#"id="threshold-unit""#), "{html}");
1766 assert!(html.contains(">dBFS</span>"), "{html}");
1767 assert!(
1768 html.contains(r#"aria-describedby="threshold-unit""#),
1769 "{html}"
1770 );
1771 // The label is the question's name and keeps no unit in it.
1772 assert!(html.contains(">Threshold</label>"), "{html}");
1773 }
1774
1775 #[test]
1776 fn a_unit_takes_its_place_between_the_hint_and_the_error() {
1777 let f = Field {
1778 unit: Some("ms"),
1779 hint: Some("How long the fade runs."),
1780 error: Some("Too long."),
1781 ..Field::new(FieldKind::Number, "fade", "Fade")
1782 };
1783 let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
1784 assert!(
1785 html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
1786 "{html}"
1787 );
1788 }
1789
1790 #[test]
1791 fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
1792 // Sayable and ignored, the way `options` is on a kind that offers none.
1793 // The renderer asks the description which kinds are measurable rather
1794 // than keeping its own list.
1795 let f = Field {
1796 unit: Some("s"),
1797 ..Field::new(FieldKind::Text, "name", "Name")
1798 };
1799 let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
1800 assert!(!html.contains("name-unit"), "{html}");
1801 assert!(!html.contains("aria-describedby"), "{html}");
1802 }
1803
1804 #[test]
1805 fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
1806 let f = Field {
1807 unit: Some("</span><script>"),
1808 ..Field::new(FieldKind::Number, "n", "N")
1809 };
1810 let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
1811 assert!(!html.contains("<script>"), "{html}");
1812 assert!(html.contains("&lt;script&gt;"), "{html}");
1813 }
1814
1815 #[test]
1816 fn a_constant_ratio_curve_still_emits_a_linear_track() {
1817 // Honest shortfall rather than a silent one: HTML has no logarithmic
1818 // range input, so the browser draws the extent linearly. The value it
1819 // submits is still a value in the field's own units, which is what
1820 // every handler on this path reads. See the crate header.
1821 let f = Field {
1822 curve: Curve::Logarithmic {
1823 step: Some("0.001"),
1824 },
1825 ..Field::range("attack", "Attack", "0.001", "5")
1826 };
1827 let html = field_html(&f, &Filling::of(Value::Text("0.005")), &Emit::default());
1828 assert!(html.contains("type=\"range\""), "{html}");
1829 assert!(html.contains("min=\"0.001\""), "{html}");
1830 assert!(html.contains("max=\"5\""), "{html}");
1831 assert!(html.contains("step=\"0.001\""), "{html}");
1832 }
1833
1834 #[test]
1835 fn a_number_with_bounds_is_still_typed_into() {
1836 // The distinction the kind exists for, at the renderer where getting it
1837 // wrong is most visible: goingson's `min="1"` duration must not come
1838 // back as a slider.
1839 let f = Field {
1840 min: Some("1"),
1841 ..Field::new(FieldKind::Number, "minutes", "Minutes")
1842 };
1843 let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
1844 assert!(html.contains("type=\"number\""), "{html}");
1845 assert!(!html.contains("type=\"range\""), "{html}");
1846 // And nothing invents a step for it.
1847 assert!(!html.contains("step="), "{html}");
1848 }
1849
1850 #[test]
1851 fn an_unavailable_option_is_disabled_and_says_why() {
1852 let options = [
1853 Choice::new("chromatic", "Chromatic"),
1854 Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1855 ];
1856 let f = Field::radio("mode", "Mode", &options);
1857 let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
1858
1859 assert!(html.contains(" disabled"), "{html}");
1860 assert!(html.contains("Drop a second sample."), "{html}");
1861 // The option is still offered: dropping it is what costs the user the
1862 // knowledge that the mode exists.
1863 assert!(html.contains("value=\"multi\""), "{html}");
1864 // And the reason is its own element, not run into the label.
1865 assert!(html.contains("form-option-reason"), "{html}");
1866 }
1867
1868 #[test]
1869 fn an_unavailable_select_option_carries_its_reason_in_its_text() {
1870 // A `<select>` gives an option no room for a second element, so the
1871 // reason has to be in the text or be unreadable without a pointer.
1872 let options = [Choice::new("multi", "Multi-sample").unless("Drop a second sample.")];
1873 let f = Field::select("mode", "Mode", &options);
1874 let html = field_html(&f, &Filling::default(), &Emit::default());
1875 assert!(
1876 html.contains(">Multi-sample: Drop a second sample.</option>"),
1877 "{html}"
1878 );
1879 assert!(html.contains("disabled"), "{html}");
1880 }
1881
1882 #[test]
1883 fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
1884 // The association inverts, and getting it wrong is silent: a
1885 // `<label for>` aimed at a group points at no element, so the group
1886 // simply has no accessible name and nothing reports that.
1887 let options = [Choice::plain("copy"), Choice::plain("reference")];
1888 let f = Field::radio("storage", "Storage style", &options);
1889 let html = field_html(&f, &Filling::of(Value::Text("copy")), &Emit::default());
1890
1891 assert!(html.contains("id=\"storage-label\""), "{html}");
1892 assert!(!html.contains("for=\"storage\""), "{html}");
1893 assert!(html.contains("role=\"radiogroup\""), "{html}");
1894 assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
1895 }
1896
1897 #[test]
1898 fn an_interval_is_one_labelled_group_holding_both_ends() {
1899 // The markup MNW's discover sidebar writes by hand, which is the
1900 // measurement that decided the member: `role="group"` naming the
1901 // question, two number boxes under it.
1902 let f = Field::interval("min_price", "max_price", "Price");
1903 let html = field_html(
1904 &f,
1905 &Filling::of(Value::Between {
1906 lower: "5",
1907 upper: "40",
1908 }),
1909 &Emit::default(),
1910 );
1911
1912 assert!(html.contains("role=\"group\""), "{html}");
1913 assert!(
1914 html.contains("aria-labelledby=\"min_price-label\""),
1915 "{html}"
1916 );
1917 assert!(html.contains("id=\"min_price-label\""), "{html}");
1918 assert!(!html.contains("for=\"min_price\""), "{html}");
1919 assert!(html.contains("name=\"min_price\""), "{html}");
1920 assert!(html.contains("name=\"max_price\""), "{html}");
1921 assert!(html.contains("value=\"5\""), "{html}");
1922 assert!(html.contains("value=\"40\""), "{html}");
1923 assert_eq!(html.matches("type=\"number\"").count(), 2, "{html}");
1924 }
1925
1926 #[test]
1927 fn both_ends_of_an_interval_take_the_whole_extent() {
1928 // The extent describes the axis rather than either end of it, so a
1929 // browser refuses the same values in both boxes.
1930 let f = Field {
1931 min: Some("0"),
1932 max: Some("300"),
1933 step: Some("1"),
1934 ..Field::interval("bpm_min", "bpm_max", "BPM")
1935 };
1936 let html = field_html(&f, &Filling::default(), &Emit::default());
1937
1938 assert_eq!(html.matches("min=\"0\"").count(), 2, "{html}");
1939 assert_eq!(html.matches("max=\"300\"").count(), 2, "{html}");
1940 assert_eq!(html.matches("step=\"1\"").count(), 2, "{html}");
1941 // Neither box holds anything, which is the open interval rather than an
1942 // empty form: no filter on this axis at all.
1943 assert_eq!(html.matches("value=\"\"").count(), 2, "{html}");
1944 }
1945
1946 #[test]
1947 fn an_interval_carries_the_fault_on_the_group_and_not_on_one_end() {
1948 // A crossed interval is wrong about the answer, and the answer is the
1949 // pair. This is the half two `Number` fields could not say.
1950 let f = Field {
1951 error: Some("The high end is below the low one."),
1952 hint: Some("Leave an end empty for no bound."),
1953 ..Field::interval("bpm_min", "bpm_max", "BPM")
1954 };
1955 let html = field_html(&f, &Filling::default(), &Emit::default());
1956
1957 assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
1958 let group = html.find("role=\"group\"").expect("group");
1959 let invalid = html.find("aria-invalid").expect("invalid");
1960 let first_input = html.find("<input").expect("input");
1961 assert!(invalid > group && invalid < first_input, "{html}");
1962 assert!(
1963 html.contains("aria-describedby=\"bpm_min-hint bpm_min-error\""),
1964 "{html}"
1965 );
1966 }
1967
1968 #[test]
1969 fn an_interval_with_one_end_named_draws_one_box() {
1970 // Drawn as described rather than repaired. Inventing a name for the
1971 // upper end would submit a parameter no handler reads, and
1972 // `Field::interval` is what makes the omission unsayable at the source.
1973 let f = Field::new(FieldKind::Interval, "bpm_min", "BPM");
1974 let html = field_html(&f, &Filling::default(), &Emit::default());
1975
1976 assert_eq!(html.matches("<input").count(), 1, "{html}");
1977 assert!(html.contains("name=\"bpm_min\""), "{html}");
1978 }
1979
1980 #[test]
1981 fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
1982 // One `name` is what makes them one answer rather than three; distinct
1983 // ids are what keep each `<label>` wrapping its own input.
1984 let options = [
1985 Choice::plain("copy"),
1986 Choice::plain("reference"),
1987 Choice::plain("link"),
1988 ];
1989 let f = Field::radio("storage", "Storage style", &options);
1990 let html = field_html(&f, &Filling::of(Value::Text("reference")), &Emit::default());
1991
1992 assert_eq!(html.matches("name=\"storage\"").count(), 3, "{html}");
1993 assert_eq!(html.matches(" checked").count(), 1, "{html}");
1994 assert!(
1995 html.contains("value=\"reference\" checked"),
1996 "the checked one is the one held: {html}"
1997 );
1998 for index in 0..3 {
1999 assert!(html.contains(&format!("id=\"storage-{index}\"")), "{html}");
2000 }
2001 }
2002
2003 #[test]
2004 fn a_radio_group_carries_the_error_rather_than_any_one_option() {
2005 // What is wrong is the answer, not one of the alternatives, so marking
2006 // a single input invalid would say something false. Same reading
2007 // `Field::invalid` gives one level up.
2008 let options = [Choice::plain("copy"), Choice::plain("reference")];
2009 let f = Field {
2010 error: Some("Pick one."),
2011 hint: Some("Cannot be changed later."),
2012 ..Field::radio("storage", "Storage style", &options)
2013 };
2014 let html = field_html(&f, &Filling::default(), &Emit::default());
2015
2016 assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
2017 assert!(
2018 html.contains("aria-describedby=\"storage-hint storage-error\""),
2019 "{html}"
2020 );
2021 // The group is the element that carries them, so they land before the
2022 // first option rather than on it.
2023 let group = html.find("role=\"radiogroup\"").expect("group");
2024 let first = html.find("type=\"radio\"").expect("an option");
2025 assert!(group < first, "{html}");
2026 }
2027
2028 #[test]
2029 fn a_compulsory_radio_group_marks_every_option() {
2030 // How HTML says a group is compulsory: the constraint reads as
2031 // satisfied when any one of them is checked.
2032 let options = [Choice::plain("copy"), Choice::plain("reference")];
2033 let f = Field {
2034 required: true,
2035 ..Field::radio("storage", "Storage style", &options)
2036 };
2037 let html = field_html(&f, &Filling::default(), &Emit::default());
2038 assert_eq!(html.matches(" required").count(), 2, "{html}");
2039 }
2040
2041 #[test]
2042 fn a_radio_option_cannot_break_out_of_its_attribute() {
2043 // Values are `&str` and carry whatever the app put in them. The ids are
2044 // numbered rather than derived from the value for the same reason.
2045 let hostile = [Choice::new(
2046 "x\" onclick=alert(1) data-x=\"",
2047 "<script>alert(1)</script>",
2048 )];
2049 let f = Field::radio("storage", "Storage style", &hostile);
2050 let html = field_html(&f, &Filling::default(), &Emit::default());
2051
2052 // The payload survives as text; what must not survive is the quote
2053 // that would end the attribute and let the rest of it become markup.
2054 assert!(html.contains("value=\"x&quot; onclick=alert(1)"), "{html}");
2055 assert!(!html.contains("<script>"), "{html}");
2056 assert!(html.contains("id=\"storage-0\""), "{html}");
2057 }
2058
2059 #[test]
2060 fn a_radio_group_with_no_options_emits_an_empty_group() {
2061 // Same position the select takes, and the description's own.
2062 let f = Field::radio("storage", "Storage style", &[]);
2063 let html = field_html(&f, &Filling::default(), &Emit::default());
2064 assert!(html.contains("role=\"radiogroup\""), "{html}");
2065 assert!(!html.contains("type=\"radio\""), "{html}");
2066 }
2067
2068 #[test]
2069 fn a_placeholder_comes_off_the_description_and_is_escaped() {
2070 // It arrived in `Filling` until makeover-layout 0.8.0 and was never
2071 // covered here; it is a value in an attribute like any other.
2072 let f = Field {
2073 placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
2074 ..field(FieldKind::Text)
2075 };
2076 let html = field_html(&f, &Filling::default(), &Emit::default());
2077 assert!(html.contains("placeholder=\""), "{html}");
2078 assert!(!html.contains("\" onfocus"), "{html}");
2079 }
2080
2081 #[test]
2082 fn a_select_marks_the_option_that_matches() {
2083 let options = [Choice::plain("1"), Choice::plain("3")];
2084 let f = Field::select("title", "Title", &options);
2085 let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
2086 assert!(
2087 html.contains("<option value=\"3\" selected>3</option>"),
2088 "{html}"
2089 );
2090 assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
2091 assert!(!html.contains("data-unmatched"), "{html}");
2092 }
2093
2094 #[test]
2095 fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
2096 let filling = Filling::of(Value::Text("two\nlines"));
2097 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
2098 assert!(html.contains(">two\nlines</textarea>"), "{html}");
2099 }
2100
2101 #[test]
2102 fn a_markdown_field_is_a_textarea_that_says_what_its_value_is() {
2103 // The mark is the whole difference. Without it a described editor is a
2104 // plain box, and an enhancement looking for editors to upgrade has
2105 // nothing to find -- which is the state MNW's four hand-written section
2106 // editors would have had to keep living in.
2107 let filling = Filling::of(Value::Text("# Heading"));
2108 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
2109 assert!(html.contains("<textarea"), "{html}");
2110 assert!(html.contains(r#"data-format="markdown""#), "{html}");
2111 assert!(html.contains("># Heading</textarea>"), "{html}");
2112
2113 // A plain textarea claims nothing about its value, so the marker has to
2114 // be absent rather than present-and-different.
2115 let plain = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
2116 assert!(!plain.contains("data-format"), "{plain}");
2117
2118 // And it is not an input: the catch-all in `input_type` would have
2119 // degraded it to a single-line text box, which is the wrong shape for
2120 // markdown rather than a lossless fallback.
2121 assert!(!html.contains("<input"), "{html}");
2122 }
2123
2124 #[test]
2125 fn a_markdown_field_gets_the_preview_the_member_permits() {
2126 // The mark on its own is what 0.50.0 shipped, and nothing read it. What
2127 // a conversion needs is the pair MNW's `partial-item-text-editor.js`
2128 // already draws, so describing the field is not a way to lose it.
2129 let filling = Filling::of(Value::Text("# Heading"));
2130 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
2131 assert!(html.contains("data-editor-mode=\"write\""), "{html}");
2132 assert!(html.contains("data-editor-mode=\"preview\""), "{html}");
2133 assert!(html.contains("data-editor-preview"), "{html}");
2134 // Write is the mode a fresh editor is in, and the segment says so twice
2135 // because the sheet reads one and a screen reader reads the other.
2136 assert!(
2137 html.contains(
2138 "class=\"segment chosen\" data-editor-mode=\"write\" aria-pressed=\"true\""
2139 ),
2140 "{html}"
2141 );
2142 assert!(
2143 html.contains("data-editor-mode=\"preview\" aria-pressed=\"false\""),
2144 "{html}"
2145 );
2146 // The value is still the textarea's, and still text rather than an
2147 // attribute. The chrome sits around the control, not in place of it.
2148 assert!(html.contains("># Heading</textarea>"), "{html}");
2149 }
2150
2151 #[test]
2152 fn a_plain_textarea_gets_no_editor_chrome() {
2153 let filling = Filling::of(Value::Text("plain"));
2154 let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
2155 assert!(!html.contains("data-editor-mode"), "{html}");
2156 assert!(!html.contains("data-editor-preview"), "{html}");
2157 assert!(!html.contains("segment"), "{html}");
2158 }
2159
2160 #[test]
2161 fn nothing_the_editor_emits_renders_the_value_as_markup() {
2162 // The whole of this crate's half of the sanitising question: the pane is
2163 // empty, so no value reaches markup through it, and the host's own
2164 // renderer keeps the guarantee it already has.
2165 let filling = Filling::of(Value::Text("<img src=x onerror=alert(1)>"));
2166 let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
2167 assert!(html.contains("data-editor-preview></div>"), "{html}");
2168 assert!(!html.contains("<img"), "{html}");
2169 assert!(
2170 html.contains("&lt;img src=x onerror=alert(1)&gt;"),
2171 "{html}"
2172 );
2173 }
2174
2175 #[test]
2176 fn the_editor_rules_gate_on_the_attribute_and_on_a_binding() {
2177 let css = editor_rules(&Emit::default());
2178 // Behind the attribute, which is the reason the mark is an attribute:
2179 // a class-keyed gate would be prefixed away from the enhancement that
2180 // selects on it.
2181 for line in css.lines().filter(|line| line.contains('{')) {
2182 assert!(line.contains("[data-format=\"markdown\"]"), "{line}");
2183 }
2184 // Nothing is hidden and no control appears until something binds the
2185 // editor. A reader with no script gets the textarea alone.
2186 assert!(
2187 css.contains(
2188 "[data-format=\"markdown\"] > .form-editor-modes {\n display: none;\n}"
2189 )
2190 );
2191 assert!(css.contains(
2192 "[data-format=\"markdown\"][data-ready] > .form-editor-modes {\n display: block;\n}"
2193 ));
2194 assert!(css.contains(
2195 "[data-ready][data-mode=\"preview\"] > .form-editor-preview {\n display: block;\n}"
2196 ));
2197 assert!(
2198 css.contains("[data-ready][data-mode=\"preview\"] > .field {\n display: none;\n}")
2199 );
2200 // No magnitude, the line this crate holds everywhere else.
2201 assert!(!css.contains("px"), "{css}");
2202 assert!(!css.contains("rem"), "{css}");
2203 }
2204
2205 /// The prefix reaches the chrome as well, and the gate deliberately does
2206 /// not: an app assembling the sheet with its own prefix still has the
2207 /// selector an enhancement finds the editors by.
2208 #[test]
2209 fn the_editor_chrome_is_prefixed_and_its_gate_is_not() {
2210 let opts = Emit {
2211 class_prefix: "mk-",
2212 ..Emit::default()
2213 };
2214 let html = field_html(&field(FieldKind::Rich), &Filling::default(), &opts);
2215 assert!(html.contains("class=\"mk-form-editor-modes\""), "{html}");
2216 assert!(html.contains("class=\"mk-form-editor-preview\""), "{html}");
2217 assert!(html.contains("class=\"mk-segment chosen\""), "{html}");
2218 assert!(html.contains("data-format=\"markdown\""), "{html}");
2219
2220 let css = editor_rules(&opts);
2221 assert!(css.contains(".mk-form-editor-modes"), "{css}");
2222 assert!(css.contains("[data-format=\"markdown\"]"), "{css}");
2223 }
2224
2225 /// Every class the editor puts in markup is one the generated sheet rules,
2226 /// which is `FACET_CLASSES`' obligation without a list to keep: these two
2227 /// have rules, so the vocabulary seal picks them up from the sheet itself.
2228 #[test]
2229 fn the_editor_classes_are_in_the_vocabulary() {
2230 let opts = Emit::default();
2231 let names = crate::vocabulary::names(&opts);
2232 for name in ["form-editor-modes", "form-editor-preview", "segment"] {
2233 assert!(names.contains(name), "{name} is not in the vocabulary");
2234 }
2235 }
2236
2237 #[test]
2238 fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
2239 let opts = Emit {
2240 class_prefix: "mk-",
2241 ..Emit::default()
2242 };
2243 let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
2244 assert!(html.contains("class=\"mk-form-group\""), "{html}");
2245 assert!(html.contains("class=\"mk-field\""), "{html}");
2246 }
2247
2248 #[test]
2249 fn a_datetime_asking_for_an_instant_is_marked_for_the_script_that_converts_it() {
2250 let mut f = field(FieldKind::DateTime);
2251 f.as_instant = true;
2252 let html = field_html(&f, &Filling::default(), &Emit::default());
2253 assert!(html.contains("data-instant=\"true\""), "{html}");
2254 // The control is unchanged: the flag says what is submitted, not what
2255 // is drawn.
2256 assert!(html.contains("type=\"datetime-local\""), "{html}");
2257 }
2258
2259 #[test]
2260 fn only_a_datetime_can_name_a_moment_so_only_a_datetime_is_marked() {
2261 for kind in [FieldKind::Date, FieldKind::Text, FieldKind::Number] {
2262 let mut f = field(kind);
2263 f.as_instant = true;
2264 let html = field_html(&f, &Filling::default(), &Emit::default());
2265 assert!(!html.contains("data-instant"), "{kind:?}: {html}");
2266 }
2267 }
2268
2269 #[test]
2270 fn a_datetime_that_did_not_ask_carries_no_mark() {
2271 let html = field_html(
2272 &field(FieldKind::DateTime),
2273 &Filling::default(),
2274 &Emit::default(),
2275 );
2276 assert!(!html.contains("data-instant"), "{html}");
2277 }
2278
2279 #[test]
2280 fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
2281 let mut f = field(FieldKind::Text);
2282 f.extended = true;
2283 let html = field_html(&f, &Filling::default(), &Emit::default());
2284 assert!(html.contains("data-extended=\"true\""), "{html}");
2285 }
2286
2287 /// The prefix scopes the id and leaves the name alone. Prefixing the name
2288 /// too would change what the form submits, which is the failure this pair
2289 /// of assertions exists to catch rather than describe.
2290 #[test]
2291 fn the_id_prefix_scopes_the_id_and_never_the_name() {
2292 let mut f = field(FieldKind::Text);
2293 f.hint = Some("Keep it short");
2294 f.error = Some("Required");
2295 let filling = Filling {
2296 id_prefix: Some("form-modal-task-edit"),
2297 ..Filling::default()
2298 };
2299 let html = field_html(&f, &filling, &Emit::default());
2300
2301 assert!(
2302 html.contains(r#"id="form-modal-task-edit-title""#),
2303 "{html}"
2304 );
2305 assert!(html.contains(r#"name="title""#), "{html}");
2306 assert!(
2307 !html.contains(r#"name="form-modal-task-edit-title""#),
2308 "{html}"
2309 );
2310
2311 // The label and both associations follow the id, or they point at
2312 // nothing once the same form is on screen twice.
2313 assert!(
2314 html.contains(r#"for="form-modal-task-edit-title""#),
2315 "{html}"
2316 );
2317 assert!(
2318 html.contains(
2319 r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
2320 ),
2321 "{html}"
2322 );
2323 assert!(
2324 html.contains(r#"id="form-modal-task-edit-title-hint""#),
2325 "{html}"
2326 );
2327 }
2328
2329 #[test]
2330 fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
2331 let filling = Filling {
2332 value: Value::Text("42"),
2333 id_prefix: Some("scoped"),
2334 ..Filling::default()
2335 };
2336 let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
2337 assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
2338 }
2339
2340 /// These three exist so a touch keyboard and the platform's validation
2341 /// arrive with the field. Emitting text for any of them is the regression
2342 /// the variants were added to prevent, so the type is asserted directly.
2343 #[test]
2344 fn a_constraint_becomes_the_browsers_own_attribute() {
2345 // makeover-layout 0.11.0's model: the description carries the rule and
2346 // each renderer emits its host's idiom for it. Enforcement is still
2347 // whoever validated's, and arrives back as `error`.
2348 let html = field_html(
2349 &Field {
2350 max_length: Some(100),
2351 min: Some("1"),
2352 max: Some("240"),
2353 required: true,
2354 ..Field::new(FieldKind::Number, "minutes", "Minutes")
2355 },
2356 &Filling::default(),
2357 &Emit::default(),
2358 );
2359 assert!(html.contains(r#"maxlength="100""#));
2360 assert!(html.contains(r#"min="1""#));
2361 assert!(html.contains(r#"max="240""#));
2362 assert!(html.contains(" required"));
2363 }
2364
2365 #[test]
2366 fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() {
2367 // The bound is text because it is only a number for some of the kinds
2368 // that take one; goingson's own sites are a duration and a datetime.
2369 let html = field_html(
2370 &Field {
2371 min: Some("2026-08-09T14:30"),
2372 ..Field::new(FieldKind::Text, "starts", "Starts")
2373 },
2374 &Filling::default(),
2375 &Emit::default(),
2376 );
2377 assert!(html.contains(r#"min="2026-08-09T14:30""#));
2378 }
2379
2380 #[test]
2381 fn a_file_field_is_a_file_input() {
2382 // `844b5ae0`. A field that takes any file emits no `accept` at all,
2383 // which is the browser's own "any file". `accept=""` is a filter that
2384 // means nothing on one browser and everything on another.
2385 let html = field_html(
2386 &Field::new(FieldKind::File, "attachment", "Attachment"),
2387 &Filling::default(),
2388 &Emit::default(),
2389 );
2390 assert!(html.contains(r#"type="file""#));
2391 assert!(!html.contains("accept="));
2392 assert!(!html.contains("multiple"));
2393 // And it never carries a value: a file input's value is not settable
2394 // from markup, and the browser refuses one that tries.
2395 assert!(!html.contains("value="));
2396 }
2397
2398 #[test]
2399 fn an_accept_list_is_comma_joined_in_the_attributes_own_format() {
2400 // `f7261a5a`, makeover-layout 0.31.0. Each entry writes itself: a
2401 // family is its wildcard, a media type is itself, a suffix keeps its
2402 // leading dot and however many more it has.
2403 const MIXED: &[Accepted<'_>] = &[
2404 Accepted::Family(Family::Image),
2405 Accepted::Type("text/csv"),
2406 Accepted::Suffix(".tar.gz"),
2407 ];
2408 let html = field_html(
2409 &Field {
2410 multiple: true,
2411 ..Field::upload("drop", "Drop files", MIXED)
2412 },
2413 &Filling::default(),
2414 &Emit::default(),
2415 );
2416 assert!(
2417 html.contains(r#"accept="image/*,text/csv,.tar.gz""#),
2418 "{html}"
2419 );
2420 assert!(html.contains(" multiple"), "{html}");
2421 }
2422
2423 #[test]
2424 fn an_accept_entry_cannot_end_the_attribute_it_sits_in() {
2425 // The list reaches an attribute value, so it is escaped like every
2426 // other string that does. Nothing in the tree writes a quote into one;
2427 // that it cannot is the point.
2428 const HOSTILE: &[Accepted<'_>] = &[Accepted::Type(r#"image/x" onload="x"#)];
2429 let html = field_html(
2430 &Field::upload("cover", "Cover", HOSTILE),
2431 &Filling::default(),
2432 &Emit::default(),
2433 );
2434 assert!(!html.contains(r#"onload="x"#), "{html}");
2435 }
2436
2437 #[test]
2438 fn the_typed_text_kinds_keep_their_input_type() {
2439 for (kind, expected) in [
2440 (FieldKind::Email, "email"),
2441 (FieldKind::Url, "url"),
2442 (FieldKind::Tel, "tel"),
2443 (FieldKind::Date, "date"),
2444 (FieldKind::DateTime, "datetime-local"),
2445 ] {
2446 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
2447 assert!(
2448 html.contains(&format!(r#"type="{expected}""#)),
2449 "{kind:?} emitted {html}"
2450 );
2451 }
2452 }
2453
2454 #[test]
2455 fn a_temporal_field_is_a_native_control_and_not_a_hinted_text_box() {
2456 // The regression this closes: described as text with a hint reading
2457 // "YYYY-MM-DD", which loses the picker, the platform's validation and
2458 // the touch keyboard, and asks prose to do all three.
2459 for kind in [FieldKind::Date, FieldKind::DateTime] {
2460 let html = field_html(&field(kind), &Filling::default(), &Emit::default());
2461 assert!(!html.contains(r#"type="text""#), "{kind:?} emitted {html}");
2462 }
2463 }
2464
2465 #[test]
2466 fn no_prefix_leaves_the_id_as_the_name() {
2467 let html = field_html(
2468 &field(FieldKind::Text),
2469 &Filling::default(),
2470 &Emit::default(),
2471 );
2472 assert!(html.contains(r#"id="title" name="title""#), "{html}");
2473 }
2474
2475 /// Two variants and two tiers, which is the smallest list that can show
2476 /// where a group opens and that two badges differ.
2477 const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
2478 makeover_layout::ThemeChoice::new(
2479 "goingson",
2480 "GoingsOn",
2481 ThemeVariant::Light,
2482 makeover_layout::Contrast::High,
2483 ),
2484 makeover_layout::ThemeChoice::new(
2485 "ayu-light",
2486 "Ayu Light",
2487 ThemeVariant::Light,
2488 makeover_layout::Contrast::Low,
2489 ),
2490 makeover_layout::ThemeChoice::new(
2491 "carbonfox",
2492 "Carbonfox",
2493 ThemeVariant::Dark,
2494 makeover_layout::Contrast::High,
2495 ),
2496 ];
2497
2498 #[test]
2499 fn a_theme_picker_opens_one_optgroup_per_variant() {
2500 let f = Field::theme("theme", "Theme", THEMES);
2501 let html = field_html(&f, &Filling::default(), &Emit::default());
2502
2503 assert_eq!(html.matches("<optgroup").count(), 2, "{html}");
2504 assert_eq!(html.matches("</optgroup>").count(), 2, "{html}");
2505 assert!(
2506 html.contains(r#"<optgroup label="Light" data-variant="light">"#),
2507 "{html}"
2508 );
2509 assert!(
2510 html.contains(r#"<optgroup label="Dark" data-variant="dark">"#),
2511 "{html}"
2512 );
2513 // The two light themes share one group: a new group opens on a change
2514 // of variant and on nothing else.
2515 assert!(
2516 html.find("Ayu Light") < html.find("<optgroup label=\"Dark\""),
2517 "{html}"
2518 );
2519 }
2520
2521 #[test]
2522 fn every_theme_carries_its_measured_tier() {
2523 // The fact the three hand-written pickers lost. It rides in the text
2524 // because a `<select>`'s options take no elements, and in an attribute
2525 // because a stylesheet cannot read text.
2526 let f = Field::theme("theme", "Theme", THEMES);
2527 let html = field_html(&f, &Filling::default(), &Emit::default());
2528
2529 assert!(html.contains(r#"data-contrast="high""#), "{html}");
2530 assert!(html.contains(r#"data-contrast="low""#), "{html}");
2531 assert!(html.contains("GoingsOn (AA)"), "{html}");
2532 assert!(html.contains("Ayu Light (low)"), "{html}");
2533 }
2534
2535 #[test]
2536 fn the_follow_row_is_first_and_sits_in_no_group() {
2537 // It names no theme and belongs to no variant, so grouping it would be
2538 // inventing a fourth variant for one row.
2539 let f = Field::theme("theme", "Theme", THEMES)
2540 .following(Choice::new("system", "Follow System"));
2541 let html = field_html(&f, &Filling::default(), &Emit::default());
2542
2543 let follow = html.find("Follow System").expect("the row was offered");
2544 assert!(follow < html.find("<optgroup").expect("groups"), "{html}");
2545 }
2546
2547 #[test]
2548 fn the_stored_theme_is_the_selected_one() {
2549 let f = Field::theme("theme", "Theme", THEMES)
2550 .following(Choice::new("system", "Follow System"));
2551
2552 let named = field_html(&f, &Filling::of(Value::Text("carbonfox")), &Emit::default());
2553 assert!(
2554 named.contains(r#"value="carbonfox" data-contrast="high" selected"#),
2555 "{named}"
2556 );
2557 assert!(!named.contains(r#"value="system" selected"#), "{named}");
2558
2559 let following = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
2560 assert!(
2561 following.contains(r#"value="system" selected"#),
2562 "{following}"
2563 );
2564 }
2565
2566 #[test]
2567 fn a_theme_that_is_no_longer_installed_keeps_its_value() {
2568 // `push_options`' rule, met again: a value no row carries is a wrong
2569 // answer rather than an absent one, and dropping it would save a
2570 // different theme over the user's on the next write.
2571 let f = Field::theme("theme", "Theme", THEMES);
2572 let html = field_html(
2573 &f,
2574 &Filling::of(Value::Text("deleted-theme")),
2575 &Emit::default(),
2576 );
2577 assert!(html.contains(r#"data-unmatched="true""#), "{html}");
2578 assert!(
2579 html.contains(r#"<option value="deleted-theme" selected"#),
2580 "{html}"
2581 );
2582 }
2583
2584 #[test]
2585 fn the_follow_row_is_not_a_stray_value() {
2586 // The near-miss: `system` is carried by no `ThemeChoice`, so a check
2587 // that only walked the theme list would emit a duplicate unmatched row
2588 // beside the real one.
2589 let f = Field::theme("theme", "Theme", THEMES)
2590 .following(Choice::new("system", "Follow System"));
2591 let html = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
2592 assert!(!html.contains("data-unmatched"), "{html}");
2593 }
2594
2595 #[test]
2596 fn a_machine_with_no_themes_still_gets_a_picker() {
2597 // `Field::themes`' own position: an app whose theme directories hold
2598 // nothing has exactly this, and the empty control says so on screen.
2599 let f =
2600 Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
2601 let html = field_html(&f, &Filling::default(), &Emit::default());
2602 assert!(html.contains("<select"), "{html}");
2603 assert!(!html.contains("<optgroup"), "{html}");
2604 assert!(html.contains("Follow System"), "{html}");
2605 }
2606 }
2607