use crate::form::escape_into;
use crate::{Emit, class, push_class};
use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
use std::fmt::Write as _;
pub const FACET_CLASSES: &[&str] = &[
"facet",
"facet-name",
"facet-values",
"facet-value",
"facet-take",
"facet-count",
"facet-prune",
];
#[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",
_ => "unknown",
}
}
#[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",
_ => "open",
}
}
#[must_use]
pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String {
let mut html = String::new();
facet_html_into(facet, opts, &mut html);
html
}
pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
out.push_str("<div class=\"");
push_class(out, "facet", opts);
out.push_str("\" role=\"group\" data-selecting=\"");
out.push_str(selecting_name(facet.mode));
let _ = write!(out, "\" style=\"--facet-reach: {}\">", facet.reach());
out.push_str("<p class=\"");
push_class(out, "facet-name", opts);
out.push_str("\">");
escape_into(facet.name, out);
out.push_str("</p>");
out.push_str("<ul class=\"");
push_class(out, "facet-values", opts);
out.push_str("\">");
if facet.mode.offers_values() {
for value in facet.values {
value_html_into(facet, value, opts, out);
}
}
out.push_str("</ul></div>");
}
fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) {
out.push_str("<li class=\"");
push_class(out, "facet-value", opts);
out.push_str("\" data-standing=\"");
out.push_str(standing_name(value.standing));
let _ = write!(out, "\" style=\"--facet-depth: {}\">", value.depth);
out.push_str("<button type=\"button\" class=\"");
push_class(out, "facet-take", opts);
out.push_str("\" data-facet-value=\"");
escape_into(value.value, out);
out.push_str("\" aria-pressed=\"");
out.push_str(if value.standing.in_force() {
"true\""
} else {
"false\""
});
if value.branching {
out.push_str(" aria-expanded=\"");
out.push_str(if value.standing.in_force() {
"true\""
} else {
"false\""
});
}
out.push('>');
escape_into(value.label, out);
if let Some(count) = value.count {
out.push_str("<span class=\"");
push_class(out, "facet-count", opts);
let _ = write!(out, "\">{count}</span>");
}
out.push_str("</button>");
if facet.mode.prunes() {
out.push_str("<button type=\"button\" class=\"");
push_class(out, "facet-prune", opts);
out.push_str("\" data-facet-value=\"");
escape_into(value.value, out);
out.push_str("\" aria-pressed=\"");
out.push_str(if value.standing == Standing::Pruned {
"true\""
} else {
"false\""
});
out.push_str(" aria-label=\"Exclude ");
escape_into(value.label, out);
out.push_str("\">\u{2715}</button>");
}
out.push_str("</li>");
}
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);
let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}");
let _ = writeln!(
css,
".{values} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}"
);
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}}"
);
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,
));
let _ = writeln!(
css,
".{value}[data-standing=\"pruned\"] .{take} {{\n color: var(--{});\n}}",
Standing::Pruned.intent()
);
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() {
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() {
let values = tag_values();
let html = facet_html(
&Facet::new("Tag", Selecting::Subtree, &values),
&Emit::default(),
);
let synths = html
.split("<li")
.find(|chunk| chunk.contains("music/synths"))
.expect("the inherited value");
assert!(synths.contains(r#"data-standing="inherited""#));
assert!(synths.contains(r#"aria-pressed="true""#));
let drums = html
.split("<li")
.find(|chunk| chunk.contains("music/drums"))
.expect("the pruned value");
assert!(drums.contains(r#"data-standing="pruned""#));
assert!(drums.contains(r#"aria-pressed="false""#));
assert!(drums.contains(r#"aria-label="Exclude Drums""#));
}
#[test]
fn a_mode_that_lists_nothing_still_renders_its_panel() {
let html = facet_html(
&Facet::new("Search", Selecting::Text, &[]),
&Emit::default(),
);
assert!(html.contains("facet-name"));
assert!(html.contains(r#"data-selecting="text""#));
assert!(!html.contains("facet-take"));
}
#[test]
fn an_unmeasured_count_emits_no_number_at_all() {
let values = [FacetValue::of("Ambient")];
let html = facet_html(
&Facet::new("Tag", Selecting::AnyOf, &values),
&Emit::default(),
);
assert!(!html.contains("facet-count"));
let counted = [FacetValue::of("Ambient").counted(0)];
let html = facet_html(
&Facet::new("Tag", Selecting::AnyOf, &counted),
&Emit::default(),
);
assert!(html.contains(">0</span>"));
}
#[test]
fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
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() {
let values = [FacetValue::new("a&b", "A & B")];
let html = facet_html(
&Facet::new("T<ag>", 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("<ag>"));
}
#[test]
fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
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() {
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");
}
}
}