use crate::form::escape_into;
use crate::reset::Reset;
use crate::{Emit, class, push_class};
use makeover_layout::{Depth, Facet, FacetValue, GroupRank, Join, 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",
"facet-rank",
"facet-start",
"facet-cut",
"facet-join",
"facet-join-word",
];
#[must_use]
pub const fn kind_name(value: &FacetValue<'_>) -> &'static str {
if value.pickable {
"value"
} else if value.branching {
"heading"
} else {
"link"
}
}
#[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",
Standing::PrunedAbove => "pruned-above",
_ => "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) {
emit_facet(facet, opts, out, None);
}
pub fn facet_html_placed(
facet: &Facet<'_>,
opts: &Emit,
out: &mut String,
placed: &mut Vec<core::ops::Range<usize>>,
) {
emit_facet(facet, opts, out, Some(placed));
}
fn emit_facet(
facet: &Facet<'_>,
opts: &Emit,
out: &mut String,
mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
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,
"\" {}=\"--facet-reach: {}\">",
crate::VARS_ATTR,
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 {
let at = out.len();
value_html_into(facet, value, opts, out);
if let Some(placed) = placed.as_deref_mut() {
placed.push(at..out.len());
}
}
}
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));
out.push_str("\" data-kind=\"");
out.push_str(kind_name(value));
let _ = write!(
out,
"\" {}=\"--facet-depth: {}\">",
crate::VARS_ATTR,
value.depth.level
);
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('"');
if value.pickable {
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.is_open() { "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 sign = if value.gain { "+" } else { "" };
let _ = write!(out, "\">{sign}{count}</span>");
} else if let Some(rank) = value.rank {
rank_html_into(rank, opts, out);
}
out.push_str("</button>");
if !value.pickable
&& value.is_open()
&& let Some(join) = value.join
{
join_html_into(value, join, opts, out);
}
if facet.mode.prunes() && value.pickable {
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=\"");
escape_into(opts.exclude_label, out);
out.push(' ');
escape_into(value.label, out);
out.push_str("\">\u{2715}</button>");
}
out.push_str("</li>");
}
pub fn rank_html_into(rank: GroupRank, opts: &Emit, out: &mut String) {
out.push_str("<span class=\"");
push_class(out, "facet-rank", opts);
out.push_str("\">");
if rank.start {
out.push_str("<span class=\"");
push_class(out, "facet-start", opts);
out.push_str("\">");
escape_into(opts.start_label, out);
out.push_str("</span>");
}
if rank.in_use || !rank.live {
out.push_str("<span class=\"");
push_class(out, "facet-count", opts);
out.push_str("\">");
escape_into(
if rank.in_use {
opts.in_use_label
} else {
opts.dead_label
},
out,
);
out.push_str("</span>");
} else {
out.push_str("<span class=\"");
push_class(out, "facet-cut", opts);
let _ = write!(
out,
"\" aria-hidden=\"true\" {}=\"--facet-cut: {}%\"></span>",
crate::VARS_ATTR,
rank.cut
);
}
out.push_str("</span>");
}
fn join_html_into(value: &FacetValue<'_>, join: Join, opts: &Emit, out: &mut String) {
out.push_str("<span class=\"");
push_class(out, "facet-join", opts);
out.push_str("\" role=\"group\" aria-label=\"");
escape_into(opts.join_label, out);
out.push(' ');
escape_into(value.label, out);
out.push_str("\">");
for (word, label) in [(Join::Any, opts.any_label), (Join::All, opts.all_label)] {
out.push_str("<button type=\"button\" class=\"");
push_class(out, "facet-join-word", opts);
out.push_str("\" data-facet-value=\"");
escape_into(value.value, out);
out.push_str("\" data-facet-join=\"");
out.push_str(join_name(word));
out.push_str("\" aria-pressed=\"");
out.push_str(if word == join { "true\">" } else { "false\">" });
escape_into(label, out);
out.push_str("</button>");
}
out.push_str("</span>");
}
#[must_use]
pub const fn join_name(join: Join) -> &'static str {
match join {
Join::All => "all",
_ => "any",
}
}
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}}");
css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
let _ = writeln!(
css,
".{value} {{\n display: flex;\n align-items: center;\n gap: var(--gap-bound);\n padding-inline-start: calc(var(--facet-depth, 0) * var(--gap-group));\n}}"
);
let _ = writeln!(
css,
".{panel} {{\n min-inline-size: calc(var(--facet-reach, 0) * var(--gap-group));\n}}"
);
css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
let _ = writeln!(
css,
".{take} {{\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: space-between;\n gap: var(--gap-peer);\n min-block-size: var(--tap-target, 2rem);\n padding: 0 var(--gap-bound);\n color: var(--content);\n text-decoration: none;\n text-align: start;\n font: inherit;\n}}"
);
css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
css.push_str(&held_rules(&take));
for standing in [Standing::Pruned, Standing::PrunedAbove] {
let _ = writeln!(
css,
".{value}[data-standing=\"{}\"] .{take} {{\n color: var(--{});\n}}",
standing_name(standing),
standing.intent()
);
}
let _ = writeln!(
css,
".{count} {{\n color: var(--content-muted);\n font-variant-numeric: tabular-nums;\n}}"
);
css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{prune}")));
let _ = writeln!(
css,
".{prune} {{\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-block-size: var(--tap-target, 2rem);\n min-inline-size: var(--tap-target, 2rem);\n color: var(--content-secondary);\n text-decoration: none;\n font: inherit;\n}}"
);
css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
css.push_str(&held_rules(&prune));
let rank = class("facet-rank", opts);
let start = class("facet-start", opts);
let cut = class("facet-cut", opts);
let join = class("facet-join", opts);
let word = class("facet-join-word", opts);
let _ = writeln!(
css,
".{value}[data-kind=\"heading\"] .{take} {{\n font-weight: bold;\n}}"
);
let _ = writeln!(
css,
".{value}[data-kind=\"link\"] .{take} {{\n color: var(--content-secondary);\n}}"
);
let _ = writeln!(
css,
".{rank} {{\n display: inline-flex;\n align-items: center;\n gap: var(--gap-peer);\n font-weight: normal;\n}}"
);
let _ = writeln!(css, ".{start} {{\n color: var(--content-secondary);\n}}");
let _ = writeln!(
css,
".{cut} {{\n inline-size: calc(var(--gap-pane) * 2);\n block-size: var(--gap-bound);\n background: linear-gradient(to right, var(--content-secondary) var(--facet-cut, 0%), var(--surface-raised) 0);\n}}"
);
let _ = writeln!(
css,
".{join} {{\n display: inline-flex;\n flex: none;\n}}"
);
css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{word}")));
let _ = writeln!(
css,
".{word} {{\n min-block-size: var(--tap-target, 2rem);\n padding: 0 var(--gap-bound);\n color: var(--content-secondary);\n font: inherit;\n}}"
);
css.push_str(&crate::interactive_rules(&word, Depth::Flat, opts));
css.push_str(&held_rules(&word));
css
}
fn held_rules(class: &str) -> String {
let mut css = String::new();
for state in ["aria-pressed", "aria-current"] {
css.push_str(&crate::depth_rule(
&format!("{class}[{state}=\"true\"]"),
Depth::Well,
));
}
css
}
#[cfg(test)]
mod tests {
use super::*;
use makeover_layout::Nesting;
fn tag_values() -> [FacetValue<'static>; 3] {
[
FacetValue::new("music", "Music")
.standing(Standing::Taken)
.counted(128)
.at(Nesting::at(0), true),
FacetValue::new("music/synths", "Synths")
.standing(Standing::Inherited)
.at(Nesting::at(1), false),
FacetValue::new("music/drums", "Drums")
.standing(Standing::Pruned)
.at(Nesting::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_value_is_one_line_laid_out_with_geometry_that_exists() {
let css = facet_rules(&Emit::default());
assert!(!css.contains("--space-tight"), "{css}");
assert!(css.contains("var(--gap-group)"), "{css}");
assert!(css.contains("justify-content: space-between"), "{css}");
assert!(css.contains("font-variant-numeric: tabular-nums"), "{css}");
assert!(css.contains("text-decoration: none"), "{css}");
}
#[test]
fn a_value_drawn_as_a_link_is_held_in_while_it_is_in_force() {
let css = facet_rules(&Emit::default());
for control in ["facet-take", "facet-prune"] {
assert!(
css.contains(&format!(r#".{control}[aria-current="true"]"#)),
"{control} drawn as an anchor must read pressed in force: {css}"
);
assert!(css.contains(&format!(r#".{control}[aria-pressed="true"]"#)));
}
}
#[test]
fn a_value_under_a_prune_is_named_and_reads_back_like_one() {
let values = [FacetValue::new("music/drums/808", "808")
.standing(Standing::PrunedAbove)
.at(Nesting::at(2), false)];
let html = facet_html(
&Facet::new("Tag", Selecting::Subtree, &values),
&Emit::default(),
);
assert!(html.contains(r#"data-standing="pruned-above""#), "{html}");
assert!(
!html
.contains(r#"facet-prune" data-facet-value="music/drums/808" aria-pressed="true""#)
);
let css = facet_rules(&Emit::default());
assert!(
css.contains(r#"[data-standing="pruned-above"]"#),
"the sheet reads a value under a prune back a step: {css}"
);
}
#[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 a_value_reads_flat_before_it_is_touched() {
let css = facet_rules(&Emit::default());
for name in ["facet-take", "facet-prune"] {
assert!(
css.contains(&format!(
".{name} {{\n background: none;\n border: none;\n box-shadow: none;\n}}"
)),
"{name} is not withdrawn: {css}"
);
}
}
#[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 mut html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
let groups = group_values();
html.push_str(&facet_html(
&Facet::new("Tags", Selecting::Subtree, &groups),
&opts,
));
for name in FACET_CLASSES {
assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
}
}
fn group_values() -> [FacetValue<'static>; 5] {
[
FacetValue::new("genre", "Genre")
.heading()
.at(Nesting::at(0), true)
.opened(true)
.joined(Join::All)
.ranked(GroupRank::in_use()),
FacetValue::new("genre.ambient", "Ambient")
.standing(Standing::Taken)
.counted(12)
.at(Nesting::at(1), false),
FacetValue::new("genre.drone", "Drone")
.counted(3)
.gained()
.at(Nesting::at(1), false),
FacetValue::new("mood", "Mood")
.heading()
.at(Nesting::at(0), true)
.opened(false)
.joined(Join::Any)
.ranked(GroupRank::cutting(70).starting()),
FacetValue::new("more", "More groups (2)").heading(),
]
}
#[test]
fn a_group_opens_rather_than_picks_and_says_how_its_picks_combine() {
let groups = group_values();
let html = facet_html(
&Facet::new("Tags", Selecting::Subtree, &groups),
&Emit::default(),
);
let genre = html.split("<li").nth(1).unwrap();
assert!(genre.contains(r#"data-kind="heading""#), "{genre}");
assert!(genre.contains(r#"aria-expanded="true""#), "{genre}");
assert!(!genre.contains("aria-pressed=\"false\">Genre"), "{genre}");
assert!(!genre.contains("facet-prune"), "{genre}");
assert!(genre.contains("in use"), "{genre}");
assert!(
genre.contains(r#"data-facet-join="all" aria-pressed="true""#),
"{genre}"
);
assert!(html.contains(">+3</span>"), "{html}");
let mood = html.split("<li").nth(4).unwrap();
assert!(!mood.contains("facet-join"), "{mood}");
assert!(mood.contains("start here"), "{mood}");
assert!(mood.contains("--facet-cut: 70%"), "{mood}");
let more = html.split("<li").nth(5).unwrap();
assert!(more.contains(r#"data-kind="link""#), "{more}");
assert!(
!more.contains("aria-pressed") && !more.contains("aria-expanded"),
"{more}"
);
}
}