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::{Emit, class, push_class};
39use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
40use std::fmt::Write as _;
41
42/// The classes this module can put in markup.
43///
44/// [`crate::list::ROW_PART_CLASSES`]' obligation, and it exists for the same
45/// reason: every class here is also ruled by [`facet_rules`], so the vocabulary
46/// seal picks them up from the generated sheet, and this list is what a test
47/// checks that against.
48pub const FACET_CLASSES: &[&str] = &[
49 "facet",
50 "facet-name",
51 "facet-values",
52 "facet-value",
53 "facet-take",
54 "facet-count",
55 "facet-prune",
56];
57
58/// The name a selection mode goes by in `data-selecting`.
59///
60/// An attribute rather than a class, for `data-selector`'s reason on a selector
61/// group: the mode changes what the panel *means*, not how one value is
62/// painted, and a class there would read as the styling hook the value's class
63/// actually is.
64#[must_use]
65pub const fn selecting_name(mode: Selecting) -> &'static str {
66 match mode {
67 Selecting::OneOf => "one-of",
68 Selecting::AnyOf => "any-of",
69 Selecting::Range => "range",
70 Selecting::Text => "text",
71 Selecting::Subtree => "subtree",
72 // A mode added to the description since this renderer was built.
73 // `Selecting` is `#[non_exhaustive]`, and an unknown mode reads as the
74 // one that offers no values and prunes nothing: drawing a value list
75 // for a mode whose values mean something else is the worse mistake.
76 _ => "unknown",
77 }
78}
79
80/// The name a standing goes by in `data-standing`.
81#[must_use]
82pub const fn standing_name(standing: Standing) -> &'static str {
83 match standing {
84 Standing::Open => "open",
85 Standing::Taken => "taken",
86 Standing::Inherited => "inherited",
87 Standing::Pruned => "pruned",
88 // Unknown reads as open, which is the state that claims nothing about
89 // the set.
90 _ => "open",
91 }
92}
93
94/// A facet as a labelled list of values.
95///
96/// ```
97/// use makeover_layout::{Facet, FacetValue, Selecting, Standing};
98/// use makeover_webview::{Emit, facet::facet_html};
99///
100/// let values = [
101/// FacetValue::new("music", "Music")
102/// .standing(Standing::Taken)
103/// .counted(128)
104/// .at(0, true),
105/// FacetValue::new("music/synths", "Synths").at(1, false),
106/// ];
107/// let facet = Facet::new("Tag", Selecting::Subtree, &values);
108/// let html = facet_html(&facet, &Emit::default());
109///
110/// assert!(html.contains(r#"data-selecting="subtree""#));
111/// assert!(html.contains(r#"data-facet-value="music/synths""#));
112/// // A subtree is the one mode that offers a way to prune a branch out.
113/// assert!(html.contains("facet-prune"));
114/// ```
115///
116/// A [`Selecting::Text`] or [`Selecting::Range`] facet lists nothing, so what
117/// comes back is the panel and its name with an empty list inside it. That is
118/// deliberate rather than an empty string: the app puts its own box in the
119/// panel, and the panel is what gives the box the group label and the shared
120/// geometry.
121#[must_use]
122pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String {
123 let mut html = String::new();
124 facet_html_into(facet, opts, &mut html);
125 html
126}
127
128/// A facet, written into a buffer the caller already has.
129///
130/// [`facet_html`]'s streaming form, byte-identical to it.
131pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
132 out.push_str("<div class=\"");
133 push_class(out, "facet", opts);
134 out.push_str("\" role=\"group\" data-selecting=\"");
135 out.push_str(selecting_name(facet.mode));
136 // The gutter an indenting renderer reserves before it draws anything, so
137 // the panel does not widen as deeper values arrive. "First paint is final
138 // paint" applied to a tree.
139 let _ = write!(out, "\" style=\"--facet-reach: {}\">", facet.reach());
140
141 out.push_str("<p class=\"");
142 push_class(out, "facet-name", opts);
143 out.push_str("\">");
144 escape_into(facet.name, out);
145 out.push_str("</p>");
146
147 out.push_str("<ul class=\"");
148 push_class(out, "facet-values", opts);
149 out.push_str("\">");
150 if facet.mode.offers_values() {
151 for value in facet.values {
152 value_html_into(facet, value, opts, out);
153 }
154 }
155 out.push_str("</ul></div>");
156}
157
158fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) {
159 out.push_str("<li class=\"");
160 push_class(out, "facet-value", opts);
161 out.push_str("\" data-standing=\"");
162 out.push_str(standing_name(value.standing));
163 let _ = write!(out, "\" style=\"--facet-depth: {}\">", value.depth);
164
165 out.push_str("<button type=\"button\" class=\"");
166 push_class(out, "facet-take", opts);
167 out.push_str("\" data-facet-value=\"");
168 escape_into(value.value, out);
169 // `aria-pressed` and not `aria-selected`: the values are toggles over a set
170 // rather than options in a listbox, and an inherited value is pressed in
171 // fact even though nobody pressed it. That is `Standing::in_force`, which
172 // exists so a renderer does not have to know which of the two it has.
173 out.push_str("\" aria-pressed=\"");
174 out.push_str(if value.standing.in_force() {
175 "true\""
176 } else {
177 "false\""
178 });
179 // A branch that opens says so, so a reader is told there is more before
180 // pressing rather than after.
181 if value.branching {
182 out.push_str(" aria-expanded=\"");
183 out.push_str(if value.standing.in_force() {
184 "true\""
185 } else {
186 "false\""
187 });
188 }
189 out.push('>');
190 escape_into(value.label, out);
191
192 // Absent rather than zero when it was not measured, which is the
193 // description's own position: a written zero reads as "none of them".
194 if let Some(count) = value.count {
195 out.push_str("<span class=\"");
196 push_class(out, "facet-count", opts);
197 let _ = write!(out, "\">{count}</span>");
198 }
199 out.push_str("</button>");
200
201 if facet.mode.prunes() {
202 out.push_str("<button type=\"button\" class=\"");
203 push_class(out, "facet-prune", opts);
204 out.push_str("\" data-facet-value=\"");
205 escape_into(value.value, out);
206 out.push_str("\" aria-pressed=\"");
207 out.push_str(if value.standing == Standing::Pruned {
208 "true\""
209 } else {
210 "false\""
211 });
212 // The accessible name is built here rather than described, for
213 // `meter_text`'s reason: a tooltip wants a sentence and a terminal
214 // wants a glyph, and a description shipping either would choose for
215 // both.
216 out.push_str(" aria-label=\"Exclude ");
217 escape_into(value.label, out);
218 // The heavier, simpler mark. It is the glyph and not a class, because a
219 // renderer that swaps it is not changing what the control means.
220 out.push_str("\">\u{2715}</button>");
221 }
222
223 out.push_str("</li>");
224}
225
226/// The rules for a facet panel.
227///
228/// Depth comes from the description: a value at rest sits as
229/// [`Depth::Flat`] and a taken one is held in, which is
230/// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same
231/// sentence — this one is picked, so it is pressed. Nothing here states a
232/// colour or a size; the indent is a count multiplied by a geometry step, and
233/// the step is the one variable this crate is allowed to read.
234pub(crate) fn facet_rules(opts: &Emit) -> String {
235 let mut css = String::new();
236 let panel = class("facet", opts);
237 let name = class("facet-name", opts);
238 let values = class("facet-values", opts);
239 let value = class("facet-value", opts);
240 let take = class("facet-take", opts);
241 let count = class("facet-count", opts);
242 let prune = class("facet-prune", opts);
243
244 // The name of the dimension. A caption, and captions are legitimately
245 // muted: it was never going to answer a press.
246 let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}");
247
248 // The list undoes the bullet a browser adds, for `row_rules`' reason: a
249 // described set of tags is not a bulleted list and rendered as one because
250 // nothing said otherwise.
251 let _ = writeln!(
252 css,
253 ".{values} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}"
254 );
255
256 // The indent is the level times one step. `--facet-depth` is written per
257 // value and `--facet-reach` per panel; the panel one reserves the gutter so
258 // nothing moves as deeper values arrive.
259 let _ = writeln!(
260 css,
261 ".{value} {{\n padding-inline-start: calc(var(--facet-depth, 0) * var(--space-tight, 0.5rem));\n}}"
262 );
263
264 let _ = writeln!(
265 css,
266 ".{panel} {{\n min-inline-size: calc(var(--facet-reach, 0) * var(--space-tight, 0.5rem));\n}}"
267 );
268
269 // The value's own control. Flat at rest, held in when it is in force, and
270 // that is the segmented control's sentence rather than a new one.
271 css.push_str(&crate::depth_rule(&take, Depth::Flat));
272 css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
273 css.push_str(&crate::depth_rule(
274 &format!("{take}[aria-pressed=\"true\"]"),
275 Depth::Well,
276 ));
277
278 // A pruned branch reads one step back and stays live: pressing it takes the
279 // prune off, so it may not wear `content-muted`. `Standing::intent` is
280 // where that is decided.
281 let _ = writeln!(
282 css,
283 ".{value}[data-standing=\"pruned\"] .{take} {{\n color: var(--{});\n}}",
284 Standing::Pruned.intent()
285 );
286
287 // Inherited is in force and was not chosen. It reads as the thing itself,
288 // like a taken value, and the difference is carried by the attribute for
289 // whoever wants it rather than by a colour claiming something.
290 let _ = writeln!(css, ".{count} {{\n color: var(--content-muted);\n}}");
291
292 css.push_str(&crate::depth_rule(&prune, Depth::Flat));
293 css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
294 css.push_str(&crate::depth_rule(
295 &format!("{prune}[aria-pressed=\"true\"]"),
296 Depth::Well,
297 ));
298
299 css
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn tag_values() -> [FacetValue<'static>; 3] {
307 [
308 FacetValue::new("music", "Music")
309 .standing(Standing::Taken)
310 .counted(128)
311 .at(0, true),
312 FacetValue::new("music/synths", "Synths")
313 .standing(Standing::Inherited)
314 .at(1, false),
315 FacetValue::new("music/drums", "Drums")
316 .standing(Standing::Pruned)
317 .at(1, false),
318 ]
319 }
320
321 #[test]
322 fn a_streamed_facet_is_the_facet_the_other_form_returns() {
323 let opts = Emit {
324 class_prefix: "mk-",
325 ..Emit::default()
326 };
327 let values = tag_values();
328 for facet in [
329 Facet::new("Tag", Selecting::Subtree, &values),
330 Facet::new("Type", Selecting::AnyOf, &values),
331 Facet::new("Search", Selecting::Text, &[]),
332 ] {
333 let mut streamed = String::new();
334 facet_html_into(&facet, &opts, &mut streamed);
335 assert_eq!(streamed, facet_html(&facet, &opts));
336 }
337 }
338
339 #[test]
340 fn only_a_subtree_draws_an_exclude_affordance() {
341 // The one control a flat facet has no use for: excluding a value there
342 // is the same fact as not picking it.
343 let values = tag_values();
344 let subtree = facet_html(
345 &Facet::new("Tag", Selecting::Subtree, &values),
346 &Emit::default(),
347 );
348 assert!(subtree.contains("facet-prune"));
349 assert!(subtree.contains(r#"aria-label="Exclude Drums""#));
350
351 let flat = facet_html(
352 &Facet::new("Type", Selecting::AnyOf, &values),
353 &Emit::default(),
354 );
355 assert!(!flat.contains("facet-prune"));
356 }
357
358 #[test]
359 fn an_inherited_value_reads_as_pressed_without_having_been_pressed() {
360 // The distinction `Standing` has four members for. A renderer given a
361 // bool marks every descendant of a taken branch or marks none, and both
362 // are wrong on screen.
363 let values = tag_values();
364 let html = facet_html(
365 &Facet::new("Tag", Selecting::Subtree, &values),
366 &Emit::default(),
367 );
368 let synths = html
369 .split("<li")
370 .find(|chunk| chunk.contains("music/synths"))
371 .expect("the inherited value");
372 assert!(synths.contains(r#"data-standing="inherited""#));
373 assert!(synths.contains(r#"aria-pressed="true""#));
374
375 let drums = html
376 .split("<li")
377 .find(|chunk| chunk.contains("music/drums"))
378 .expect("the pruned value");
379 // Pruned is a decision and it is not in force, so the take control is
380 // not pressed and the prune control is.
381 assert!(drums.contains(r#"data-standing="pruned""#));
382 assert!(drums.contains(r#"aria-pressed="false""#));
383 assert!(drums.contains(r#"aria-label="Exclude Drums""#));
384 }
385
386 #[test]
387 fn a_mode_that_lists_nothing_still_renders_its_panel() {
388 // The app puts its own box in; the panel is what gives it the group
389 // label and the shared geometry.
390 let html = facet_html(
391 &Facet::new("Search", Selecting::Text, &[]),
392 &Emit::default(),
393 );
394 assert!(html.contains("facet-name"));
395 assert!(html.contains(r#"data-selecting="text""#));
396 assert!(!html.contains("facet-take"));
397 }
398
399 #[test]
400 fn an_unmeasured_count_emits_no_number_at_all() {
401 // A written zero reads as "none of them", which is a different claim
402 // from "not counted".
403 let values = [FacetValue::of("Ambient")];
404 let html = facet_html(
405 &Facet::new("Tag", Selecting::AnyOf, &values),
406 &Emit::default(),
407 );
408 assert!(!html.contains("facet-count"));
409
410 let counted = [FacetValue::of("Ambient").counted(0)];
411 let html = facet_html(
412 &Facet::new("Tag", Selecting::AnyOf, &counted),
413 &Emit::default(),
414 );
415 assert!(html.contains(">0</span>"));
416 }
417
418 #[test]
419 fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
420 // "First paint is final paint" applied to a tree: a gutter widened as
421 // deeper values arrive is the reflow that rule forbids.
422 let values = tag_values();
423 let html = facet_html(
424 &Facet::new("Tag", Selecting::Subtree, &values),
425 &Emit::default(),
426 );
427 assert!(html.contains("--facet-reach: 1"));
428 assert!(html.contains("--facet-depth: 0"));
429 assert!(html.contains("--facet-depth: 1"));
430 }
431
432 #[test]
433 fn labels_and_identifiers_are_escaped_like_every_other_string() {
434 // Both arrive from the app, and a tag path is user-supplied on a system
435 // where a user names their own tags.
436 let values = [FacetValue::new("a&b", "A & B")];
437 let html = facet_html(
438 &Facet::new("T<ag>", Selecting::AnyOf, &values),
439 &Emit::default(),
440 );
441 assert!(html.contains("A & B"));
442 assert!(html.contains(r#"data-facet-value="a&b""#));
443 assert!(html.contains("T<ag>"));
444 assert!(!html.contains("<ag>"));
445 }
446
447 #[test]
448 fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
449 // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
450 // sheet does not rule is invisible to the dead-vocabulary seal.
451 let names = crate::vocabulary::names(&Emit::default());
452 for name in FACET_CLASSES {
453 assert!(
454 names.contains(&crate::class(name, &Emit::default())),
455 "{name} is not in the vocabulary"
456 );
457 }
458 }
459
460 #[test]
461 fn the_prefix_reaches_every_class_in_the_markup() {
462 // A prefixed build claims its own names, and the emitted CSS selects
463 // descendants: miss one and the rule stops matching.
464 let opts = Emit {
465 class_prefix: "mo-",
466 ..Emit::default()
467 };
468 let values = tag_values();
469 let html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
470 for name in FACET_CLASSES {
471 assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
472 }
473 }
474}