]> git.draconx.ca Git - homepage.git/blobdiff - content/weblog/responsive-tables.md
Let's start a blog!
[homepage.git] / content / weblog / responsive-tables.md
diff --git a/content/weblog/responsive-tables.md b/content/weblog/responsive-tables.md
new file mode 100644 (file)
index 0000000..2063484
--- /dev/null
@@ -0,0 +1,442 @@
+---
+title: Adventure in Responsive WWW Tables
+copyright: 2020 Nick Bowler
+license: cc-by-sa-4.0
+published: 2020-07-05T23:25:02-0400
+---
+
+*[CSS]: Cascading Style Sheets
+*[HTML]: HyperText Markup Language
+
+A quest to improve table display on the World Wide Web, using modern
+technologies that degrade gracefully.
+
+Sometimes, when presenting tables, better use of screen (or paper) space is
+achieved by displaying table rows in two (or more) side-by-side vertical
+columns.  I would generally use this approach whenever rows can be reasonably
+formatted to fit in less than half the page width and the table is sufficiently
+long.
+
+For an example, compare and contrast [table 1](#t1) (a straightforward HTML
+table) with [table 2](#t2) (exactly the same information, split into two
+sets of columns).
+
+   ID | Name      | Age
+   ---|-----------|----
+    1 | Barbara   | 34
+    2 | Charles   | 42
+    3 | David     | 53
+    4 | Elizabeth | 32
+    5 | James     | 33
+    6 | Jennifer  | 38
+    7 | Jessica   | 37
+    8 | John      | 31
+    9 | Joseph    | 38
+   10 | Karen     | 57
+   {:#t1 caption="<%= counter(:table) %>:
+    An inefficient use of available space"}
+
+   ID | Name      | Age | ID | Name     | Age
+   ---|-----------|-----|----|----------|----
+    1 | Barbara   | 34  |  6 | Jennifer | 38
+    2 | Charles   | 42  |  7 | Jessica  | 37
+    3 | David     | 53  |  8 | John     | 31
+    4 | Elizabeth | 32  |  9 | Joseph   | 38
+    5 | James     | 33  | 10 | Karen    | 57
+   {:#t2 caption="<%= counter(:table) %>:
+    Same information, more compact presentation"}
+
+This works, but there is a significant problem: the presentation of [table
+2](#t2) into two sets of columns is hardcoded in the HTML markup.  Aside from a
+general aversion to mixing content with style, there is a practical problem: on
+a narrow display the table could easily be too wide.  If columns end up off the
+screen this would be worse than [table 1](#t1) which probably fits nicely.
+
+What we really want is for the user agent to automatically select the most
+appropriate presentation for display---a so-called responsive layout.
+
+# Enter CSS Grid
+
+[cssgrid]: //www.w3.org/TR/css-grid-1/
+
+The [CSS grid module][cssgrid] enables CSS rules to define a table-like
+presentation, with quite a lot of flexibility.  Unfortunately CSS grids
+have a number of major practical problems and limitations.  We will discuss
+and overcome some of these limitations to achieve a responsive layout for
+our table with a high degree of compatibility.
+
+The first and most obvious problem is that not all browsers support CSS grids.
+Whatever solution we implement must, to the greatest extent possible, work in
+all browsers.  This means graceful degradation: if the grid does not work for
+any reason we want to get a readable table, ideally one like [table 1](#t1).
+
+Even with the latest and greatest browser, there is no guarantee the styles
+actually work because they might not even load.  Firefox still lets users
+disable styles at the touch of a button.  Our table must be readable in
+these scenarios.
+
+With a CSS grid, the _grid items_ are all the child elements of the _grid
+container_, which is an element styled with `display: grid`.  In particular,
+descendent elements that are not direct children of the grid container do not
+participate in the grid layout.  While the CSS `display: contents` style exists
+to help overcome this limitation, it turns out to not be very useful: among
+browsers that support grids, not all support `display: contents`.  Thus we
+cannot expect grid layouts to work if we depend on `display: contents`.
+
+If you search online for laying out tabular data with CSS grid, you will find
+many examples that flatten all the table cells into a linear sequence of
+elements within a big container `div` or similar.  This satisfies the markup
+constraints but is, quite frankly, terrible.  The structure of the data has
+been moved from the markup into the stylesheet.  The table is meaningless if
+the stylesheet is not working for any reason.  Whatever happened to separation
+of content and style?
+
+To achieve graceful degradation, we must start with markup that looks very
+similar to the markup of [table 1](#t1)---a straightforward HTML table, and
+only apply styles on top of that.
+
+## Rows as grid items
+
+The markup constraints introduce an immediate practical problem: the actual
+table cells cannot be the primary grid items for our responsive layout,
+because we need to arrange items row-by-row.  So instead, our table rows will
+be the grid items.  Therefore the grid containers must be `thead` and `tbody`.
+We will not bother with `tfoot` at this time, but it should be pretty similar
+to `thead`.
+
+Using a media query, a simple 2-column grid can be configured which fills the
+available space only if there is sufficient width available.
+
+#### <%= counter(:listing) %>: Markup for [table 3](#t3)
+<generate-xhtml-listing target='t3' />
+
+#### <%= counter(:listing) %>: Style for [table 3](#t3)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+  #t3>* {
+    grid-column-gap: 0.5ex;
+    grid-template-columns: 1fr 1fr;
+  }
+
+  @media (min-width: 35em) {
+    #t3>thead, #t3>tbody { display: grid; }
+  }
+}
+</style>
+
+<table id='t3'>
+  <caption><%= counter(:table) %>: First layout attempt with grid</caption>
+  <thead>
+    <tr><th>Header</th></tr>
+  </thead>
+  <tbody>
+    <tr><td>Row 1</td></tr>
+    <tr><td>Row 2</td></tr>
+    <tr><td>Row 3</td></tr>
+    <tr><td>Row 4</td></tr>
+    <tr><td>Row 5</td></tr>
+    <tr><td>Row 6</td></tr>
+  </tbody>
+</table>
+
+[Table 3](#t3) shows the basic structure working but there are several
+obvious deficiencies.  The most glaring is the lack of a header on the
+right-hand grid column.  Some of the table styling (odd/even row shading
+in particular) is busted.  And the rows are not in the desired location:
+row 2 should be beneath row 1, not beside it.
+
+## Duplicating the header
+
+There is unfortunately no way to duplicate the header in a stylesheet, so we
+have no choice but to duplicate the header row in the table markup itself.  We
+can hide it with an inline style attribute and reveal it when the grid is
+enabled in the media query.  An inline `display: none` is widely supported to
+hide this row normally.
+
+#### <%= counter(:listing) %>: Markup for [table 4](#t4)
+<generate-xhtml-listing target='t4' />
+
+#### <%= counter(:listing) %>: Style for [table 4](#t4)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+  #t4>* {
+    grid-column-gap: 0.5ex;
+    grid-template-columns: 1fr 1fr;
+  }
+
+  @media (min-width: 35em) {
+    #t4>*>tr { display: initial !important; }
+    #t4>thead, #t4>tbody { display: grid; }
+  }
+}
+</style>
+
+<table id='t4'>
+  <caption><%= counter(:table) %>: Duplicated header on grid</caption>
+  <thead>
+    <tr><th>Header</th></tr>
+    <tr style='display: none;'><th>Header (duplicated)</th></tr>
+  </thead>
+  <tbody>
+    <tr><td>Row 1</td></tr>
+    <tr><td>Row 2</td></tr>
+    <tr><td>Row 3</td></tr>
+    <tr><td>Row 4</td></tr>
+    <tr><td>Row 5</td></tr>
+    <tr><td>Row 6</td></tr>
+  </tbody>
+</table>
+
+This is not perfect: the duplicate header will appear if styles are disabled
+or if the browser does not support CSS at all, which includes all text-mode
+browsers that I am aware of.  But it is a fairly minor annoyance and the
+situation can be improved somewhat with some hacks that we might explore in
+another adventure.
+
+## Fixing the row placement
+
+Our grid currently consists of two grid columns and a dynamic number of
+grid rows, and the automatic grid layout fills each row before creating
+a new one.  We can place the rows where they need to go by styling the
+first half of the rows differently from the last half.  The first half
+can be forced to the first column, and the remainder will auto-place
+to the correct locations when using `grid-auto-flow: dense`.
+
+Perhaps the easiest way to do this is to markup the halfway point with a
+class, so that CSS selectors can reference this row without hardcoding the
+number of rows in the table.
+
+#### <%= counter(:listing) %>: Markup for [table 5](#t5)
+<generate-xhtml-listing target='t5' />
+
+#### <%= counter(:listing) %>: Style for [table 5](#t5)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+  #t5>* {
+    grid-column-gap: 0.5ex;
+    grid-template-columns: 1fr 1fr;
+    grid-auto-flow: dense;
+  }
+
+  #t5>tbody>tr { grid-column-start: 1; }
+  #t5>tbody>tr.t5-split ~ tr { grid-column-start: auto; }
+
+  @media (min-width: 35em) {
+    #t5>*>tr { display: initial !important; }
+    #t5>thead, #t5>tbody { display: grid; }
+  }
+}
+</style>
+
+<table id='t5'>
+  <caption><%= counter(:table) %>: Correct row placement on grid</caption>
+  <thead>
+    <tr><th>Header</th></tr>
+    <tr style='display: none;'><th>Header (duplicated)</th></tr>
+  </thead>
+  <tbody>
+    <tr><td>Row 1</td></tr>
+    <tr><td>Row 2</td></tr>
+    <tr class='t5-split'><td>Row 3</td></tr>
+    <tr><td>Row 4</td></tr>
+    <tr><td>Row 5</td></tr>
+    <tr><td>Row 6</td></tr>
+  </tbody>
+</table>
+
+## Fixing the row styling
+
+Depending on the style used, [table 5](#t5) might be good enough.  But here,
+the odd/even row shading is messed up because the split was not performed
+on an even-numbered row, and there is no bottom border on the first grid
+column.  Now that the the row positioning is correct, both of these issues
+are pretty easy to fix in the stylesheet.
+
+#### <%= counter(:listing) %>: Markup for [table 6](#t6)
+<generate-xhtml-listing target='t6' />
+
+#### <%= counter(:listing) %>: Style for [table 6](#t6)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+  #t6>* {
+    grid-column-gap: 0.5ex;
+    grid-template-columns: 1fr 1fr;
+    grid-auto-flow: dense;
+  }
+
+  #t6>tbody>tr { grid-column-start: 1; }
+  #t6>tbody>tr.t6-split ~ tr { grid-column-start: auto; }
+
+  @media (min-width: 35em) {
+    #t6>*>tr { display: initial !important; }
+    #t6>thead, #t6>tbody { display: grid; }
+
+    #t6>tbody>tr.t6-split { border-bottom: 1px solid <%=
+      scss_get_var(:ruledefaultcolour) %>; }
+    #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
+      background-color: initial;
+    }
+    #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
+      background-color: <%= scss_get_var(:tableshadecolour) %>;
+    }
+  }
+}
+</style>
+
+<table id='t6'>
+  <caption><%= counter(:table) %>: Correct row styling on grid</caption>
+  <thead>
+    <tr><th>Header</th></tr>
+    <tr style='display: none;'><th>Header (duplicated)</th></tr>
+  </thead>
+  <tbody>
+    <tr><td>Row 1</td></tr>
+    <tr><td>Row 2</td></tr>
+    <tr class='t6-split'><td>Row 3</td></tr>
+    <tr><td>Row 4</td></tr>
+    <tr><td>Row 5</td></tr>
+    <tr><td>Row 6</td></tr>
+  </tbody>
+</table>
+
+# Realistic Tables
+
+[Table 6](#t6) is pretty close to what we want, except for the minor detail
+that real tables typically have more than one column.  So let's try applying
+these techniques to [table 1](#t1).
+
+#### <%= counter(:listing) %>: Markup for [table 7](#t7)
+<generate-xhtml-listing target='t7' />
+
+#### <%= counter(:listing) %>: Style for [table 7](#t7)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+  #t7>* {
+    grid-column-gap: 0.5ex;
+    grid-template-columns: 1fr 1fr;
+    grid-auto-flow: dense;
+  }
+
+  #t7>tbody>tr { grid-column-start: 1; }
+  #t7>tbody>tr.t7-split ~ tr { grid-column-start: auto; }
+
+  @media (min-width: 35em) {
+    #t7>*>tr { display: initial !important; }
+    #t7>thead, #t7>tbody { display: grid; }
+
+    #t7>tbody>tr.t7-split { border-bottom: 1px solid <%=
+      scss_get_var(:ruledefaultcolour) %>; }
+    #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
+      background-color: initial;
+    }
+    #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
+      background-color: <%= scss_get_var(:tableshadecolour) %>;
+    }
+  }
+}
+</style>
+
+<table id="t7">
+  <caption><%= counter(:table) %>: Column misalignment on grid</caption>
+  <thead>
+    <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
+    <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
+  </thead>
+  <tbody>
+    <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
+    <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
+    <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
+    <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
+    <tr class='t7-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
+    <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
+    <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
+    <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
+    <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
+    <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
+  </tbody>
+</table>
+
+The fallback for [table 7](#t7) works properly but when the grid columns are
+activated, the alignment of table cells into their respective columns is not
+correct.  This is something that we could potentially solve with CSS subgrids,
+but as with `display: contents` lack of browser support makes their use
+untenable.
+
+Nevertheless, provided that the width of each cell is independent of its
+contents, they will all line up correctly.  There are many ways to do this
+in a stylesheet.  We will do it with more grids.
+
+#### <%= counter(:listing) %>: Markup for [table 8](#t8)
+<generate-xhtml-listing target='t8' />
+
+#### <%= counter(:listing) %>: Style for [table 8](#t8)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+  #t8>* {
+    grid-column-gap: 0.5ex;
+    grid-template-columns: 1fr 1fr;
+    grid-auto-flow: dense;
+  }
+
+  #t8>tbody>tr { grid-column-start: 1; }
+  #t8>tbody>tr.t8-split ~ tr { grid-column-start: auto; }
+
+  @media (min-width: 35em) {
+    #t8>*>tr { display: grid !important; }
+    #t8>thead, #t8>tbody { display: grid; }
+
+    #t8>tbody>tr.t8-split { border-bottom: 1px solid <%=
+      scss_get_var(:ruledefaultcolour) %>; }
+    #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
+      background-color: initial;
+    }
+    #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
+      background-color: <%= scss_get_var(:tableshadecolour) %>;
+    }
+  }
+
+  #t8>*>tr {
+    grid-template-columns: minmax(3em, 1fr) 5fr minmax(3em, 1fr);
+  }
+}
+</style>
+
+<table id="t8">
+  <caption><%= counter(:table) %>: Fully working responsive table</caption>
+  <thead>
+    <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
+    <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
+  </thead>
+  <tbody>
+    <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
+    <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
+    <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
+    <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
+    <tr class='t8-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
+    <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
+    <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
+    <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
+    <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
+    <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
+  </tbody>
+</table>
+
+# Epilogue
+
+Some issues remain, but I think the results in [table 8](#t8) are pretty good.
+
+I don't consider the loss of automatic cell sizing to be a big deal, sizes
+usually need to be tweaked per-table anyway and the use of `fr` units can
+give pretty nice results that scale with available width.
+
+More than two grid columns should be possible but I have not attempted to do
+so.  There might be a lot more fighting with the automatic grid placement.
+
+Depending on the table it may be useful to tweak various alignment properties
+of the grid items.
+
+It seems that Firefox really sucks at printing these grids if they happen to
+cross a page boundary (I did not try printing in other browsers).  Firefox is
+pretty bad at printing regular tables when they do this too, but it is way
+worse with grids.  Using e.g. `@media screen` in the stylesheet can help by
+falling back to an ordinary table for printouts.