1use crate::form::escape_into;
38use crate::reset::Reset;
39use crate::{Emit, class, push_class};
40use makeover_layout::{Depth, Facet, FacetValue, GroupRank, Join, Selecting, Standing};
41use std::fmt::Write as _;
42
43pub const FACET_CLASSES: &[&str] = &[
50 "facet",
51 "facet-name",
52 "facet-values",
53 "facet-value",
54 "facet-take",
55 "facet-count",
56 "facet-prune",
57 "facet-rank",
58 "facet-start",
59 "facet-cut",
60 "facet-join",
61 "facet-join-word",
62];
63
64#[must_use]
67pub const fn kind_name(value: &FacetValue<'_>) -> &'static str {
68 if value.pickable {
69 "value"
70 } else if value.branching {
71 "heading"
72 } else {
73 "link"
74 }
75}
76
77#[must_use]
84pub const fn selecting_name(mode: Selecting) -> &'static str {
85 match mode {
86 Selecting::OneOf => "one-of",
87 Selecting::AnyOf => "any-of",
88 Selecting::Range => "range",
89 Selecting::Text => "text",
90 Selecting::Subtree => "subtree",
91 _ => "unknown",
96 }
97}
98
99#[must_use]
101pub const fn standing_name(standing: Standing) -> &'static str {
102 match standing {
103 Standing::Open => "open",
104 Standing::Taken => "taken",
105 Standing::Inherited => "inherited",
106 Standing::Pruned => "pruned",
107 Standing::PrunedAbove => "pruned-above",
108 _ => "open",
111 }
112}
113
114#[must_use]
142pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String {
143 let mut html = String::new();
144 facet_html_into(facet, opts, &mut html);
145 html
146}
147
148pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
152 emit_facet(facet, opts, &mut Buttons, out, None);
153}
154
155pub fn facet_html_with(
161 facet: &Facet<'_>,
162 opts: &Emit,
163 controls: &mut impl FacetControls,
164 out: &mut String,
165) {
166 emit_facet(facet, opts, controls, out, None);
167}
168
169pub fn facet_html_placed(
177 facet: &Facet<'_>,
178 opts: &Emit,
179 out: &mut String,
180 placed: &mut Vec<core::ops::Range<usize>>,
181) {
182 emit_facet(facet, opts, &mut Buttons, out, Some(placed));
183}
184
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum FacetControl {
189 Take,
191 Join(Join),
193 Prune,
195}
196
197pub trait FacetControls {
202 fn draws(&mut self, _index: usize, _control: FacetControl) -> bool {
204 true
205 }
206
207 fn open(&mut self, _index: usize, _control: FacetControl, out: &mut String) -> &'static str {
210 out.push_str("<button type=\"button\"");
211 "</button>"
212 }
213
214 fn state(&mut self, _index: usize, _control: FacetControl, on: bool, out: &mut String) {
216 out.push_str(if on {
217 " aria-pressed=\"true\""
218 } else {
219 " aria-pressed=\"false\""
220 });
221 }
222
223 fn attrs(&mut self, _index: usize, _control: FacetControl, _out: &mut String) {}
225}
226
227struct Buttons;
228
229impl FacetControls for Buttons {}
230
231fn emit_facet(
232 facet: &Facet<'_>,
233 opts: &Emit,
234 controls: &mut impl FacetControls,
235 out: &mut String,
236 mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
237) {
238 out.push_str("<div class=\"");
239 push_class(out, "facet", opts);
240 out.push_str("\" role=\"group\" data-selecting=\"");
241 out.push_str(selecting_name(facet.mode));
242 let _ = write!(
246 out,
247 "\" {}=\"--facet-reach: {}\">",
248 crate::VARS_ATTR,
249 facet.reach()
250 );
251
252 out.push_str("<p class=\"");
253 push_class(out, "facet-name", opts);
254 out.push_str("\">");
255 escape_into(facet.name, out);
256 out.push_str("</p>");
257
258 out.push_str("<ul class=\"");
259 push_class(out, "facet-values", opts);
260 out.push_str("\">");
261 if facet.mode.offers_values() {
262 for (index, value) in facet.values.iter().enumerate() {
263 let at = out.len();
264 value_html_into(facet, index, value, opts, controls, out);
265 if let Some(placed) = placed.as_deref_mut() {
266 placed.push(at..out.len());
267 }
268 }
269 }
270 out.push_str("</ul></div>");
271}
272
273fn control_open(
276 controls: &mut impl FacetControls,
277 index: usize,
278 control: FacetControl,
279 class: &str,
280 value: &FacetValue<'_>,
281 opts: &Emit,
282 out: &mut String,
283) -> &'static str {
284 let close = controls.open(index, control, out);
285 out.push_str(" class=\"");
286 push_class(out, class, opts);
287 out.push_str("\" data-facet-value=\"");
288 escape_into(value.value, out);
289 out.push('"');
290 close
291}
292
293fn value_html_into(
294 facet: &Facet<'_>,
295 index: usize,
296 value: &FacetValue<'_>,
297 opts: &Emit,
298 controls: &mut impl FacetControls,
299 out: &mut String,
300) {
301 out.push_str("<li class=\"");
302 push_class(out, "facet-value", opts);
303 out.push_str("\" data-standing=\"");
304 out.push_str(standing_name(value.standing));
305 out.push_str("\" data-kind=\"");
306 out.push_str(kind_name(value));
307 let _ = write!(
308 out,
309 "\" {}=\"--facet-depth: {}\">",
310 crate::VARS_ATTR,
311 value.depth.level
312 );
313
314 let take = FacetControl::Take;
315 let close = control_open(controls, index, take, "facet-take", value, opts, out);
316 if value.pickable {
320 controls.state(index, take, value.standing.in_force(), out);
321 }
322 if value.branching {
326 out.push_str(" aria-expanded=\"");
327 out.push_str(if value.is_open() { "true\"" } else { "false\"" });
328 }
329 controls.attrs(index, take, out);
330 out.push('>');
331 escape_into(value.label, out);
332
333 if let Some(count) = value.count {
338 out.push_str("<span class=\"");
339 push_class(out, "facet-count", opts);
340 let sign = if value.gain { "+" } else { "" };
341 let _ = write!(out, "\">{sign}{count}</span>");
342 } else if let Some(rank) = value.rank {
343 rank_html_into(rank, opts, out);
344 }
345 out.push_str(close);
346
347 if !value.pickable
348 && value.is_open()
349 && let Some(join) = value.join
350 {
351 join_html_into(index, value, join, opts, controls, out);
352 }
353
354 let prune = FacetControl::Prune;
355 if facet.mode.prunes() && value.pickable && controls.draws(index, prune) {
356 let close = control_open(controls, index, prune, "facet-prune", value, opts, out);
357 controls.state(index, prune, value.standing == Standing::Pruned, out);
360 out.push_str(" aria-label=\"");
364 escape_into(opts.exclude_label, out);
365 out.push(' ');
366 escape_into(value.label, out);
367 out.push('"');
368 controls.attrs(index, prune, out);
369 out.push_str(">\u{2718}");
374 out.push_str(close);
375 }
376
377 out.push_str("</li>");
378}
379
380pub fn rank_html_into(rank: GroupRank, opts: &Emit, out: &mut String) {
388 out.push_str("<span class=\"");
389 push_class(out, "facet-rank", opts);
390 out.push_str("\">");
391 if rank.start {
392 out.push_str("<span class=\"");
393 push_class(out, "facet-start", opts);
394 out.push_str("\">");
395 escape_into(opts.start_label, out);
396 out.push_str("</span>");
397 }
398 if rank.in_use || !rank.live {
399 out.push_str("<span class=\"");
400 push_class(out, "facet-count", opts);
401 out.push_str("\">");
402 escape_into(
403 if rank.in_use {
404 opts.in_use_label
405 } else {
406 opts.dead_label
407 },
408 out,
409 );
410 out.push_str("</span>");
411 } else {
412 out.push_str("<span class=\"");
413 push_class(out, "facet-cut", opts);
414 let _ = write!(
415 out,
416 "\" aria-hidden=\"true\" {}=\"--facet-cut: {}%\"></span>",
417 crate::VARS_ATTR,
418 rank.cut
419 );
420 }
421 out.push_str("</span>");
422}
423
424fn join_html_into(
428 index: usize,
429 value: &FacetValue<'_>,
430 join: Join,
431 opts: &Emit,
432 controls: &mut impl FacetControls,
433 out: &mut String,
434) {
435 let words = [(Join::Any, opts.any_label), (Join::All, opts.all_label)];
436 if !words
437 .iter()
438 .all(|&(word, _)| controls.draws(index, FacetControl::Join(word)))
439 {
440 return;
441 }
442 out.push_str("<span class=\"");
443 push_class(out, "facet-join", opts);
444 out.push_str("\" role=\"group\" aria-label=\"");
445 escape_into(opts.join_label, out);
446 out.push(' ');
447 escape_into(value.label, out);
448 out.push_str("\">");
449 for (word, label) in words {
450 let control = FacetControl::Join(word);
451 let close = control_open(
452 controls,
453 index,
454 control,
455 "facet-join-word",
456 value,
457 opts,
458 out,
459 );
460 out.push_str(" data-facet-join=\"");
461 out.push_str(join_name(word));
462 out.push('"');
463 controls.state(index, control, word == join, out);
464 controls.attrs(index, control, out);
465 out.push('>');
466 escape_into(label, out);
467 out.push_str(close);
468 }
469 out.push_str("</span>");
470}
471
472#[must_use]
474pub const fn join_name(join: Join) -> &'static str {
475 match join {
476 Join::All => "all",
477 _ => "any",
479 }
480}
481
482pub(crate) fn facet_rules(opts: &Emit) -> String {
491 let mut css = String::new();
492 let panel = class("facet", opts);
493 let name = class("facet-name", opts);
494 let values = class("facet-values", opts);
495 let value = class("facet-value", opts);
496 let take = class("facet-take", opts);
497 let count = class("facet-count", opts);
498 let prune = class("facet-prune", opts);
499
500 let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}");
503
504 css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
508
509 let _ = writeln!(
516 css,
517 ".{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}}"
518 );
519
520 let _ = writeln!(
521 css,
522 ".{panel} {{\n min-inline-size: calc(var(--facet-reach, 0) * var(--gap-group));\n}}"
523 );
524
525 css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
535 let _ = writeln!(
540 css,
541 ".{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}}"
542 );
543 css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
544 css.push_str(&held_rules(&take));
545
546 for standing in [Standing::Pruned, Standing::PrunedAbove] {
552 let _ = writeln!(
553 css,
554 ".{value}[data-standing=\"{}\"] .{take} {{\n color: var(--{});\n}}",
555 standing_name(standing),
556 standing.intent()
557 );
558 }
559
560 let _ = writeln!(
565 css,
566 ".{count} {{\n color: var(--content-muted);\n font-variant-numeric: tabular-nums;\n}}"
567 );
568
569 css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{prune}")));
573 let _ = writeln!(
574 css,
575 ".{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}}"
576 );
577 css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
578 css.push_str(&held_rules(&prune));
579
580 let rank = class("facet-rank", opts);
584 let start = class("facet-start", opts);
585 let cut = class("facet-cut", opts);
586 let join = class("facet-join", opts);
587 let word = class("facet-join-word", opts);
588 let _ = writeln!(
589 css,
590 ".{value}[data-kind=\"heading\"] .{take} {{\n font-weight: bold;\n}}"
591 );
592 let _ = writeln!(
595 css,
596 ".{value}[data-kind=\"link\"] .{take} {{\n color: var(--content-secondary);\n}}"
597 );
598
599 let _ = writeln!(
603 css,
604 ".{rank} {{\n display: inline-flex;\n align-items: center;\n gap: var(--gap-peer);\n font-weight: normal;\n}}"
605 );
606 let _ = writeln!(css, ".{start} {{\n color: var(--content-secondary);\n}}");
607 let _ = writeln!(
608 css,
609 ".{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}}"
610 );
611
612 let _ = writeln!(
615 css,
616 ".{join} {{\n display: inline-flex;\n flex: none;\n}}"
617 );
618 css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{word}")));
619 let _ = writeln!(
620 css,
621 ".{word} {{\n min-block-size: var(--tap-target, 2rem);\n padding: 0 var(--gap-bound);\n color: var(--content-secondary);\n font: inherit;\n}}"
622 );
623 css.push_str(&crate::interactive_rules(&word, Depth::Flat, opts));
624 css.push_str(&held_rules(&word));
625
626 css
627}
628
629fn held_rules(class: &str) -> String {
638 let mut css = String::new();
639 for state in ["aria-pressed", "aria-current"] {
640 css.push_str(&crate::depth_rule(
641 &format!("{class}[{state}=\"true\"]"),
642 Depth::Well,
643 ));
644 }
645 css
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use makeover_layout::Nesting;
652
653 fn tag_values() -> [FacetValue<'static>; 3] {
654 [
655 FacetValue::new("music", "Music")
656 .standing(Standing::Taken)
657 .counted(128)
658 .at(Nesting::at(0), true),
659 FacetValue::new("music/synths", "Synths")
660 .standing(Standing::Inherited)
661 .at(Nesting::at(1), false),
662 FacetValue::new("music/drums", "Drums")
663 .standing(Standing::Pruned)
664 .at(Nesting::at(1), false),
665 ]
666 }
667
668 #[test]
669 fn a_streamed_facet_is_the_facet_the_other_form_returns() {
670 let opts = Emit {
671 class_prefix: "mk-",
672 ..Emit::default()
673 };
674 let values = tag_values();
675 for facet in [
676 Facet::new("Tag", Selecting::Subtree, &values),
677 Facet::new("Type", Selecting::AnyOf, &values),
678 Facet::new("Search", Selecting::Text, &[]),
679 ] {
680 let mut streamed = String::new();
681 facet_html_into(&facet, &opts, &mut streamed);
682 assert_eq!(streamed, facet_html(&facet, &opts));
683 }
684 }
685
686 struct Links;
689
690 impl FacetControls for Links {
691 fn draws(&mut self, index: usize, control: FacetControl) -> bool {
692 !(index == 0 && control == FacetControl::Prune)
693 }
694
695 fn open(&mut self, _index: usize, control: FacetControl, out: &mut String) -> &'static str {
696 if control == FacetControl::Take {
697 out.push_str("<a");
698 "</a>"
699 } else {
700 out.push_str("<button type=\"button\"");
701 "</button>"
702 }
703 }
704
705 fn state(&mut self, _index: usize, control: FacetControl, on: bool, out: &mut String) {
706 if control != FacetControl::Take {
707 out.push_str(if on {
708 " aria-pressed=\"true\""
709 } else {
710 " aria-pressed=\"false\""
711 });
712 } else if on {
713 out.push_str(" aria-current=\"true\"");
714 }
715 }
716
717 fn attrs(&mut self, index: usize, control: FacetControl, out: &mut String) {
718 let _ = write!(out, " data-at=\"{index}:{control:?}\"");
719 }
720 }
721
722 #[test]
723 fn the_controls_a_caller_finishes_keep_the_anatomy_and_take_its_choices() {
724 let opts = Emit::default();
725 let values = tag_values();
726 let facet = Facet::new("Tag", Selecting::Subtree, &values);
727 let mut html = String::new();
728 facet_html_with(&facet, &opts, &mut Links, &mut html);
729
730 assert!(html.contains(
731 r#"<a class="facet-take" data-facet-value="music" aria-current="true" aria-expanded="true" data-at="0:Take">Music"#
732 ), "{html}");
733 assert!(
734 html.contains(r#"<span class="facet-count">128</span></a>"#),
735 "{html}"
736 );
737 assert!(!html.contains(r#"data-at="0:Prune""#), "{html}");
739 assert!(html.contains(
740 r#"<button type="button" class="facet-prune" data-facet-value="music/drums" aria-pressed="true" aria-label="Exclude Drums" data-at="2:Prune">"#
741 ), "{html}");
742
743 let groups = group_values();
744 let facet = Facet::new("Tags", Selecting::Subtree, &groups);
745 let mut html = String::new();
746 facet_html_with(&facet, &opts, &mut Links, &mut html);
747 assert!(html.contains(
748 r#"class="facet-join-word" data-facet-value="genre" data-facet-join="all" aria-pressed="true" data-at="0:Join(All)">"#
749 ), "{html}");
750 }
751
752 #[test]
753 fn a_caller_that_changes_nothing_writes_the_plain_facet() {
754 let opts = Emit {
755 class_prefix: "mk-",
756 ..Emit::default()
757 };
758 struct Plain;
759 impl FacetControls for Plain {}
760 let values = tag_values();
761 let groups = group_values();
762 for facet in [
763 Facet::new("Tag", Selecting::Subtree, &values),
764 Facet::new("Tags", Selecting::Subtree, &groups),
765 ] {
766 let mut with = String::new();
767 facet_html_with(&facet, &opts, &mut Plain, &mut with);
768 assert_eq!(with, facet_html(&facet, &opts));
769 }
770 }
771
772 #[test]
773 fn only_a_subtree_draws_an_exclude_affordance() {
774 let values = tag_values();
777 let subtree = facet_html(
778 &Facet::new("Tag", Selecting::Subtree, &values),
779 &Emit::default(),
780 );
781 assert!(subtree.contains("facet-prune"));
782 assert!(subtree.contains(r#"aria-label="Exclude Drums""#));
783 assert!(subtree.contains("\u{2718}</button>"), "{subtree}");
785 assert!(!subtree.contains('\u{2715}'), "{subtree}");
786
787 let flat = facet_html(
788 &Facet::new("Type", Selecting::AnyOf, &values),
789 &Emit::default(),
790 );
791 assert!(!flat.contains("facet-prune"));
792 }
793
794 #[test]
795 fn an_inherited_value_reads_as_pressed_without_having_been_pressed() {
796 let values = tag_values();
800 let html = facet_html(
801 &Facet::new("Tag", Selecting::Subtree, &values),
802 &Emit::default(),
803 );
804 let synths = html
805 .split("<li")
806 .find(|chunk| chunk.contains("music/synths"))
807 .expect("the inherited value");
808 assert!(synths.contains(r#"data-standing="inherited""#));
809 assert!(synths.contains(r#"aria-pressed="true""#));
810
811 let drums = html
812 .split("<li")
813 .find(|chunk| chunk.contains("music/drums"))
814 .expect("the pruned value");
815 assert!(drums.contains(r#"data-standing="pruned""#));
818 assert!(drums.contains(r#"aria-pressed="false""#));
819 assert!(drums.contains(r#"aria-label="Exclude Drums""#));
820 }
821
822 #[test]
823 fn a_value_is_one_line_laid_out_with_geometry_that_exists() {
824 let css = facet_rules(&Emit::default());
825 assert!(!css.contains("--space-tight"), "{css}");
828 assert!(css.contains("var(--gap-group)"), "{css}");
829 assert!(css.contains("justify-content: space-between"), "{css}");
830 assert!(css.contains("font-variant-numeric: tabular-nums"), "{css}");
831 assert!(css.contains("text-decoration: none"), "{css}");
832 }
833
834 #[test]
835 fn a_value_drawn_as_a_link_is_held_in_while_it_is_in_force() {
836 let css = facet_rules(&Emit::default());
837 for control in ["facet-take", "facet-prune"] {
838 assert!(
839 css.contains(&format!(r#".{control}[aria-current="true"]"#)),
840 "{control} drawn as an anchor must read pressed in force: {css}"
841 );
842 assert!(css.contains(&format!(r#".{control}[aria-pressed="true"]"#)));
843 }
844 }
845
846 #[test]
847 fn a_value_under_a_prune_is_named_and_reads_back_like_one() {
848 let values = [FacetValue::new("music/drums/808", "808")
849 .standing(Standing::PrunedAbove)
850 .at(Nesting::at(2), false)];
851 let html = facet_html(
852 &Facet::new("Tag", Selecting::Subtree, &values),
853 &Emit::default(),
854 );
855 assert!(html.contains(r#"data-standing="pruned-above""#), "{html}");
856 assert!(
858 !html
859 .contains(r#"facet-prune" data-facet-value="music/drums/808" aria-pressed="true""#)
860 );
861
862 let css = facet_rules(&Emit::default());
863 assert!(
864 css.contains(r#"[data-standing="pruned-above"]"#),
865 "the sheet reads a value under a prune back a step: {css}"
866 );
867 }
868
869 #[test]
870 fn a_mode_that_lists_nothing_still_renders_its_panel() {
871 let html = facet_html(
874 &Facet::new("Search", Selecting::Text, &[]),
875 &Emit::default(),
876 );
877 assert!(html.contains("facet-name"));
878 assert!(html.contains(r#"data-selecting="text""#));
879 assert!(!html.contains("facet-take"));
880 }
881
882 #[test]
883 fn an_unmeasured_count_emits_no_number_at_all() {
884 let values = [FacetValue::of("Ambient")];
887 let html = facet_html(
888 &Facet::new("Tag", Selecting::AnyOf, &values),
889 &Emit::default(),
890 );
891 assert!(!html.contains("facet-count"));
892
893 let counted = [FacetValue::of("Ambient").counted(0)];
894 let html = facet_html(
895 &Facet::new("Tag", Selecting::AnyOf, &counted),
896 &Emit::default(),
897 );
898 assert!(html.contains(">0</span>"));
899 }
900
901 #[test]
902 fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
903 let values = tag_values();
906 let html = facet_html(
907 &Facet::new("Tag", Selecting::Subtree, &values),
908 &Emit::default(),
909 );
910 assert!(html.contains("--facet-reach: 1"));
911 assert!(html.contains("--facet-depth: 0"));
912 assert!(html.contains("--facet-depth: 1"));
913 }
914
915 #[test]
916 fn labels_and_identifiers_are_escaped_like_every_other_string() {
917 let values = [FacetValue::new("a&b", "A & B")];
920 let html = facet_html(
921 &Facet::new("T<ag>", Selecting::AnyOf, &values),
922 &Emit::default(),
923 );
924 assert!(html.contains("A & B"));
925 assert!(html.contains(r#"data-facet-value="a&b""#));
926 assert!(html.contains("T<ag>"));
927 assert!(!html.contains("<ag>"));
928 }
929
930 #[test]
931 fn a_value_reads_flat_before_it_is_touched() {
932 let css = facet_rules(&Emit::default());
938 for name in ["facet-take", "facet-prune"] {
939 assert!(
940 css.contains(&format!(
941 ".{name} {{\n background: none;\n border: none;\n box-shadow: none;\n}}"
942 )),
943 "{name} is not withdrawn: {css}"
944 );
945 }
946 }
947
948 #[test]
949 fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
950 let names = crate::vocabulary::names(&Emit::default());
953 for name in FACET_CLASSES {
954 assert!(
955 names.contains(&crate::class(name, &Emit::default())),
956 "{name} is not in the vocabulary"
957 );
958 }
959 }
960
961 #[test]
962 fn the_prefix_reaches_every_class_in_the_markup() {
963 let opts = Emit {
966 class_prefix: "mo-",
967 ..Emit::default()
968 };
969 let values = tag_values();
970 let mut html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
971 let groups = group_values();
972 html.push_str(&facet_html(
973 &Facet::new("Tags", Selecting::Subtree, &groups),
974 &opts,
975 ));
976 for name in FACET_CLASSES {
977 assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
978 }
979 }
980
981 fn group_values() -> [FacetValue<'static>; 5] {
984 [
985 FacetValue::new("genre", "Genre")
986 .heading()
987 .at(Nesting::at(0), true)
988 .opened(true)
989 .joined(Join::All)
990 .ranked(GroupRank::in_use()),
991 FacetValue::new("genre.ambient", "Ambient")
992 .standing(Standing::Taken)
993 .counted(12)
994 .at(Nesting::at(1), false),
995 FacetValue::new("genre.drone", "Drone")
996 .counted(3)
997 .gained()
998 .at(Nesting::at(1), false),
999 FacetValue::new("mood", "Mood")
1000 .heading()
1001 .at(Nesting::at(0), true)
1002 .opened(false)
1003 .joined(Join::Any)
1004 .ranked(GroupRank::cutting(70).starting()),
1005 FacetValue::new("more", "More groups (2)").heading(),
1006 ]
1007 }
1008
1009 #[test]
1010 fn a_group_opens_rather_than_picks_and_says_how_its_picks_combine() {
1011 let groups = group_values();
1012 let html = facet_html(
1013 &Facet::new("Tags", Selecting::Subtree, &groups),
1014 &Emit::default(),
1015 );
1016 let genre = html.split("<li").nth(1).unwrap();
1018 assert!(genre.contains(r#"data-kind="heading""#), "{genre}");
1019 assert!(genre.contains(r#"aria-expanded="true""#), "{genre}");
1020 assert!(!genre.contains("aria-pressed=\"false\">Genre"), "{genre}");
1021 assert!(!genre.contains("facet-prune"), "{genre}");
1022 assert!(genre.contains("in use"), "{genre}");
1023 assert!(
1025 genre.contains(r#"data-facet-join="all" aria-pressed="true""#),
1026 "{genre}"
1027 );
1028 assert!(html.contains(">+3</span>"), "{html}");
1030 let mood = html.split("<li").nth(4).unwrap();
1032 assert!(!mood.contains("facet-join"), "{mood}");
1033 assert!(mood.contains("start here"), "{mood}");
1034 assert!(mood.contains("--facet-cut: 70%"), "{mood}");
1035 let more = html.split("<li").nth(5).unwrap();
1037 assert!(more.contains(r#"data-kind="link""#), "{more}");
1038 assert!(
1039 !more.contains("aria-pressed") && !more.contains("aria-expanded"),
1040 "{more}"
1041 );
1042 }
1043}