Skip to main content

max / makeover-webview

31.3 KB · 796 lines History Blame Raw
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, push_class};
35 use makeover_layout::{CellPart, Column, Flow, 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 ///
95 /// The name is reduced to identifier characters first. See [`push_column_name`].
96 #[must_use]
97 pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
98 let mut out = String::new();
99 push_column_class(&mut out, column, opts);
100 out
101 }
102
103 /// The class a cell of this column carries, written into a buffer the caller
104 /// already has.
105 ///
106 /// [`column_class`]'s streaming form. It is the one that runs per cell per row,
107 /// and it used to allocate twice to get there: once for `col-<name>` and once
108 /// for the prefix in front of it.
109 pub fn push_column_class(out: &mut String, column: &Column<'_>, opts: &Emit) {
110 out.push_str(opts.class_prefix);
111 out.push_str("col-");
112 push_column_name(out, column.name);
113 }
114
115 /// A column's name as the identifier half of its class.
116 ///
117 /// # Why this is not escaping
118 ///
119 /// The name is the one app-supplied string this crate puts in a class attribute
120 /// rather than in text or an `aria-label`, and until 0.41.0 it went in raw. A
121 /// column named `a" onclick="steal()` emitted
122 ///
123 /// ```html
124 /// <div class="cell col-a" onclick="steal() cell-fill cell-keeps">
125 /// ```
126 ///
127 /// which is a live event handler on every cell of that column. HTML escaping is
128 /// the reflex and it is the wrong tool here, because a class is read twice: once
129 /// by the HTML parser, which would decode `&quot;` back to a quote, and once by
130 /// a CSS selector, which [`narrowing_css`] writes from this same function. An
131 /// escaped name is safe in the attribute and unmatchable from the stylesheet,
132 /// so the two halves of the narrowing would stop meeting -- silently, the way
133 /// every other defect this module's comments record did.
134 ///
135 /// Reducing the name to identifier characters answers both. What comes out is a
136 /// valid CSS identifier, so the selector matches, and it holds none of the five
137 /// characters an attribute value can be ended with, so there is nothing to
138 /// escape.
139 ///
140 /// # What it changes for a name that was already fine
141 ///
142 /// Nothing. Alphanumerics, `_` and `-` pass through, and every column name in
143 /// the tree is made of those. A name that is *not* was already broken rather
144 /// than merely unsafe: `Due date` emitted `col-Due date`, which the HTML parser
145 /// reads as the two classes `col-Due` and `date`, and which `narrowing_css`
146 /// wrote as a descendant selector that matched neither. Both now agree on
147 /// `col-Due-date`.
148 ///
149 /// Alphanumeric in the Unicode sense, not the ASCII one. CSS identifiers admit
150 /// everything from U+00A0 up, so a column named `Größe` keeps its name; folding
151 /// it to `Gr--e` would collide with a neighbouring column for nothing.
152 pub fn push_column_name(out: &mut String, name: &str) {
153 for ch in name.chars() {
154 // Substituted rather than dropped. Two columns called `a b` and `ab`
155 // are different columns, and dropping would give them one class and one
156 // set of narrowing rules between them.
157 if ch.is_alphanumeric() || ch == '_' || ch == '-' {
158 out.push(ch);
159 } else {
160 out.push('-');
161 }
162 }
163 }
164
165 /// The class saying how wide a cell of this column asks to be.
166 ///
167 /// A bounded vocabulary, unlike [`column_class`], which is why the stylesheet
168 /// can carry the rule. [`Width`] is `#[non_exhaustive]`, and a member added
169 /// upstream lands on the fill class: a column that takes the slack is the
170 /// behaviour that makes no claim, matching the `auto` track
171 /// [`Sizing::track`] falls back to for the same reason.
172 fn width_class(width: Width) -> &'static str {
173 match width {
174 Width::Content => "cell-content",
175 Width::Fixed => "cell-fixed",
176 _ => "cell-fill",
177 }
178 }
179
180 /// The class saying when a cell of this column drops.
181 ///
182 /// [`Priority`] said as a class rather than as a cutoff, so the hiding can live
183 /// in the stylesheet instead of being generated per table. That is what a
184 /// [`display: table`](crate::table_rules) frame needs and a grid one cannot use:
185 /// a grid also has to shorten its track list, which only the columns themselves
186 /// can say.
187 fn drop_class(priority: Priority) -> &'static str {
188 match priority {
189 Priority::Optional => "cell-drops-first",
190 Priority::Secondary => "cell-drops-next",
191 // A priority added upstream keeps its column. `Priority` is
192 // `#[non_exhaustive]`, and of the two ways to be wrong about one this
193 // renderer has not learned, showing a column that should have dropped
194 // is the one the user can see and work around.
195 _ => "cell-keeps",
196 }
197 }
198
199 /// Every class a cell of this column carries.
200 ///
201 /// The column's own name, how wide it asks to be, and when it drops. A header
202 /// cell has to carry the same three or the header and the body disagree about
203 /// which column just disappeared, and a renderer emitting its own header row
204 /// should call this rather than assemble the list a second time.
205 #[must_use]
206 pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String {
207 let mut out = String::new();
208 push_column_classes(&mut out, column, opts);
209 out
210 }
211
212 /// Every class a cell of this column carries, written into a buffer the caller
213 /// already has.
214 ///
215 /// [`column_classes`]'s streaming form, and four allocations fewer per cell: the
216 /// three names and the string joining them.
217 pub fn push_column_classes(out: &mut String, column: &Column<'_>, opts: &Emit) {
218 push_column_class(out, column, opts);
219 out.push(' ');
220 push_class(out, width_class(column.width), opts);
221 out.push(' ');
222 push_class(out, drop_class(column.priority), opts);
223 }
224
225 /// The `grid-template-columns` value for the columns kept at `cutoff`.
226 ///
227 /// Emitting only the surviving tracks is what keeps the track list and the
228 /// hiding in agreement. An app that hides a cell with `display: none` but
229 /// leaves its track in place gets a column of empty space, which is the other
230 /// half of goingson's mobile bug: its narrow rule drops to four tracks by hand
231 /// and has to be edited in step with the `nth-child` cut.
232 #[must_use]
233 pub fn grid_template_columns(
234 columns: &[Column<'_>],
235 sizing: &Sizing<'_>,
236 cutoff: Priority,
237 ) -> String {
238 columns
239 .iter()
240 .filter(|column| column.kept_at(cutoff))
241 .map(|column| sizing.track(column))
242 .collect::<Vec<_>>()
243 .join(" ")
244 }
245
246 /// The rules that narrow `selector` to the columns kept at `cutoff`.
247 ///
248 /// Both halves together: the shortened track list, and `display: none` on each
249 /// dropped column *by its own class*. Nothing counts positions, so inserting a
250 /// column changes what is emitted rather than changing which column vanishes.
251 ///
252 /// `selector` may be a selector list. A descendant is appended to each part
253 /// rather than to the whole, because appending to the whole changes what the
254 /// earlier parts match: `.head, .row > .col-x` reads as "`.head`, or a `.col-x`
255 /// inside `.row`", so `.head` itself would be hidden.
256 #[must_use]
257 pub fn narrowing_css(
258 columns: &[Column<'_>],
259 selector: &str,
260 sizing: &Sizing<'_>,
261 cutoff: Priority,
262 opts: &Emit,
263 ) -> String {
264 let parts: Vec<&str> = selector.split(',').map(str::trim).collect();
265
266 let mut css = format!(
267 "{} {{\n grid-template-columns: {};\n}}\n",
268 parts.join(", "),
269 grid_template_columns(columns, sizing, cutoff)
270 );
271
272 for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
273 let class = column_class(column, opts);
274 let targets: Vec<String> = parts
275 .iter()
276 .map(|part| format!("{part} > .{class}"))
277 .collect();
278 let _ = write!(css, "{} {{\n display: none;\n}}\n", targets.join(",\n"));
279 }
280 css
281 }
282
283 /// One cell of a row.
284 ///
285 /// The contents are [`Markup`] rather than text, and that is the whole shape of
286 /// this module: a cell holds whatever the app builds, and the app says so by
287 /// naming it. Escaping a cell here would be wrong as well as impossible — a
288 /// task row's description cell is five nested spans and a badge.
289 #[derive(Debug, Clone, Copy)]
290 pub struct Cell<'a> {
291 /// Which column this fills, by name.
292 pub column: &'a str,
293 /// What the cell holds, when the whole cell is one thing.
294 ///
295 /// Carries the cell-part class the stylesheet half emits, so a cell that is
296 /// nothing but controls says so in the description's own words rather than
297 /// in the app's.
298 ///
299 /// This was `Option<RowPart>` until 0.25.0, which was the drift
300 /// `makeover-layout` 0.14.0 named: a table cell borrowing the list row's
301 /// vocabulary, because the table side had none. A row's parts answer a
302 /// different question (which of six emphases this run of text takes) from a
303 /// cell's (whether this is text, tokens, controls or a link).
304 ///
305 /// `None` for a cell mixing parts. A cell holding a value *and* a strip of
306 /// tokens *and* a control is three parts in one container, and each one
307 /// wears its own class inside — this field is for the single-part case,
308 /// where a wrapper span would say nothing the cell has not already said.
309 pub part: Option<CellPart>,
310 /// The contents. Trusted app markup.
311 pub content: Markup<'a>,
312 }
313
314 impl<'a> Cell<'a> {
315 /// A cell with no cell part.
316 #[must_use]
317 pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
318 Self {
319 column,
320 part: None,
321 content,
322 }
323 }
324 }
325
326 /// The class for a row part.
327 ///
328 /// This comment used to say `RowPart` was the one closed enum left here, and
329 /// that gaining a member would stop this compiling — "the same lockstep break
330 /// `non_exhaustive` was added elsewhere to end". makeover-layout 0.9.0 ended
331 /// it: the enum gained [`RowPart::Tokens`] and `#[non_exhaustive]` in the same
332 /// release, so the prediction was paid off rather than waited for.
333 ///
334 /// The fallback is what that costs. A member added upstream lands here as a
335 /// bare `row-part` with no rule of its own, which is a thing rendering plainly
336 /// rather than a build that stops. Grep this function when adopting a new
337 /// makeover-layout.
338 ///
339 /// Public since 0.27.0. A row's parts are emitted by whoever builds the row
340 /// element, and that is not always this crate: `cells_html` emits a table's
341 /// cells, but a list row carries the app's identity and hooks, so a screen
342 /// renderer writes it. quasi-webview wrote this list out a second time to do
343 /// that, which made the obligation in the paragraph above land on a function
344 /// its author would not think to grep.
345 /// Every class [`part_class`] can return, including the fallback.
346 ///
347 /// Beside the match rather than derived from it, because a `match` over a
348 /// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
349 /// same obligation the match does and a test below holds the two together, so
350 /// a new arm added without a new entry fails rather than silently narrowing
351 /// what a checker believes this crate can emit.
352 pub const ROW_PART_CLASSES: &[&str] = &[
353 "row-primary",
354 "row-secondary",
355 "row-meta",
356 "row-actions",
357 "row-tokens",
358 "row-proportion",
359 "row-part",
360 ];
361
362 /// Every class [`flow_class`] can return.
363 ///
364 /// `Flow::Tight` has no class: one line is what a run already does, so a rule
365 /// for it would restate the default on every part in every row. Only the tier
366 /// that departs from it is named, which is also why a renderer emitting nothing
367 /// for an unknown flow is correct rather than lossy.
368 pub const FLOW_CLASSES: &[&str] = &["row-relaxed"];
369
370 /// The class for a part's flow, if it needs one.
371 ///
372 /// `None` for [`Flow::Tight`] and for any tier added upstream that this crate
373 /// has not been taught, which lands as one line: the same trade
374 /// [`part_class`]'s fallback makes, and the safe direction, since a part that
375 /// grows without bound breaks the rows around it while a part that stays on one
376 /// line only looks like the old rendering. Grep this when adopting a new
377 /// makeover-layout.
378 #[must_use]
379 pub fn flow_class(flow: Flow) -> Option<&'static str> {
380 match flow {
381 Flow::Relaxed => Some("row-relaxed"),
382 _ => None,
383 }
384 }
385
386 /// Every class [`width_class`](fn@width_class) can return, including the
387 /// fallback.
388 ///
389 /// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three
390 /// carry a rule -- a fill is what a cell does when the sheet says nothing --
391 /// which is exactly why the list is here rather than being read off the
392 /// generated CSS: `cell-fill` reached every table in the tree and the
393 /// vocabulary named it nowhere.
394 pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];
395
396 /// Every class [`drop_class`](fn@drop_class) can return, including the
397 /// fallback.
398 ///
399 /// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is
400 /// the tier the narrowing never hides, so the sheet writes no rule for it and
401 /// a scraped set cannot see it.
402 pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];
403
404 /// Every class [`cell_part_class`] can return, including the fallback.
405 ///
406 /// See [`ROW_PART_CLASSES`] for why it is written out.
407 pub const CELL_PART_CLASSES: &[&str] = &[
408 "cell-value",
409 "cell-tokens",
410 "cell-actions",
411 "cell-link",
412 "cell-part",
413 ];
414
415 #[must_use]
416 pub fn part_class(part: RowPart) -> &'static str {
417 match part {
418 RowPart::Primary => "row-primary",
419 RowPart::Secondary => "row-secondary",
420 RowPart::Meta => "row-meta",
421 RowPart::Actions => "row-actions",
422 RowPart::Tokens => "row-tokens",
423 RowPart::Proportion => "row-proportion",
424 _ => "row-part",
425 }
426 }
427
428 /// The class for a cell part.
429 ///
430 /// [`part_class`]'s table half, added with `makeover-layout` 0.14.0's
431 /// [`CellPart`]. The fallback is there for the same reason and costs the same
432 /// thing: a member added upstream lands as a bare `cell-part` with no rule of
433 /// its own, rather than as a build that stops. Grep this function too when
434 /// adopting a new makeover-layout, and public since 0.27.0 for the reason
435 /// [`part_class`] is.
436 #[must_use]
437 pub fn cell_part_class(part: CellPart) -> &'static str {
438 match part {
439 CellPart::Value => "cell-value",
440 CellPart::Tokens => "cell-tokens",
441 CellPart::Actions => "cell-actions",
442 CellPart::Link => "cell-link",
443 _ => "cell-part",
444 }
445 }
446
447 /// A row's cells, in column order.
448 ///
449 /// Ordered by the columns and not by the cells, so a row cannot silently
450 /// disagree with its table about what comes where. A column with no cell gets
451 /// an empty container, which keeps the grid aligned; a cell naming no column is
452 /// dropped, because there is nowhere to put it.
453 ///
454 /// Emits the cells alone, not the row element. The row carries the app's
455 /// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
456 /// state classes — and none of that is describable here.
457 ///
458 /// # Not for a webview's scroll path
459 ///
460 /// This has no consumer in either webview app, deliberately, and wiring one in
461 /// would be a mistake worth naming. goingson renders rows through a virtual
462 /// scroller whose `_render` calls its row builder **synchronously** while
463 /// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
464 /// this is the hot path. Reaching Rust from there means an IPC round trip and
465 /// an `await` in that loop, per visible range, during a drag.
466 ///
467 /// So this is for the hosts where rendering already happens in Rust: an axum
468 /// route, and the router when it lands. There the objection does not apply,
469 /// because nothing crosses a process boundary to reach it. A webview app should
470 /// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
471 #[must_use]
472 pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
473 let mut html = String::new();
474 cells_html_into(columns, cells, opts, &mut html);
475 html
476 }
477
478 /// A row's cells, written into a buffer the caller already has.
479 ///
480 /// [`cells_html`]'s streaming form, byte-identical to it, and the one a host
481 /// rendering a table should call: a row is emitted once per row per render, so
482 /// this is where a `String` per cell class is paid for most often.
483 pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) {
484 for column in columns {
485 let found = cells.iter().find(|cell| cell.column == column.name);
486 out.push_str("<div class=\"");
487 push_class(out, "cell", opts);
488 out.push(' ');
489 push_column_classes(out, column, opts);
490 if let Some(part) = found.and_then(|cell| cell.part) {
491 out.push(' ');
492 push_class(out, cell_part_class(part), opts);
493 }
494 out.push_str("\">");
495 out.push_str(found.map_or("", |cell| cell.content.0));
496 out.push_str("</div>");
497 }
498 }
499
500 #[cfg(test)]
501 mod tests {
502 use super::*;
503
504 #[test]
505 fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() {
506 // The obligation ROW_PART_CLASSES carries. Both matches have a wildcard
507 // arm, so a member added upstream lands on a class that is already in
508 // the list; what this catches is a new arm returning a new name, which
509 // would otherwise narrow what a checker believes this crate emits
510 // without narrowing what it writes.
511 for width in [Width::Content, Width::Fixed, Width::Fill] {
512 assert!(
513 CELL_WIDTH_CLASSES.contains(&width_class(width)),
514 "{width:?} is missing from CELL_WIDTH_CLASSES"
515 );
516 }
517 for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] {
518 assert!(
519 CELL_DROP_CLASSES.contains(&drop_class(priority)),
520 "{priority:?} is missing from CELL_DROP_CLASSES"
521 );
522 }
523 let names = crate::vocabulary::names(&Emit::default());
524 for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) {
525 assert!(names.contains(*name), "{name} is not in the vocabulary");
526 }
527 }
528
529 #[test]
530 fn a_column_name_cannot_break_out_of_the_class_attribute() {
531 // Until 0.41.0 the name went in raw, so this emitted
532 // `class="cell col-a" onclick="steal() cell-fill ...">` -- a live
533 // handler on every cell of the column. The name is the one
534 // app-supplied string this crate puts in a class rather than in text.
535 let name = "a\" onclick=\"steal()";
536 let columns = vec![Column::new(name)];
537 let cells = vec![Cell {
538 column: name,
539 part: None,
540 content: Markup("x"),
541 }];
542 let html = cells_html(&columns, &cells, &Emit::default());
543
544 assert!(!html.contains("onclick=\"steal()"), "{html}");
545 assert!(html.contains("col-a--onclick--steal--"), "{html}");
546 // Two quotes in the whole cell, both this crate's: the ones opening and
547 // closing the class attribute. A third would be the name ending it.
548 assert_eq!(html.matches('"').count(), 2, "{html}");
549 }
550
551 #[test]
552 fn the_class_and_the_selector_that_hides_it_agree_on_the_name() {
553 // The reason the fix is a filter and not an escape. A class is read by
554 // the HTML parser and again by a CSS selector; an escaped name would be
555 // safe in the attribute and unmatchable from the stylesheet, so the
556 // narrowing would stop hiding the column it names.
557 let columns = vec![Column {
558 priority: Priority::Optional,
559 ..Column::new("Due date")
560 }];
561 let cells = vec![Cell {
562 column: "Due date",
563 part: None,
564 content: Markup("x"),
565 }];
566 let opts = Emit::default();
567
568 let html = cells_html(&columns, &cells, &opts);
569 let css = narrowing_css(&columns, ".row", &sizing(), Priority::Essential, &opts);
570
571 // One class, not the two `col-Due date` parsed as.
572 assert!(html.contains("class=\"cell col-Due-date "), "{html}");
573 assert!(css.contains(".row > .col-Due-date {"), "{css}");
574 }
575
576 #[test]
577 fn a_name_already_made_of_identifier_characters_is_untouched() {
578 // Every column name in the tree is one of these, which is what makes
579 // 0.41.0 a fix rather than a rename.
580 for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
581 let mut out = String::new();
582 push_column_name(&mut out, name);
583 assert_eq!(out, name);
584 }
585 }
586
587 #[test]
588 fn a_name_outside_ascii_keeps_itself() {
589 // CSS identifiers admit everything from U+00A0 up, so folding these to
590 // dashes would collide two columns for nothing.
591 let mut out = String::new();
592 push_column_name(&mut out, "Größe");
593 assert_eq!(out, "Größe");
594 }
595
596 fn columns() -> Vec<Column<'static>> {
597 vec![
598 Column {
599 width: Width::Fill,
600 priority: Priority::Essential,
601 ..Column::new("description")
602 },
603 Column {
604 width: Width::Fixed,
605 priority: Priority::Secondary,
606 ..Column::new("due")
607 },
608 Column {
609 width: Width::Fixed,
610 priority: Priority::Optional,
611 ..Column::new("progress")
612 },
613 ]
614 }
615
616 fn sizing() -> Sizing<'static> {
617 Sizing {
618 lengths: &[
619 ("description", "200px"),
620 ("due", "110px"),
621 ("progress", "100px"),
622 ],
623 fallback: "",
624 }
625 }
626
627 #[test]
628 fn a_fill_column_gets_a_floor_and_the_slack() {
629 let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
630 assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
631 }
632
633 #[test]
634 fn a_column_with_no_length_makes_no_claim() {
635 let sizing = Sizing::default();
636 let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
637 assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
638 }
639
640 /// The point of the module. Raising the cutoff drops columns by what they
641 /// are worth, and the track list shortens to match, so the two cannot
642 /// disagree the way a hand-written `nth-child` cut and a hand-written
643 /// track list can.
644 #[test]
645 fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
646 let columns = columns();
647
648 let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
649 assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
650
651 let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
652 assert_eq!(narrow, "minmax(200px, 1fr) 110px");
653
654 let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
655 assert_eq!(narrowest, "minmax(200px, 1fr)");
656 }
657
658 #[test]
659 fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
660 let css = narrowing_css(
661 &columns(),
662 ".ui-mode-mobile .task-row",
663 &sizing(),
664 Priority::Secondary,
665 &Emit::default(),
666 );
667 assert!(
668 css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
669 "{css}"
670 );
671 assert!(
672 css.contains(".ui-mode-mobile .task-row > .col-progress {"),
673 "{css}"
674 );
675 assert!(!css.contains("nth-child"), "{css}");
676 // The kept columns are not mentioned as hidden.
677 assert!(!css.contains(".col-due {\n display: none"), "{css}");
678 }
679
680 /// A selector list has to distribute, or the earlier parts of it get the
681 /// child combinator appended to the whole and start matching things they
682 /// never named. This hid an entire table header the first time it ran.
683 #[test]
684 fn a_selector_list_distributes_the_hidden_column() {
685 let css = narrowing_css(
686 &columns(),
687 ".task-header-row, .task-row",
688 &sizing(),
689 Priority::Secondary,
690 &Emit::default(),
691 );
692 assert!(
693 css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"),
694 "{css}"
695 );
696 // The bare header selector must never appear as a hiding target.
697 assert!(
698 !css.contains(".task-header-row {\n display: none"),
699 "{css}"
700 );
701 assert!(
702 css.contains(".task-header-row, .task-row {\n grid-template-columns:"),
703 "{css}"
704 );
705 }
706
707 #[test]
708 fn cells_follow_the_columns_and_carry_their_column_class() {
709 let cells = [
710 Cell {
711 column: "due",
712 part: Some(CellPart::Value),
713 content: Markup("tomorrow"),
714 },
715 Cell::new("description", Markup("<span>Ship it</span>")),
716 ];
717 let html = cells_html(&columns(), &cells, &Emit::default());
718
719 // Column order, not cell order: description was passed second.
720 let description = html.find("Ship it").expect("description cell");
721 let due = html.find("tomorrow").expect("due cell");
722 assert!(description < due, "{html}");
723
724 // Three classes, not one: the column's own name, how wide it asks to
725 // be, and when it drops. The last two are what lets the stylesheet
726 // carry rules a described table cannot generate per table.
727 assert!(
728 html.contains(r#"<div class="cell col-description cell-fill cell-keeps">"#),
729 "{html}"
730 );
731 assert!(
732 html.contains(r#"<div class="cell col-due cell-fixed cell-drops-next cell-value">"#),
733 "{html}"
734 );
735 // progress had no cell, so it is present and empty rather than absent,
736 // or the grid would shift left by one.
737 assert!(
738 html.contains(r#"<div class="cell col-progress cell-fixed cell-drops-first"></div>"#),
739 "{html}"
740 );
741 }
742
743 /// A row is emitted once per row per render, so the streaming form is the
744 /// one a host should call and the two have to agree byte for byte.
745 #[test]
746 fn streamed_cells_are_the_cells_the_other_form_returns() {
747 let opts = Emit {
748 class_prefix: "mk-",
749 ..Emit::default()
750 };
751 let cells = [
752 Cell {
753 column: "due",
754 part: Some(CellPart::Value),
755 content: Markup("tomorrow"),
756 },
757 Cell::new("description", Markup("<span>Ship it</span>")),
758 ];
759 for cells in [&cells[..], &[]] {
760 let mut streamed = String::new();
761 cells_html_into(&columns(), cells, &opts, &mut streamed);
762 assert_eq!(streamed, cells_html(&columns(), cells, &opts));
763 }
764 for column in &columns() {
765 let mut streamed = String::new();
766 push_column_classes(&mut streamed, column, &opts);
767 assert_eq!(streamed, column_classes(column, &opts));
768 }
769 }
770
771 #[test]
772 fn a_cell_naming_no_column_is_dropped() {
773 let cells = [Cell::new("nonexistent", Markup("nowhere"))];
774 let html = cells_html(&columns(), &cells, &Emit::default());
775 assert!(!html.contains("nowhere"), "{html}");
776 }
777
778 #[test]
779 fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
780 let opts = Emit {
781 class_prefix: "mk-",
782 ..Emit::default()
783 };
784 let cells = [Cell::new("due", Markup("x"))];
785 assert!(
786 cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
787 "prefix missing"
788 );
789 assert!(
790 narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
791 .contains(".mk-col-progress"),
792 "prefix missing"
793 );
794 }
795 }
796