Skip to main content

makeover_webview/
facet.rs

1//! A dimension a set is narrowed by, rendered as a list of values.
2//!
3//! The fourth phase-B emitter, beside [`form`](crate::form),
4//! [`list`](crate::list) and [`meter`](crate::meter). Same split as those: this
5//! owns the structure of the panel and the app owns the routes. A value's
6//! identifier leaves in `data-facet-value`, which is the hook an app wires its
7//! own request onto, exactly as [`list`](crate::list) writes `data-column` and
8//! lets the app decide what pressing a heading calls.
9//!
10//! # Why this is markup and not only CSS
11//!
12//! Phase A's rule is that an app keeps its markup and gains the classes, and
13//! that rule works because the markup already existed. Here it mostly does not:
14//! a facet panel is the shape MNW's discover page reached by writing a tick box
15//! and a chevron per row because filtering and browsing were two mechanisms, and
16//! the whole point of [`makeover_layout::Selecting::Subtree`] is that they stop
17//! being two. There is nothing to keep.
18//!
19//! # The one thing drawn that no flat control has
20//!
21//! An exclude affordance beside each value, in a subtree facet only. It is a
22//! visible control rather than a modifier or a long press, and that was ruled
23//! rather than chosen here: a gesture a terminal cannot express is a gesture
24//! half the renderers leave out, and an affordance nothing teaches is one users
25//! do not find. The glyph is this renderer's pick, and it takes the standing
26//! preference for the heavier, simpler mark.
27//!
28//! # What the depth does and does not do
29//!
30//! `--facet-depth` carries the tree level as a number, and the indent rule
31//! multiplies it by one geometry step. That keeps the whole tree one flat list
32//! in the DOM rather than nested lists, which is what lets a renderer draw the
33//! same description as a breadcrumb or a column of panes without the markup
34//! disagreeing. It is not a size: the number is the level, and the step is
35//! `makeover-geometry`'s.
36
37use 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
43/// The classes this module can put in markup.
44///
45/// [`crate::list::ROW_PART_CLASSES`]' obligation, and it exists for the same
46/// reason: every class here is also ruled by `facet_rules`, so the vocabulary
47/// seal picks them up from the generated sheet, and this list is what a test
48/// checks that against.
49pub 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/// What a value is to a reader, as `data-kind` says it: a pick, a group's
65/// heading (pressing opens it), or a link row (pressing is its whole meaning).
66#[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/// The name a selection mode goes by in `data-selecting`.
78///
79/// An attribute rather than a class, for `data-selector`'s reason on a selector
80/// group: the mode changes what the panel *means*, not how one value is
81/// painted, and a class there would read as the styling hook the value's class
82/// actually is.
83#[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        // A mode added to the description since this renderer was built.
92        // `Selecting` is `#[non_exhaustive]`, and an unknown mode reads as the
93        // one that offers no values and prunes nothing: drawing a value list
94        // for a mode whose values mean something else is the worse mistake.
95        _ => "unknown",
96    }
97}
98
99/// The name a standing goes by in `data-standing`.
100#[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        // Unknown reads as open, which is the state that claims nothing about
109        // the set.
110        _ => "open",
111    }
112}
113
114/// A facet as a labelled list of values.
115///
116/// ```
117/// use makeover_layout::{Facet, FacetValue, Nesting, Selecting, Standing};
118/// use makeover_webview::{Emit, facet::facet_html};
119///
120/// let values = [
121///     FacetValue::new("music", "Music")
122///         .standing(Standing::Taken)
123///         .counted(128)
124///         .at(Nesting::at(0), true),
125///     FacetValue::new("music/synths", "Synths").at(Nesting::at(1), false),
126/// ];
127/// let facet = Facet::new("Tag", Selecting::Subtree, &values);
128/// let html = facet_html(&facet, &Emit::default());
129///
130/// assert!(html.contains(r#"data-selecting="subtree""#));
131/// assert!(html.contains(r#"data-facet-value="music/synths""#));
132/// // A subtree is the one mode that offers a way to prune a branch out.
133/// assert!(html.contains("facet-prune"));
134/// ```
135///
136/// A [`Selecting::Text`] or [`Selecting::Range`] facet lists nothing, so what
137/// comes back is the panel and its name with an empty list inside it. That is
138/// deliberate rather than an empty string: the app puts its own box in the
139/// panel, and the panel is what gives the box the group label and the shared
140/// geometry.
141#[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
148/// A facet, written into a buffer the caller already has.
149///
150/// [`facet_html`]'s streaming form, byte-identical to it.
151pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
152    emit_facet(facet, opts, &mut Buttons, out, None);
153}
154
155/// A facet whose controls the caller finishes, written into its buffer.
156///
157/// The anatomy is [`facet_html_into`]'s, class for class; `controls` decides
158/// what tag each control is, how it says it is in force, which attributes it
159/// carries beyond the emitter's own, and whether it is drawn at all.
160pub 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
169/// A facet, saying where each value landed.
170///
171/// Byte-identical to [`facet_html_into`], and it appends one entry to `placed`
172/// per value drawn, in order: the offsets in `out` between which that value's
173/// whole `<li>` was written. For a caller compiling this markup into a
174/// template: a value's label may appear elsewhere in the document, so the
175/// writer is the only source for which bytes one value produced.
176pub 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/// One of the controls a value draws.
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum FacetControl {
189    /// The value's own control.
190    Take,
191    /// One of an open group's two join words.
192    Join(Join),
193    /// The exclude control, in a mode that prunes.
194    Prune,
195}
196
197/// What an app decides about each control [`facet_html_with`] draws.
198///
199/// `index` is the value's position in [`Facet::values`]. Every method defaults
200/// to what [`facet_html`] writes: a button, `aria-pressed`, nothing more.
201pub trait FacetControls {
202    /// Whether the control is drawn at all.
203    fn draws(&mut self, _index: usize, _control: FacetControl) -> bool {
204        true
205    }
206
207    /// Open the control's tag up to its class, and return the tag that closes
208    /// it.
209    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    /// Say whether the control is in force, as an attribute.
215    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    /// Attributes written after the emitter's own, before the tag closes.
224    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    // The gutter an indenting renderer reserves before it draws anything, so
243    // the panel does not widen as deeper values arrive. "First paint is final
244    // paint" applied to a tree.
245    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
273/// Open a control through `controls`: its tag, its class and the value it
274/// stands for. Returns the tag that closes it.
275fn 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    // In force and not picked by hand: an inherited value is pressed in fact
317    // even though nobody pressed it, which is `Standing::in_force`. A heading
318    // or a link row picks nothing, so it is not a toggle at all.
319    if value.pickable {
320        controls.state(index, take, value.standing.in_force(), out);
321    }
322    // A branch that opens says so, so a reader is told there is more before
323    // pressing rather than after. Open is its own state where the app keeps
324    // one, since opening a group is not picking it.
325    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    // Absent rather than zero when it was not measured, which is the
334    // description's own position: a written zero reads as "none of them". A
335    // gain is what picking adds beside a pick already made under the same
336    // parent, so it carries its sign.
337    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        // Pressed for the value's own prune only: a value out by a prune above
358        // it is not pressed here, because the decision is not here.
359        controls.state(index, prune, value.standing == Standing::Pruned, out);
360        // The accessible name is built here rather than described: a tooltip
361        // wants a sentence and a terminal wants a glyph, and a description
362        // shipping either would choose for both.
363        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        // The heavier, simpler mark. It is the glyph and not a class, because a
370        // renderer that swaps it is not changing what the control means.
371        // U+2718 because the house faces draw it (quasi-type's BODY_SURFACE);
372        // U+2715 is in no served face and drew as a missing glyph.
373        out.push_str(">\u{2718}");
374        out.push_str(close);
375    }
376
377    out.push_str("</li>");
378}
379
380/// Where a group ranks, at the end of its heading: the start mark, then a bar
381/// whose length is the cut, or a word when the group is in use or dead.
382///
383/// The bar is drawn, not written: its length is `--facet-cut` through
384/// [`VARS_ATTR`](crate::VARS_ATTR), as the depth is, so a strict style policy
385/// refuses nothing. It is hidden from the accessibility tree, since the order
386/// of the groups already says what the bars do.
387pub 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
424/// The toggle on an open group: how the picks under it combine. Two words, the
425/// one in force pressed, each naming the group in `data-facet-value` and its
426/// word in `data-facet-join` for the app to wire, as the take and the prune do.
427fn 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/// The name a join goes by in `data-facet-join`.
473#[must_use]
474pub const fn join_name(join: Join) -> &'static str {
475    match join {
476        Join::All => "all",
477        // Unknown reads as any, the default every parent starts at.
478        _ => "any",
479    }
480}
481
482/// The rules for a facet panel.
483///
484/// Depth comes from the description: a value at rest sits as
485/// [`Depth::Flat`] and a taken one is held in, which is
486/// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same
487/// sentence, this one is picked, so it is pressed. Nothing here states a
488/// colour or a size; the indent is a count multiplied by a geometry step, and
489/// the step is the one variable this crate is allowed to read.
490pub(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    // The name of the dimension. A caption, and captions are legitimately
501    // muted: it was never going to answer a press.
502    let _ = writeln!(css, ".{name} {{\n    color: var(--content-muted);\n}}");
503
504    // The list gives back what a `<ul>` brought, the same ask `row_rules`
505    // makes: a described set of tags is not a bulleted list and rendered as one
506    // because nothing said otherwise.
507    css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
508
509    // A value is one line: its control, then its exclude control. The indent
510    // is the level times one geometry step. `--facet-depth` is written per value
511    // and `--facet-reach` per panel; the panel one reserves the gutter so
512    // nothing moves as deeper values arrive. Both read `--gap-group`, which
513    // makeover-geometry defines: they read `--space-tight`, which nothing
514    // defines, and so always took the literal fallback.
515    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    // The value's own control. Flat at rest, held in when it is in force, and
526    // that is the segmented control's sentence rather than a new one.
527    //
528    // Flat states nothing, no fill and no bevel, so `depth_rule` emitted an
529    // empty string and saying it was the whole of what this arm did. Where an
530    // app hands makeover the cascade with `revert-layer`, an empty layer rolls
531    // the handoff past makeover to the app's own bare `button` rule and the
532    // value renders raised, with `[aria-pressed="true"]` its only true state.
533    // Flat here has to be said out loud, which is what the reset is for.
534    css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
535    // The control fills the line, label at its start and count at its end, so
536    // the counts of a column of values line up. It reads as content whichever
537    // element it is: a host giving a value a real address draws an anchor,
538    // which would otherwise wear link colour and an underline.
539    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    // A pruned branch reads one step back and stays live: pressing it takes the
547    // prune off, so it may not wear `content-muted`. `Standing::intent` is
548    // where that is decided. A value out because a branch above it was pruned
549    // reads the same, since to the set the two mean the same thing; the
550    // attribute keeps the difference for whoever wants it, as with inherited.
551    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    // Inherited is in force and was not chosen. It reads as the thing itself,
561    // like a taken value, and the difference is carried by the attribute for
562    // whoever wants it rather than by a colour claiming something.
563    // Figures of one width, so a column of counts reads down.
564    let _ = writeln!(
565        css,
566        ".{count} {{\n    color: var(--content-muted);\n    font-variant-numeric: tabular-nums;\n}}"
567    );
568
569    // Same withdrawal as the take, for the same reason, and a square target at
570    // the end of the line. It reads one step back: it is an offer about the
571    // value, not the value.
572    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    // A group's heading reads as a heading: the label carries the weight a
581    // section caption does, and nothing about it says pressed, because
582    // opening a group picks nothing.
583    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    // A link row is an offer about the list, not a member of it: one step
593    // back, and live.
594    let _ = writeln!(
595        css,
596        ".{value}[data-kind=\"link\"] .{take} {{\n    color: var(--content-secondary);\n}}"
597    );
598
599    // The rank sits where a count would, at the line's end: the start mark,
600    // then the bar. The bar's track is the ramp's own neutral step and its
601    // fill the content tone, so no hue claims anything about a group.
602    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    // The join toggle: two words, the one in force held in, the segmented
613    // control's sentence again.
614    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
629/// A control held in while it is in force, whichever element it turned out to
630/// be.
631///
632/// This emitter writes buttons, which say it with `aria-pressed`. A host that
633/// gives a value a real address writes an anchor for a read, and an anchor says
634/// it with `aria-current`, since `aria-pressed` means nothing on one. Keyed on
635/// the first alone, a taken value drawn as a link looked exactly like an
636/// untaken one.
637fn 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    /// Links for every take, the prune on the first value left out, and each
687    /// control marked with what it is.
688    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        // The first value's prune was refused; the others draw theirs.
738        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        // The one control a flat facet has no use for: excluding a value there
775        // is the same fact as not picking it.
776        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        // The mark is one the house faces draw: U+2715 rendered as a box.
784        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        // The distinction `Standing` has four members for. A renderer given a
797        // bool marks every descendant of a taken branch or marks none, and both
798        // are wrong on screen.
799        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        // Pruned is a decision and it is not in force, so the take control is
816        // not pressed and the prune control is.
817        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        // `--space-tight` is defined nowhere, so a rule reading it always took
826        // its literal fallback.
827        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        // Not a decision: its own prune control is not pressed.
857        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        // The app puts its own box in; the panel is what gives it the group
872        // label and the shared geometry.
873        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        // A written zero reads as "none of them", which is a different claim
885        // from "not counted".
886        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        // "First paint is final paint" applied to a tree: a gutter widened as
904        // deeper values arrive is the reflow that rule forbids.
905        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        // Both arrive from the app, and a tag path is user-supplied on a system
918        // where a user names their own tags.
919        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 &amp; B"));
925        assert!(html.contains(r#"data-facet-value="a&amp;b""#));
926        assert!(html.contains("T&lt;ag&gt;"));
927        assert!(!html.contains("<ag>"));
928    }
929
930    #[test]
931    fn a_value_reads_flat_before_it_is_touched() {
932        // The reason the arm exists: `Depth::Flat` declares nothing, so an app
933        // handing makeover the cascade with `revert-layer` rolled the handoff
934        // past an empty layer onto its own bare `button` rule and the value
935        // came out raised. Both controls say flat out loud now, and the states
936        // below it are what a press is allowed to change.
937        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        // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
951        // sheet does not rule is invisible to the dead-vocabulary seal.
952        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        // A prefixed build claims its own names, and the emitted CSS selects
964        // descendants: miss one and the rule stops matching.
965        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    /// A ranked tree: an open group with a pick under it, a closed group
982    /// marked as where to start, a dead one, and a link row.
983    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        // The heading is no toggle: it opens, and offers no exclude.
1017        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        // Open, so its join toggle shows with all pressed.
1024        assert!(
1025            genre.contains(r#"data-facet-join="all" aria-pressed="true""#),
1026            "{genre}"
1027        );
1028        // A gain carries its sign.
1029        assert!(html.contains(">+3</span>"), "{html}");
1030        // A closed group keeps its join out of sight, and shows its bar.
1031        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        // A link row neither toggles nor branches.
1036        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}