| 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 |
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 |
135 |
|
Self {
|
| 116 |
136 |
|
value,
|
| 117 |
137 |
|
trailing: None,
|
|
138 |
+ |
control_attrs: None,
|
| 118 |
139 |
|
id_prefix: None,
|
| 119 |
140 |
|
}
|
| 120 |
141 |
|
}
|
| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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();
|