Skip to main content

max / makeover-webview

0.53.0: a host can write its own attributes onto the control quasi's suggestion source is the first caller: a field that owns a list of candidates is a role=combobox pointing at the list it owns, and neither half is anything makeover-layout can say. Markup's hole in the same wall, named the same way, and emitted last so a host cannot be overwritten by what this crate decided.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-21 13:50 UTC
Signed with PGP, not checked
Commit: e96ffb883a0e67ef03e3f97184c31cfe04b88824
Parent: 4bc15ff
3 files changed, +146 insertions, -9 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.52.0"
3 + version = "0.53.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
M src/form.rs +144 -8
@@ -41,7 +41,7 @@
41 41 //! write it back, at which point it is a form model.
42 42
43 43 use crate::{Emit, class, push_class};
44 - use makeover_layout::{Choice, Depth, Field, FieldKind, Selector};
44 + use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, State};
45 45 use std::fmt::Write as _;
46 46
47 47 /// A string that is already markup, and is emitted without escaping.
@@ -94,6 +94,26 @@
94 94 pub value: Value<'a>,
95 95 /// Markup appended inside the group, after the hint. Not escaped.
96 96 pub trailing: Option<Markup<'a>>,
97 + /// Attributes written onto the control element itself. Not escaped.
98 + ///
99 + /// [`trailing`](Self::trailing)'s argument at attribute scale: a host knows
100 + /// facts about the control that no description layer carries, and until
101 + /// this existed the only way to attach one was to stop calling this emitter
102 + /// and write a second one. quasi's suggestion source is the first caller —
103 + /// a field that owns a list of candidates is a `role="combobox"` pointing
104 + /// at the list it owns, and neither half is anything
105 + /// [`makeover_layout::Field`] can say.
106 + ///
107 + /// Written verbatim, so a caller supplies `attr="value"` pairs with no
108 + /// leading space and does its own escaping. It is [`Markup`]'s hole in the
109 + /// same wall, named the same way so a caller has to state that the contents
110 + /// are trusted.
111 + ///
112 + /// A [`FieldKind::Radio`] drops them, and that is deliberate rather than an
113 + /// oversight: a radio group is a set of sibling inputs with no one control
114 + /// element, so there is nowhere honest to put an attribute meant for the
115 + /// control. The group carries the descriptions for the same reason.
116 + pub control_attrs: Option<Markup<'a>>,
97 117 /// Scopes the `id` attributes to one instance of the form.
98 118 ///
99 119 /// The field's `name` is what the value submits under and is the same
@@ -115,6 +135,7 @@
115 135 Self {
116 136 value,
117 137 trailing: None,
138 + control_attrs: None,
118 139 id_prefix: None,
119 140 }
120 141 }
@@ -263,7 +284,13 @@
263 284 out.push('"');
264 285 }
265 286
266 - fn push_control_attributes(out: &mut String, field: &Field<'_>, id: &str, name: &str) {
287 + fn push_control_attributes(
288 + out: &mut String,
289 + field: &Field<'_>,
290 + filling: &Filling<'_>,
291 + id: &str,
292 + name: &str,
293 + ) {
267 294 let _ = write!(out, " id=\"{id}\" name=\"");
268 295 escape_into(name, out);
269 296 out.push('"');
@@ -301,6 +328,15 @@
301 328 }
302 329
303 330 push_described_by(out, field, id);
331 +
332 + // Last, so that a host attaching a fact of its own can see everything this
333 + // emitter decided and cannot be overwritten by it. Duplicate attributes are
334 + // the caller's to avoid: HTML takes the first of a repeated pair, so an
335 + // attribute spelled here as well as there keeps this crate's answer.
336 + if let Some(Markup(attrs)) = filling.control_attrs {
337 + out.push(' ');
338 + out.push_str(attrs);
339 + }
304 340 }
305 341
306 342 /// The `aria-describedby` naming whatever of the hint and the error exist.
@@ -521,7 +557,7 @@
521 557 if rich {
522 558 out.push_str(" data-format=\"markdown\"");
523 559 }
524 - push_control_attributes(out, field, &id, field.name);
560 + push_control_attributes(out, field, filling, &id, field.name);
525 561 placeholder(out);
526 562 out.push('>');
527 563 escape_into(filling.value.as_text(), out);
@@ -534,7 +570,7 @@
534 570 out.push_str("<select class=\"");
535 571 push_class(out, "field", opts);
536 572 out.push('"');
537 - push_control_attributes(out, field, &id, field.name);
573 + push_control_attributes(out, field, filling, &id, field.name);
538 574 out.push('>');
539 575 // A select described with no options emits an empty select, which
540 576 // says so on screen rather than in a log. That is the description's
@@ -546,7 +582,7 @@
546 582 out.push_str("<label class=\"");
547 583 push_class(out, "form-checkbox-label", opts);
548 584 out.push_str("\"><input type=\"checkbox\"");
549 - push_control_attributes(out, field, &id, field.name);
585 + push_control_attributes(out, field, filling, &id, field.name);
550 586 if matches!(filling.value, Value::On(true)) {
551 587 out.push_str(" checked");
552 588 }
@@ -564,7 +600,7 @@
564 600 out.push_str("<input type=\"password\" class=\"");
565 601 push_class(out, "field", opts);
566 602 out.push('"');
567 - push_control_attributes(out, field, &id, field.name);
603 + push_control_attributes(out, field, filling, &id, field.name);
568 604 placeholder(out);
569 605 out.push('>');
570 606 }
@@ -576,7 +612,7 @@
576 612 out.push_str("<input type=\"file\" class=\"");
577 613 push_class(out, "field", opts);
578 614 out.push('"');
579 - push_control_attributes(out, field, &id, field.name);
615 + push_control_attributes(out, field, filling, &id, field.name);
580 616 push_accept(out, field);
581 617 if field.multiple {
582 618 out.push_str(" multiple");
@@ -587,7 +623,7 @@
587 623 let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
588 624 push_class(out, "field", opts);
589 625 out.push('"');
590 - push_control_attributes(out, field, &id, field.name);
626 + push_control_attributes(out, field, filling, &id, field.name);
591 627 placeholder(out);
592 628 out.push_str(" value=\"");
593 629 escape_into(filling.value.as_text(), out);
@@ -743,6 +779,71 @@
743 779 css
744 780 }
745 781
782 + /// The rules a field's suggestion list needs.
783 + ///
784 + /// [`editor_rules`]' precedent and its argument: the class names this module's
785 + /// markup emits are the apps' own and stay unruled, and these three have no app
786 + /// counterpart to keep because the list did not exist before the member did.
787 + /// The markup is `quasi-webview`'s rather than this crate's — a suggestion
788 + /// source is a route, which no description layer carries — and the look is
789 + /// still this crate's, because a renderer inventing how a list of candidates
790 + /// reads is the drift the vocabulary check exists to catch.
791 + ///
792 + /// # In flow, and not floating
793 + ///
794 + /// An absolutely positioned list needs a positioned ancestor, and the only
795 + /// candidate is `.form-group`, which is the app's class and deliberately
796 + /// unruled here. So the list stands under the control and moves what is below
797 + /// it. An app that wants it over the form positions the group itself, which is
798 + /// one declaration and is the app's call about its own layout.
799 + ///
800 + /// `:empty` is what takes it away, so a route that answers with no candidates
801 + /// leaves no box behind. It is a content question rather than a whitespace one
802 + /// only because the emitter writes no whitespace inside the container, which is
803 + /// stated in `quasi-webview`'s own test.
804 + ///
805 + /// # Nothing about size
806 + ///
807 + /// No height, no scroll ceiling, no padding. How tall a list of candidates gets
808 + /// to be before it scrolls is a magnitude, and magnitudes are
809 + /// `makeover-geometry`'s, exactly as the preview pane's height is.
810 + pub(crate) fn suggestion_rules(opts: &Emit) -> String {
811 + let list = class("form-suggestions", opts);
812 + let entry = class("form-suggestion", opts);
813 + let why = class("form-suggestion-why", opts);
814 + let mut css = String::new();
815 +
816 + let _ = writeln!(css, ".{list}:empty {{\n display: none;\n}}");
817 + // Over what it covers, which is what a list of candidates is even in flow:
818 + // it is answering the box above it and goes away when the answer is taken.
819 + css.push_str(&crate::depth_rule(&list, Depth::Overlay));
820 + // An entry answers a click, so it gets every state one implies -- including
821 + // the disabled rule, which is what draws the candidate that cannot be
822 + // picked and is why `aria-disabled` is the mark rather than a class.
823 + css.push_str(&crate::interactive_rules(&entry, Depth::Flat, opts));
824 + // The keyboard's highlight and the pointer's are the same surface. They are
825 + // the same fact told two ways, and a list where arrowing and hovering look
826 + // different is a list that has two current entries.
827 + //
828 + // Keyed on `aria-selected` rather than on a class, for the reason
829 + // `aria-invalid` carries the error state: it is what a screen reader hears,
830 + // so a look keyed on it cannot drift from what is announced. A `.current`
831 + // class would also be a name apps already spell for their own reasons --
832 + // the MNW server has one -- and unlayered app CSS beats this layer in
833 + // silence.
834 + let _ = writeln!(
835 + css,
836 + ".{entry}[aria-selected=\"true\"] {{\n background: var(--hover-surface);\n}}"
837 + );
838 + let _ = writeln!(
839 + css,
840 + ".{why} {{\n color: var(--{});\n}}",
841 + State::Disabled.token()
842 + );
843 +
844 + css
845 + }
846 +
746 847 /// One field, as the group the app drops into its form.
747 848 ///
748 849 /// The shape is goingson's, down to the class names, so adoption there deletes
@@ -873,6 +974,40 @@
873 974 );
874 975 }
875 976
977 + /// The seam quasi's suggestion source needs: a host's own attributes land
978 + /// on the control, unescaped, and after everything this crate decided.
979 + #[test]
980 + fn a_host_can_write_its_own_attributes_onto_the_control() {
981 + let mut filling = Filling::of(Value::Text("ru"));
982 + filling.control_attrs = Some(Markup(
983 + r#"role="combobox" aria-expanded="false" aria-controls="title-suggestions""#,
984 + ));
985 + let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
986 + assert!(html.contains(r#"role="combobox""#), "{html}");
987 + assert!(
988 + html.contains(r#"aria-controls="title-suggestions""#),
989 + "{html}"
990 + );
991 + // After the id, which is what "last" buys: a host can read what this
992 + // emitter wrote and cannot be overwritten by it.
993 + let id = html.find(r#"id="title""#).expect("id");
994 + let role = html.find(r#"role="combobox""#).expect("role");
995 + assert!(id < role, "{html}");
996 + }
997 +
998 + /// A radio group has no one control element, so there is nowhere honest to
999 + /// put an attribute meant for the control. Documented on the member.
1000 + #[test]
1001 + fn a_radio_group_drops_control_attributes() {
1002 + let mut f = field(FieldKind::Radio);
1003 + let options = [Choice::new("a", "A")];
1004 + f.options = &options;
1005 + let mut filling = Filling::default();
1006 + filling.control_attrs = Some(Markup(r#"data-host="1""#));
1007 + let html = field_html(&f, &filling, &Emit::default());
1008 + assert!(!html.contains("data-host"), "{html}");
1009 + }
1010 +
876 1011 #[test]
877 1012 fn a_label_cannot_open_a_tag() {
878 1013 let mut f = field(FieldKind::Text);
@@ -948,6 +1083,7 @@
948 1083 let filling = Filling {
949 1084 value: Value::Text("one"),
950 1085 trailing: Some(Markup("<i>t</i>")),
1086 + control_attrs: Some(Markup(r#"data-host="1""#)),
951 1087 id_prefix: Some("modal"),
952 1088 };
953 1089 let mut streamed = String::new();
M src/lib.rs +1
@@ -1666,6 +1666,7 @@
1666 1666 css.push_str(&table_rules(opts));
1667 1667 css.push_str(&facet::facet_rules(opts));
1668 1668 css.push_str(&form::editor_rules(opts));
1669 + css.push_str(&form::suggestion_rules(opts));
1669 1670 css
1670 1671 }
1671 1672