max / makeover-webview
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+486 insertions,
-6 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makeover-webview" | |
| 3 | - | version = "0.58.0" | |
| 3 | + | version = "0.59.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 |
| @@ -29,6 +29,19 @@ | |||
| 29 | 29 | use makeover_layout::{Figure, Intent, Tone}; | |
| 30 | 30 | use std::fmt::Write as _; | |
| 31 | 31 | ||
| 32 | + | /// Every class this module can put in markup. | |
| 33 | + | /// | |
| 34 | + | /// [`crate::facet::FACET_CLASSES`]' obligation. `figures` is the strip around | |
| 35 | + | /// them and carries no rule of its own -- how tiles sit in a row is the app's | |
| 36 | + | /// layout -- so a scraped set cannot see it. | |
| 37 | + | pub const FIGURE_CLASSES: &[&str] = &[ | |
| 38 | + | "figures", | |
| 39 | + | "figure", | |
| 40 | + | "figure-value", | |
| 41 | + | "figure-caption", | |
| 42 | + | "figure-change", | |
| 43 | + | ]; | |
| 44 | + | ||
| 32 | 45 | /// The accessible name for a figure: the noun, then the number. | |
| 33 | 46 | /// | |
| 34 | 47 | /// Built here rather than carried, for the reason [`meter_text`] is: a strip |
| @@ -44,6 +44,54 @@ | |||
| 44 | 44 | use makeover_layout::{Choice, Depth, Field, FieldKind, Selector}; | |
| 45 | 45 | use std::fmt::Write as _; | |
| 46 | 46 | ||
| 47 | + | /// Every class this module can put in markup. | |
| 48 | + | /// | |
| 49 | + | /// [`crate::facet::FACET_CLASSES`]' obligation, and the module where it was | |
| 50 | + | /// missing longest. Most of these carry no rule and never will: `.form-group`, | |
| 51 | + | /// `.form-label`, `.form-hint` and `.form-error` are the apps' own names, kept | |
| 52 | + | /// so adoption deletes goingson's `renderFormField` rather than restyling | |
| 53 | + | /// anything, and phase A emits only what it can generate from the description. | |
| 54 | + | /// A class with no rule is invisible to [`crate::vocabulary::vocabulary`], | |
| 55 | + | /// which reads the generated sheet, so the unruled half of a renderer's | |
| 56 | + | /// vocabulary can only be written down. | |
| 57 | + | /// | |
| 58 | + | /// What went wrong without it: an app checking its stylesheet against | |
| 59 | + | /// [`crate::vocabulary::names`] concluded that its live `.form-group` and | |
| 60 | + | /// `.form-label` rules matched nothing and were safe to delete. quasi-webview | |
| 61 | + | /// carried them in a `MAKEOVER_UNLISTED` constant of its own until 0.59.0 | |
| 62 | + | /// rather than let that happen. | |
| 63 | + | pub const FIELD_CLASSES: &[&str] = &[ | |
| 64 | + | "field", | |
| 65 | + | "form-checkbox-label", | |
| 66 | + | "form-editor-modes", | |
| 67 | + | "form-editor-preview", | |
| 68 | + | "form-error", | |
| 69 | + | "form-group", | |
| 70 | + | "form-hint", | |
| 71 | + | "form-interval", | |
| 72 | + | "form-label", | |
| 73 | + | "form-option-reason", | |
| 74 | + | "form-radio-group", | |
| 75 | + | "form-radio-label", | |
| 76 | + | "form-suggestion", | |
| 77 | + | "form-suggestion-detail", | |
| 78 | + | "form-suggestions", | |
| 79 | + | "form-unit", | |
| 80 | + | ]; | |
| 81 | + | ||
| 82 | + | /// The state classes a field carries, which take no prefix. | |
| 83 | + | /// | |
| 84 | + | /// `chosen` and `latched`'s convention, stated in | |
| 85 | + | /// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component | |
| 86 | + | /// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix | |
| 87 | + | /// moves the thing and not its state. | |
| 88 | + | /// | |
| 89 | + | /// `has-error` marks the group and `visible` marks the message, which is | |
| 90 | + | /// [`makeover_layout::Field::invalid`]'s own reasoning: a renderer with no | |
| 91 | + | /// descendant selectors cannot find the group from the message, so both are | |
| 92 | + | /// told. | |
| 93 | + | pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"]; | |
| 94 | + | ||
| 47 | 95 | /// A string that is already markup, and is emitted without escaping. | |
| 48 | 96 | /// | |
| 49 | 97 | /// The one hole in the escaping, and it has to be named to be used. goingson |
| @@ -386,6 +386,13 @@ | |||
| 386 | 386 | pub mod placeholder; | |
| 387 | 387 | pub mod vocabulary; | |
| 388 | 388 | ||
| 389 | + | /// A render of every emitter, scraped for the classes it wrote. | |
| 390 | + | /// | |
| 391 | + | /// Test-only, and the guard behind [`vocabulary::names`]. See the module's own | |
| 392 | + | /// header for why the check renders rather than reads the source. | |
| 393 | + | #[cfg(test)] | |
| 394 | + | mod corpus; | |
| 395 | + | ||
| 389 | 396 | use crate::list::{cell_part_class, part_class}; | |
| 390 | 397 | use makeover_geometry::{Density, SizeClass}; | |
| 391 | 398 | // Re-exported rather than redefined. An app assembling its own stylesheet out |
| @@ -383,6 +383,24 @@ | |||
| 383 | 383 | } | |
| 384 | 384 | } | |
| 385 | 385 | ||
| 386 | + | /// Every class [`width_class`](fn@width_class) can return, including the | |
| 387 | + | /// fallback. | |
| 388 | + | /// | |
| 389 | + | /// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three | |
| 390 | + | /// carry a rule -- a fill is what a cell does when the sheet says nothing -- | |
| 391 | + | /// which is exactly why the list is here rather than being read off the | |
| 392 | + | /// generated CSS: `cell-fill` reached every table in the tree and the | |
| 393 | + | /// vocabulary named it nowhere. | |
| 394 | + | pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"]; | |
| 395 | + | ||
| 396 | + | /// Every class [`drop_class`](fn@drop_class) can return, including the | |
| 397 | + | /// fallback. | |
| 398 | + | /// | |
| 399 | + | /// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is | |
| 400 | + | /// the tier the narrowing never hides, so the sheet writes no rule for it and | |
| 401 | + | /// a scraped set cannot see it. | |
| 402 | + | pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"]; | |
| 403 | + | ||
| 386 | 404 | /// Every class [`cell_part_class`] can return, including the fallback. | |
| 387 | 405 | /// | |
| 388 | 406 | /// See [`ROW_PART_CLASSES`] for why it is written out. | |
| @@ -483,6 +501,31 @@ | |||
| 483 | 501 | mod tests { | |
| 484 | 502 | use super::*; | |
| 485 | 503 | ||
| 504 | + | #[test] | |
| 505 | + | fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() { | |
| 506 | + | // The obligation ROW_PART_CLASSES carries. Both matches have a wildcard | |
| 507 | + | // arm, so a member added upstream lands on a class that is already in | |
| 508 | + | // the list; what this catches is a new arm returning a new name, which | |
| 509 | + | // would otherwise narrow what a checker believes this crate emits | |
| 510 | + | // without narrowing what it writes. | |
| 511 | + | for width in [Width::Content, Width::Fixed, Width::Fill] { | |
| 512 | + | assert!( | |
| 513 | + | CELL_WIDTH_CLASSES.contains(&width_class(width)), | |
| 514 | + | "{width:?} is missing from CELL_WIDTH_CLASSES" | |
| 515 | + | ); | |
| 516 | + | } | |
| 517 | + | for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] { | |
| 518 | + | assert!( | |
| 519 | + | CELL_DROP_CLASSES.contains(&drop_class(priority)), | |
| 520 | + | "{priority:?} is missing from CELL_DROP_CLASSES" | |
| 521 | + | ); | |
| 522 | + | } | |
| 523 | + | let names = crate::vocabulary::names(&Emit::default()); | |
| 524 | + | for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) { | |
| 525 | + | assert!(names.contains(*name), "{name} is not in the vocabulary"); | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | ||
| 486 | 529 | #[test] | |
| 487 | 530 | fn a_column_name_cannot_break_out_of_the_class_attribute() { | |
| 488 | 531 | // Until 0.41.0 the name went in raw, so this emitted |
| @@ -25,6 +25,13 @@ | |||
| 25 | 25 | use makeover_layout::{Intent, Meter, Tone}; | |
| 26 | 26 | use std::fmt::Write as _; | |
| 27 | 27 | ||
| 28 | + | /// Every class this module can put in markup. | |
| 29 | + | /// | |
| 30 | + | /// [`crate::facet::FACET_CLASSES`]' obligation. Both carry rules, so the | |
| 31 | + | /// scraped vocabulary already holds them; the list is what keeps that true if | |
| 32 | + | /// a rule goes away. | |
| 33 | + | pub const METER_CLASSES: &[&str] = &["progress", "progress-fill"]; | |
| 34 | + | ||
| 28 | 35 | /// The accessible name for a meter: the two numbers, and the noun if it has one. | |
| 29 | 36 | /// | |
| 30 | 37 | /// The description carries the noun alone, so the sentence is built here. That |
| @@ -29,6 +29,13 @@ | |||
| 29 | 29 | use makeover_layout::{Intent, Readiness, Tone}; | |
| 30 | 30 | use std::fmt::Write as _; | |
| 31 | 31 | ||
| 32 | + | /// Every class this module can put in markup. | |
| 33 | + | /// | |
| 34 | + | /// [`crate::facet::FACET_CLASSES`]' obligation. `placeholder-action` is | |
| 35 | + | /// unruled: what a way out of an empty state looks like is the button inside | |
| 36 | + | /// it, and the wrapper only says where it goes. | |
| 37 | + | pub const PLACEHOLDER_CLASSES: &[&str] = &["placeholder", "placeholder-text", "placeholder-action"]; | |
| 38 | + | ||
| 32 | 39 | /// A region's stand-in, or nothing at all when the region has its content. | |
| 33 | 40 | /// | |
| 34 | 41 | /// ``` |
| @@ -34,7 +34,14 @@ | |||
| 34 | 34 | //! from, and a class added to an emitter enters the vocabulary in the same | |
| 35 | 35 | //! commit that adds it. | |
| 36 | 36 | ||
| 37 | - | use crate::list::{CELL_PART_CLASSES, FLOW_CLASSES, ROW_PART_CLASSES}; | |
| 37 | + | use crate::facet::FACET_CLASSES; | |
| 38 | + | use crate::figure::FIGURE_CLASSES; | |
| 39 | + | use crate::form::{FIELD_CLASSES, FIELD_STATE_CLASSES}; | |
| 40 | + | use crate::list::{ | |
| 41 | + | CELL_DROP_CLASSES, CELL_PART_CLASSES, CELL_WIDTH_CLASSES, FLOW_CLASSES, ROW_PART_CLASSES, | |
| 42 | + | }; | |
| 43 | + | use crate::meter::METER_CLASSES; | |
| 44 | + | use crate::placeholder::PLACEHOLDER_CLASSES; | |
| 38 | 45 | use crate::{Emit, option_class}; | |
| 39 | 46 | use makeover_layout::Selector; | |
| 40 | 47 | use std::collections::{BTreeMap, BTreeSet}; | |
| @@ -53,10 +60,27 @@ | |||
| 53 | 60 | ||
| 54 | 61 | /// Every class this crate can put in markup or in a rule. | |
| 55 | 62 | /// | |
| 56 | - | /// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is | |
| 57 | - | /// the set to check a renderer's emitted markup against: a class outside it is | |
| 58 | - | /// a name that renderer invented, which is how quasi-webview came to spell | |
| 59 | - | /// `tabs`, `segmented` and `option` and render every described selector flat. | |
| 63 | + | /// [`vocabulary`] plus every class an emitter here writes without the sheet | |
| 64 | + | /// ruling it. This is the set to check a renderer's emitted markup against: a | |
| 65 | + | /// class outside it is a name that renderer invented, which is how | |
| 66 | + | /// quasi-webview came to spell `tabs`, `segmented` and `option` and render | |
| 67 | + | /// every described selector flat. | |
| 68 | + | /// | |
| 69 | + | /// # The unruled half is written down, module by module | |
| 70 | + | /// | |
| 71 | + | /// One list per module that emits markup, each beside its emitters, and this | |
| 72 | + | /// is their union. That shape is the fix for how the set was wrong until | |
| 73 | + | /// 0.59.0: it held four deliberate omissions and the emitters had fifteen, so | |
| 74 | + | /// `cell-fill`, `form-group`, `form-label` and a dozen others came out in | |
| 75 | + | /// documents that this function said were impossible. An app reading it | |
| 76 | + | /// concluded its live rules for them were dead and would have deleted them, | |
| 77 | + | /// which is why quasi-webview carried a `MAKEOVER_UNLISTED` constant of its own | |
| 78 | + | /// to put them back. | |
| 79 | + | /// | |
| 80 | + | /// [`crate::corpus`] is what keeps the union honest, and it renders rather than | |
| 81 | + | /// reading the source: a width class, a drop class and a state appended to an | |
| 82 | + | /// open attribute are literals nowhere, which is what a reading of the | |
| 83 | + | /// emitters missed for eleven of the fifteen. | |
| 60 | 84 | #[must_use] | |
| 61 | 85 | pub fn names(opts: &Emit) -> BTreeSet<String> { | |
| 62 | 86 | let mut all = vocabulary(opts); | |
| @@ -64,8 +88,15 @@ | |||
| 64 | 88 | ROW_PART_CLASSES | |
| 65 | 89 | .iter() | |
| 66 | 90 | .chain(CELL_PART_CLASSES) | |
| 91 | + | .chain(CELL_WIDTH_CLASSES) | |
| 92 | + | .chain(CELL_DROP_CLASSES) | |
| 67 | 93 | .chain(FLOW_CLASSES) | |
| 68 | 94 | .chain(crate::RUN_CLASSES) | |
| 95 | + | .chain(FACET_CLASSES) | |
| 96 | + | .chain(FIELD_CLASSES) | |
| 97 | + | .chain(FIGURE_CLASSES) | |
| 98 | + | .chain(METER_CLASSES) | |
| 99 | + | .chain(PLACEHOLDER_CLASSES) | |
| 69 | 100 | .map(|name| crate::class(name, opts)), | |
| 70 | 101 | ); | |
| 71 | 102 | all.extend( | |
| @@ -73,6 +104,10 @@ | |||
| 73 | 104 | .into_iter() | |
| 74 | 105 | .map(|s| crate::class(option_class(s), opts)), | |
| 75 | 106 | ); | |
| 107 | + | // Unprefixed, deliberately, exactly as the `chosen` and `latched` the | |
| 108 | + | // scraped half brings in: a state qualifies a prefixed component rather | |
| 109 | + | // than standing on its own. | |
| 110 | + | all.extend(FIELD_STATE_CLASSES.iter().map(|name| (*name).to_owned())); | |
| 76 | 111 | all | |
| 77 | 112 | } | |
| 78 | 113 |
| @@ -1,0 +1,320 @@ | |||
| 1 | + | //! A render of everything this crate can emit, scraped for class names. | |
| 2 | + | //! | |
| 3 | + | //! [`vocabulary::names`](crate::vocabulary::names) claims to hold every class | |
| 4 | + | //! this crate can put in markup, and until 0.59.0 nothing checked the claim. It | |
| 5 | + | //! was wrong by a whole family: `quasi-webview`'s own corpus guard found | |
| 6 | + | //! `cell-fill`, `form-group` and `form-label` coming out in rendered documents | |
| 7 | + | //! and had to carry them in a `MAKEOVER_UNLISTED` constant of its own, because | |
| 8 | + | //! an app checking its stylesheet against our set alone concludes that its | |
| 9 | + | //! rules for them are dead and deletes live styling. | |
| 10 | + | //! | |
| 11 | + | //! What that guard recorded is also why this is a render and not a scan of the | |
| 12 | + | //! emitters: a careful read of `quasi-webview`'s emitters produced 35 names and | |
| 13 | + | //! its corpus found 14 more. A class assembled at runtime -- a width class, a | |
| 14 | + | //! drop class, a state appended to an attribute already open -- is a literal | |
| 15 | + | //! nowhere in this source, and that is the shape of every name that was | |
| 16 | + | //! missing here. | |
| 17 | + | //! | |
| 18 | + | //! # What it is not | |
| 19 | + | //! | |
| 20 | + | //! Not a rendering test. Nothing here asserts what an emitter produced, only | |
| 21 | + | //! which classes came out, so it stays quiet when markup changes and speaks | |
| 22 | + | //! when the vocabulary does. | |
| 23 | + | ||
| 24 | + | use crate::form::{Filling, Markup, Value, field_html}; | |
| 25 | + | use crate::list::{Cell, cells_html}; | |
| 26 | + | use crate::{Emit, facet::facet_html, figure::figures_html}; | |
| 27 | + | use crate::{meter::meter_html, placeholder::placeholder_html}; | |
| 28 | + | use makeover_layout::{ | |
| 29 | + | Accepted, CellPart, Choice, Column, Facet, FacetValue, Field, FieldKind, Figure, Meter, | |
| 30 | + | Priority, Readiness, Selecting, Sort, Standing, Tone, Width, | |
| 31 | + | }; | |
| 32 | + | use std::collections::BTreeSet; | |
| 33 | + | ||
| 34 | + | /// Every class the corpus puts in a document, unprefixed. | |
| 35 | + | /// | |
| 36 | + | /// Scraped from `class="..."` rather than predicted, which is the point. | |
| 37 | + | pub(crate) fn emitted() -> BTreeSet<String> { | |
| 38 | + | emitted_with(&Emit::default()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// [`emitted`], with the emit options a host would set. | |
| 42 | + | fn emitted_with(opts: &Emit) -> BTreeSet<String> { | |
| 43 | + | let mut found = BTreeSet::new(); | |
| 44 | + | for html in documents(opts) { | |
| 45 | + | let mut rest = html.as_str(); | |
| 46 | + | while let Some(at) = rest.find("class=\"") { | |
| 47 | + | rest = &rest[at + "class=\"".len()..]; | |
| 48 | + | let end = rest.find('"').expect("an attribute closes"); | |
| 49 | + | for name in rest[..end].split_whitespace() { | |
| 50 | + | found.insert(name.to_owned()); | |
| 51 | + | } | |
| 52 | + | rest = &rest[end..]; | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | found | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// One document per emitter, over every input that changes what it writes. | |
| 59 | + | /// | |
| 60 | + | /// Every markup emitter this crate has is called here. A new one that is not | |
| 61 | + | /// added is the one hole this guard has, which is why the list is short enough | |
| 62 | + | /// to read: `placeholder`, `form`, `figure`, `facet`, `meter` and `list` are | |
| 63 | + | /// the whole of what emits markup, and `lib.rs` writes rules rather than | |
| 64 | + | /// documents. | |
| 65 | + | fn documents(opts: &Emit) -> Vec<String> { | |
| 66 | + | let mut out = vec![placeholders(opts), fields(opts), rows(opts)]; | |
| 67 | + | out.push(figures_html( | |
| 68 | + | &[ | |
| 69 | + | Figure::new("42", "Tasks"), | |
| 70 | + | Figure::new("12.5%", "Growth") | |
| 71 | + | .change("+3") | |
| 72 | + | .tone(Tone::Success), | |
| 73 | + | ], | |
| 74 | + | opts, | |
| 75 | + | )); | |
| 76 | + | for mode in [ | |
| 77 | + | Selecting::OneOf, | |
| 78 | + | Selecting::AnyOf, | |
| 79 | + | Selecting::Range, | |
| 80 | + | Selecting::Text, | |
| 81 | + | Selecting::Subtree, | |
| 82 | + | ] { | |
| 83 | + | let values = [ | |
| 84 | + | FacetValue::new("music", "Music") | |
| 85 | + | .standing(Standing::Taken) | |
| 86 | + | .counted(128) | |
| 87 | + | .at(0, true), | |
| 88 | + | FacetValue::new("music/synths", "Synths") | |
| 89 | + | .standing(Standing::Inherited) | |
| 90 | + | .at(1, false), | |
| 91 | + | FacetValue::new("music/drums", "Drums") | |
| 92 | + | .standing(Standing::Pruned) | |
| 93 | + | .at(1, false), | |
| 94 | + | FacetValue::new("talk", "Talk").at(0, false), | |
| 95 | + | ]; | |
| 96 | + | out.push(facet_html(&Facet::new("Tag", mode, &values), opts)); | |
| 97 | + | } | |
| 98 | + | // Toned, untoned, and over its total, which is the one state that adds an | |
| 99 | + | // attribute of its own. | |
| 100 | + | for meter in [ | |
| 101 | + | Meter::new(3, 7), | |
| 102 | + | Meter::new(3, 7).tone(Tone::Success).label("subtasks"), | |
| 103 | + | Meter::new(9, 7).tone(Tone::Danger), | |
| 104 | + | ] { | |
| 105 | + | out.push(meter_html(&meter, opts)); | |
| 106 | + | } | |
| 107 | + | out | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Every readiness, with and without the action a stand-in can carry. | |
| 111 | + | fn placeholders(opts: &Emit) -> String { | |
| 112 | + | let mut html = String::new(); | |
| 113 | + | for state in [ | |
| 114 | + | Readiness::Ready, | |
| 115 | + | Readiness::Pending, | |
| 116 | + | Readiness::Empty, | |
| 117 | + | Readiness::Failed, | |
| 118 | + | ] { | |
| 119 | + | html.push_str(&placeholder_html(state, "Nothing here", None, opts)); | |
| 120 | + | html.push_str(&placeholder_html( | |
| 121 | + | state, | |
| 122 | + | "Nothing here", | |
| 123 | + | Some(Markup("<button>Add one</button>")), | |
| 124 | + | opts, | |
| 125 | + | )); | |
| 126 | + | } | |
| 127 | + | html | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Every field kind, twice: plain, and carrying everything a group can hold. | |
| 131 | + | /// | |
| 132 | + | /// The second pass is where the vocabulary lives. A hint, a unit and an error | |
| 133 | + | /// each add a class, and an error adds two -- one on the message and one on the | |
| 134 | + | /// group, which is `Field::invalid`'s own reasoning about a renderer that | |
| 135 | + | /// cannot find the group from the message. | |
| 136 | + | fn fields(opts: &Emit) -> String { | |
| 137 | + | const OPTIONS: &[Choice<'_>] = &[ | |
| 138 | + | Choice::new("a", "The first"), | |
| 139 | + | Choice::new("b", "The second").unless("Not while the first is running"), | |
| 140 | + | ]; | |
| 141 | + | const ACCEPT: &[Accepted<'_>] = &[Accepted::Type("image/png"), Accepted::Suffix(".zip")]; | |
| 142 | + | ||
| 143 | + | let mut html = String::new(); | |
| 144 | + | for kind in [ | |
| 145 | + | FieldKind::Text, | |
| 146 | + | FieldKind::Secret, | |
| 147 | + | FieldKind::Number, | |
| 148 | + | FieldKind::Range, | |
| 149 | + | FieldKind::Interval, | |
| 150 | + | FieldKind::Email, | |
| 151 | + | FieldKind::Url, | |
| 152 | + | FieldKind::Tel, | |
| 153 | + | FieldKind::Date, | |
| 154 | + | FieldKind::DateTime, | |
| 155 | + | FieldKind::Textarea, | |
| 156 | + | FieldKind::Rich, | |
| 157 | + | FieldKind::Select, | |
| 158 | + | FieldKind::Radio, | |
| 159 | + | FieldKind::Checkbox, | |
| 160 | + | FieldKind::File, | |
| 161 | + | FieldKind::Hidden, | |
| 162 | + | ] { | |
| 163 | + | let plain = Field { | |
| 164 | + | options: OPTIONS, | |
| 165 | + | accept: ACCEPT, | |
| 166 | + | upper_name: Some("upper"), | |
| 167 | + | min: Some("0"), | |
| 168 | + | max: Some("10"), | |
| 169 | + | ..Field::new(kind, "name", "Label") | |
| 170 | + | }; | |
| 171 | + | let dressed = Field { | |
| 172 | + | hint: Some("What it is for"), | |
| 173 | + | error: Some("That will not do"), | |
| 174 | + | unit: Some("minutes"), | |
| 175 | + | required: true, | |
| 176 | + | ..plain | |
| 177 | + | }; | |
| 178 | + | for field in [plain, dressed] { | |
| 179 | + | for value in [ | |
| 180 | + | Value::Absent, | |
| 181 | + | Value::Text("a"), | |
| 182 | + | Value::On(true), | |
| 183 | + | Value::Between { | |
| 184 | + | lower: "1", | |
| 185 | + | upper: "9", | |
| 186 | + | }, | |
| 187 | + | ] { | |
| 188 | + | html.push_str(&field_html(&field, &Filling::of(value), opts)); | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | html | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// A cell of every width and every priority, and every part a cell can be. | |
| 196 | + | /// | |
| 197 | + | /// The width and the drop are the classes no source literal carries: they are | |
| 198 | + | /// chosen from the column and pushed, which is how `cell-fill` came to be | |
| 199 | + | /// emitted by every table in the tree and named by nothing. | |
| 200 | + | fn rows(opts: &Emit) -> String { | |
| 201 | + | let mut columns = Vec::new(); | |
| 202 | + | for (index, width) in [Width::Content, Width::Fixed, Width::Fill] | |
| 203 | + | .into_iter() | |
| 204 | + | .enumerate() | |
| 205 | + | { | |
| 206 | + | for (rank, priority) in [Priority::Optional, Priority::Secondary, Priority::Essential] | |
| 207 | + | .into_iter() | |
| 208 | + | .enumerate() | |
| 209 | + | { | |
| 210 | + | columns.push(Column { | |
| 211 | + | width, | |
| 212 | + | priority, | |
| 213 | + | sortable: true, | |
| 214 | + | sorted: Some(if rank % 2 == 0 { | |
| 215 | + | Sort::Ascending | |
| 216 | + | } else { | |
| 217 | + | Sort::Descending | |
| 218 | + | }), | |
| 219 | + | ..Column::new(NAMES[index * 3 + rank]) | |
| 220 | + | }); | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | let parts = [ | |
| 225 | + | None, | |
| 226 | + | Some(CellPart::Value), | |
| 227 | + | Some(CellPart::Tokens), | |
| 228 | + | Some(CellPart::Actions), | |
| 229 | + | Some(CellPart::Link), | |
| 230 | + | ]; | |
| 231 | + | let mut html = String::new(); | |
| 232 | + | for part in parts { | |
| 233 | + | let cells: Vec<Cell<'_>> = columns | |
| 234 | + | .iter() | |
| 235 | + | .map(|column| Cell { | |
| 236 | + | column: column.name, | |
| 237 | + | part, | |
| 238 | + | content: Markup("<span>x</span>"), | |
| 239 | + | }) | |
| 240 | + | .collect(); | |
| 241 | + | html.push_str(&cells_html(&columns, &cells, opts)); | |
| 242 | + | } | |
| 243 | + | html | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// A name per column, so the nine are nine columns rather than one repeated. | |
| 247 | + | const NAMES: [&str; 9] = ["a", "b", "c", "d", "e", "f", "g", "h", "i"]; | |
| 248 | + | ||
| 249 | + | #[cfg(test)] | |
| 250 | + | mod tests { | |
| 251 | + | use super::*; | |
| 252 | + | ||
| 253 | + | #[test] | |
| 254 | + | fn every_class_this_crate_emits_is_one_its_vocabulary_names() { | |
| 255 | + | // The guard `MAKEOVER_UNLISTED` in quasi-webview was standing in for. | |
| 256 | + | // A name emitted and not written down shrinks the set an app checks | |
| 257 | + | // against, so the app concludes its live rules are dead -- silently, | |
| 258 | + | // and in the direction that deletes styling rather than keeping too | |
| 259 | + | // much of it. | |
| 260 | + | let opts = Emit::default(); | |
| 261 | + | let names = crate::vocabulary::names(&opts); | |
| 262 | + | let emitted = emitted(); | |
| 263 | + | assert!( | |
| 264 | + | emitted.len() > 30, | |
| 265 | + | "the corpus rendered {} classes, which reads as the corpus having \ | |
| 266 | + | stopped calling the emitters rather than the crate having shrunk", | |
| 267 | + | emitted.len() | |
| 268 | + | ); | |
| 269 | + | ||
| 270 | + | let stranger: Vec<&String> = emitted | |
| 271 | + | .iter() | |
| 272 | + | // The one open family, and it is this crate's: a cell carries a | |
| 273 | + | // class built from its column's name, which is app data and which | |
| 274 | + | // no set can hold. See `list::push_column_class`. | |
| 275 | + | .filter(|class| !class.starts_with("col-")) | |
| 276 | + | .filter(|class| !names.contains(*class)) | |
| 277 | + | .collect(); | |
| 278 | + | assert!( | |
| 279 | + | stranger.is_empty(), | |
| 280 | + | "{} class(es) emitted that `vocabulary::names` does not hold. Give \ | |
| 281 | + | them a rule, or add them to the unruled list `names` reads:\n{}", | |
| 282 | + | stranger.len(), | |
| 283 | + | stranger | |
| 284 | + | .iter() | |
| 285 | + | .map(|c| format!(" .{c}")) | |
| 286 | + | .collect::<Vec<_>>() | |
| 287 | + | .join("\n") | |
| 288 | + | ); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn a_class_prefix_reaches_every_class_but_the_states() { | |
| 293 | + | // The same guard with a prefix set, which is a different question: a | |
| 294 | + | // name that reached markup without going through `class` is prefixed | |
| 295 | + | // nowhere and would pass the check above, then fail in the one app that | |
| 296 | + | // sets a prefix. `names` answers for both, because it prefixes the | |
| 297 | + | // written-down half and leaves the states alone. | |
| 298 | + | let opts = Emit { | |
| 299 | + | class_prefix: "mk-", | |
| 300 | + | ..Emit::default() | |
| 301 | + | }; | |
| 302 | + | let names = crate::vocabulary::names(&opts); | |
| 303 | + | let stranger: Vec<String> = emitted_with(&opts) | |
| 304 | + | .into_iter() | |
| 305 | + | .filter(|class| !class.starts_with("mk-col-")) | |
| 306 | + | .filter(|class| !names.contains(class)) | |
| 307 | + | .collect(); | |
| 308 | + | assert!( | |
| 309 | + | stranger.is_empty(), | |
| 310 | + | "{} class(es) a prefixed render emits that `vocabulary::names` does \ | |
| 311 | + | not hold:\n{}", | |
| 312 | + | stranger.len(), | |
| 313 | + | stranger | |
| 314 | + | .iter() | |
| 315 | + | .map(|c| format!(" .{c}")) | |
| 316 | + | .collect::<Vec<_>>() | |
| 317 | + | .join("\n") | |
| 318 | + | ); | |
| 319 | + | } | |
| 320 | + | } |