//! A dimension a set is narrowed by, rendered as a list of values. //! //! The fourth phase-B emitter, beside [`form`](crate::form), //! [`list`](crate::list) and [`meter`](crate::meter). Same split as those: this //! owns the structure of the panel and the app owns the routes. A value's //! identifier leaves in `data-facet-value`, which is the hook an app wires its //! own request onto, exactly as [`list`](crate::list) writes `data-column` and //! lets the app decide what pressing a heading calls. //! //! # Why this is markup and not only CSS //! //! Phase A's rule is that an app keeps its markup and gains the classes, and //! that rule works because the markup already existed. Here it mostly does not: //! a facet panel is the shape MNW's discover page reached by writing a tick box //! and a chevron per row because filtering and browsing were two mechanisms, and //! the whole point of [`makeover_layout::Selecting::Subtree`] is that they stop //! being two. There is nothing to keep. //! //! # The one thing drawn that no flat control has //! //! An exclude affordance beside each value, in a subtree facet only. It is a //! visible control rather than a modifier or a long press, and that was ruled //! rather than chosen here: a gesture a terminal cannot express is a gesture //! half the renderers leave out, and an affordance nothing teaches is one users //! do not find. The glyph is this renderer's pick, and it takes the standing //! preference for the heavier, simpler mark. //! //! # What the depth does and does not do //! //! `--facet-depth` carries the tree level as a number, and the indent rule //! multiplies it by one geometry step. That keeps the whole tree one flat list //! in the DOM rather than nested lists, which is what lets a renderer draw the //! same description as a breadcrumb or a column of panes without the markup //! disagreeing. It is not a size: the number is the level, and the step is //! `makeover-geometry`'s. use crate::form::escape_into; use crate::{Emit, class, push_class}; use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing}; use std::fmt::Write as _; /// The classes this module can put in markup. /// /// [`crate::list::ROW_PART_CLASSES`]' obligation, and it exists for the same /// reason: every class here is also ruled by [`facet_rules`], so the vocabulary /// seal picks them up from the generated sheet, and this list is what a test /// checks that against. pub const FACET_CLASSES: &[&str] = &[ "facet", "facet-name", "facet-values", "facet-value", "facet-take", "facet-count", "facet-prune", ]; /// The name a selection mode goes by in `data-selecting`. /// /// An attribute rather than a class, for `data-selector`'s reason on a selector /// group: the mode changes what the panel *means*, not how one value is /// painted, and a class there would read as the styling hook the value's class /// actually is. #[must_use] pub const fn selecting_name(mode: Selecting) -> &'static str { match mode { Selecting::OneOf => "one-of", Selecting::AnyOf => "any-of", Selecting::Range => "range", Selecting::Text => "text", Selecting::Subtree => "subtree", // A mode added to the description since this renderer was built. // `Selecting` is `#[non_exhaustive]`, and an unknown mode reads as the // one that offers no values and prunes nothing: drawing a value list // for a mode whose values mean something else is the worse mistake. _ => "unknown", } } /// The name a standing goes by in `data-standing`. #[must_use] pub const fn standing_name(standing: Standing) -> &'static str { match standing { Standing::Open => "open", Standing::Taken => "taken", Standing::Inherited => "inherited", Standing::Pruned => "pruned", // Unknown reads as open, which is the state that claims nothing about // the set. _ => "open", } } /// A facet as a labelled list of values. /// /// ``` /// use makeover_layout::{Facet, FacetValue, Selecting, Standing}; /// use makeover_webview::{Emit, facet::facet_html}; /// /// let values = [ /// FacetValue::new("music", "Music") /// .standing(Standing::Taken) /// .counted(128) /// .at(0, true), /// FacetValue::new("music/synths", "Synths").at(1, false), /// ]; /// let facet = Facet::new("Tag", Selecting::Subtree, &values); /// let html = facet_html(&facet, &Emit::default()); /// /// assert!(html.contains(r#"data-selecting="subtree""#)); /// assert!(html.contains(r#"data-facet-value="music/synths""#)); /// // A subtree is the one mode that offers a way to prune a branch out. /// assert!(html.contains("facet-prune")); /// ``` /// /// A [`Selecting::Text`] or [`Selecting::Range`] facet lists nothing, so what /// comes back is the panel and its name with an empty list inside it. That is /// deliberate rather than an empty string: the app puts its own box in the /// panel, and the panel is what gives the box the group label and the shared /// geometry. #[must_use] pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String { let mut html = String::new(); facet_html_into(facet, opts, &mut html); html } /// A facet, written into a buffer the caller already has. /// /// [`facet_html`]'s streaming form, byte-identical to it. pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) { out.push_str("
", facet.reach()); out.push_str("

"); escape_into(facet.name, out); out.push_str("

"); out.push_str("
"); } fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) { out.push_str("
  • ", value.depth); out.push_str(""); if facet.mode.prunes() { out.push_str(""); } out.push_str("
  • "); } /// The rules for a facet panel. /// /// Depth comes from the description: a value at rest sits as /// [`Depth::Flat`] and a taken one is held in, which is /// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same /// sentence — this one is picked, so it is pressed. Nothing here states a /// colour or a size; the indent is a count multiplied by a geometry step, and /// the step is the one variable this crate is allowed to read. pub(crate) fn facet_rules(opts: &Emit) -> String { let mut css = String::new(); let panel = class("facet", opts); let name = class("facet-name", opts); let values = class("facet-values", opts); let value = class("facet-value", opts); let take = class("facet-take", opts); let count = class("facet-count", opts); let prune = class("facet-prune", opts); // The name of the dimension. A caption, and captions are legitimately // muted: it was never going to answer a press. let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}"); // The list undoes the bullet a browser adds, for `row_rules`' reason: a // described set of tags is not a bulleted list and rendered as one because // nothing said otherwise. let _ = writeln!( css, ".{values} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}" ); // The indent is the level times one step. `--facet-depth` is written per // value and `--facet-reach` per panel; the panel one reserves the gutter so // nothing moves as deeper values arrive. let _ = writeln!( css, ".{value} {{\n padding-inline-start: calc(var(--facet-depth, 0) * var(--space-tight, 0.5rem));\n}}" ); let _ = writeln!( css, ".{panel} {{\n min-inline-size: calc(var(--facet-reach, 0) * var(--space-tight, 0.5rem));\n}}" ); // The value's own control. Flat at rest, held in when it is in force, and // that is the segmented control's sentence rather than a new one. css.push_str(&crate::depth_rule(&take, Depth::Flat)); css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts)); css.push_str(&crate::depth_rule( &format!("{take}[aria-pressed=\"true\"]"), Depth::Well, )); // A pruned branch reads one step back and stays live: pressing it takes the // prune off, so it may not wear `content-muted`. `Standing::intent` is // where that is decided. let _ = writeln!( css, ".{value}[data-standing=\"pruned\"] .{take} {{\n color: var(--{});\n}}", Standing::Pruned.intent() ); // Inherited is in force and was not chosen. It reads as the thing itself, // like a taken value, and the difference is carried by the attribute for // whoever wants it rather than by a colour claiming something. let _ = writeln!(css, ".{count} {{\n color: var(--content-muted);\n}}"); css.push_str(&crate::depth_rule(&prune, Depth::Flat)); css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts)); css.push_str(&crate::depth_rule( &format!("{prune}[aria-pressed=\"true\"]"), Depth::Well, )); css } #[cfg(test)] mod tests { use super::*; fn tag_values() -> [FacetValue<'static>; 3] { [ FacetValue::new("music", "Music") .standing(Standing::Taken) .counted(128) .at(0, true), FacetValue::new("music/synths", "Synths") .standing(Standing::Inherited) .at(1, false), FacetValue::new("music/drums", "Drums") .standing(Standing::Pruned) .at(1, false), ] } #[test] fn a_streamed_facet_is_the_facet_the_other_form_returns() { let opts = Emit { class_prefix: "mk-", ..Emit::default() }; let values = tag_values(); for facet in [ Facet::new("Tag", Selecting::Subtree, &values), Facet::new("Type", Selecting::AnyOf, &values), Facet::new("Search", Selecting::Text, &[]), ] { let mut streamed = String::new(); facet_html_into(&facet, &opts, &mut streamed); assert_eq!(streamed, facet_html(&facet, &opts)); } } #[test] fn only_a_subtree_draws_an_exclude_affordance() { // The one control a flat facet has no use for: excluding a value there // is the same fact as not picking it. let values = tag_values(); let subtree = facet_html( &Facet::new("Tag", Selecting::Subtree, &values), &Emit::default(), ); assert!(subtree.contains("facet-prune")); assert!(subtree.contains(r#"aria-label="Exclude Drums""#)); let flat = facet_html( &Facet::new("Type", Selecting::AnyOf, &values), &Emit::default(), ); assert!(!flat.contains("facet-prune")); } #[test] fn an_inherited_value_reads_as_pressed_without_having_been_pressed() { // The distinction `Standing` has four members for. A renderer given a // bool marks every descendant of a taken branch or marks none, and both // are wrong on screen. let values = tag_values(); let html = facet_html( &Facet::new("Tag", Selecting::Subtree, &values), &Emit::default(), ); let synths = html .split("0")); } #[test] fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() { // "First paint is final paint" applied to a tree: a gutter widened as // deeper values arrive is the reflow that rule forbids. let values = tag_values(); let html = facet_html( &Facet::new("Tag", Selecting::Subtree, &values), &Emit::default(), ); assert!(html.contains("--facet-reach: 1")); assert!(html.contains("--facet-depth: 0")); assert!(html.contains("--facet-depth: 1")); } #[test] fn labels_and_identifiers_are_escaped_like_every_other_string() { // Both arrive from the app, and a tag path is user-supplied on a system // where a user names their own tags. let values = [FacetValue::new("a&b", "A & B")]; let html = facet_html( &Facet::new("T", Selecting::AnyOf, &values), &Emit::default(), ); assert!(html.contains("A & B")); assert!(html.contains(r#"data-facet-value="a&b""#)); assert!(html.contains("T<ag>")); assert!(!html.contains("")); } #[test] fn every_class_this_module_emits_is_one_the_stylesheet_rules() { // `ROW_PART_CLASSES`' obligation: a class this crate writes and the // sheet does not rule is invisible to the dead-vocabulary seal. let names = crate::vocabulary::names(&Emit::default()); for name in FACET_CLASSES { assert!( names.contains(&crate::class(name, &Emit::default())), "{name} is not in the vocabulary" ); } } #[test] fn the_prefix_reaches_every_class_in_the_markup() { // A prefixed build claims its own names, and the emitted CSS selects // descendants: miss one and the rule stops matching. let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let values = tag_values(); let html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts); for name in FACET_CLASSES { assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed"); } } }