Skip to main content

max / makeover-webview

Honour an option that says it is chosen A select marks an option that carries `Choice::chosen` or whose value matches the field's, and a description uses one of the two. This crate cannot refuse the pair: it is handed a list and a value with no record of which spelling built them, so a caller that sets both gets both marked and quasi-declare is where that is caught. The placeholder and the stray-option paths key on the value alone, so a list that marks itself has an empty value and reaches neither.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 13:40 UTC
Signed with PGP, not checked
Commit: a260211b350b4711c4642c5857a31cd87726c894
Parent: 2dae673
3 files changed, +46 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.74.0"
3 + version = "0.74.1"
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
M src/form.rs +16 -1
@@ -679,6 +679,10 @@
679 679 /// The options of a select: the unanswered instruction, an unmatched current
680 680 /// value carried as its own, then the options themselves.
681 681 ///
682 + /// An option is marked either by [`Choice::chosen`] or by carrying the field's
683 + /// current value; the stray-option and placeholder paths below key on the value
684 + /// alone, so a list that marks itself has an empty value and reaches neither.
685 + ///
682 686 /// A select handed a value no option carries renders with nothing selected, the
683 687 /// browser falls back to the first option, and the next save writes a value
684 688 /// nobody chose. goingson hit exactly that with a backup-retention default of
@@ -720,7 +724,18 @@
720 724 out.push_str("<option value=\"");
721 725 escape_into(opt.value, out);
722 726 out.push('"');
723 - if opt.value == value {
727 + // Two ways an option is the marked one, and a description uses one of
728 + // them: the option says so itself, or the field's value names it. See
729 + // [`makeover_layout::Choice::chosen`] for why both exist and why this
730 + // crate cannot refuse the pair -- a caller that sets both gets both
731 + // marked, and quasi-declare is where that is caught.
732 + //
733 + // A `placeholder` is unaffected and still rides on an empty value: it
734 + // is emitted `selected` to show the unanswered state, and a list whose
735 + // own option is chosen leaves two options selected, which HTML resolves
736 + // to the last one in tree order. That is the chosen option, since the
737 + // placeholder is emitted first.
738 + if opt.chosen || opt.value == value {
724 739 out.push_str(" selected");
725 740 }
726 741 // `disabled` is what the browser reads, and it says nothing about why.
@@ -294,6 +294,35 @@
294 294 assert!(html.contains("<option value=\"10\" selected"), "{html}");
295 295 }
296 296
297 + #[test]
298 + fn a_select_marks_the_option_that_says_it_is_chosen() {
299 + // The option list knows per row, so the field carries no value at all.
300 + // Comparing a value against each option is the other spelling and this
301 + // test is the one that says they are separate paths.
302 + let options = [
303 + Choice::plain("dark"),
304 + Choice::plain("light").chosen(),
305 + Choice::plain("paper"),
306 + ];
307 + let f = Field::select("title", "Title", &options);
308 + let html = field_html(&f, &Filling::of(Value::Text("")), &Emit::default());
309 + assert!(html.contains("<option value=\"light\" selected"), "{html}");
310 + assert!(html.contains("<option value=\"dark\">"), "{html}");
311 + assert!(html.contains("<option value=\"paper\">"), "{html}");
312 + // The empty value reaches neither the stray-option path nor a placeholder.
313 + assert!(!html.contains("data-unmatched"), "{html}");
314 + }
315 +
316 + #[test]
317 + fn a_select_whose_options_mark_none_marks_none() {
318 + // The other half of the branch, and the shape a residual holds: the same
319 + // compiled row body with the guard not passing.
320 + let options = [Choice::plain("dark"), Choice::plain("light")];
321 + let f = Field::select("title", "Title", &options);
322 + let html = field_html(&f, &Filling::of(Value::Text("")), &Emit::default());
323 + assert!(!html.contains("selected"), "{html}");
324 + }
325 +
297 326 #[test]
298 327 fn a_select_with_no_options_emits_an_empty_select() {
299 328 // The description says a select with no options is sayable, because an