|
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 |
+ |
|
|
37 |
+ |
use crate::form::escape_into;
|
|
38 |
+ |
use crate::{Emit, class, push_class};
|
|
39 |
+ |
use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
|
|
40 |
+ |
use 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.
|
|
48 |
+ |
pub 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]
|
|
65 |
+ |
pub 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]
|
|
82 |
+ |
pub 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]
|
|
122 |
+ |
pub 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.
|
|
131 |
+ |
pub 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 |
+ |
|
|
158 |
+ |
fn 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.
|
|
234 |
+ |
pub(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)]
|
|
303 |
+ |
mod 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 |
+ |
}
|