Skip to main content

max / makeover-webview

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