| 358 |
358 |
|
/// Its own function because a radio group carries it on the group rather than
|
| 359 |
359 |
|
/// on a control, and one reading of "what describes this field" is the point.
|
| 360 |
360 |
|
fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
|
| 361 |
|
- |
if field.hint.is_none() && field.error.is_none() {
|
|
361 |
+ |
let unit = unit_of(field).is_some();
|
|
362 |
+ |
if field.hint.is_none() && field.error.is_none() && !unit {
|
| 362 |
363 |
|
return;
|
| 363 |
364 |
|
}
|
|
365 |
+ |
let mut written = false;
|
| 364 |
366 |
|
out.push_str(" aria-describedby=\"");
|
| 365 |
367 |
|
if field.hint.is_some() {
|
| 366 |
368 |
|
let _ = write!(out, "{id}-hint");
|
|
369 |
+ |
written = true;
|
|
370 |
+ |
}
|
|
371 |
+ |
// The unit before the error and after the hint, which is the order they are
|
|
372 |
+ |
// useful in: what the number is measured in is standing context like the
|
|
373 |
+ |
// hint, and what is wrong with it now comes last.
|
|
374 |
+ |
if unit {
|
|
375 |
+ |
if written {
|
|
376 |
+ |
out.push(' ');
|
|
377 |
+ |
}
|
|
378 |
+ |
let _ = write!(out, "{id}-unit");
|
|
379 |
+ |
written = true;
|
| 367 |
380 |
|
}
|
| 368 |
381 |
|
if field.error.is_some() {
|
| 369 |
|
- |
if field.hint.is_some() {
|
|
382 |
+ |
if written {
|
| 370 |
383 |
|
out.push(' ');
|
| 371 |
384 |
|
}
|
| 372 |
385 |
|
let _ = write!(out, "{id}-error");
|
| 374 |
387 |
|
out.push('"');
|
| 375 |
388 |
|
}
|
| 376 |
389 |
|
|
|
390 |
+ |
/// The unit to draw beside this field's value, if there is one to draw.
|
|
391 |
+ |
///
|
|
392 |
+ |
/// Two conditions rather than one: the field has to carry a unit and its kind
|
|
393 |
+ |
/// has to be one that means anything by it. `FieldKind::measurable` is the
|
|
394 |
+ |
/// description answering the second, so this renderer keeps no list of its own
|
|
395 |
+ |
/// of which kinds are quantities.
|
|
396 |
+ |
fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
|
|
397 |
+ |
field.unit.filter(|_| field.kind.measurable())
|
|
398 |
+ |
}
|
|
399 |
+ |
|
| 377 |
400 |
|
/// Whether the field's control is a set of elements rather than one.
|
| 378 |
401 |
|
///
|
| 379 |
402 |
|
/// A DOM concern rather than a description one, which is why it is decided here
|
| 788 |
811 |
|
css
|
| 789 |
812 |
|
}
|
| 790 |
813 |
|
|
|
814 |
+ |
/// The rule a field's unit needs.
|
|
815 |
+ |
///
|
|
816 |
+ |
/// [`suggestion_rules`]' precedent and its argument: `.form-group`,
|
|
817 |
+ |
/// `.form-label`, `.form-hint` and `.form-error` are the apps' own names and
|
|
818 |
+ |
/// stay unruled here, and this one has no app counterpart to keep because
|
|
819 |
+ |
/// nothing emitted it before `Field::unit` existed.
|
|
820 |
+ |
///
|
|
821 |
+ |
/// One declaration, and it is the whole look. A unit is a fact about the number
|
|
822 |
+ |
/// beside it rather than a second thing to read, so it takes the muted content
|
|
823 |
+ |
/// intent -- the same reading `.figure-caption` and `.track-tick` take, and for
|
|
824 |
+ |
/// the same reason.
|
|
825 |
+ |
///
|
|
826 |
+ |
/// Nothing about placement or spacing. Where the span sits relative to the
|
|
827 |
+ |
/// control is the app's layout, exactly as `.form-hint`'s is, and a margin
|
|
828 |
+ |
/// asserted here would be this crate deciding a magnitude that belongs to
|
|
829 |
+ |
/// `makeover-geometry`.
|
|
830 |
+ |
pub(crate) fn unit_rules(opts: &Emit) -> String {
|
|
831 |
+ |
let unit = class("form-unit", opts);
|
|
832 |
+ |
let mut css = String::new();
|
|
833 |
+ |
let _ = writeln!(css, ".{unit} {{\n color: var(--content-muted);\n}}");
|
|
834 |
+ |
css
|
|
835 |
+ |
}
|
|
836 |
+ |
|
| 791 |
837 |
|
/// The rules a field's suggestion list needs.
|
| 792 |
838 |
|
///
|
| 793 |
839 |
|
/// [`editor_rules`]' precedent and its argument: the class names this module's
|
| 938 |
984 |
|
|
| 939 |
985 |
|
push_control(out, field, filling, opts);
|
| 940 |
986 |
|
|
|
987 |
+ |
// Adjacent text, because HTML has no unit attribute and inventing one would
|
|
988 |
+ |
// be markup nothing reads. Pointed at by `aria-describedby` so it is not
|
|
989 |
+ |
// decoration a screen reader skips: the number and what it is measured in
|
|
990 |
+ |
// are one fact, and reading the first without the second is reading it
|
|
991 |
+ |
// wrong.
|
|
992 |
+ |
if let Some(unit) = unit_of(field) {
|
|
993 |
+ |
out.push_str("<span class=\"");
|
|
994 |
+ |
push_class(out, "form-unit", opts);
|
|
995 |
+ |
let _ = write!(out, "\" id=\"{id}-unit\">");
|
|
996 |
+ |
escape_into(unit, out);
|
|
997 |
+ |
out.push_str("</span>");
|
|
998 |
+ |
}
|
|
999 |
+ |
|
| 941 |
1000 |
|
if let Some(hint) = field.hint {
|
| 942 |
1001 |
|
out.push_str("<div class=\"");
|
| 943 |
1002 |
|
push_class(out, "form-hint", opts);
|
| 1294 |
1353 |
|
assert!(!html.contains("step="), "{html}");
|
| 1295 |
1354 |
|
}
|
| 1296 |
1355 |
|
|
|
1356 |
+ |
#[test]
|
|
1357 |
+ |
fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
|
|
1358 |
+ |
// Not decoration: the number and what it is measured in are one fact,
|
|
1359 |
+ |
// so the association is what makes this worth emitting at all.
|
|
1360 |
+ |
let f = Field {
|
|
1361 |
+ |
unit: Some("dBFS"),
|
|
1362 |
+ |
..Field::range("threshold", "Threshold", "-96", "-20")
|
|
1363 |
+ |
};
|
|
1364 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
|
|
1365 |
+ |
assert!(html.contains(r#"id="threshold-unit""#), "{html}");
|
|
1366 |
+ |
assert!(html.contains(">dBFS</span>"), "{html}");
|
|
1367 |
+ |
assert!(
|
|
1368 |
+ |
html.contains(r#"aria-describedby="threshold-unit""#),
|
|
1369 |
+ |
"{html}"
|
|
1370 |
+ |
);
|
|
1371 |
+ |
// The label is the question's name and keeps no unit in it.
|
|
1372 |
+ |
assert!(html.contains(">Threshold</label>"), "{html}");
|
|
1373 |
+ |
}
|
|
1374 |
+ |
|
|
1375 |
+ |
#[test]
|
|
1376 |
+ |
fn a_unit_takes_its_place_between_the_hint_and_the_error() {
|
|
1377 |
+ |
let f = Field {
|
|
1378 |
+ |
unit: Some("ms"),
|
|
1379 |
+ |
hint: Some("How long the fade runs."),
|
|
1380 |
+ |
error: Some("Too long."),
|
|
1381 |
+ |
..Field::new(FieldKind::Number, "fade", "Fade")
|
|
1382 |
+ |
};
|
|
1383 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
|
|
1384 |
+ |
assert!(
|
|
1385 |
+ |
html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
|
|
1386 |
+ |
"{html}"
|
|
1387 |
+ |
);
|
|
1388 |
+ |
}
|
|
1389 |
+ |
|
|
1390 |
+ |
#[test]
|
|
1391 |
+ |
fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
|
|
1392 |
+ |
// Sayable and ignored, the way `options` is on a kind that offers none.
|
|
1393 |
+ |
// The renderer asks the description which kinds are measurable rather
|
|
1394 |
+ |
// than keeping its own list.
|
|
1395 |
+ |
let f = Field {
|
|
1396 |
+ |
unit: Some("s"),
|
|
1397 |
+ |
..Field::new(FieldKind::Text, "name", "Name")
|
|
1398 |
+ |
};
|
|
1399 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
|
|
1400 |
+ |
assert!(!html.contains("name-unit"), "{html}");
|
|
1401 |
+ |
assert!(!html.contains("aria-describedby"), "{html}");
|
|
1402 |
+ |
}
|
|
1403 |
+ |
|
|
1404 |
+ |
#[test]
|
|
1405 |
+ |
fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
|
|
1406 |
+ |
let f = Field {
|
|
1407 |
+ |
unit: Some("</span><script>"),
|
|
1408 |
+ |
..Field::new(FieldKind::Number, "n", "N")
|
|
1409 |
+ |
};
|
|
1410 |
+ |
let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
|
|
1411 |
+ |
assert!(!html.contains("<script>"), "{html}");
|
|
1412 |
+ |
assert!(html.contains("<script>"), "{html}");
|
|
1413 |
+ |
}
|
|
1414 |
+ |
|
| 1297 |
1415 |
|
#[test]
|
| 1298 |
1416 |
|
fn a_constant_ratio_curve_still_emits_a_linear_track() {
|
| 1299 |
1417 |
|
// Honest shortfall rather than a silent one: HTML has no logarithmic
|