|
1 |
+ |
//! Column layout and row structure for lists and tables.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! The other half of phase B. [`form`](crate::form) renders a field; this
|
|
4 |
+ |
//! renders the frame a list of rows sits in: which columns exist, how wide they
|
|
5 |
+ |
//! are, which ones survive a narrow viewport, and the cell containers a row is
|
|
6 |
+ |
//! made of.
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! # What this does not do
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! It does not render a cell's contents. That is the crate's own limit, stated
|
|
11 |
+ |
//! in `makeover_layout`'s "Where the description stops": generate the boring
|
|
12 |
+ |
//! 80% so the bespoke 20% gets the attention. A goingson task row carries
|
|
13 |
+ |
//! delegated action hooks with argument substitution, four nested sub-renderers,
|
|
14 |
+ |
//! conditional state classes and aria labels built from data. A description
|
|
15 |
+ |
//! expressive enough to emit that is a templating language wearing a
|
|
16 |
+ |
//! description's name.
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! So the split is the one [`Markup`] already draws for forms: this owns the
|
|
19 |
+ |
//! structure and the app owns what goes in it. What that removes from an app is
|
|
20 |
+ |
//! not small — cell order, cell classes, the grid tracks, and above all the
|
|
21 |
+ |
//! narrowing rules, which is where addressing columns by position goes wrong.
|
|
22 |
+ |
//!
|
|
23 |
+ |
//! # Why positions are the bug
|
|
24 |
+ |
//!
|
|
25 |
+ |
//! goingson hides its mobile columns with `nth-child(n+5)` against a
|
|
26 |
+ |
//! seven-column table, plus a separate `nth-child(3)`, plus two class-based
|
|
27 |
+ |
//! rules — the same fact said three ways, two of them positional. Insert a
|
|
28 |
+ |
//! column anywhere left of the cut and the wrong one disappears, silently,
|
|
29 |
+ |
//! because nothing in the stylesheet knows what column five *is*.
|
|
30 |
+ |
//! [`Priority`] is the fix: a renderer narrows by raising a cutoff, and never
|
|
31 |
+ |
//! by counting.
|
|
32 |
+ |
|
|
33 |
+ |
use crate::form::Markup;
|
|
34 |
+ |
use crate::{Emit, class};
|
|
35 |
+ |
use makeover_layout::{Column, Priority, RowPart, Width};
|
|
36 |
+ |
use std::fmt::Write as _;
|
|
37 |
+ |
|
|
38 |
+ |
/// The lengths the description deferred.
|
|
39 |
+ |
///
|
|
40 |
+ |
/// [`Width`] says `Content`, `Fixed` or `Fill` and deliberately carries no
|
|
41 |
+ |
/// magnitude, because a magnitude is a CSS answer and the description is read
|
|
42 |
+ |
/// by renderers that have no pixels. So the numbers arrive here instead, the
|
|
43 |
+ |
/// way a field's value arrives in [`Filling`](crate::form::Filling) rather than
|
|
44 |
+ |
/// in `Field`.
|
|
45 |
+ |
///
|
|
46 |
+ |
/// Looked up by column name, because an app's columns are not all one size:
|
|
47 |
+ |
/// goingson's task table has six distinct fixed widths.
|
|
48 |
+ |
#[derive(Debug, Clone, Copy, Default)]
|
|
49 |
+ |
pub struct Sizing<'a> {
|
|
50 |
+ |
/// `(column name, CSS length)`. The length is the track for a
|
|
51 |
+ |
/// [`Width::Fixed`] column and the floor for a [`Width::Fill`] one.
|
|
52 |
+ |
pub lengths: &'a [(&'a str, &'a str)],
|
|
53 |
+ |
/// Used for a column with no entry above. Empty means `auto`.
|
|
54 |
+ |
pub fallback: &'a str,
|
|
55 |
+ |
}
|
|
56 |
+ |
|
|
57 |
+ |
impl Sizing<'_> {
|
|
58 |
+ |
/// The length for a named column.
|
|
59 |
+ |
fn length_for(&self, name: &str) -> &str {
|
|
60 |
+ |
self.lengths
|
|
61 |
+ |
.iter()
|
|
62 |
+ |
.find(|(column, _)| *column == name)
|
|
63 |
+ |
.map_or_else(
|
|
64 |
+ |
|| {
|
|
65 |
+ |
if self.fallback.is_empty() {
|
|
66 |
+ |
"auto"
|
|
67 |
+ |
} else {
|
|
68 |
+ |
self.fallback
|
|
69 |
+ |
}
|
|
70 |
+ |
},
|
|
71 |
+ |
|(_, length)| *length,
|
|
72 |
+ |
)
|
|
73 |
+ |
}
|
|
74 |
+ |
|
|
75 |
+ |
/// The grid track for one column.
|
|
76 |
+ |
fn track(&self, column: &Column<'_>) -> String {
|
|
77 |
+ |
match column.width {
|
|
78 |
+ |
Width::Content => "max-content".to_owned(),
|
|
79 |
+ |
Width::Fixed => self.length_for(column.name).to_owned(),
|
|
80 |
+ |
Width::Fill => format!("minmax({}, 1fr)", self.length_for(column.name)),
|
|
81 |
+ |
// A width added to the description since this renderer was built.
|
|
82 |
+ |
// `auto` is the track that makes no claim, which is the honest
|
|
83 |
+ |
// answer to a claim this renderer cannot read.
|
|
84 |
+ |
_ => "auto".to_owned(),
|
|
85 |
+ |
}
|
|
86 |
+ |
}
|
|
87 |
+ |
}
|
|
88 |
+ |
|
|
89 |
+ |
/// The class a cell of this column carries.
|
|
90 |
+ |
///
|
|
91 |
+ |
/// Derived from the column's own name, which is what makes the narrowing rules
|
|
92 |
+ |
/// addressable. `data-column` would do as well; a class is what both webview
|
|
93 |
+ |
/// apps already key their cell styling on.
|
|
94 |
+ |
#[must_use]
|
|
95 |
+ |
pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
|
|
96 |
+ |
class(&format!("col-{}", column.name), opts)
|
|
97 |
+ |
}
|
|
98 |
+ |
|
|
99 |
+ |
/// The `grid-template-columns` value for the columns kept at `cutoff`.
|
|
100 |
+ |
///
|
|
101 |
+ |
/// Emitting only the surviving tracks is what keeps the track list and the
|
|
102 |
+ |
/// hiding in agreement. An app that hides a cell with `display: none` but
|
|
103 |
+ |
/// leaves its track in place gets a column of empty space, which is the other
|
|
104 |
+ |
/// half of goingson's mobile bug: its narrow rule drops to four tracks by hand
|
|
105 |
+ |
/// and has to be edited in step with the `nth-child` cut.
|
|
106 |
+ |
#[must_use]
|
|
107 |
+ |
pub fn grid_template_columns(
|
|
108 |
+ |
columns: &[Column<'_>],
|
|
109 |
+ |
sizing: &Sizing<'_>,
|
|
110 |
+ |
cutoff: Priority,
|
|
111 |
+ |
) -> String {
|
|
112 |
+ |
columns
|
|
113 |
+ |
.iter()
|
|
114 |
+ |
.filter(|column| column.kept_at(cutoff))
|
|
115 |
+ |
.map(|column| sizing.track(column))
|
|
116 |
+ |
.collect::<Vec<_>>()
|
|
117 |
+ |
.join(" ")
|
|
118 |
+ |
}
|
|
119 |
+ |
|
|
120 |
+ |
/// The rules that narrow `selector` to the columns kept at `cutoff`.
|
|
121 |
+ |
///
|
|
122 |
+ |
/// Both halves together: the shortened track list, and `display: none` on each
|
|
123 |
+ |
/// dropped column *by its own class*. Nothing counts positions, so inserting a
|
|
124 |
+ |
/// column changes what is emitted rather than changing which column vanishes.
|
|
125 |
+ |
#[must_use]
|
|
126 |
+ |
pub fn narrowing_css(
|
|
127 |
+ |
columns: &[Column<'_>],
|
|
128 |
+ |
selector: &str,
|
|
129 |
+ |
sizing: &Sizing<'_>,
|
|
130 |
+ |
cutoff: Priority,
|
|
131 |
+ |
opts: &Emit,
|
|
132 |
+ |
) -> String {
|
|
133 |
+ |
let mut css = format!(
|
|
134 |
+ |
"{selector} {{\n grid-template-columns: {};\n}}\n",
|
|
135 |
+ |
grid_template_columns(columns, sizing, cutoff)
|
|
136 |
+ |
);
|
|
137 |
+ |
|
|
138 |
+ |
for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
|
|
139 |
+ |
let _ = write!(
|
|
140 |
+ |
css,
|
|
141 |
+ |
"{selector} > .{} {{\n display: none;\n}}\n",
|
|
142 |
+ |
column_class(column, opts)
|
|
143 |
+ |
);
|
|
144 |
+ |
}
|
|
145 |
+ |
css
|
|
146 |
+ |
}
|
|
147 |
+ |
|
|
148 |
+ |
/// One cell of a row.
|
|
149 |
+ |
///
|
|
150 |
+ |
/// The contents are [`Markup`] rather than text, and that is the whole shape of
|
|
151 |
+ |
/// this module: a cell holds whatever the app builds, and the app says so by
|
|
152 |
+ |
/// naming it. Escaping a cell here would be wrong as well as impossible — a
|
|
153 |
+ |
/// task row's description cell is five nested spans and a badge.
|
|
154 |
+ |
#[derive(Debug, Clone, Copy)]
|
|
155 |
+ |
pub struct Cell<'a> {
|
|
156 |
+ |
/// Which column this fills, by name.
|
|
157 |
+ |
pub column: &'a str,
|
|
158 |
+ |
/// What kind of text it is, when it is text.
|
|
159 |
+ |
///
|
|
160 |
+ |
/// Carries the row-part class the stylesheet half already emits, so a
|
|
161 |
+ |
/// secondary cell says it is secondary in the description's own words
|
|
162 |
+ |
/// rather than in the app's.
|
|
163 |
+ |
pub part: Option<RowPart>,
|
|
164 |
+ |
/// The contents. Trusted app markup.
|
|
165 |
+ |
pub content: Markup<'a>,
|
|
166 |
+ |
}
|
|
167 |
+ |
|
|
168 |
+ |
impl<'a> Cell<'a> {
|
|
169 |
+ |
/// A cell with no row part.
|
|
170 |
+ |
#[must_use]
|
|
171 |
+ |
pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
|
|
172 |
+ |
Self {
|
|
173 |
+ |
column,
|
|
174 |
+ |
part: None,
|
|
175 |
+ |
content,
|
|
176 |
+ |
}
|
|
177 |
+ |
}
|
|
178 |
+ |
}
|
|
179 |
+ |
|
|
180 |
+ |
/// The class for a row part.
|
|
181 |
+ |
///
|
|
182 |
+ |
/// Exhaustive, unlike the matches on [`Width`] and [`Priority`] above:
|
|
183 |
+ |
/// `RowPart` is the one vocabulary in this module that is still a closed enum.
|
|
184 |
+ |
/// If it ever gains a member this stops compiling, which is the same lockstep
|
|
185 |
+ |
/// break `non_exhaustive` was added elsewhere to end.
|
|
186 |
+ |
fn part_class(part: RowPart) -> &'static str {
|
|
187 |
+ |
match part {
|
|
188 |
+ |
RowPart::Primary => "row-primary",
|
|
189 |
+ |
RowPart::Secondary => "row-secondary",
|
|
190 |
+ |
RowPart::Meta => "row-meta",
|
|
191 |
+ |
RowPart::Actions => "row-actions",
|
|
192 |
+ |
}
|
|
193 |
+ |
}
|
|
194 |
+ |
|
|
195 |
+ |
/// A row's cells, in column order.
|
|
196 |
+ |
///
|
|
197 |
+ |
/// Ordered by the columns and not by the cells, so a row cannot silently
|
|
198 |
+ |
/// disagree with its table about what comes where. A column with no cell gets
|
|
199 |
+ |
/// an empty container, which keeps the grid aligned; a cell naming no column is
|
|
200 |
+ |
/// dropped, because there is nowhere to put it.
|
|
201 |
+ |
///
|
|
202 |
+ |
/// Emits the cells alone, not the row element. The row carries the app's
|
|
203 |
+ |
/// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
|
|
204 |
+ |
/// state classes — and none of that is describable here.
|
|
205 |
+ |
#[must_use]
|
|
206 |
+ |
pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
|
|
207 |
+ |
let cell_class = class("cell", opts);
|
|
208 |
+ |
let mut html = String::new();
|
|
209 |
+ |
|
|
210 |
+ |
for column in columns {
|
|
211 |
+ |
let found = cells.iter().find(|cell| cell.column == column.name);
|
|
212 |
+ |
let mut classes = format!("{cell_class} {}", column_class(column, opts));
|
|
213 |
+ |
if let Some(part) = found.and_then(|cell| cell.part) {
|
|
214 |
+ |
let _ = write!(classes, " {}", class(part_class(part), opts));
|
|
215 |
+ |
}
|
|
216 |
+ |
let _ = write!(
|
|
217 |
+ |
html,
|
|
218 |
+ |
"<div class=\"{classes}\">{}</div>",
|
|
219 |
+ |
found.map_or("", |cell| cell.content.0)
|
|
220 |
+ |
);
|
|
221 |
+ |
}
|
|
222 |
+ |
html
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
#[cfg(test)]
|
|
226 |
+ |
mod tests {
|
|
227 |
+ |
use super::*;
|
|
228 |
+ |
|
|
229 |
+ |
fn columns() -> Vec<Column<'static>> {
|
|
230 |
+ |
vec![
|
|
231 |
+ |
Column {
|
|
232 |
+ |
name: "description",
|
|
233 |
+ |
width: Width::Fill,
|
|
234 |
+ |
priority: Priority::Essential,
|
|
235 |
+ |
},
|
|
236 |
+ |
Column {
|
|
237 |
+ |
name: "due",
|
|
238 |
+ |
width: Width::Fixed,
|
|
239 |
+ |
priority: Priority::Secondary,
|
|
240 |
+ |
},
|
|
241 |
+ |
Column {
|
|
242 |
+ |
name: "progress",
|
|
243 |
+ |
width: Width::Fixed,
|
|
244 |
+ |
priority: Priority::Optional,
|
|
245 |
+ |
},
|
|
246 |
+ |
]
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
fn sizing() -> Sizing<'static> {
|
|
250 |
+ |
Sizing {
|
|
251 |
+ |
lengths: &[
|
|
252 |
+ |
("description", "200px"),
|
|
253 |
+ |
("due", "110px"),
|
|
254 |
+ |
("progress", "100px"),
|
|
255 |
+ |
],
|
|
256 |
+ |
fallback: "",
|
|
257 |
+ |
}
|
|
258 |
+ |
}
|
|
259 |
+ |
|
|
260 |
+ |
#[test]
|
|
261 |
+ |
fn a_fill_column_gets_a_floor_and_the_slack() {
|
|
262 |
+ |
let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
|
|
263 |
+ |
assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
|
|
264 |
+ |
}
|
|
265 |
+ |
|
|
266 |
+ |
#[test]
|
|
267 |
+ |
fn a_column_with_no_length_makes_no_claim() {
|
|
268 |
+ |
let sizing = Sizing::default();
|
|
269 |
+ |
let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
|
|
270 |
+ |
assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
|
|
271 |
+ |
}
|
|
272 |
+ |
|
|
273 |
+ |
/// The point of the module. Raising the cutoff drops columns by what they
|
|
274 |
+ |
/// are worth, and the track list shortens to match, so the two cannot
|
|
275 |
+ |
/// disagree the way a hand-written `nth-child` cut and a hand-written
|
|
276 |
+ |
/// track list can.
|
|
277 |
+ |
#[test]
|
|
278 |
+ |
fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
|
|
279 |
+ |
let columns = columns();
|
|
280 |
+ |
|
|
281 |
+ |
let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
|
|
282 |
+ |
assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
|
|
283 |
+ |
|
|
284 |
+ |
let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
|
|
285 |
+ |
assert_eq!(narrow, "minmax(200px, 1fr) 110px");
|
|
286 |
+ |
|
|
287 |
+ |
let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
|
|
288 |
+ |
assert_eq!(narrowest, "minmax(200px, 1fr)");
|
|
289 |
+ |
}
|
|
290 |
+ |
|
|
291 |
+ |
#[test]
|
|
292 |
+ |
fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
|
|
293 |
+ |
let css = narrowing_css(
|
|
294 |
+ |
&columns(),
|
|
295 |
+ |
".ui-mode-mobile .task-row",
|
|
296 |
+ |
&sizing(),
|
|
297 |
+ |
Priority::Secondary,
|
|
298 |
+ |
&Emit::default(),
|
|
299 |
+ |
);
|
|
300 |
+ |
assert!(
|
|
301 |
+ |
css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
|
|
302 |
+ |
"{css}"
|
|
303 |
+ |
);
|
|
304 |
+ |
assert!(
|
|
305 |
+ |
css.contains(".ui-mode-mobile .task-row > .col-progress {"),
|
|
306 |
+ |
"{css}"
|
|
307 |
+ |
);
|
|
308 |
+ |
assert!(!css.contains("nth-child"), "{css}");
|
|
309 |
+ |
// The kept columns are not mentioned as hidden.
|
|
310 |
+ |
assert!(!css.contains(".col-due {\n display: none"), "{css}");
|
|
311 |
+ |
}
|
|
312 |
+ |
|
|
313 |
+ |
#[test]
|
|
314 |
+ |
fn cells_follow_the_columns_and_carry_their_column_class() {
|
|
315 |
+ |
let cells = [
|
|
316 |
+ |
Cell {
|
|
317 |
+ |
column: "due",
|
|
318 |
+ |
part: Some(RowPart::Meta),
|
|
319 |
+ |
content: Markup("tomorrow"),
|
|
320 |
+ |
},
|
|
321 |
+ |
Cell::new("description", Markup("<span>Ship it</span>")),
|
|
322 |
+ |
];
|
|
323 |
+ |
let html = cells_html(&columns(), &cells, &Emit::default());
|
|
324 |
+ |
|
|
325 |
+ |
// Column order, not cell order: description was passed second.
|
|
326 |
+ |
let description = html.find("Ship it").expect("description cell");
|
|
327 |
+ |
let due = html.find("tomorrow").expect("due cell");
|
|
328 |
+ |
assert!(description < due, "{html}");
|
|
329 |
+ |
|
|
330 |
+ |
assert!(
|
|
331 |
+ |
html.contains(r#"<div class="cell col-description">"#),
|
|
332 |
+ |
"{html}"
|
|
333 |
+ |
);
|
|
334 |
+ |
assert!(
|
|
335 |
+ |
html.contains(r#"<div class="cell col-due row-meta">"#),
|
|
336 |
+ |
"{html}"
|
|
337 |
+ |
);
|
|
338 |
+ |
// progress had no cell, so it is present and empty rather than absent,
|
|
339 |
+ |
// or the grid would shift left by one.
|
|
340 |
+ |
assert!(
|
|
341 |
+ |
html.contains(r#"<div class="cell col-progress"></div>"#),
|
|
342 |
+ |
"{html}"
|
|
343 |
+ |
);
|
|
344 |
+ |
}
|
|
345 |
+ |
|
|
346 |
+ |
#[test]
|
|
347 |
+ |
fn a_cell_naming_no_column_is_dropped() {
|
|
348 |
+ |
let cells = [Cell::new("nonexistent", Markup("nowhere"))];
|
|
349 |
+ |
let html = cells_html(&columns(), &cells, &Emit::default());
|
|
350 |
+ |
assert!(!html.contains("nowhere"), "{html}");
|
|
351 |
+ |
}
|
|
352 |
+ |
|
|
353 |
+ |
#[test]
|
|
354 |
+ |
fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
|
|
355 |
+ |
let opts = Emit {
|
|
356 |
+ |
class_prefix: "mk-",
|
|
357 |
+ |
..Emit::default()
|
|
358 |
+ |
};
|
|
359 |
+ |
let cells = [Cell::new("due", Markup("x"))];
|
|
360 |
+ |
assert!(
|
|
361 |
+ |
cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
|
|
362 |
+ |
"prefix missing"
|
|
363 |
+ |
);
|
|
364 |
+ |
assert!(
|
|
365 |
+ |
narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
|
|
366 |
+ |
.contains(".mk-col-progress"),
|
|
367 |
+ |
"prefix missing"
|
|
368 |
+ |
);
|
|
369 |
+ |
}
|
|
370 |
+ |
}
|