Release cdecl99-1.1.
[homepage.git] / content / weblog / responsive-tables.md
1 ---
2 title: Adventure in Responsive WWW Tables
3 copyright: 2020 Nick Bowler
4 license: cc-by-sa-4.0
5 published: 2020-07-05T23:25:02-0400
6 ---
7
8 *[CSS]: Cascading Style Sheets
9 *[HTML]: HyperText Markup Language
10
11 A quest to improve table display on the World Wide Web, using modern
12 technologies that degrade gracefully.
13
14 Sometimes, when presenting tables, better use of screen (or paper) space is
15 achieved by displaying table rows in two (or more) side-by-side vertical
16 columns.  I would generally use this approach whenever rows can be reasonably
17 formatted to fit in less than half the page width and the table is sufficiently
18 long.
19
20 For an example, compare and contrast [table 1](#t1) (a straightforward HTML
21 table) with [table 2](#t2) (exactly the same information, split into two
22 sets of columns).
23
24    ID | Name      | Age
25    ---|-----------|----
26     1 | Barbara   | 34
27     2 | Charles   | 42
28     3 | David     | 53
29     4 | Elizabeth | 32
30     5 | James     | 33
31     6 | Jennifer  | 38
32     7 | Jessica   | 37
33     8 | John      | 31
34     9 | Joseph    | 38
35    10 | Karen     | 57
36    {:#t1 caption="<%= counter(:table) %>:
37     An inefficient use of available space"}
38
39    ID | Name      | Age | ID | Name     | Age
40    ---|-----------|-----|----|----------|----
41     1 | Barbara   | 34  |  6 | Jennifer | 38
42     2 | Charles   | 42  |  7 | Jessica  | 37
43     3 | David     | 53  |  8 | John     | 31
44     4 | Elizabeth | 32  |  9 | Joseph   | 38
45     5 | James     | 33  | 10 | Karen    | 57
46    {:#t2 caption="<%= counter(:table) %>:
47     Same information, more compact presentation"}
48
49 This works, but there is a significant problem: the presentation of [table
50 2](#t2) into two sets of columns is hardcoded in the HTML markup.  Aside from a
51 general aversion to mixing content with style, there is a practical problem: on
52 a narrow display the table could easily be too wide.  If columns end up off the
53 screen this would be worse than [table 1](#t1) which probably fits nicely.
54
55 What we really want is for the user agent to automatically select the most
56 appropriate presentation for display---a so-called responsive layout.
57
58 # Enter CSS Grid
59
60 [cssgrid]: //www.w3.org/TR/css-grid-1/
61
62 The [CSS grid module][cssgrid] enables CSS rules to define a table-like
63 presentation, with quite a lot of flexibility.  Unfortunately CSS grids
64 have a number of major practical problems and limitations.  We will discuss
65 and overcome some of these limitations to achieve a responsive layout for
66 our table with a high degree of compatibility.
67
68 The first and most obvious problem is that not all browsers support CSS grids.
69 Whatever solution we implement must, to the greatest extent possible, work in
70 all browsers.  This means graceful degradation: if the grid does not work for
71 any reason we want to get a readable table, ideally one like [table 1](#t1).
72
73 Even with the latest and greatest browser, there is no guarantee the styles
74 actually work because they might not even load.  Firefox still lets users
75 disable styles at the touch of a button.  Our table must be readable in
76 these scenarios.
77
78 With a CSS grid, the _grid items_ are all the child elements of the _grid
79 container_, which is an element styled with `display: grid`.  In particular,
80 descendent elements that are not direct children of the grid container do not
81 participate in the grid layout.  While the CSS `display: contents` style exists
82 to help overcome this limitation, it turns out to not be very useful: among
83 browsers that support grids, not all support `display: contents`.  Thus we
84 cannot expect grid layouts to work if we depend on `display: contents`.
85
86 If you search online for laying out tabular data with CSS grid, you will find
87 many examples that flatten all the table cells into a linear sequence of
88 elements within a big container `div` or similar.  This satisfies the markup
89 constraints but is, quite frankly, terrible.  The structure of the data has
90 been moved from the markup into the stylesheet.  The table is meaningless if
91 the stylesheet is not working for any reason.  Whatever happened to separation
92 of content and style?
93
94 To achieve graceful degradation, we must start with markup that looks very
95 similar to the markup of [table 1](#t1)---a straightforward HTML table, and
96 only apply styles on top of that.
97
98 ## Rows as grid items
99
100 The markup constraints introduce an immediate practical problem: the actual
101 table cells cannot be the primary grid items for our responsive layout,
102 because we need to arrange items row-by-row.  So instead, our table rows will
103 be the grid items.  Therefore the grid containers must be `thead` and `tbody`.
104 We will not bother with `tfoot` at this time, but it should be pretty similar
105 to `thead`.
106
107 Using a media query, a simple 2-column grid can be configured which fills the
108 available space only if there is sufficient width available.
109
110 #### <%= counter(:listing) %>: Markup for [table 3](#t3)
111 <generate-xhtml-listing target='t3' />
112
113 #### <%= counter(:listing) %>: Style for [table 3](#t3)
114 <style type='text/css' generate-listing>
115 @supports (display: grid) {
116   #t3>* {
117     grid-column-gap: 0.5ex;
118     grid-template-columns: 1fr 1fr;
119   }
120
121   @media (min-width: 35em) {
122     #t3>thead, #t3>tbody { display: grid; }
123   }
124 }
125 </style>
126
127 <table id='t3'>
128   <caption><%= counter(:table) %>: First layout attempt with grid</caption>
129   <thead>
130     <tr><th>Header</th></tr>
131   </thead>
132   <tbody>
133     <tr><td>Row 1</td></tr>
134     <tr><td>Row 2</td></tr>
135     <tr><td>Row 3</td></tr>
136     <tr><td>Row 4</td></tr>
137     <tr><td>Row 5</td></tr>
138     <tr><td>Row 6</td></tr>
139   </tbody>
140 </table>
141
142 [Table 3](#t3) shows the basic structure working but there are several
143 obvious deficiencies.  The most glaring is the lack of a header on the
144 right-hand grid column.  Some of the table styling (odd/even row shading
145 in particular) is busted.  And the rows are not in the desired location:
146 row 2 should be beneath row 1, not beside it.
147
148 ## Duplicating the header
149
150 There is unfortunately no way to duplicate the header in a stylesheet, so we
151 have no choice but to duplicate the header row in the table markup itself.  We
152 can hide it with an inline style attribute and reveal it when the grid is
153 enabled in the media query.  An inline `display: none` is widely supported to
154 hide this row normally.
155
156 #### <%= counter(:listing) %>: Markup for [table 4](#t4)
157 <generate-xhtml-listing target='t4' />
158
159 #### <%= counter(:listing) %>: Style for [table 4](#t4)
160 <style type='text/css' generate-listing>
161 @supports (display: grid) {
162   #t4>* {
163     grid-column-gap: 0.5ex;
164     grid-template-columns: 1fr 1fr;
165   }
166
167   @media (min-width: 35em) {
168     #t4>*>tr { display: initial !important; }
169     #t4>thead, #t4>tbody { display: grid; }
170   }
171 }
172 </style>
173
174 <table id='t4'>
175   <caption><%= counter(:table) %>: Duplicated header on grid</caption>
176   <thead>
177     <tr><th>Header</th></tr>
178     <tr style='display: none;'><th>Header (duplicated)</th></tr>
179   </thead>
180   <tbody>
181     <tr><td>Row 1</td></tr>
182     <tr><td>Row 2</td></tr>
183     <tr><td>Row 3</td></tr>
184     <tr><td>Row 4</td></tr>
185     <tr><td>Row 5</td></tr>
186     <tr><td>Row 6</td></tr>
187   </tbody>
188 </table>
189
190 This is not perfect: the duplicate header will appear if styles are disabled
191 or if the browser does not support CSS at all, which includes all text-mode
192 browsers that I am aware of.  But it is a fairly minor annoyance and the
193 situation can be improved somewhat with some hacks that we might explore in
194 another adventure.
195
196 ## Fixing the row placement
197
198 Our grid currently consists of two grid columns and a dynamic number of
199 grid rows, and the automatic grid layout fills each row before creating
200 a new one.  We can place the rows where they need to go by styling the
201 first half of the rows differently from the last half.  The first half
202 can be forced to the first column, and the remainder will auto-place
203 to the correct locations when using `grid-auto-flow: dense`.
204
205 Perhaps the easiest way to do this is to markup the halfway point with a
206 class, so that CSS selectors can reference this row without hardcoding the
207 number of rows in the table.
208
209 #### <%= counter(:listing) %>: Markup for [table 5](#t5)
210 <generate-xhtml-listing target='t5' />
211
212 #### <%= counter(:listing) %>: Style for [table 5](#t5)
213 <style type='text/css' generate-listing>
214 @supports (display: grid) {
215   #t5>* {
216     grid-column-gap: 0.5ex;
217     grid-template-columns: 1fr 1fr;
218     grid-auto-flow: dense;
219   }
220
221   #t5>tbody>tr { grid-column-start: 1; }
222   #t5>tbody>tr.t5-split ~ tr { grid-column-start: auto; }
223
224   @media (min-width: 35em) {
225     #t5>*>tr { display: initial !important; }
226     #t5>thead, #t5>tbody { display: grid; }
227   }
228 }
229 </style>
230
231 <table id='t5'>
232   <caption><%= counter(:table) %>: Correct row placement on grid</caption>
233   <thead>
234     <tr><th>Header</th></tr>
235     <tr style='display: none;'><th>Header (duplicated)</th></tr>
236   </thead>
237   <tbody>
238     <tr><td>Row 1</td></tr>
239     <tr><td>Row 2</td></tr>
240     <tr class='t5-split'><td>Row 3</td></tr>
241     <tr><td>Row 4</td></tr>
242     <tr><td>Row 5</td></tr>
243     <tr><td>Row 6</td></tr>
244   </tbody>
245 </table>
246
247 ## Fixing the row styling
248
249 Depending on the style used, [table 5](#t5) might be good enough.  But here,
250 the odd/even row shading is messed up because the split was not performed
251 on an even-numbered row, and there is no bottom border on the first grid
252 column.  Now that the the row positioning is correct, both of these issues
253 are pretty easy to fix in the stylesheet.
254
255 #### <%= counter(:listing) %>: Markup for [table 6](#t6)
256 <generate-xhtml-listing target='t6' />
257
258 #### <%= counter(:listing) %>: Style for [table 6](#t6)
259 <style type='text/css' generate-listing>
260 @supports (display: grid) {
261   #t6>* {
262     grid-column-gap: 0.5ex;
263     grid-template-columns: 1fr 1fr;
264     grid-auto-flow: dense;
265   }
266
267   #t6>tbody>tr { grid-column-start: 1; }
268   #t6>tbody>tr.t6-split ~ tr { grid-column-start: auto; }
269
270   @media (min-width: 35em) {
271     #t6>*>tr { display: initial !important; }
272     #t6>thead, #t6>tbody { display: grid; }
273
274     #t6>tbody>tr.t6-split { border-bottom: 1px solid <%=
275       scss_get_var(:ruledefaultcolour) %>; }
276     #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
277       background-color: initial;
278     }
279     #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
280       background-color: <%= scss_get_var(:tableshadecolour) %>;
281     }
282   }
283 }
284 </style>
285
286 <table id='t6'>
287   <caption><%= counter(:table) %>: Correct row styling on grid</caption>
288   <thead>
289     <tr><th>Header</th></tr>
290     <tr style='display: none;'><th>Header (duplicated)</th></tr>
291   </thead>
292   <tbody>
293     <tr><td>Row 1</td></tr>
294     <tr><td>Row 2</td></tr>
295     <tr class='t6-split'><td>Row 3</td></tr>
296     <tr><td>Row 4</td></tr>
297     <tr><td>Row 5</td></tr>
298     <tr><td>Row 6</td></tr>
299   </tbody>
300 </table>
301
302 # Realistic Tables
303
304 [Table 6](#t6) is pretty close to what we want, except for the minor detail
305 that real tables typically have more than one column.  So let's try applying
306 these techniques to [table 1](#t1).
307
308 #### <%= counter(:listing) %>: Markup for [table 7](#t7)
309 <generate-xhtml-listing target='t7' />
310
311 #### <%= counter(:listing) %>: Style for [table 7](#t7)
312 <style type='text/css' generate-listing>
313 @supports (display: grid) {
314   #t7>* {
315     grid-column-gap: 0.5ex;
316     grid-template-columns: 1fr 1fr;
317     grid-auto-flow: dense;
318   }
319
320   #t7>tbody>tr { grid-column-start: 1; }
321   #t7>tbody>tr.t7-split ~ tr { grid-column-start: auto; }
322
323   @media (min-width: 35em) {
324     #t7>*>tr { display: initial !important; }
325     #t7>thead, #t7>tbody { display: grid; }
326
327     #t7>tbody>tr.t7-split { border-bottom: 1px solid <%=
328       scss_get_var(:ruledefaultcolour) %>; }
329     #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
330       background-color: initial;
331     }
332     #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
333       background-color: <%= scss_get_var(:tableshadecolour) %>;
334     }
335   }
336 }
337 </style>
338
339 <table id="t7">
340   <caption><%= counter(:table) %>: Column misalignment on grid</caption>
341   <thead>
342     <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
343     <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
344   </thead>
345   <tbody>
346     <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
347     <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
348     <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
349     <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
350     <tr class='t7-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
351     <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
352     <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
353     <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
354     <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
355     <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
356   </tbody>
357 </table>
358
359 The fallback for [table 7](#t7) works properly but when the grid columns are
360 activated, the alignment of table cells into their respective columns is not
361 correct.  This is something that we could potentially solve with CSS subgrids,
362 but as with `display: contents` lack of browser support makes their use
363 untenable.
364
365 Nevertheless, provided that the width of each cell is independent of its
366 contents, they will all line up correctly.  There are many ways to do this
367 in a stylesheet.  We will do it with more grids.
368
369 #### <%= counter(:listing) %>: Markup for [table 8](#t8)
370 <generate-xhtml-listing target='t8' />
371
372 #### <%= counter(:listing) %>: Style for [table 8](#t8)
373 <style type='text/css' generate-listing>
374 @supports (display: grid) {
375   #t8>* {
376     grid-column-gap: 0.5ex;
377     grid-template-columns: 1fr 1fr;
378     grid-auto-flow: dense;
379   }
380
381   #t8>tbody>tr { grid-column-start: 1; }
382   #t8>tbody>tr.t8-split ~ tr { grid-column-start: auto; }
383
384   @media (min-width: 35em) {
385     #t8>*>tr { display: grid !important; }
386     #t8>thead, #t8>tbody { display: grid; }
387
388     #t8>tbody>tr.t8-split { border-bottom: 1px solid <%=
389       scss_get_var(:ruledefaultcolour) %>; }
390     #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
391       background-color: initial;
392     }
393     #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
394       background-color: <%= scss_get_var(:tableshadecolour) %>;
395     }
396   }
397
398   #t8>*>tr {
399     grid-template-columns: minmax(3em, 1fr) 5fr minmax(3em, 1fr);
400   }
401 }
402 </style>
403
404 <table id="t8">
405   <caption><%= counter(:table) %>: Fully working responsive table</caption>
406   <thead>
407     <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
408     <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
409   </thead>
410   <tbody>
411     <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
412     <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
413     <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
414     <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
415     <tr class='t8-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
416     <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
417     <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
418     <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
419     <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
420     <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
421   </tbody>
422 </table>
423
424 # Epilogue
425
426 Some issues remain, but I think the results in [table 8](#t8) are pretty good.
427
428 I don't consider the loss of automatic cell sizing to be a big deal, sizes
429 usually need to be tweaked per-table anyway and the use of `fr` units can
430 give pretty nice results that scale with available width.
431
432 More than two grid columns should be possible but I have not attempted to do
433 so.  There might be a lot more fighting with the automatic grid placement.
434
435 Depending on the table it may be useful to tweak various alignment properties
436 of the grid items.
437
438 It seems that Firefox really sucks at printing these grids if they happen to
439 cross a page boundary (I did not try printing in other browsers).  Firefox is
440 pretty bad at printing regular tables when they do this too, but it is way
441 worse with grids.  Using e.g. `@media screen` in the stylesheet can help by
442 falling back to an ordinary table for printouts.