| 75 |
75 |
|
Text(&'a str),
|
| 76 |
76 |
|
/// A checkbox, on or off.
|
| 77 |
77 |
|
On(bool),
|
|
78 |
+ |
/// Both ends of a [`FieldKind::Interval`], lower first.
|
|
79 |
+ |
///
|
|
80 |
+ |
/// Two values rather than one string with a separator, for
|
|
81 |
+ |
/// [`makeover_layout::Field::upper_name`]'s reason one level down: an
|
|
82 |
+ |
/// interval submits under two names, so it comes back as two values, and a
|
|
83 |
+ |
/// delimiter this crate owned could appear inside either of them.
|
|
84 |
+ |
///
|
|
85 |
+ |
/// Either end may be empty while the other stands. "Over 120 BPM" is a
|
|
86 |
+ |
/// lower end and no upper one, and it is an answer rather than a
|
|
87 |
+ |
/// half-filled form.
|
|
88 |
+ |
///
|
|
89 |
+ |
/// Added 0.56.0 with makeover-layout 0.34.0.
|
|
90 |
+ |
Between {
|
|
91 |
+ |
/// What the lower box holds now.
|
|
92 |
+ |
lower: &'a str,
|
|
93 |
+ |
/// What the upper box holds now.
|
|
94 |
+ |
upper: &'a str,
|
|
95 |
+ |
},
|
| 78 |
96 |
|
}
|
| 79 |
97 |
|
|
| 80 |
98 |
|
impl<'a> Value<'a> {
|
| 81 |
99 |
|
/// The value as text, for the kinds that submit one.
|
| 82 |
100 |
|
const fn as_text(&self) -> &'a str {
|
| 83 |
101 |
|
match self {
|
| 84 |
|
- |
Self::Text(text) => text,
|
|
102 |
+ |
Self::Text(text) | Self::Between { lower: text, .. } => text,
|
| 85 |
103 |
|
Self::Absent | Self::On(_) => "",
|
| 86 |
104 |
|
}
|
| 87 |
105 |
|
}
|
| 88 |
106 |
|
}
|
| 89 |
107 |
|
|
|
108 |
+ |
impl<'a> Value<'a> {
|
|
109 |
+ |
/// The upper end, for the one variant that has one.
|
|
110 |
+ |
const fn upper_text(&self) -> &'a str {
|
|
111 |
+ |
match self {
|
|
112 |
+ |
Self::Between { upper, .. } => upper,
|
|
113 |
+ |
Self::Absent | Self::Text(_) | Self::On(_) => "",
|
|
114 |
+ |
}
|
|
115 |
+ |
}
|
|
116 |
+ |
}
|
|
117 |
+ |
|
| 90 |
118 |
|
/// Everything about the field that the description does not carry.
|
| 91 |
119 |
|
#[derive(Debug, Clone, Copy, Default)]
|
| 92 |
120 |
|
pub struct Filling<'a> {
|
| 284 |
312 |
|
out.push('"');
|
| 285 |
313 |
|
}
|
| 286 |
314 |
|
|
| 287 |
|
- |
fn push_control_attributes(
|
| 288 |
|
- |
out: &mut String,
|
| 289 |
|
- |
field: &Field<'_>,
|
| 290 |
|
- |
filling: &Filling<'_>,
|
| 291 |
|
- |
id: &str,
|
| 292 |
|
- |
name: &str,
|
| 293 |
|
- |
) {
|
| 294 |
|
- |
let _ = write!(out, " id=\"{id}\" name=\"");
|
| 295 |
|
- |
escape_into(name, out);
|
| 296 |
|
- |
out.push('"');
|
| 297 |
|
- |
if field.required {
|
| 298 |
|
- |
out.push_str(" required");
|
| 299 |
|
- |
}
|
| 300 |
|
- |
// makeover-layout 0.11.0's constraints. The description carries the rule and
|
| 301 |
|
- |
// this emits the browser's idiom for it, which is the model `required` has
|
| 302 |
|
- |
// been using since before the crate wrote down that it carried none.
|
| 303 |
|
- |
// Enforcement is still whoever validated's, and arrives back as `error`.
|
| 304 |
|
- |
if let Some(limit) = field.max_length {
|
| 305 |
|
- |
let _ = write!(out, " maxlength=\"{limit}\"");
|
| 306 |
|
- |
}
|
|
315 |
+ |
/// The extent and the granularity, as the browser spells them.
|
|
316 |
+ |
///
|
|
317 |
+ |
/// Its own function because an interval writes them onto both of its ends: they
|
|
318 |
+ |
/// describe the axis rather than either end of it, which is what
|
|
319 |
+ |
/// [`FieldKind::Interval`] says and what the six audiofiles filter axes are.
|
|
320 |
+ |
fn push_bounds(out: &mut String, field: &Field<'_>) {
|
| 307 |
321 |
|
if let Some(min) = field.min {
|
| 308 |
322 |
|
out.push_str(" min=\"");
|
| 309 |
323 |
|
escape_into(min, out);
|
| 332 |
346 |
|
escape_into(step, out);
|
| 333 |
347 |
|
out.push('"');
|
| 334 |
348 |
|
}
|
|
349 |
+ |
}
|
|
350 |
+ |
|
|
351 |
+ |
fn push_control_attributes(
|
|
352 |
+ |
out: &mut String,
|
|
353 |
+ |
field: &Field<'_>,
|
|
354 |
+ |
filling: &Filling<'_>,
|
|
355 |
+ |
id: &str,
|
|
356 |
+ |
name: &str,
|
|
357 |
+ |
) {
|
|
358 |
+ |
let _ = write!(out, " id=\"{id}\" name=\"");
|
|
359 |
+ |
escape_into(name, out);
|
|
360 |
+ |
out.push('"');
|
|
361 |
+ |
if field.required {
|
|
362 |
+ |
out.push_str(" required");
|
|
363 |
+ |
}
|
|
364 |
+ |
// makeover-layout 0.11.0's constraints. The description carries the rule and
|
|
365 |
+ |
// this emits the browser's idiom for it, which is the model `required` has
|
|
366 |
+ |
// been using since before the crate wrote down that it carried none.
|
|
367 |
+ |
// Enforcement is still whoever validated's, and arrives back as `error`.
|
|
368 |
+ |
if let Some(limit) = field.max_length {
|
|
369 |
+ |
let _ = write!(out, " maxlength=\"{limit}\"");
|
|
370 |
+ |
}
|
|
371 |
+ |
push_bounds(out, field);
|
| 335 |
372 |
|
if field.invalid() {
|
| 336 |
373 |
|
out.push_str(" aria-invalid=\"true\"");
|
| 337 |
374 |
|
}
|
| 406 |
443 |
|
/// association has to invert — the label takes an id and the group names itself
|
| 407 |
444 |
|
/// with `aria-labelledby`.
|
| 408 |
445 |
|
const fn is_group_control(kind: FieldKind) -> bool {
|
| 409 |
|
- |
matches!(kind, FieldKind::Radio)
|
|
446 |
+ |
matches!(kind, FieldKind::Radio | FieldKind::Interval)
|
|
447 |
+ |
}
|
|
448 |
+ |
|
|
449 |
+ |
/// An interval: two number boxes inside one labelled group.
|
|
450 |
+ |
///
|
|
451 |
+ |
/// The markup MNW's discover sidebar writes by hand -- a `role="group"` with
|
|
452 |
+ |
/// `aria-labelledby` pointing at the question, holding `min_price` and
|
|
453 |
+ |
/// `max_price` -- which is HTML saying by hand exactly what
|
|
454 |
+ |
/// [`FieldKind::Interval`] now says in the description. So this emits what that
|
|
455 |
+ |
/// page already proved is right, rather than inventing a shape.
|
|
456 |
+ |
///
|
|
457 |
+ |
/// The group carries the error state and the descriptions, for
|
|
458 |
+ |
/// [`push_radio`]'s reason: what is wrong is the answer, and marking one box
|
|
459 |
+ |
/// invalid would name the wrong half of a fault that belongs to both ends.
|
|
460 |
+ |
///
|
|
461 |
+ |
/// # Both boxes take the same extent
|
|
462 |
+ |
///
|
|
463 |
+ |
/// [`Field::min`], [`Field::max`] and [`Field::step`] describe the axis rather
|
|
464 |
+ |
/// than either end, so [`push_bounds`] writes them onto both. The crossing rule
|
|
465 |
+ |
/// is not emitted, because the description does not carry it and the browser
|
|
466 |
+ |
/// has no attribute for it: an upper end below the lower one is a refusal
|
|
467 |
+ |
/// whoever validated hands back as [`Field::error`], which lands on the group.
|
|
468 |
+ |
///
|
|
469 |
+ |
/// # Which end is which, in words
|
|
470 |
+ |
///
|
|
471 |
+ |
/// `aria-label`, because the description states direction structurally -- the
|
|
472 |
+ |
/// lower end's name is [`Field::name`] and the upper one's is
|
|
473 |
+ |
/// [`Field::upper_name`] -- and never in words. Words for the ends are the
|
|
474 |
+ |
/// host's, the same way a slider's readout is, and a page with visible Min and
|
|
475 |
+ |
/// Max captions supplies them through [`Filling::trailing`] rather than having
|
|
476 |
+ |
/// this crate own two strings of English.
|
|
477 |
+ |
fn push_interval(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
|
|
478 |
+ |
let id = filling.id_for(field.name);
|
|
479 |
+ |
|
|
480 |
+ |
out.push_str("<div class=\"");
|
|
481 |
+ |
push_class(out, "form-interval", opts);
|
|
482 |
+ |
let _ = write!(out, "\" role=\"group\" aria-labelledby=\"{id}-label\"");
|
|
483 |
+ |
if field.invalid() {
|
|
484 |
+ |
out.push_str(" aria-invalid=\"true\"");
|
|
485 |
+ |
}
|
|
486 |
+ |
push_described_by(out, field, &id);
|
|
487 |
+ |
out.push('>');
|
|
488 |
+ |
|
|
489 |
+ |
// An interval with no upper name has one end that can be submitted, which
|
|
490 |
+ |
// is what the description said and is drawn honestly rather than repaired:
|
|
491 |
+ |
// `Field::interval` is what makes it unsayable, and inventing a name here
|
|
492 |
+ |
// would submit a parameter no handler is reading.
|
|
493 |
+ |
let ends: [(&str, &str, &str); 2] = [
|
|
494 |
+ |
("lower", field.name, filling.value.as_text()),
|
|
495 |
+ |
(
|
|
496 |
+ |
"upper",
|
|
497 |
+ |
field.upper_name.unwrap_or(""),
|
|
498 |
+ |
filling.value.upper_text(),
|
|
499 |
+ |
),
|
|
500 |
+ |
];
|
|
501 |
+ |
for (end, name, value) in ends {
|
|
502 |
+ |
if name.is_empty() {
|
|
503 |
+ |
continue;
|
|
504 |
+ |
}
|
|
505 |
+ |
out.push_str("<input type=\"number\" class=\"");
|
|
506 |
+ |
push_class(out, "field", opts);
|
|
507 |
+ |
let _ = write!(out, "\" id=\"{id}-{end}\" name=\"");
|
|
508 |
+ |
escape_into(name, out);
|
|
509 |
+ |
let _ = write!(out, "\" aria-label=\"{end}\"");
|
|
510 |
+ |
if field.required {
|
|
511 |
+ |
out.push_str(" required");
|
|
512 |
+ |
}
|
|
513 |
+ |
push_bounds(out, field);
|
|
514 |
+ |
if let Some(text) = field.placeholder {
|
|
515 |
+ |
out.push_str(" placeholder=\"");
|
|
516 |
+ |
escape_into(text, out);
|
|
517 |
+ |
out.push('"');
|
|
518 |
+ |
}
|
|
519 |
+ |
out.push_str(" value=\"");
|
|
520 |
+ |
escape_into(value, out);
|
|
521 |
+ |
out.push_str("\">");
|
|
522 |
+ |
}
|
|
523 |
+ |
|
|
524 |
+ |
out.push_str("</div>");
|
| 410 |
525 |
|
}
|
| 411 |
526 |
|
|
| 412 |
527 |
|
/// A radio group: the options as sibling inputs sharing one `name`.
|
| 556 |
671 |
|
push_radio(out, field, filling, opts);
|
| 557 |
672 |
|
return;
|
| 558 |
673 |
|
}
|
|
674 |
+ |
// The same split one kind along: an interval is two inputs and one
|
|
675 |
+ |
// question, so the group carries the error and the descriptions and the
|
|
676 |
+ |
// boxes carry what submits.
|
|
677 |
+ |
if matches!(field.kind, FieldKind::Interval) {
|
|
678 |
+ |
push_interval(out, field, filling, opts);
|
|
679 |
+ |
return;
|
|
680 |
+ |
}
|
| 559 |
681 |
|
|
| 560 |
682 |
|
let id = filling.id_for(field.name);
|
| 561 |
683 |
|
let placeholder = |out: &mut String| {
|
| 1494 |
1616 |
|
assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
|
| 1495 |
1617 |
|
}
|
| 1496 |
1618 |
|
|
|
1619 |
+ |
#[test]
|
|
1620 |
+ |
fn an_interval_is_one_labelled_group_holding_both_ends() {
|
|
1621 |
+ |
// The markup MNW's discover sidebar writes by hand, which is the
|
|
1622 |
+ |
// measurement that decided the member: `role="group"` naming the
|
|
1623 |
+ |
// question, two number boxes under it.
|
|
1624 |
+ |
let f = Field::interval("min_price", "max_price", "Price");
|
|
1625 |
+ |
let html = field_html(
|
|
1626 |
+ |
&f,
|
|
1627 |
+ |
&Filling::of(Value::Between {
|
|
1628 |
+ |
lower: "5",
|
|
1629 |
+ |
upper: "40",
|
|
1630 |
+ |
}),
|
|
1631 |
+ |
&Emit::default(),
|
|
1632 |
+ |
);
|
|
1633 |
+ |
|
|
1634 |
+ |
assert!(html.contains("role=\"group\""), "{html}");
|
|
1635 |
+ |
assert!(
|
|
1636 |
+ |
html.contains("aria-labelledby=\"min_price-label\""),
|
|
1637 |
+ |
"{html}"
|
|
1638 |
+ |
);
|
|
1639 |
+ |
assert!(html.contains("id=\"min_price-label\""), "{html}");
|
|
1640 |
+ |
assert!(!html.contains("for=\"min_price\""), "{html}");
|
|
1641 |
+ |
assert!(html.contains("name=\"min_price\""), "{html}");
|
|
1642 |
+ |
assert!(html.contains("name=\"max_price\""), "{html}");
|
|
1643 |
+ |
assert!(html.contains("value=\"5\""), "{html}");
|
|
1644 |
+ |
assert!(html.contains("value=\"40\""), "{html}");
|
|
1645 |
+ |
assert_eq!(html.matches("type=\"number\"").count(), 2, "{html}");
|
|
1646 |
+ |
}
|
|
1647 |
+ |
|
|
1648 |
+ |
#[test]
|
|
1649 |
+ |
fn both_ends_of_an_interval_take_the_whole_extent() {
|
|
1650 |
+ |
// The extent describes the axis rather than either end of it, so a
|
|
1651 |
+ |
// browser refuses the same values in both boxes.
|
|
1652 |
+ |
let f = Field {
|
|
1653 |
+ |
min: Some("0"),
|
|
1654 |
+ |
max: Some("300"),
|
|
1655 |
+ |
step: Some("1"),
|
|
1656 |
+ |
..Field::interval("bpm_min", "bpm_max", "BPM")
|
|
1657 |
+ |
};
|
|
1658 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
1659 |
+ |
|
|
1660 |
+ |
assert_eq!(html.matches("min=\"0\"").count(), 2, "{html}");
|
|
1661 |
+ |
assert_eq!(html.matches("max=\"300\"").count(), 2, "{html}");
|
|
1662 |
+ |
assert_eq!(html.matches("step=\"1\"").count(), 2, "{html}");
|
|
1663 |
+ |
// Neither box holds anything, which is the open interval rather than an
|
|
1664 |
+ |
// empty form: no filter on this axis at all.
|
|
1665 |
+ |
assert_eq!(html.matches("value=\"\"").count(), 2, "{html}");
|
|
1666 |
+ |
}
|
|
1667 |
+ |
|
|
1668 |
+ |
#[test]
|
|
1669 |
+ |
fn an_interval_carries_the_fault_on_the_group_and_not_on_one_end() {
|
|
1670 |
+ |
// A crossed interval is wrong about the answer, and the answer is the
|
|
1671 |
+ |
// pair. This is the half two `Number` fields could not say.
|
|
1672 |
+ |
let f = Field {
|
|
1673 |
+ |
error: Some("The high end is below the low one."),
|
|
1674 |
+ |
hint: Some("Leave an end empty for no bound."),
|
|
1675 |
+ |
..Field::interval("bpm_min", "bpm_max", "BPM")
|
|
1676 |
+ |
};
|
|
1677 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
1678 |
+ |
|
|
1679 |
+ |
assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
|
|
1680 |
+ |
let group = html.find("role=\"group\"").expect("group");
|
|
1681 |
+ |
let invalid = html.find("aria-invalid").expect("invalid");
|
|
1682 |
+ |
let first_input = html.find("<input").expect("input");
|
|
1683 |
+ |
assert!(invalid > group && invalid < first_input, "{html}");
|
|
1684 |
+ |
assert!(
|
|
1685 |
+ |
html.contains("aria-describedby=\"bpm_min-hint bpm_min-error\""),
|
|
1686 |
+ |
"{html}"
|
|
1687 |
+ |
);
|
|
1688 |
+ |
}
|
|
1689 |
+ |
|
|
1690 |
+ |
#[test]
|
|
1691 |
+ |
fn an_interval_with_one_end_named_draws_one_box() {
|
|
1692 |
+ |
// Drawn as described rather than repaired. Inventing a name for the
|
|
1693 |
+ |
// upper end would submit a parameter no handler reads, and
|
|
1694 |
+ |
// `Field::interval` is what makes the omission unsayable at the source.
|
|
1695 |
+ |
let f = Field::new(FieldKind::Interval, "bpm_min", "BPM");
|
|
1696 |
+ |
let html = field_html(&f, &Filling::default(), &Emit::default());
|
|
1697 |
+ |
|
|
1698 |
+ |
assert_eq!(html.matches("<input").count(), 1, "{html}");
|
|
1699 |
+ |
assert!(html.contains("name=\"bpm_min\""), "{html}");
|
|
1700 |
+ |
}
|
|
1701 |
+ |
|
| 1497 |
1702 |
|
#[test]
|
| 1498 |
1703 |
|
fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
|
| 1499 |
1704 |
|
// One `name` is what makes them one answer rather than three; distinct
|