Skip to main content

max / makeover-webview

Emit a range input, a select's ghost option, and a disabled option's reason makeover-layout 0.28.0's three form findings. step is emitted only when the description carries one, since the browser's default of 1 is what an absent step means. The unanswered select takes HTML's own idiom, a disabled selected valueless first option, which required keeps working through. An unavailable option's reason goes beside the label in a radio group and into the text in a select, because an option element has room for nothing else.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:47 UTC
Commit: 1440f27563637316824997e19d21cc98a558a6a5
Parent: caf1f63
3 files changed, +211 insertions, -10 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.46.0"
3 + version = "0.47.0"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
@@ -17,7 +17,7 @@
17 17 # patch satisfy the requirement and still fail to compile. That happened once
18 18 # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where
19 19 # it surfaced, one release later.
20 - makeover-layout = "0.27.5"
20 + makeover-layout = "0.28.0"
21 21 # The capability axis. `makeover-touch` decides whether a hover rule should be
22 22 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
23 23 # answers are owned elsewhere and neither is re-derived here.
@@ -27,7 +27,7 @@
27 27 # satisfies "0.8" and keeps a second makeover-geometry in the graph next to the
28 28 # 0.7 this crate asks for. `Density` is nominally distinct across the two and
29 29 # the build fails on a type that reads as identical.
30 - makeover-touch = "0.19.0"
30 + makeover-touch = "0.20.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/form.rs +188 -7
@@ -206,6 +206,10 @@
206 206 FieldKind::Date => "date",
207 207 FieldKind::DateTime => "datetime-local",
208 208 FieldKind::Radio => "radio",
209 + // The clearest case in this list that a kind is not decoration: a
210 + // number and a range submit the same value and are different controls,
211 + // and the browser is the one drawing the difference.
212 + FieldKind::Range => "range",
209 213 // Select and Textarea are not inputs at all; they never reach here.
210 214 // Radio is one, but it is emitted once per option by `radio_html` and
211 215 // so does not reach here either.
@@ -253,6 +257,15 @@
253 257 escape_into(max, out);
254 258 out.push('"');
255 259 }
260 + // The browser's own default is `step="1"`, which turns a 0-to-1 threshold
261 + // into a two-position control. That is the granularity the description
262 + // means when it says nothing, so this is emitted only when an app has said
263 + // otherwise rather than defaulted here.
264 + if let Some(step) = field.step {
265 + out.push_str(" step=\"");
266 + escape_into(step, out);
267 + out.push('"');
268 + }
256 269 if field.invalid() {
257 270 out.push_str(" aria-invalid=\"true\"");
258 271 }
@@ -343,6 +356,22 @@
343 356 if field.required {
344 357 out.push_str(" required");
345 358 }
359 + // A radio group has room a `<select>` does not, so the reason gets its
360 + // own element beside the label rather than being run into it. The class
361 + // is what a stylesheet mutes; the text is there either way, which is
362 + // the half that matters — the finding was a greyed control with its
363 + // explanation behind a hover.
364 + if let Some(reason) = opt.unavailable {
365 + out.push_str(" disabled");
366 + out.push_str("><span>");
367 + escape_into(opt.label, out);
368 + out.push_str("</span><span class=\"");
369 + push_class(out, "form-option-reason", opts);
370 + out.push_str("\">");
371 + escape_into(reason, out);
372 + out.push_str("</span></label>");
373 + continue;
374 + }
346 375 out.push_str("><span>");
347 376 escape_into(opt.label, out);
348 377 out.push_str("</span></label>");
@@ -351,14 +380,37 @@
351 380 out.push_str("</div>");
352 381 }
353 382
354 - /// The options of a select, with an unmatched current value carried as its own.
383 + /// The options of a select: the unanswered instruction, an unmatched current
384 + /// value carried as its own, then the options themselves.
355 385 ///
356 386 /// A select handed a value no option carries renders with nothing selected, the
357 387 /// browser falls back to the first option, and the next save writes a value
358 388 /// nobody chose. goingson hit exactly that with a backup-retention default of
359 389 /// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
360 390 /// here so the second app gets it without hitting the bug first.
361 - fn push_options(out: &mut String, options: &[Choice<'_>], value: &str) {
391 + fn push_options(out: &mut String, field: &Field<'_>, options: &[Choice<'_>], value: &str) {
392 + // The unanswered state, which HTML has no attribute for: `placeholder` is
393 + // not a `<select>` attribute, and the idiom is an empty option that cannot
394 + // be chosen back. `disabled` is what stops it being re-selected once the
395 + // user has answered, and `selected` is what puts it in the closed control
396 + // while the value is empty; together they read as an instruction rather
397 + // than as an option.
398 + //
399 + // `required` keeps working through it rather than around it: the option's
400 + // value is empty, so a required select with this showing is invalid, which
401 + // is the true report on a question nobody has answered.
402 + //
403 + // Emitted only while the value is empty, so it does not sit in the open
404 + // list once the field is answered. A non-empty value no option carries is a
405 + // wrong answer rather than an absent one and takes the stray-option path
406 + // below.
407 + if value.is_empty() {
408 + if let Some(text) = field.placeholder {
409 + out.push_str("<option value=\"\" disabled selected>");
410 + escape_into(text, out);
411 + out.push_str("</option>");
412 + }
413 + }
362 414 if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
363 415 // The one place an escaped value is worth keeping: it is written twice,
364 416 // as the option's value and as its text.
@@ -375,6 +427,22 @@
375 427 if opt.value == value {
376 428 out.push_str(" selected");
377 429 }
430 + // `disabled` is what the browser reads, and it says nothing about why.
431 + // The reason goes in the option's own text, because a `<select>` gives
432 + // its options no room for anything else: no title attribute the
433 + // keyboard reaches, no second line, no element inside. So the row reads
434 + // "Multi-sample: Drop a second sample onto the keyboard." and is the
435 + // one place the precondition can be both attached to its option and
436 + // read without a pointer.
437 + if let Some(reason) = opt.unavailable {
438 + out.push_str(" disabled");
439 + out.push('>');
440 + escape_into(opt.label, out);
441 + out.push_str(": ");
442 + escape_into(reason, out);
443 + out.push_str("</option>");
444 + continue;
445 + }
378 446 out.push('>');
379 447 escape_into(opt.label, out);
380 448 out.push_str("</option>");
@@ -420,7 +488,7 @@
420 488 // A select described with no options emits an empty select, which
421 489 // says so on screen rather than in a log. That is the description's
422 490 // own position on `Field::options`, not a fallback invented here.
423 - push_options(out, field.options, filling.value.as_text());
491 + push_options(out, field, field.options, filling.value.as_text());
424 492 out.push_str("</select>");
425 493 }
426 494 FieldKind::Checkbox => {
@@ -798,6 +866,119 @@
798 866 assert!(!html.contains("<option"), "{html}");
799 867 }
800 868
869 + #[test]
870 + fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
871 + let options = [Choice::new("sp404", "SP-404")];
872 + let f = Field {
873 + placeholder: Some("Select device..."),
874 + ..Field::select("device", "Conform for device", &options)
875 + };
876 + let html = field_html(&f, &Filling::default(), &Emit::default());
877 +
878 + assert!(
879 + html.contains("<option value=\"\" disabled selected>Select device...</option>"),
880 + "{html}"
881 + );
882 + // First, so the closed control reads it rather than the first real
883 + // option.
884 + assert!(
885 + html.find("Select device...") < html.find("SP-404"),
886 + "{html}"
887 + );
888 + }
889 +
890 + #[test]
891 + fn an_answered_select_drops_the_ghost_text() {
892 + // It is an instruction about an empty field, so it has nothing to say
893 + // once the field is answered, and leaving it in the list is one dead
894 + // row every time the control is opened afterwards.
895 + let options = [Choice::new("sp404", "SP-404")];
896 + let f = Field {
897 + placeholder: Some("Select device..."),
898 + ..Field::select("device", "Conform for device", &options)
899 + };
900 + let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
901 + assert!(!html.contains("Select device..."), "{html}");
902 + }
903 +
904 + #[test]
905 + fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
906 + // The two paths through `push_options` meet here. An unmatched value is
907 + // an answer that is wrong and stays visible as itself; only the empty
908 + // value is unanswered.
909 + let options = [Choice::plain("1"), Choice::plain("7")];
910 + let f = Field {
911 + placeholder: Some("Pick one"),
912 + ..Field::select("retention", "Keep backups for", &options)
913 + };
914 + let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
915 + assert!(html.contains("data-unmatched=\"true\""), "{html}");
916 + assert!(!html.contains("Pick one"), "{html}");
917 + }
918 +
919 + #[test]
920 + fn a_range_is_a_range_input_and_carries_its_extent() {
921 + let f = Field {
922 + step: Some("0.01"),
923 + ..Field::range("review", "Review above", "0", "1")
924 + };
925 + let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
926 + assert!(html.contains("type=\"range\""), "{html}");
927 + assert!(html.contains("min=\"0\""), "{html}");
928 + assert!(html.contains("max=\"1\""), "{html}");
929 + // Without it the browser steps by 1 and a 0-to-1 question becomes a
930 + // two-position control.
931 + assert!(html.contains("step=\"0.01\""), "{html}");
932 + }
933 +
934 + #[test]
935 + fn a_number_with_bounds_is_still_typed_into() {
936 + // The distinction the kind exists for, at the renderer where getting it
937 + // wrong is most visible: goingson's `min="1"` duration must not come
938 + // back as a slider.
939 + let f = Field {
940 + min: Some("1"),
941 + ..Field::new(FieldKind::Number, "minutes", "Minutes")
942 + };
943 + let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
944 + assert!(html.contains("type=\"number\""), "{html}");
945 + assert!(!html.contains("type=\"range\""), "{html}");
946 + // And nothing invents a step for it.
947 + assert!(!html.contains("step="), "{html}");
948 + }
949 +
950 + #[test]
951 + fn an_unavailable_option_is_disabled_and_says_why() {
952 + let options = [
953 + Choice::new("chromatic", "Chromatic"),
954 + Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
955 + ];
956 + let f = Field::radio("mode", "Mode", &options);
957 + let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
958 +
959 + assert!(html.contains(" disabled"), "{html}");
960 + assert!(html.contains("Drop a second sample."), "{html}");
961 + // The option is still offered: dropping it is what costs the user the
962 + // knowledge that the mode exists.
963 + assert!(html.contains("value=\"multi\""), "{html}");
964 + // And the reason is its own element, not run into the label.
965 + assert!(html.contains("form-option-reason"), "{html}");
966 + }
967 +
968 + #[test]
969 + fn an_unavailable_select_option_carries_its_reason_in_its_text() {
970 + // A `<select>` gives an option no room for a second element, so the
971 + // reason has to be in the text or be unreadable without a pointer.
972 + let options = [Choice::new("multi", "Multi-sample").unless("Drop a second sample.")];
973 + let f = Field::select("mode", "Mode", &options);
974 + let html = field_html(&f, &Filling::default(), &Emit::default());
975 + assert!(
976 + html.contains(">Multi-sample: Drop a second sample.</option>"),
977 + "{html}"
978 + );
979 + assert!(html.contains("disabled"), "{html}");
980 + }
981 +
801 982 #[test]
802 983 fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
803 984 // The association inverts, and getting it wrong is silent: a
@@ -878,10 +1059,10 @@
878 1059 fn a_radio_option_cannot_break_out_of_its_attribute() {
879 1060 // Values are `&str` and carry whatever the app put in them. The ids are
880 1061 // numbered rather than derived from the value for the same reason.
881 - let hostile = [Choice {
882 - value: "x\" onclick=alert(1) data-x=\"",
883 - label: "<script>alert(1)</script>",
884 - }];
1062 + let hostile = [Choice::new(
1063 + "x\" onclick=alert(1) data-x=\"",
1064 + "<script>alert(1)</script>",
1065 + )];
885 1066 let f = Field::radio("storage", "Storage style", &hostile);
886 1067 let html = field_html(&f, &Filling::default(), &Emit::default());
887 1068
M src/lib.rs +20
@@ -159,6 +159,26 @@
159 159 //! the same magnitude with its sign off the depth. Both values are the measured
160 160 //! consensus rather than a new opinion.
161 161 //!
162 + //! # 0.47.0: a range, a chooser's ghost text, and an option that cannot be
163 + //! picked yet
164 + //!
165 + //! `makeover-layout` 0.28.0's three form findings, all of them cheap here and
166 + //! none of them cheap in the app that found them.
167 + //!
168 + //! - `FieldKind::Range` emits `<input type="range">`, and `Field::step` emits
169 + //! `step`. The step is emitted only when the description carries one: the
170 + //! browser's own default is `step="1"`, which is what a description means by
171 + //! saying nothing, and is also what turns a 0-to-1 threshold into a
172 + //! two-position control.
173 + //! - A select with nothing chosen emits a disabled, selected, valueless first
174 + //! option carrying `Field::placeholder`. HTML has no placeholder attribute on
175 + //! `<select>`; this is the idiom, and `required` keeps working through it
176 + //! because the option's value is empty.
177 + //! - `Choice::unavailable` emits `disabled` plus the reason. Where it goes
178 + //! differs by control and the difference is forced: a radio group gets a
179 + //! `.form-option-reason` span beside the label, and a `<select>` option has
180 + //! room for no element at all, so the reason runs into its text.
181 + //!
162 182 //! # 0.25.0: a cell says what it holds
163 183 //!
164 184 //! 0.23.0 gave a table its layout and left every cell the same. One `.cell`