max / makeover-webview
3 files changed,
+188 insertions,
-13 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makeover-webview" | |
| 3 | - | version = "0.61.0" | |
| 3 | + | version = "0.62.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 | |
| @@ -17,7 +17,7 @@ | |||
| 17 | 17 | # patch satisfy the requirement and still fail to compile. That happened once | |
| 18 | 18 | # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where | |
| 19 | 19 | # it surfaced, one release later. | |
| 20 | - | makeover-layout = "0.35.0" | |
| 20 | + | makeover-layout = "0.36.0" | |
| 21 | 21 | # The capability axis. `makeover-touch` decides whether a hover rule should be | |
| 22 | 22 | # gated at all; `makeover-geometry` spells the gate as a media condition. Both | |
| 23 | 23 | # answers are owned elsewhere and neither is re-derived here. | |
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | # satisfies "0.8" and keeps a second makeover-geometry in the graph next to the | |
| 28 | 28 | # 0.7 this crate asks for. `Density` is nominally distinct across the two and | |
| 29 | 29 | # the build fails on a type that reads as identical. | |
| 30 | - | makeover-touch = "0.25.0" | |
| 30 | + | makeover-touch = "0.26.0" | |
| 31 | 31 | makeover-geometry = "0.7" | |
| 32 | 32 | ||
| 33 | 33 | [lints.rust] |
| @@ -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, Tone}; | |
| 45 | 45 | use std::fmt::Write as _; | |
| 46 | 46 | ||
| 47 | 47 | /// Every class this module can put in markup. | |
| @@ -70,6 +70,7 @@ | |||
| 70 | 70 | "form-hint", | |
| 71 | 71 | "form-interval", | |
| 72 | 72 | "form-label", | |
| 73 | + | "form-note", | |
| 73 | 74 | "form-option-reason", | |
| 74 | 75 | "form-radio-group", | |
| 75 | 76 | "form-radio-label", | |
| @@ -448,7 +449,7 @@ | |||
| 448 | 449 | /// on a control, and one reading of "what describes this field" is the point. | |
| 449 | 450 | fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) { | |
| 450 | 451 | let unit = unit_of(field).is_some(); | |
| 451 | - | if field.hint.is_none() && field.error.is_none() && !unit { | |
| 452 | + | if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit { | |
| 452 | 453 | return; | |
| 453 | 454 | } | |
| 454 | 455 | let mut written = false; | |
| @@ -467,6 +468,16 @@ | |||
| 467 | 468 | let _ = write!(out, "{id}-unit"); | |
| 468 | 469 | written = true; | |
| 469 | 470 | } | |
| 471 | + | // The note after the unit and before the error, matching the order the | |
| 472 | + | // three are drawn in and the order they are useful in: what the answer | |
| 473 | + | // costs is context, and what is wrong with it now still comes last. | |
| 474 | + | if field.note.is_some() { | |
| 475 | + | if written { | |
| 476 | + | out.push(' '); | |
| 477 | + | } | |
| 478 | + | let _ = write!(out, "{id}-note"); | |
| 479 | + | written = true; | |
| 480 | + | } | |
| 470 | 481 | if field.error.is_some() { | |
| 471 | 482 | if written { | |
| 472 | 483 | out.push(' '); | |
| @@ -1001,6 +1012,31 @@ | |||
| 1001 | 1012 | /// control is the app's layout, exactly as `.form-hint`'s is, and a margin | |
| 1002 | 1013 | /// asserted here would be this crate deciding a magnitude that belongs to | |
| 1003 | 1014 | /// `makeover-geometry`. | |
| 1015 | + | /// The rules a field's note needs. | |
| 1016 | + | /// | |
| 1017 | + | /// [`unit_rules`]' precedent and its argument: `.form-hint` and `.form-error` | |
| 1018 | + | /// are the apps' own names and stay unruled here, and this one has no app | |
| 1019 | + | /// counterpart to keep because nothing emitted it before [`Field::note`] | |
| 1020 | + | /// existed. | |
| 1021 | + | /// | |
| 1022 | + | /// Colour only, and the tones are the four a badge carries. The bare class is | |
| 1023 | + | /// `content` rather than `content-muted`: a note is a consequence the user is | |
| 1024 | + | /// meant to read before answering, so muting it by default would be this crate | |
| 1025 | + | /// deciding it does not matter. | |
| 1026 | + | pub(crate) fn note_rules(opts: &Emit) -> String { | |
| 1027 | + | let note = class("form-note", opts); | |
| 1028 | + | let mut css = String::new(); | |
| 1029 | + | let _ = writeln!(css, ".{note} {{\n color: var(--content);\n}}"); | |
| 1030 | + | for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { | |
| 1031 | + | let _ = writeln!( | |
| 1032 | + | css, | |
| 1033 | + | ".{note}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}", | |
| 1034 | + | tone.token() | |
| 1035 | + | ); | |
| 1036 | + | } | |
| 1037 | + | css | |
| 1038 | + | } | |
| 1039 | + | ||
| 1004 | 1040 | pub(crate) fn unit_rules(opts: &Emit) -> String { | |
| 1005 | 1041 | let unit = class("form-unit", opts); | |
| 1006 | 1042 | let mut css = String::new(); | |
| @@ -1177,6 +1213,29 @@ | |||
| 1177 | 1213 | escape_into(hint, out); | |
| 1178 | 1214 | out.push_str("</div>"); | |
| 1179 | 1215 | } | |
| 1216 | + | // A consequence of the answer, between the standing help and the failure. | |
| 1217 | + | // The tone rides on `data-tone` -- the same attribute every other toned | |
| 1218 | + | // thing in this crate takes -- and it also picks the live region: Warning | |
| 1219 | + | // and Danger are assertive, which is quasi-webview's own reading at | |
| 1220 | + | // `node.rs:1403` and is honoured here rather than restated differently. | |
| 1221 | + | if let Some((tone, note)) = field.note { | |
| 1222 | + | out.push_str("<div class=\""); | |
| 1223 | + | push_class(out, "form-note", opts); | |
| 1224 | + | let assertive = matches!(tone, Tone::Warning | Tone::Danger); | |
| 1225 | + | let _ = write!( | |
| 1226 | + | out, | |
| 1227 | + | "\" id=\"{id}-note\" role=\"{}\"", | |
| 1228 | + | if assertive { "alert" } else { "status" } | |
| 1229 | + | ); | |
| 1230 | + | // Neutral is the bare class rather than a variant, matching every | |
| 1231 | + | // other toned component here: it is the absence of a status. | |
| 1232 | + | if tone != Tone::Neutral { | |
| 1233 | + | let _ = write!(out, " data-tone=\"{}\"", tone.token()); | |
| 1234 | + | } | |
| 1235 | + | out.push('>'); | |
| 1236 | + | escape_into(note, out); | |
| 1237 | + | out.push_str("</div>"); | |
| 1238 | + | } | |
| 1180 | 1239 | if let Some(Markup(markup)) = filling.trailing { | |
| 1181 | 1240 | out.push_str(markup); | |
| 1182 | 1241 | } | |
| @@ -1387,6 +1446,56 @@ | |||
| 1387 | 1446 | assert!(!html.contains("has-error"), "{html}"); | |
| 1388 | 1447 | } | |
| 1389 | 1448 | ||
| 1449 | + | #[test] | |
| 1450 | + | fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() { | |
| 1451 | + | let mut f = field(FieldKind::Text); | |
| 1452 | + | f.hint = Some("Keep it short"); | |
| 1453 | + | f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF")); | |
| 1454 | + | f.error = Some("Required"); | |
| 1455 | + | let html = field_html(&f, &Filling::default(), &Emit::default()); | |
| 1456 | + | ||
| 1457 | + | // All three associated, in the order they are drawn. | |
| 1458 | + | assert!( | |
| 1459 | + | html.contains(r#"aria-describedby="title-hint title-note title-error""#), | |
| 1460 | + | "{html}" | |
| 1461 | + | ); | |
| 1462 | + | assert!( | |
| 1463 | + | html.contains(r#"id="title-note" role="alert" data-tone="warning""#), | |
| 1464 | + | "{html}" | |
| 1465 | + | ); | |
| 1466 | + | // And in that order in the document, so the reading order matches. | |
| 1467 | + | let hint = html.find("title-hint").unwrap(); | |
| 1468 | + | let note = html.rfind("title-note").unwrap(); | |
| 1469 | + | let err = html.rfind("title-error").unwrap(); | |
| 1470 | + | assert!(hint < note && note < err, "{html}"); | |
| 1471 | + | } | |
| 1472 | + | ||
| 1473 | + | #[test] | |
| 1474 | + | fn a_quiet_note_is_polite_and_wears_no_tone_attribute() { | |
| 1475 | + | // Neutral is the bare class, matching every other toned component | |
| 1476 | + | // here, and only Warning and Danger interrupt. | |
| 1477 | + | let mut f = field(FieldKind::Text); | |
| 1478 | + | f.note = Some((Tone::Info, "This is what that setting implies")); | |
| 1479 | + | let html = field_html(&f, &Filling::default(), &Emit::default()); | |
| 1480 | + | assert!(html.contains(r#"role="status" data-tone="info""#), "{html}"); | |
| 1481 | + | ||
| 1482 | + | f.note = Some((Tone::Neutral, "An ordinary fact")); | |
| 1483 | + | let html = field_html(&f, &Filling::default(), &Emit::default()); | |
| 1484 | + | assert!(html.contains(r#"id="title-note" role="status">"#), "{html}"); | |
| 1485 | + | assert!(!html.contains("data-tone"), "{html}"); | |
| 1486 | + | } | |
| 1487 | + | ||
| 1488 | + | #[test] | |
| 1489 | + | fn a_note_does_not_mark_the_group_invalid() { | |
| 1490 | + | // `Field::invalid` stays `error.is_some()`, and the renderer's | |
| 1491 | + | // `has-error` follows it rather than any message being present. | |
| 1492 | + | let mut f = field(FieldKind::Text); | |
| 1493 | + | f.note = Some((Tone::Danger, "This cannot be undone")); | |
| 1494 | + | let html = field_html(&f, &Filling::default(), &Emit::default()); | |
| 1495 | + | assert!(!html.contains("has-error"), "{html}"); | |
| 1496 | + | assert!(!html.contains(r#"aria-invalid="true""#), "{html}"); | |
| 1497 | + | } | |
| 1498 | + | ||
| 1390 | 1499 | #[test] | |
| 1391 | 1500 | fn the_hint_survives_an_error_arriving() { | |
| 1392 | 1501 | let mut f = field(FieldKind::Text); |
| @@ -941,11 +941,13 @@ | |||
| 941 | 941 | // No `depth_rule` call here, deliberately: `Token::Badge.depth(_)` is Flat, | |
| 942 | 942 | // and a label with an edge says it can be pressed. | |
| 943 | 943 | let badge = class("badge", opts); | |
| 944 | - | let _ = writeln!( | |
| 945 | - | css, | |
| 946 | - | ".{badge} {{\n color: var(--{});\n}}", | |
| 947 | - | Tone::Neutral.token() | |
| 948 | - | ); | |
| 944 | + | // `content-muted` literally, not `Tone::Neutral.token()`. What makes a | |
| 945 | + | // badge quiet is `Token::Badge` answering no click, which this crate holds | |
| 946 | + | // and `Tone` genuinely does not know. Routing it through Neutral put the | |
| 947 | + | // claim where the evidence was not, and the bill arrived on the figure | |
| 948 | + | // value: it took the same muting from the same call and read as its own | |
| 949 | + | // caption. Neutral answers `content` from makeover-layout 0.36.0. | |
| 950 | + | let _ = writeln!(css, ".{badge} {{\n color: var(--content-muted);\n}}"); | |
| 949 | 951 | for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { | |
| 950 | 952 | let _ = writeln!( | |
| 951 | 953 | css, | |
| @@ -1409,10 +1411,15 @@ | |||
| 1409 | 1411 | let change = class("figure-change", opts); | |
| 1410 | 1412 | let mut css = String::new(); | |
| 1411 | 1413 | ||
| 1414 | + | // `content` literally. A figure's value is the thing itself, at full | |
| 1415 | + | // weight -- wiki `three-tone-convention` classes it "active, emphasised", | |
| 1416 | + | // and both other renderers already draw it that way (makeover-tui | |
| 1417 | + | // `piece.rs:391` bold, makeover-immediate `widget.rs:244`). It reached | |
| 1418 | + | // here through `Tone::Neutral.token()` and came out `content-muted`, so | |
| 1419 | + | // the headline number sat at the colour of its own caption. | |
| 1412 | 1420 | let _ = writeln!( | |
| 1413 | 1421 | css, | |
| 1414 | - | ".{figure} > .{value} {{\n color: var(--{});\n}}", | |
| 1415 | - | Tone::Neutral.token() | |
| 1422 | + | ".{figure} > .{value} {{\n color: var(--content);\n}}" | |
| 1416 | 1423 | ); | |
| 1417 | 1424 | let _ = writeln!( | |
| 1418 | 1425 | css, | |
| @@ -1781,7 +1788,19 @@ | |||
| 1781 | 1788 | ".{table} {{\n display: table;\n width: 100%;\n}}" | |
| 1782 | 1789 | ); | |
| 1783 | 1790 | let _ = writeln!(css, ".{head},\n.{row} {{\n display: table-row;\n}}"); | |
| 1784 | - | let _ = writeln!(css, ".{heading},\n.{cell} {{\n display: table-cell;\n}}"); | |
| 1791 | + | // Scoped under `.{table}` rather than keyed on the classes alone. The | |
| 1792 | + | // table model is what a cell takes *by being in a table*, and only there: | |
| 1793 | + | // goingson's task headings are `.table-heading` inside a CSS grid, so the | |
| 1794 | + | // unscoped rule reached them, and the sort caret and the sortable cursor | |
| 1795 | + | // -- which `state_rules` keys on `.table-heading` and rightly still does | |
| 1796 | + | // -- came with a `display` the grid had to blockify away. | |
| 1797 | + | // | |
| 1798 | + | // It costs no markup anywhere: quasi-webview always nests the heading | |
| 1799 | + | // inside the table (`quasi-webview/src/node.rs:1591`, `:1605`, `:1615`). | |
| 1800 | + | let _ = writeln!( | |
| 1801 | + | css, | |
| 1802 | + | ".{table} .{heading},\n.{table} .{cell} {{\n display: table-cell;\n}}" | |
| 1803 | + | ); | |
| 1785 | 1804 | ||
| 1786 | 1805 | // A content column shrinks to what is in it. `width: 1%` is how a CSS table | |
| 1787 | 1806 | // is told that: auto layout hands the slack to the columns that asked for | |
| @@ -1889,6 +1908,7 @@ | |||
| 1889 | 1908 | css.push_str(&form::editor_rules(opts)); | |
| 1890 | 1909 | css.push_str(&form::suggestion_rules(opts)); | |
| 1891 | 1910 | css.push_str(&form::unit_rules(opts)); | |
| 1911 | + | css.push_str(&form::note_rules(opts)); | |
| 1892 | 1912 | css | |
| 1893 | 1913 | } | |
| 1894 | 1914 | ||
| @@ -2698,6 +2718,52 @@ | |||
| 2698 | 2718 | assert!(!css.contains(".button[data-tone=\"danger\"] {\n background")); | |
| 2699 | 2719 | } | |
| 2700 | 2720 | ||
| 2721 | + | #[test] | |
| 2722 | + | fn the_table_model_is_scoped_to_the_table_but_the_caret_is_not() { | |
| 2723 | + | // goingson's task headings are `.table-heading` inside a CSS grid. | |
| 2724 | + | // They want the sort caret and the sortable cursor; they do not want | |
| 2725 | + | // `display: table-cell`, which a grid item blockifies away anyway. | |
| 2726 | + | // Scoping the one and not the other is what separates them. | |
| 2727 | + | let css = component_rules(&Emit::default()); | |
| 2728 | + | ||
| 2729 | + | assert!( | |
| 2730 | + | css.contains(".table .table-heading,\n.table .cell {\n display: table-cell;"), | |
| 2731 | + | "{css}" | |
| 2732 | + | ); | |
| 2733 | + | assert!( | |
| 2734 | + | !css.contains(".table-heading,\n.cell {\n display: table-cell;"), | |
| 2735 | + | "the table model is still unscoped: {css}" | |
| 2736 | + | ); | |
| 2737 | + | ||
| 2738 | + | let states = state_rules(&Emit::default()); | |
| 2739 | + | assert!( | |
| 2740 | + | states.contains(".table-heading[aria-sort"), | |
| 2741 | + | "the caret got scoped along with the model: {states}" | |
| 2742 | + | ); | |
| 2743 | + | } | |
| 2744 | + | ||
| 2745 | + | #[test] | |
| 2746 | + | fn a_figure_value_is_the_thing_itself_and_a_badge_is_quiet() { | |
| 2747 | + | // Both used to read `Tone::Neutral.token()`, which answered | |
| 2748 | + | // `content-muted`, so the headline number sat at the colour of its own | |
| 2749 | + | // caption. Neutral answers `content` now; each site states its own | |
| 2750 | + | // claim rather than borrowing one from the status axis. | |
| 2751 | + | let css = component_rules(&Emit::default()); | |
| 2752 | + | ||
| 2753 | + | assert!( | |
| 2754 | + | css.contains(".figure > .figure-value {\n color: var(--content);"), | |
| 2755 | + | "{css}" | |
| 2756 | + | ); | |
| 2757 | + | assert!( | |
| 2758 | + | css.contains(".figure > .figure-caption {\n color: var(--content-muted);"), | |
| 2759 | + | "{css}" | |
| 2760 | + | ); | |
| 2761 | + | assert!( | |
| 2762 | + | css.contains(".badge {\n color: var(--content-muted);"), | |
| 2763 | + | "{css}" | |
| 2764 | + | ); | |
| 2765 | + | } | |
| 2766 | + | ||
| 2701 | 2767 | #[test] | |
| 2702 | 2768 | fn a_described_list_is_not_a_bulleted_list() { | |
| 2703 | 2769 | let css = component_rules(&Emit::default()); |