| 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 |
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 |
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 |
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 |
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 |
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" 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
|