max / makeover-webview
5 files changed,
+303 insertions,
-4 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makeover-webview" | |
| 3 | - | version = "0.19.0" | |
| 3 | + | version = "0.20.0" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | description = "The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser." | |
| 6 | 6 | license = "MIT" | |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | # in 0.8.2. Declared as "0.8" from 0.16.1, where the radio landed, so a consumer | |
| 12 | 12 | # whose lock already held 0.8.0 got a resolve that satisfied the pin and failed | |
| 13 | 13 | # to compile. makeover-build is where that surfaced, one release later. | |
| 14 | - | makeover-layout = "0.10.0" | |
| 14 | + | makeover-layout = "0.11.0" | |
| 15 | 15 | # The capability axis. `makeover-touch` decides whether a hover rule should be | |
| 16 | 16 | # gated at all; `makeover-geometry` spells the gate as a media condition. Both | |
| 17 | 17 | # answers are owned elsewhere and neither is re-derived here. |
| @@ -157,6 +157,7 @@ | |||
| 157 | 157 | FieldKind::Secret => "password", | |
| 158 | 158 | FieldKind::Number => "number", | |
| 159 | 159 | FieldKind::Checkbox => "checkbox", | |
| 160 | + | FieldKind::File => "file", | |
| 160 | 161 | FieldKind::Hidden => "hidden", | |
| 161 | 162 | // Not decoration. Each of these changes the keyboard a touch device | |
| 162 | 163 | // offers and turns on the platform's own validation, which is why the | |
| @@ -194,6 +195,19 @@ | |||
| 194 | 195 | if field.required { | |
| 195 | 196 | attrs.push_str(" required"); | |
| 196 | 197 | } | |
| 198 | + | // makeover-layout 0.11.0's constraints. The description carries the rule and | |
| 199 | + | // this emits the browser's idiom for it, which is the model `required` has | |
| 200 | + | // been using since before the crate wrote down that it carried none. | |
| 201 | + | // Enforcement is still whoever validated's, and arrives back as `error`. | |
| 202 | + | if let Some(limit) = field.max_length { | |
| 203 | + | let _ = write!(attrs, " maxlength=\"{limit}\""); | |
| 204 | + | } | |
| 205 | + | if let Some(min) = field.min { | |
| 206 | + | let _ = write!(attrs, " min=\"{}\"", escape(min)); | |
| 207 | + | } | |
| 208 | + | if let Some(max) = field.max { | |
| 209 | + | let _ = write!(attrs, " max=\"{}\"", escape(max)); | |
| 210 | + | } | |
| 197 | 211 | if field.invalid() { | |
| 198 | 212 | attrs.push_str(" aria-invalid=\"true\""); | |
| 199 | 213 | } | |
| @@ -357,6 +371,13 @@ | |||
| 357 | 371 | FieldKind::Secret => { | |
| 358 | 372 | format!("<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>") | |
| 359 | 373 | } | |
| 374 | + | // A file input carries no value, and this is the browser's rule rather | |
| 375 | + | // than a preference: setting one from markup is refused, because a page | |
| 376 | + | // that could preselect a path could read a file the user never offered. | |
| 377 | + | // Nothing upstream needs to know, which is why the exception is here. | |
| 378 | + | FieldKind::File => { | |
| 379 | + | format!("<input type=\"file\" class=\"{field_class}\"{attrs}>") | |
| 380 | + | } | |
| 360 | 381 | kind => format!( | |
| 361 | 382 | "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">", | |
| 362 | 383 | input_type(kind), | |
| @@ -813,6 +834,59 @@ | |||
| 813 | 834 | /// These three exist so a touch keyboard and the platform's validation | |
| 814 | 835 | /// arrive with the field. Emitting text for any of them is the regression | |
| 815 | 836 | /// the variants were added to prevent, so the type is asserted directly. | |
| 837 | + | #[test] | |
| 838 | + | fn a_constraint_becomes_the_browsers_own_attribute() { | |
| 839 | + | // makeover-layout 0.11.0's model: the description carries the rule and | |
| 840 | + | // each renderer emits its host's idiom for it. Enforcement is still | |
| 841 | + | // whoever validated's, and arrives back as `error`. | |
| 842 | + | let html = field_html( | |
| 843 | + | &Field { | |
| 844 | + | max_length: Some(100), | |
| 845 | + | min: Some("1"), | |
| 846 | + | max: Some("240"), | |
| 847 | + | required: true, | |
| 848 | + | ..Field::new(FieldKind::Number, "minutes", "Minutes") | |
| 849 | + | }, | |
| 850 | + | &Filling::default(), | |
| 851 | + | &Emit::default(), | |
| 852 | + | ); | |
| 853 | + | assert!(html.contains(r#"maxlength="100""#)); | |
| 854 | + | assert!(html.contains(r#"min="1""#)); | |
| 855 | + | assert!(html.contains(r#"max="240""#)); | |
| 856 | + | assert!(html.contains(" required")); | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | #[test] | |
| 860 | + | fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() { | |
| 861 | + | // The bound is text because it is only a number for some of the kinds | |
| 862 | + | // that take one; goingson's own sites are a duration and a datetime. | |
| 863 | + | let html = field_html( | |
| 864 | + | &Field { | |
| 865 | + | min: Some("2026-08-09T14:30"), | |
| 866 | + | ..Field::new(FieldKind::Text, "starts", "Starts") | |
| 867 | + | }, | |
| 868 | + | &Filling::default(), | |
| 869 | + | &Emit::default(), | |
| 870 | + | ); | |
| 871 | + | assert!(html.contains(r#"min="2026-08-09T14:30""#)); | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | #[test] | |
| 875 | + | fn a_file_field_is_a_file_input() { | |
| 876 | + | // `844b5ae0`. It carries no `accept`, which is measured rather than | |
| 877 | + | // deferred: zero sites in either app. | |
| 878 | + | let html = field_html( | |
| 879 | + | &Field::new(FieldKind::File, "attachment", "Attachment"), | |
| 880 | + | &Filling::default(), | |
| 881 | + | &Emit::default(), | |
| 882 | + | ); | |
| 883 | + | assert!(html.contains(r#"type="file""#)); | |
| 884 | + | assert!(!html.contains("accept=")); | |
| 885 | + | // And it never carries a value: a file input's value is not settable | |
| 886 | + | // from markup, and the browser refuses one that tries. | |
| 887 | + | assert!(!html.contains("value=")); | |
| 888 | + | } | |
| 889 | + | ||
| 816 | 890 | #[test] | |
| 817 | 891 | fn the_typed_text_kinds_keep_their_input_type() { | |
| 818 | 892 | for (kind, expected) in [ |
| @@ -193,6 +193,7 @@ | |||
| 193 | 193 | ||
| 194 | 194 | #![forbid(unsafe_code)] | |
| 195 | 195 | ||
| 196 | + | pub mod figure; | |
| 196 | 197 | pub mod form; | |
| 197 | 198 | pub mod list; | |
| 198 | 199 | pub mod meter; | |
| @@ -667,6 +668,7 @@ | |||
| 667 | 668 | RowPart::Meta, | |
| 668 | 669 | RowPart::Actions, | |
| 669 | 670 | RowPart::Tokens, | |
| 671 | + | RowPart::Proportion, | |
| 670 | 672 | ] { | |
| 671 | 673 | let c = class(part_class(part), opts); | |
| 672 | 674 | ||
| @@ -674,8 +676,12 @@ | |||
| 674 | 676 | // so by returning the same intent inheriting already gives. Pinning it | |
| 675 | 677 | // would be louder than saying nothing. Tokens answer alike, for their | |
| 676 | 678 | // own reason: each token carries its own tone, and a colour on the | |
| 677 | - | // strip would fight the things sitting in it. | |
| 678 | - | if !matches!(part, RowPart::Actions | RowPart::Tokens) { | |
| 679 | + | // strip would fight the things sitting in it. A proportion is the same | |
| 680 | + | // case again: the meter inside carries the tone. | |
| 681 | + | if !matches!( | |
| 682 | + | part, | |
| 683 | + | RowPart::Actions | RowPart::Tokens | RowPart::Proportion | |
| 684 | + | ) { | |
| 679 | 685 | let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent()); | |
| 680 | 686 | } | |
| 681 | 687 | ||
| @@ -775,6 +781,47 @@ | |||
| 775 | 781 | css | |
| 776 | 782 | } | |
| 777 | 783 | ||
| 784 | + | /// A strip of figures, and the two spans inside each one. | |
| 785 | + | /// | |
| 786 | + | /// Colour only, which is the deferral rule applied to a component that badly | |
| 787 | + | /// wants to break it. A figure reads as a figure because the value is set large | |
| 788 | + | /// over a small caption, and that is a size: `makeover-geometry` answers how | |
| 789 | + | /// much space and this crate answers what the thing is. Emitting `font-size` | |
| 790 | + | /// here would be this crate naming a value, which is the one thing it is defined | |
| 791 | + | /// by not doing, and `progress_rules` is the precedent — it emits the tones and | |
| 792 | + | /// never the width, because the width is not its to know. | |
| 793 | + | /// | |
| 794 | + | /// So the arrangement and the type scale are the app's, and what is generated is | |
| 795 | + | /// the part an app cannot get right by itself: which of the two spans carries | |
| 796 | + | /// the tone. | |
| 797 | + | fn figure_rules(opts: &Emit) -> String { | |
| 798 | + | let figure = class("figure", opts); | |
| 799 | + | let value = class("figure-value", opts); | |
| 800 | + | let caption = class("figure-caption", opts); | |
| 801 | + | let mut css = String::new(); | |
| 802 | + | ||
| 803 | + | let _ = writeln!( | |
| 804 | + | css, | |
| 805 | + | ".{figure} > .{value} {{\n color: var(--{});\n}}", | |
| 806 | + | Tone::Neutral.token() | |
| 807 | + | ); | |
| 808 | + | let _ = writeln!( | |
| 809 | + | css, | |
| 810 | + | ".{figure} > .{caption} {{\n color: var(--content-muted);\n}}" | |
| 811 | + | ); | |
| 812 | + | ||
| 813 | + | // A toned figure tones the value and never the caption. The caption is the | |
| 814 | + | // noun and stays muted; the number is the thing that is saying something. | |
| 815 | + | for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { | |
| 816 | + | let _ = writeln!( | |
| 817 | + | css, | |
| 818 | + | ".{figure}[data-tone=\"{0}\"] > .{value} {{\n color: var(--{0});\n}}", | |
| 819 | + | tone.token() | |
| 820 | + | ); | |
| 821 | + | } | |
| 822 | + | css | |
| 823 | + | } | |
| 824 | + | ||
| 778 | 825 | /// The component layer: every named thing phase A emits. | |
| 779 | 826 | /// | |
| 780 | 827 | /// No scrollbar track. It was on the phase A list and came off: eight lines of | |
| @@ -788,6 +835,7 @@ | |||
| 788 | 835 | css.push_str(&selector_rules(opts)); | |
| 789 | 836 | css.push_str(&row_rules(opts)); | |
| 790 | 837 | css.push_str(&progress_rules(opts)); | |
| 838 | + | css.push_str(&figure_rules(opts)); | |
| 791 | 839 | css | |
| 792 | 840 | } | |
| 793 | 841 |
| @@ -205,6 +205,7 @@ | |||
| 205 | 205 | RowPart::Meta => "row-meta", | |
| 206 | 206 | RowPart::Actions => "row-actions", | |
| 207 | 207 | RowPart::Tokens => "row-tokens", | |
| 208 | + | RowPart::Proportion => "row-proportion", | |
| 208 | 209 | _ => "row-part", | |
| 209 | 210 | } | |
| 210 | 211 | } |
| @@ -1,0 +1,176 @@ | |||
| 1 | + | //! A figure with a caption, and a strip of them. | |
| 2 | + | //! | |
| 3 | + | //! The fourth phase-B emitter. `makeover_layout::Figure` arrived at 0.11.0 after | |
| 4 | + | //! goingson turned out to have five of these across five screens, each with its | |
| 5 | + | //! own class names for the one shape: `task-overview-stat`, `stat-box`, | |
| 6 | + | //! `month-stat-item`, `contact-summary-stat`, `sync-stat`. | |
| 7 | + | //! | |
| 8 | + | //! # Why the strip has its own function | |
| 9 | + | //! | |
| 10 | + | //! Four tiles in a row and four tiles down a column are different things, and a | |
| 11 | + | //! renderer handed one figure at a time cannot tell it is looking at a set. So | |
| 12 | + | //! the set is what gets emitted, and a lone figure is a set of one. | |
| 13 | + | //! | |
| 14 | + | //! # The reading order is markup, not CSS | |
| 15 | + | //! | |
| 16 | + | //! Visually the value is set large over a small caption, which is what four of | |
| 17 | + | //! the five sites drew. A screen reader meeting "17" before it knows what was | |
| 18 | + | //! counted has to hold the number until the noun arrives, so the figure carries | |
| 19 | + | //! its own accessible name — "Current Streak: 17" — and the two spans are hidden | |
| 20 | + | //! from the reader that has already been told. | |
| 21 | + | //! | |
| 22 | + | //! Solving it that way rather than by inverting the markup and turning it back | |
| 23 | + | //! with `column-reverse` is deliberate: the arrangement and the type scale are | |
| 24 | + | //! the app's, and a renderer that emitted them would be naming sizes. Same line | |
| 25 | + | //! `meter_html` holds when it emits the tones and never the width. | |
| 26 | + | ||
| 27 | + | use crate::form::escape; | |
| 28 | + | use crate::{Emit, class}; | |
| 29 | + | use makeover_layout::{Figure, Intent, Tone}; | |
| 30 | + | use std::fmt::Write as _; | |
| 31 | + | ||
| 32 | + | /// The accessible name for a figure: the noun, then the number. | |
| 33 | + | /// | |
| 34 | + | /// Built here rather than carried, for the reason [`meter_text`] is: a strip | |
| 35 | + | /// wants "Current Streak: 17" and a terminal at one line wants something else, | |
| 36 | + | /// and a description that shipped either would have chosen for both. | |
| 37 | + | /// | |
| 38 | + | /// [`meter_text`]: crate::meter::meter_text | |
| 39 | + | #[must_use] | |
| 40 | + | pub fn figure_text(figure: &Figure<'_>) -> String { | |
| 41 | + | format!("{}: {}", figure.caption, figure.value) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// One figure, as its own element. | |
| 45 | + | /// | |
| 46 | + | /// ``` | |
| 47 | + | /// use makeover_layout::{Figure, Tone}; | |
| 48 | + | /// use makeover_webview::{Emit, figure::figure_html}; | |
| 49 | + | /// | |
| 50 | + | /// let figure = Figure::new("17", "Current Streak").tone(Tone::Success); | |
| 51 | + | /// let html = figure_html(&figure, &Emit::default()); | |
| 52 | + | /// | |
| 53 | + | /// assert!(html.contains(r#"data-tone="success""#)); | |
| 54 | + | /// assert!(html.contains(r#"aria-label="Current Streak: 17""#)); | |
| 55 | + | /// ``` | |
| 56 | + | #[must_use] | |
| 57 | + | pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String { | |
| 58 | + | let mut html = format!( | |
| 59 | + | "<div class=\"{}\" aria-label=\"{}\"", | |
| 60 | + | class("figure", opts), | |
| 61 | + | escape(&figure_text(figure)) | |
| 62 | + | ); | |
| 63 | + | // Neutral is the ordinary fact, and `figure_rules` styles the bare class | |
| 64 | + | // for it. `data-tone="content-muted"` would match a rule that is not there. | |
| 65 | + | if figure.tone != Tone::Neutral { | |
| 66 | + | let _ = write!(html, " data-tone=\"{}\"", figure.tone.token()); | |
| 67 | + | } | |
| 68 | + | // `aria-hidden` on both, because the element above has already said the | |
| 69 | + | // whole thing. Without it a reader gets the number twice and the noun | |
| 70 | + | // twice, in the order the eye wants rather than the order the ear does. | |
| 71 | + | let _ = write!( | |
| 72 | + | html, | |
| 73 | + | "><span class=\"{}\" aria-hidden=\"true\">{}</span>\ | |
| 74 | + | <span class=\"{}\" aria-hidden=\"true\">{}</span></div>", | |
| 75 | + | class("figure-value", opts), | |
| 76 | + | escape(figure.value), | |
| 77 | + | class("figure-caption", opts), | |
| 78 | + | escape(figure.caption), | |
| 79 | + | ); | |
| 80 | + | html | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Several figures as one strip. | |
| 84 | + | /// | |
| 85 | + | /// An empty set emits the container and nothing in it, for the reason a meter | |
| 86 | + | /// over nothing and a select with no options both render: it is what an app with | |
| 87 | + | /// an unloaded count actually has, and an empty strip says so on screen rather | |
| 88 | + | /// than in a log. | |
| 89 | + | #[must_use] | |
| 90 | + | pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String { | |
| 91 | + | let mut html = format!("<div class=\"{}\">", class("figures", opts)); | |
| 92 | + | for figure in figures { | |
| 93 | + | html.push_str(&figure_html(figure, opts)); | |
| 94 | + | } | |
| 95 | + | html.push_str("</div>"); | |
| 96 | + | html | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[cfg(test)] | |
| 100 | + | mod tests { | |
| 101 | + | use super::*; | |
| 102 | + | ||
| 103 | + | #[test] | |
| 104 | + | fn the_noun_reaches_a_reader_before_the_number() { | |
| 105 | + | // The problem the accessible name solves. Visually the value comes | |
| 106 | + | // first; a reader that met "17" first would have to hold it until it | |
| 107 | + | // found out what was counted. | |
| 108 | + | let figure = Figure::new("17", "Current Streak"); | |
| 109 | + | assert_eq!(figure_text(&figure), "Current Streak: 17"); | |
| 110 | + | ||
| 111 | + | let html = figure_html(&figure, &Emit::default()); | |
| 112 | + | assert!(html.contains(r#"aria-label="Current Streak: 17""#)); | |
| 113 | + | // And the spans are not read a second time in the other order. | |
| 114 | + | assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[test] | |
| 118 | + | fn an_untoned_figure_emits_no_tone_attribute() { | |
| 119 | + | // Same reason as the meter: the bare class is the untoned rule, so an | |
| 120 | + | // attribute here would match nothing. | |
| 121 | + | let plain = figure_html(&Figure::new("17", "Total"), &Emit::default()); | |
| 122 | + | assert!(!plain.contains("data-tone")); | |
| 123 | + | ||
| 124 | + | let toned = figure_html( | |
| 125 | + | &Figure::new("0", "Current Streak").tone(Tone::Warning), | |
| 126 | + | &Emit::default(), | |
| 127 | + | ); | |
| 128 | + | assert!(toned.contains(r#"data-tone="warning""#)); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn a_value_and_a_caption_are_escaped_like_every_other_string() { | |
| 133 | + | // Both arrive from the app, the same as a field label does. | |
| 134 | + | let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default()); | |
| 135 | + | assert!(html.contains("a & b")); | |
| 136 | + | assert!(html.contains("<b>")); | |
| 137 | + | assert!(!html.contains("<b>")); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() { | |
| 142 | + | let html = figures_html( | |
| 143 | + | &[ | |
| 144 | + | Figure::new("17", "Current Streak"), | |
| 145 | + | Figure::new("84%", "Completion Rate"), | |
| 146 | + | ], | |
| 147 | + | &Emit::default(), | |
| 148 | + | ); | |
| 149 | + | assert!(html.starts_with(r#"<div class="figures">"#)); | |
| 150 | + | assert_eq!(html.matches(r#"class="figure""#).count(), 2); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn an_empty_strip_renders_as_an_empty_strip() { | |
| 155 | + | // Sayable, so it has to be emittable, and visibly empty rather than | |
| 156 | + | // absent. | |
| 157 | + | let html = figures_html(&[], &Emit::default()); | |
| 158 | + | assert_eq!(html, r#"<div class="figures"></div>"#); | |
| 159 | + | assert!(!html.contains("figure-")); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn the_prefix_reaches_every_class() { | |
| 164 | + | // A prefixed build claims its own names, and the two inner spans are | |
| 165 | + | // descendant selectors in the emitted CSS. | |
| 166 | + | let opts = Emit { | |
| 167 | + | class_prefix: "mo-", | |
| 168 | + | ..Emit::default() | |
| 169 | + | }; | |
| 170 | + | let html = figures_html(&[Figure::new("17", "Total")], &opts); | |
| 171 | + | assert!(html.contains(r#"class="mo-figures""#)); | |
| 172 | + | assert!(html.contains(r#"class="mo-figure""#)); | |
| 173 | + | assert!(html.contains(r#"class="mo-figure-value""#)); | |
| 174 | + | assert!(html.contains(r#"class="mo-figure-caption""#)); | |
| 175 | + | } | |
| 176 | + | } |