|
1 |
+ |
//! A proportion, rendered as a bar.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! The third phase-B emitter, beside [`form`](crate::form) and
|
|
4 |
+ |
//! [`list`](crate::list). It is much the smallest, and it is here rather than in
|
|
5 |
+ |
//! the app because the trough it fills has been in phase A since before anything
|
|
6 |
+ |
//! could describe one: `progress_rules` emitted `.progress` and
|
|
7 |
+ |
//! `.progress-fill[data-tone]` for every tone while the only way to say "3 of 7"
|
|
8 |
+ |
//! was to concatenate it into a heading.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! # What the pair buys, at the last layer
|
|
11 |
+ |
//!
|
|
12 |
+ |
//! `makeover_layout::Meter` carries `done` and `total` rather than a percentage,
|
|
13 |
+ |
//! and the reason shows up here. A bar that is full because it landed exactly
|
|
14 |
+ |
//! and a bar that is full because it ran over are the same width and are not the
|
|
15 |
+ |
//! same fact, so the width is not allowed to be the only thing emitted. The
|
|
16 |
+ |
//! over-run leaves as `data-over="true"`, and the accessible name keeps both
|
|
17 |
+ |
//! true numbers.
|
|
18 |
+ |
//!
|
|
19 |
+ |
//! No CSS rule is emitted for `data-over`. What an over-run should look like is
|
|
20 |
+ |
//! app taste — goingson already says it with `Tone::Danger` — and a renderer
|
|
21 |
+ |
//! that picked a stripe for everyone would be decorating rather than describing.
|
|
22 |
+ |
|
|
23 |
+ |
use crate::form::escape;
|
|
24 |
+ |
use crate::{Emit, class};
|
|
25 |
+ |
use makeover_layout::{Intent, Meter, Tone};
|
|
26 |
+ |
use std::fmt::Write as _;
|
|
27 |
+ |
|
|
28 |
+ |
/// The accessible name for a meter: the two numbers, and the noun if it has one.
|
|
29 |
+ |
///
|
|
30 |
+ |
/// The description carries the noun alone, so the sentence is built here. That
|
|
31 |
+ |
/// is the whole reason `Meter::label` is not the assembled string: a tooltip
|
|
32 |
+ |
/// wants "3 of 7 subtasks" and a terminal at one line wants "3/7", and a
|
|
33 |
+ |
/// description that shipped either one would have chosen for both.
|
|
34 |
+ |
///
|
|
35 |
+ |
/// The true `done` is used, not the clamped one. This is the text that says an
|
|
36 |
+ |
/// over-run happened.
|
|
37 |
+ |
#[must_use]
|
|
38 |
+ |
pub fn meter_text(meter: &Meter<'_>) -> String {
|
|
39 |
+ |
match meter.label {
|
|
40 |
+ |
Some(label) => format!("{} of {} {label}", meter.done, meter.total),
|
|
41 |
+ |
None => format!("{} of {}", meter.done, meter.total),
|
|
42 |
+ |
}
|
|
43 |
+ |
}
|
|
44 |
+ |
|
|
45 |
+ |
/// A meter as a filled trough.
|
|
46 |
+ |
///
|
|
47 |
+ |
/// ```
|
|
48 |
+ |
/// use makeover_layout::{Meter, Tone};
|
|
49 |
+ |
/// use makeover_webview::{Emit, meter::meter_html};
|
|
50 |
+ |
///
|
|
51 |
+ |
/// let meter = Meter::new(3, 7).tone(Tone::Success).label("subtasks");
|
|
52 |
+ |
/// let html = meter_html(&meter, &Emit::default());
|
|
53 |
+ |
///
|
|
54 |
+ |
/// assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
|
|
55 |
+ |
/// assert!(html.contains(r#"data-tone="success""#));
|
|
56 |
+ |
/// assert!(html.contains("width: 42%"));
|
|
57 |
+ |
/// ```
|
|
58 |
+ |
///
|
|
59 |
+ |
/// `aria-valuenow` is clamped to `aria-valuemax`, because a value outside the
|
|
60 |
+ |
/// range is invalid ARIA and a screen reader is entitled to ignore the whole
|
|
61 |
+ |
/// element. The unclamped truth is in the accessible name, which is read either
|
|
62 |
+ |
/// way.
|
|
63 |
+ |
#[must_use]
|
|
64 |
+ |
pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
|
|
65 |
+ |
let progress = class("progress", opts);
|
|
66 |
+ |
let fill = class("progress-fill", opts);
|
|
67 |
+ |
let reported = meter.done.min(meter.total);
|
|
68 |
+ |
|
|
69 |
+ |
let mut html = format!(
|
|
70 |
+ |
"<div class=\"{progress}\" role=\"progressbar\" aria-valuenow=\"{reported}\" \
|
|
71 |
+ |
aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{}\">",
|
|
72 |
+ |
meter.total,
|
|
73 |
+ |
escape(&meter_text(meter))
|
|
74 |
+ |
);
|
|
75 |
+ |
|
|
76 |
+ |
let _ = write!(html, "<div class=\"{fill}\"");
|
|
77 |
+ |
// Neutral is the untoned bar, and `progress_rules` gives it `--action`
|
|
78 |
+ |
// rather than a tone attribute. Emitting `data-tone="content-muted"` would
|
|
79 |
+ |
// match a rule that does not exist and read as disabled if it did.
|
|
80 |
+ |
if meter.tone != Tone::Neutral {
|
|
81 |
+ |
let _ = write!(html, " data-tone=\"{}\"", meter.tone.token());
|
|
82 |
+ |
}
|
|
83 |
+ |
if meter.overflowing() {
|
|
84 |
+ |
html.push_str(" data-over=\"true\"");
|
|
85 |
+ |
}
|
|
86 |
+ |
let _ = write!(html, " style=\"width: {}%\"></div></div>", meter.percent());
|
|
87 |
+ |
html
|
|
88 |
+ |
}
|
|
89 |
+ |
|
|
90 |
+ |
#[cfg(test)]
|
|
91 |
+ |
mod tests {
|
|
92 |
+ |
use super::*;
|
|
93 |
+ |
|
|
94 |
+ |
#[test]
|
|
95 |
+ |
fn a_full_bar_says_whether_it_ran_over() {
|
|
96 |
+ |
// The two facts a percentage could not tell apart, and the reason the
|
|
97 |
+ |
// description carries a pair. Both are 100% wide.
|
|
98 |
+ |
let exact = meter_html(&Meter::new(30, 30), &Emit::default());
|
|
99 |
+ |
let over = meter_html(&Meter::new(45, 30), &Emit::default());
|
|
100 |
+ |
|
|
101 |
+ |
assert!(exact.contains("width: 100%"));
|
|
102 |
+ |
assert!(over.contains("width: 100%"));
|
|
103 |
+ |
assert!(!exact.contains("data-over"));
|
|
104 |
+ |
assert!(over.contains(r#"data-over="true""#));
|
|
105 |
+ |
}
|
|
106 |
+ |
|
|
107 |
+ |
#[test]
|
|
108 |
+ |
fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
|
|
109 |
+ |
// The bar is clamped and the name is not. Losing this is how an
|
|
110 |
+ |
// over-run becomes invisible to anyone not looking at the colour.
|
|
111 |
+ |
let over = Meter::new(45, 30).label("minutes");
|
|
112 |
+ |
assert_eq!(meter_text(&over), "45 of 30 minutes");
|
|
113 |
+ |
assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
|
|
114 |
+ |
}
|
|
115 |
+ |
|
|
116 |
+ |
#[test]
|
|
117 |
+ |
fn aria_valuenow_stays_inside_its_range() {
|
|
118 |
+ |
// Outside it, the element is invalid and a reader may drop it whole,
|
|
119 |
+ |
// which would lose the label above along with it.
|
|
120 |
+ |
let html = meter_html(&Meter::new(45, 30), &Emit::default());
|
|
121 |
+ |
assert!(html.contains(r#"aria-valuenow="30""#));
|
|
122 |
+ |
assert!(html.contains(r#"aria-valuemax="30""#));
|
|
123 |
+ |
}
|
|
124 |
+ |
|
|
125 |
+ |
#[test]
|
|
126 |
+ |
fn an_untoned_bar_emits_no_tone_attribute() {
|
|
127 |
+ |
// `progress_rules` styles the untoned bar with `--action` on the bare
|
|
128 |
+ |
// class. A `data-tone="content-muted"` here would match no rule.
|
|
129 |
+ |
let plain = meter_html(&Meter::new(1, 2), &Emit::default());
|
|
130 |
+ |
assert!(!plain.contains("data-tone"));
|
|
131 |
+ |
|
|
132 |
+ |
let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
|
|
133 |
+ |
assert!(toned.contains(r#"data-tone="danger""#));
|
|
134 |
+ |
}
|
|
135 |
+ |
|
|
136 |
+ |
#[test]
|
|
137 |
+ |
fn an_empty_set_renders_an_empty_trough() {
|
|
138 |
+ |
// Sayable, so it has to be emittable. Nothing here divides by zero.
|
|
139 |
+ |
let html = meter_html(&Meter::new(0, 0), &Emit::default());
|
|
140 |
+ |
assert!(html.contains("width: 0%"));
|
|
141 |
+ |
assert!(html.contains(r#"aria-valuemax="0""#));
|
|
142 |
+ |
}
|
|
143 |
+ |
|
|
144 |
+ |
#[test]
|
|
145 |
+ |
fn the_label_is_escaped_like_every_other_string() {
|
|
146 |
+ |
// It arrives from the app the same as a field label does.
|
|
147 |
+ |
let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
|
|
148 |
+ |
assert!(html.contains("a & b"));
|
|
149 |
+ |
assert!(!html.contains("a & b"));
|
|
150 |
+ |
}
|
|
151 |
+ |
|
|
152 |
+ |
#[test]
|
|
153 |
+ |
fn the_prefix_reaches_both_classes() {
|
|
154 |
+ |
// A prefixed build claims its own names, and the fill is a descendant
|
|
155 |
+ |
// selector in the emitted CSS: miss one and the rule stops matching.
|
|
156 |
+ |
let opts = Emit {
|
|
157 |
+ |
class_prefix: "mo-",
|
|
158 |
+ |
..Emit::default()
|
|
159 |
+ |
};
|
|
160 |
+ |
let html = meter_html(&Meter::new(1, 2), &opts);
|
|
161 |
+ |
assert!(html.contains(r#"class="mo-progress""#));
|
|
162 |
+ |
assert!(html.contains(r#"class="mo-progress-fill""#));
|
|
163 |
+ |
}
|
|
164 |
+ |
}
|