Skip to main content

makeover_webview/
list.rs

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
33use crate::form::Markup;
34use crate::{Emit, push_class};
35use makeover_layout::{CellPart, Column, Flow, Priority, RowPart, Width};
36use 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)]
49pub 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
57impl 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]
97pub 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.
109pub 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.
152pub 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.
172fn 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.
187fn 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]
206pub 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.
217pub 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]
233pub 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]
257pub 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)]
290pub 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
314impl<'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.
352pub 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.
368pub 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]
379pub 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 [`cell_part_class`] can return, including the fallback.
387///
388/// See [`ROW_PART_CLASSES`] for why it is written out.
389pub const CELL_PART_CLASSES: &[&str] = &[
390    "cell-value",
391    "cell-tokens",
392    "cell-actions",
393    "cell-link",
394    "cell-part",
395];
396
397#[must_use]
398pub fn part_class(part: RowPart) -> &'static str {
399    match part {
400        RowPart::Primary => "row-primary",
401        RowPart::Secondary => "row-secondary",
402        RowPart::Meta => "row-meta",
403        RowPart::Actions => "row-actions",
404        RowPart::Tokens => "row-tokens",
405        RowPart::Proportion => "row-proportion",
406        _ => "row-part",
407    }
408}
409
410/// The class for a cell part.
411///
412/// [`part_class`]'s table half, added with `makeover-layout` 0.14.0's
413/// [`CellPart`]. The fallback is there for the same reason and costs the same
414/// thing: a member added upstream lands as a bare `cell-part` with no rule of
415/// its own, rather than as a build that stops. Grep this function too when
416/// adopting a new makeover-layout, and public since 0.27.0 for the reason
417/// [`part_class`] is.
418#[must_use]
419pub fn cell_part_class(part: CellPart) -> &'static str {
420    match part {
421        CellPart::Value => "cell-value",
422        CellPart::Tokens => "cell-tokens",
423        CellPart::Actions => "cell-actions",
424        CellPart::Link => "cell-link",
425        _ => "cell-part",
426    }
427}
428
429/// A row's cells, in column order.
430///
431/// Ordered by the columns and not by the cells, so a row cannot silently
432/// disagree with its table about what comes where. A column with no cell gets
433/// an empty container, which keeps the grid aligned; a cell naming no column is
434/// dropped, because there is nowhere to put it.
435///
436/// Emits the cells alone, not the row element. The row carries the app's
437/// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
438/// state classes — and none of that is describable here.
439///
440/// # Not for a webview's scroll path
441///
442/// This has no consumer in either webview app, deliberately, and wiring one in
443/// would be a mistake worth naming. goingson renders rows through a virtual
444/// scroller whose `_render` calls its row builder **synchronously** while
445/// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
446/// this is the hot path. Reaching Rust from there means an IPC round trip and
447/// an `await` in that loop, per visible range, during a drag.
448///
449/// So this is for the hosts where rendering already happens in Rust: an axum
450/// route, and the router when it lands. There the objection does not apply,
451/// because nothing crosses a process boundary to reach it. A webview app should
452/// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
453#[must_use]
454pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
455    let mut html = String::new();
456    cells_html_into(columns, cells, opts, &mut html);
457    html
458}
459
460/// A row's cells, written into a buffer the caller already has.
461///
462/// [`cells_html`]'s streaming form, byte-identical to it, and the one a host
463/// rendering a table should call: a row is emitted once per row per render, so
464/// this is where a `String` per cell class is paid for most often.
465pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) {
466    for column in columns {
467        let found = cells.iter().find(|cell| cell.column == column.name);
468        out.push_str("<div class=\"");
469        push_class(out, "cell", opts);
470        out.push(' ');
471        push_column_classes(out, column, opts);
472        if let Some(part) = found.and_then(|cell| cell.part) {
473            out.push(' ');
474            push_class(out, cell_part_class(part), opts);
475        }
476        out.push_str("\">");
477        out.push_str(found.map_or("", |cell| cell.content.0));
478        out.push_str("</div>");
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn a_column_name_cannot_break_out_of_the_class_attribute() {
488        // Until 0.41.0 the name went in raw, so this emitted
489        // `class="cell col-a" onclick="steal() cell-fill ...">` -- a live
490        // handler on every cell of the column. The name is the one
491        // app-supplied string this crate puts in a class rather than in text.
492        let name = "a\" onclick=\"steal()";
493        let columns = vec![Column::new(name)];
494        let cells = vec![Cell {
495            column: name,
496            part: None,
497            content: Markup("x"),
498        }];
499        let html = cells_html(&columns, &cells, &Emit::default());
500
501        assert!(!html.contains("onclick=\"steal()"), "{html}");
502        assert!(html.contains("col-a--onclick--steal--"), "{html}");
503        // Two quotes in the whole cell, both this crate's: the ones opening and
504        // closing the class attribute. A third would be the name ending it.
505        assert_eq!(html.matches('"').count(), 2, "{html}");
506    }
507
508    #[test]
509    fn the_class_and_the_selector_that_hides_it_agree_on_the_name() {
510        // The reason the fix is a filter and not an escape. A class is read by
511        // the HTML parser and again by a CSS selector; an escaped name would be
512        // safe in the attribute and unmatchable from the stylesheet, so the
513        // narrowing would stop hiding the column it names.
514        let columns = vec![Column {
515            priority: Priority::Optional,
516            ..Column::new("Due date")
517        }];
518        let cells = vec![Cell {
519            column: "Due date",
520            part: None,
521            content: Markup("x"),
522        }];
523        let opts = Emit::default();
524
525        let html = cells_html(&columns, &cells, &opts);
526        let css = narrowing_css(&columns, ".row", &sizing(), Priority::Essential, &opts);
527
528        // One class, not the two `col-Due date` parsed as.
529        assert!(html.contains("class=\"cell col-Due-date "), "{html}");
530        assert!(css.contains(".row > .col-Due-date {"), "{css}");
531    }
532
533    #[test]
534    fn a_name_already_made_of_identifier_characters_is_untouched() {
535        // Every column name in the tree is one of these, which is what makes
536        // 0.41.0 a fix rather than a rename.
537        for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
538            let mut out = String::new();
539            push_column_name(&mut out, name);
540            assert_eq!(out, name);
541        }
542    }
543
544    #[test]
545    fn a_name_outside_ascii_keeps_itself() {
546        // CSS identifiers admit everything from U+00A0 up, so folding these to
547        // dashes would collide two columns for nothing.
548        let mut out = String::new();
549        push_column_name(&mut out, "Größe");
550        assert_eq!(out, "Größe");
551    }
552
553    fn columns() -> Vec<Column<'static>> {
554        vec![
555            Column {
556                width: Width::Fill,
557                priority: Priority::Essential,
558                ..Column::new("description")
559            },
560            Column {
561                width: Width::Fixed,
562                priority: Priority::Secondary,
563                ..Column::new("due")
564            },
565            Column {
566                width: Width::Fixed,
567                priority: Priority::Optional,
568                ..Column::new("progress")
569            },
570        ]
571    }
572
573    fn sizing() -> Sizing<'static> {
574        Sizing {
575            lengths: &[
576                ("description", "200px"),
577                ("due", "110px"),
578                ("progress", "100px"),
579            ],
580            fallback: "",
581        }
582    }
583
584    #[test]
585    fn a_fill_column_gets_a_floor_and_the_slack() {
586        let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
587        assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
588    }
589
590    #[test]
591    fn a_column_with_no_length_makes_no_claim() {
592        let sizing = Sizing::default();
593        let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
594        assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
595    }
596
597    /// The point of the module. Raising the cutoff drops columns by what they
598    /// are worth, and the track list shortens to match, so the two cannot
599    /// disagree the way a hand-written `nth-child` cut and a hand-written
600    /// track list can.
601    #[test]
602    fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
603        let columns = columns();
604
605        let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
606        assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
607
608        let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
609        assert_eq!(narrow, "minmax(200px, 1fr) 110px");
610
611        let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
612        assert_eq!(narrowest, "minmax(200px, 1fr)");
613    }
614
615    #[test]
616    fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
617        let css = narrowing_css(
618            &columns(),
619            ".ui-mode-mobile .task-row",
620            &sizing(),
621            Priority::Secondary,
622            &Emit::default(),
623        );
624        assert!(
625            css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
626            "{css}"
627        );
628        assert!(
629            css.contains(".ui-mode-mobile .task-row > .col-progress {"),
630            "{css}"
631        );
632        assert!(!css.contains("nth-child"), "{css}");
633        // The kept columns are not mentioned as hidden.
634        assert!(!css.contains(".col-due {\n    display: none"), "{css}");
635    }
636
637    /// A selector list has to distribute, or the earlier parts of it get the
638    /// child combinator appended to the whole and start matching things they
639    /// never named. This hid an entire table header the first time it ran.
640    #[test]
641    fn a_selector_list_distributes_the_hidden_column() {
642        let css = narrowing_css(
643            &columns(),
644            ".task-header-row, .task-row",
645            &sizing(),
646            Priority::Secondary,
647            &Emit::default(),
648        );
649        assert!(
650            css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"),
651            "{css}"
652        );
653        // The bare header selector must never appear as a hiding target.
654        assert!(
655            !css.contains(".task-header-row {\n    display: none"),
656            "{css}"
657        );
658        assert!(
659            css.contains(".task-header-row, .task-row {\n    grid-template-columns:"),
660            "{css}"
661        );
662    }
663
664    #[test]
665    fn cells_follow_the_columns_and_carry_their_column_class() {
666        let cells = [
667            Cell {
668                column: "due",
669                part: Some(CellPart::Value),
670                content: Markup("tomorrow"),
671            },
672            Cell::new("description", Markup("<span>Ship it</span>")),
673        ];
674        let html = cells_html(&columns(), &cells, &Emit::default());
675
676        // Column order, not cell order: description was passed second.
677        let description = html.find("Ship it").expect("description cell");
678        let due = html.find("tomorrow").expect("due cell");
679        assert!(description < due, "{html}");
680
681        // Three classes, not one: the column's own name, how wide it asks to
682        // be, and when it drops. The last two are what lets the stylesheet
683        // carry rules a described table cannot generate per table.
684        assert!(
685            html.contains(r#"<div class="cell col-description cell-fill cell-keeps">"#),
686            "{html}"
687        );
688        assert!(
689            html.contains(r#"<div class="cell col-due cell-fixed cell-drops-next cell-value">"#),
690            "{html}"
691        );
692        // progress had no cell, so it is present and empty rather than absent,
693        // or the grid would shift left by one.
694        assert!(
695            html.contains(r#"<div class="cell col-progress cell-fixed cell-drops-first"></div>"#),
696            "{html}"
697        );
698    }
699
700    /// A row is emitted once per row per render, so the streaming form is the
701    /// one a host should call and the two have to agree byte for byte.
702    #[test]
703    fn streamed_cells_are_the_cells_the_other_form_returns() {
704        let opts = Emit {
705            class_prefix: "mk-",
706            ..Emit::default()
707        };
708        let cells = [
709            Cell {
710                column: "due",
711                part: Some(CellPart::Value),
712                content: Markup("tomorrow"),
713            },
714            Cell::new("description", Markup("<span>Ship it</span>")),
715        ];
716        for cells in [&cells[..], &[]] {
717            let mut streamed = String::new();
718            cells_html_into(&columns(), cells, &opts, &mut streamed);
719            assert_eq!(streamed, cells_html(&columns(), cells, &opts));
720        }
721        for column in &columns() {
722            let mut streamed = String::new();
723            push_column_classes(&mut streamed, column, &opts);
724            assert_eq!(streamed, column_classes(column, &opts));
725        }
726    }
727
728    #[test]
729    fn a_cell_naming_no_column_is_dropped() {
730        let cells = [Cell::new("nonexistent", Markup("nowhere"))];
731        let html = cells_html(&columns(), &cells, &Emit::default());
732        assert!(!html.contains("nowhere"), "{html}");
733    }
734
735    #[test]
736    fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
737        let opts = Emit {
738            class_prefix: "mk-",
739            ..Emit::default()
740        };
741        let cells = [Cell::new("due", Markup("x"))];
742        assert!(
743            cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
744            "prefix missing"
745        );
746        assert!(
747            narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
748                .contains(".mk-col-progress"),
749            "prefix missing"
750        );
751    }
752}