Skip to main content

max / makeover-webview

0.16.1: emit FieldKind::Radio The group carries role=radiogroup, the error state and the descriptions; the inputs carry what submits. That split is Field::invalid's reasoning one level down -- what is wrong is the answer, not one of the alternatives, so marking a single input invalid would say something false. The label association inverts. A <label for> aimed at a group points at no element, so the group has no accessible name and nothing reports that; the label takes an id and the group names itself with aria-labelledby. Decided here rather than in makeover-layout because for/id is an HTML association and egui has no counterpart to get wrong. Option ids are numbered rather than derived from option values, which can hold anything a &str can, and would otherwise need a uniqueness rule this crate would then own. required lands on every input, which is how HTML says a group is compulsory. aria-describedby moves into its own function, since the group carries it on the group rather than on a control and one reading of it is the point.
Author: Max Johnson <me@maxj.phd> · 2026-08-05 20:00 UTC
Signed with PGP, not checked
Commit: a7713fc827dfd03e93896054d9db0b01a59ac0b0
Parent: b1161c1
2 files changed, +195 insertions, -9 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.16.0"
3 + version = "0.16.1"
4 4 edition = "2024"
5 5 description = "The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser."
6 6 license = "MIT"
M src/form.rs +194 -8
@@ -165,7 +165,10 @@
165 165 FieldKind::Email => "email",
166 166 FieldKind::Url => "url",
167 167 FieldKind::Tel => "tel",
168 + FieldKind::Radio => "radio",
168 169 // Select and Textarea are not inputs at all; they never reach here.
170 + // Radio is one, but it is emitted once per option by `radio_html` and
171 + // so does not reach here either.
169 172 FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
170 173 // A kind added to the description since this renderer was built. Text
171 174 // accepts any value the others would, so it degrades rather than
@@ -195,10 +198,20 @@
195 198 attrs.push_str(" aria-invalid=\"true\"");
196 199 }
197 200
198 - // Both associations, in the order they are useful: the standing help, then
199 - // what is currently wrong. goingson's runtime path points describedby at
200 - // the error alone and drops the hint association it never made in the first
201 - // place; naming both here means the hint survives an error appearing.
201 + attrs.push_str(&described_by(field, id));
202 + attrs
203 + }
204 +
205 + /// The `aria-describedby` naming whatever of the hint and the error exist.
206 + ///
207 + /// Both associations, in the order they are useful: the standing help, then
208 + /// what is currently wrong. goingson's runtime path points describedby at the
209 + /// error alone and drops the hint association it never made in the first place;
210 + /// naming both here means the hint survives an error appearing.
211 + ///
212 + /// Its own function because a radio group carries it on the group rather than
213 + /// on a control, and one reading of "what describes this field" is the point.
214 + fn described_by(field: &Field<'_>, id: &str) -> String {
202 215 let mut described = Vec::new();
203 216 if field.hint.is_some() {
204 217 described.push(format!("{id}-hint"));
@@ -206,10 +219,71 @@
206 219 if field.error.is_some() {
207 220 described.push(format!("{id}-error"));
208 221 }
209 - if !described.is_empty() {
210 - let _ = write!(attrs, " aria-describedby=\"{}\"", described.join(" "));
222 + if described.is_empty() {
223 + return String::new();
211 224 }
212 - attrs
225 + format!(" aria-describedby=\"{}\"", described.join(" "))
226 + }
227 +
228 + /// Whether the field's control is a set of elements rather than one.
229 + ///
230 + /// A DOM concern rather than a description one, which is why it is decided here
231 + /// and not in `makeover-layout`: `for` and `id` are an HTML association and
232 + /// egui has no counterpart to get wrong. A `<label for>` aimed at a radio group
233 + /// points at nothing, because no single element carries the group's id, so the
234 + /// association has to invert — the label takes an id and the group names itself
235 + /// with `aria-labelledby`.
236 + const fn is_group_control(kind: FieldKind) -> bool {
237 + matches!(kind, FieldKind::Radio)
238 + }
239 +
240 + /// A radio group: the options as sibling inputs sharing one `name`.
241 + ///
242 + /// The group carries the error state and the descriptions, and the inputs carry
243 + /// what submits. That split is [`Field::invalid`]'s reasoning applied one level
244 + /// down: marking a single input invalid would say the wrong thing, since what
245 + /// is wrong is the answer to the question and not one of the alternatives.
246 + ///
247 + /// Ids are numbered rather than built from the option values, which can hold
248 + /// anything a `&str` can — spaces and quotes included — and would otherwise
249 + /// have to be slugged into something unique by a rule this crate would then own.
250 + ///
251 + /// `required` lands on every input, which is how HTML says a group is
252 + /// compulsory: the constraint is satisfied when any one of them is checked.
253 + fn radio_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
254 + let id = filling.id_for(field.name);
255 + let value = filling.value.as_text();
256 + let name = escape(field.name);
257 +
258 + let mut html = format!(
259 + "<div class=\"{}\" role=\"radiogroup\"",
260 + class("form-radio-group", opts)
261 + );
262 + let _ = write!(html, " aria-labelledby=\"{id}-label\"");
263 + if field.invalid() {
264 + html.push_str(" aria-invalid=\"true\"");
265 + }
266 + html.push_str(&described_by(field, &id));
267 + html.push('>');
268 +
269 + // A group described with no options emits an empty group, for the reason
270 + // `Field::options` gives: an app whose option list has not loaded has
271 + // exactly that, and an empty group says so on screen rather than in a log.
272 + for (index, opt) in field.options.iter().enumerate() {
273 + let checked = if opt.value == value { " checked" } else { "" };
274 + let required = if field.required { " required" } else { "" };
275 + let _ = write!(
276 + html,
277 + "<label class=\"{}\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" \
278 + value=\"{}\"{checked}{required}><span>{}</span></label>",
279 + class("form-radio-label", opts),
280 + escape(opt.value),
281 + escape(opt.label)
282 + );
283 + }
284 +
285 + html.push_str("</div>");
286 + html
213 287 }
214 288
215 289 /// The options of a select, with an unmatched current value carried as its own.
@@ -250,6 +324,7 @@
250 324 });
251 325
252 326 match field.kind {
327 + FieldKind::Radio => radio_html(field, filling, opts),
253 328 FieldKind::Textarea => format!(
254 329 "<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
255 330 escape(filling.value.as_text())
@@ -346,9 +421,17 @@
346 421 // this inline today, which is the tell that it belongs in the description;
347 422 // `FieldKind::labels_itself` is where it went.
348 423 if !field.kind.labels_itself() {
424 + // A group control is named *by* its label rather than pointing at it,
425 + // so the two carry opposite halves of the association. See
426 + // `is_group_control`.
427 + let association = if is_group_control(field.kind) {
428 + format!(" id=\"{id}-label\"")
429 + } else {
430 + format!(" for=\"{id}\"")
431 + };
349 432 let _ = write!(
350 433 html,
351 - "<label class=\"{}\" for=\"{id}\">{}</label>",
434 + "<label class=\"{}\"{association}>{}</label>",
352 435 class("form-label", opts),
353 436 escape(field.label)
354 437 );
@@ -519,6 +602,109 @@
519 602 assert!(!html.contains("<option"), "{html}");
520 603 }
521 604
605 + #[test]
606 + fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
607 + // The association inverts, and getting it wrong is silent: a
608 + // `<label for>` aimed at a group points at no element, so the group
609 + // simply has no accessible name and nothing reports that.
610 + let options = [Choice::plain("copy"), Choice::plain("reference")];
611 + let f = Field::radio("storage", "Storage style", &options);
612 + let html = field_html(&f, &Filling::of(Value::Text("copy")), &Emit::default());
613 +
614 + assert!(html.contains("id=\"storage-label\""), "{html}");
615 + assert!(!html.contains("for=\"storage\""), "{html}");
616 + assert!(html.contains("role=\"radiogroup\""), "{html}");
617 + assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
618 + }
619 +
620 + #[test]
621 + fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
622 + // One `name` is what makes them one answer rather than three; distinct
623 + // ids are what keep each `<label>` wrapping its own input.
624 + let options = [
625 + Choice::plain("copy"),
626 + Choice::plain("reference"),
627 + Choice::plain("link"),
628 + ];
629 + let f = Field::radio("storage", "Storage style", &options);
630 + let html = field_html(&f, &Filling::of(Value::Text("reference")), &Emit::default());
631 +
632 + assert_eq!(html.matches("name=\"storage\"").count(), 3, "{html}");
633 + assert_eq!(html.matches(" checked").count(), 1, "{html}");
634 + assert!(
635 + html.contains("value=\"reference\" checked"),
636 + "the checked one is the one held: {html}"
637 + );
638 + for index in 0..3 {
639 + assert!(html.contains(&format!("id=\"storage-{index}\"")), "{html}");
640 + }
641 + }
642 +
643 + #[test]
644 + fn a_radio_group_carries_the_error_rather_than_any_one_option() {
645 + // What is wrong is the answer, not one of the alternatives, so marking
646 + // a single input invalid would say something false. Same reading
647 + // `Field::invalid` gives one level up.
648 + let options = [Choice::plain("copy"), Choice::plain("reference")];
649 + let f = Field {
650 + error: Some("Pick one."),
651 + hint: Some("Cannot be changed later."),
652 + ..Field::radio("storage", "Storage style", &options)
653 + };
654 + let html = field_html(&f, &Filling::default(), &Emit::default());
655 +
656 + assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
657 + assert!(
658 + html.contains("aria-describedby=\"storage-hint storage-error\""),
659 + "{html}"
660 + );
661 + // The group is the element that carries them, so they land before the
662 + // first option rather than on it.
663 + let group = html.find("role=\"radiogroup\"").expect("group");
664 + let first = html.find("type=\"radio\"").expect("an option");
665 + assert!(group < first, "{html}");
666 + }
667 +
668 + #[test]
669 + fn a_compulsory_radio_group_marks_every_option() {
670 + // How HTML says a group is compulsory: the constraint reads as
671 + // satisfied when any one of them is checked.
672 + let options = [Choice::plain("copy"), Choice::plain("reference")];
673 + let f = Field {
674 + required: true,
675 + ..Field::radio("storage", "Storage style", &options)
676 + };
677 + let html = field_html(&f, &Filling::default(), &Emit::default());
678 + assert_eq!(html.matches(" required").count(), 2, "{html}");
679 + }
680 +
681 + #[test]
682 + fn a_radio_option_cannot_break_out_of_its_attribute() {
683 + // Values are `&str` and carry whatever the app put in them. The ids are
684 + // numbered rather than derived from the value for the same reason.
685 + let hostile = [Choice {
686 + value: "x\" onclick=alert(1) data-x=\"",
687 + label: "<script>alert(1)</script>",
688 + }];
689 + let f = Field::radio("storage", "Storage style", &hostile);
690 + let html = field_html(&f, &Filling::default(), &Emit::default());
691 +
692 + // The payload survives as text; what must not survive is the quote
693 + // that would end the attribute and let the rest of it become markup.
694 + assert!(html.contains("value=\"x&quot; onclick=alert(1)"), "{html}");
695 + assert!(!html.contains("<script>"), "{html}");
696 + assert!(html.contains("id=\"storage-0\""), "{html}");
697 + }
698 +
699 + #[test]
700 + fn a_radio_group_with_no_options_emits_an_empty_group() {
701 + // Same position the select takes, and the description's own.
702 + let f = Field::radio("storage", "Storage style", &[]);
703 + let html = field_html(&f, &Filling::default(), &Emit::default());
704 + assert!(html.contains("role=\"radiogroup\""), "{html}");
705 + assert!(!html.contains("type=\"radio\""), "{html}");
706 + }
707 +
522 708 #[test]
523 709 fn a_placeholder_comes_off_the_description_and_is_escaped() {
524 710 // It arrived in `Filling` until makeover-layout 0.8.0 and was never