Improve clicky table generation.
authorNick Bowler <nbowler@draconx.ca>
Thu, 18 Feb 2021 04:03:33 +0000 (23:03 -0500)
committerNick Bowler <nbowler@draconx.ca>
Thu, 18 Feb 2021 06:30:19 +0000 (01:30 -0500)
Disentangle the CSS rules for clicky tables from the file listing table
type, and eliminate all id selectors in favour of class selectors.

Then, move all the support markup out of the eruby program and into a
dedicated XSLT program which will perform the necessary transformation
based on a couple attributes added to the table elements.  The inputs,
labels, and the links between them are now automatically generated.

While it's more code overall, hopefully this reduces the complexity
of the implementation by clearly separating the various different parts.
Additionally, this should (if ever needed) make it easy to add clicky
headers to other tables, as well as supporting more than one clicky
table on a page.

Rules
content/style.scss
layouts/clickytable.xsl [new file with mode: 0644]
layouts/default.xsl
layouts/listing.erb [moved from layouts/listing.xhtml with 59% similarity]

diff --git a/Rules b/Rules
index 2460c14ba315339e7e4ca55479480101b97a6de1..7a9773bca3925ff817f42e72df2d744e801dcba4 100644 (file)
--- a/Rules
+++ b/Rules
@@ -129,8 +129,7 @@ postprocess do
 end
 
 compile '/**/index.lst' do
-    layout '/listing.xhtml'
-    filter :erb
+    layout '/listing.erb'
     layout '/default.xml'
     layout '/default.xsl'
     filter :relativize_paths, type: :xml
index 22df5d950a9ff8ef49c73aa3e014daec840018ae..dc306d72efcc51c0dae8e87f87b229f6b382b64f 100644 (file)
@@ -1,7 +1,7 @@
 /*
  * Nick's web site: default stylesheet
  *
- * Copyright © 2018-2020 Nick Bowler
+ * Copyright © 2018-2021 Nick Bowler
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -118,6 +118,7 @@ td, th {
 
 thead>tr, tbody>tr { border: solid $ruledefaultcolour; }
 th, thead>tr { border-bottom: 1px solid $rulestrongcolour; }
+tbody+tbody { border-bottom: 1px solid $ruledefaultcolour; }
 *>table, *>th { border: none; }
 thead>tr { border-width: 1px; }
 tbody>tr { border-width: 0 1px; }
@@ -137,13 +138,47 @@ table.cc {
     }
 }
 
-$sortcols: name, date, size;
-@each $col in $sortcols {
-    #filelist-#{$col}-sort {
+// CSS rules for stortable clicky table headers: Update the display of
+// the /table based on the current state.  Each column has its own set
+// nearly-identical rules, only the class names differ.
+//
+// The clickytables.xsl stylesheet generates two inputs for each column.
+// These inputs are siblings of the table and all precede it in document
+// order.  Moreover, the inputs for a column are ordered with respect to
+// each other, in this sequence:
+//
+//   input.clicky-NAME     -- checked iff NAME is selected for sorting.
+//   input.clicky-NAME-rev -- checked to select reverse order.
+//
+// One of the column selection inputs will have a 'checked' attribute to
+// indicate the default order.  This input is always first in document
+// order.  No other inputs begin checked.
+//
+// The table itself consists of a thead (where the header labels are
+// located) and two tbody elements.  The bulk of these rules relate
+// to updating the headers to visually indicate the current state.
+//
+// A sortable column's th element has the .clicky-NAME class, matching
+// its corresponding inputs, and has two label children.  The first label
+// is visible only when the column is unselected, and is linked to the
+// .clicky-NAME input to activate that column.  The second label is visible
+// only on the selected column and is linked to the .clicky-NAME-rev input
+// to toggle the reverse order.
+//
+// For the table body, the first tbody contains the default ordering
+// and is not styled by these rules (except to hide it when alternate
+// orderings are selected).  The second tbody contains rows for all
+// alternate orderings, and is revealed by these rules.  When revealed,
+// rows with the NAMEfwd or NAMErev class (for the forward and reverse
+// orderings, respectively) are shown and other rows are hidden.
+
+$clickynames: name, date, size;
+@each $col in $clickynames {
+    input.clicky-#{$col} {
         &:checked {
-            & ~ table.filelist {
-                /* Update table header state */
-                th.#{$col} {
+            &~table {
+                // Update table header state
+                & th.clicky-#{$col} {
                     label~label {
                         display: -moz-inline-box !important;
                         display: inline-block !important;
@@ -151,60 +186,110 @@ $sortcols: name, date, size;
                     label { display: none; }
                 }
 
-                /* Show only appropriate items from the sort body (forward) */
-                tbody+tbody>tr.#{$col} { display: table-row; }
-                tbody+tbody>tr { display: none; }
+                // Show only appropriate items from the sort body (forward)
+                &>tbody+tbody>tr.#{$col}fwd { display: table-row; }
+                &>tbody+tbody>tr { display: none; }
+
+                // Unhide sort body
+                &>tbody {
+                    &+tbody { display: table-row-group !important; }
+                    display: none;
+                }
             }
 
-            & ~ #filelist-#{$col}-rev:checked ~ table.filelist {
-                /* Show only appropriate items from sort body (reversed) */
-                tbody+tbody>tr.#{$col}rev { display: table-row; }
-                tbody+tbody>tr { display: none; }
+            // reverse state for selected sort column
+            &~input.clicky-#{$col}-rev {
+                &:checked ~ table {
+                    // Show only appropriate items from sort body (reversed)
+                    &>tbody+tbody>tr.#{$col}rev { display: table-row; }
+                    &>tbody+tbody>tr { display: none; }
+
+                    // Unhide sort body
+                    &>tbody {
+                        &+tbody { display: table-row-group !important; }
+                        display: none;
+                    }
+                }
+
+                // Unhide to allow keyboard navigation to this input
+                display: block !important;
             }
+        }
 
-            /* Unhide associated checkbox for keyboard navigation */
-            & ~ #filelist-#{$col}-rev { display: block !important; }
+        // If default input element is the only one selected, match it to
+        // return to default view (overriding the changes above).  It is
+        // always the first input element among all the sibling elements.
+        // This seems to interoperate better than using the [checked] or
+        // :first-of-type selectors.
+        @at-root &:first-child, :not(input)+& {
+            &:checked~table>tbody {
+                &+tbody { display: none !important; }
+                display: table-row-group;
+            }
         }
 
-        &:focus ~ table.filelist th>label~label>span {
-            border: 1px dotted;
-            padding: 0;
+        &:focus ~ table th.clicky-#{$col}>label~label>span {
+            border-color: $foregroundcolour;
         }
 
+        // Unhide to allow keyboard navigation
         display: block !important;
+        pointer-events: none;
         position: absolute;
-        z-index: -1;
         opacity: 0;
+        z-index: -1;
     }
 
-    #filelist-#{$col}-rev {
-        &:checked ~ table.filelist {
-            /* Update table header state */
-            th.#{$col} {
+    input.clicky-#{$col}-rev {
+        &:checked ~ table {
+            // Update table header state
+            & th.clicky-#{$col} {
                 img+img {
                     display: -moz-inline-box !important;
-                    display: inline !important;
+                    display: inline-block !important;
                 }
                 img { display: none; }
             }
         }
 
-        &:focus ~ table.filelist th>label~label>img {
-            border: 1px dotted;
-            padding: 0;
+        &:focus ~ table th.clicky-#{$col}>label~label>img {
+            border-color: $foregroundcolour;
         }
 
+        pointer-events: none;
         position: absolute;
-        z-index: -2;
         opacity: 0;
+        z-index: -2;
     }
-}
 
-/* Enable the sorted tables only when non-default option is selected */
-#filelist-name-rev, #filelist-date-sort, #filelist-size-sort {
-    &:checked~table.filelist>tbody {
-        &+tbody { display: table-row-group !important; }
-        display: none;
+    th.clicky-#{$col}>label {
+        &, &>* {
+            white-space: nowrap;
+            vertical-align: middle;
+            display: -moz-inline-box;
+            display: inline-block;
+            cursor: pointer;
+        }
+
+        &>* { border: 1px dotted transparent; }
+
+        // Expand the first label a bit so the table (hopefully)
+        // does not reshape as columns are selected.
+        &:first-child {
+            margin-right: 1.75em;
+            padding-right: 2px;
+        }
+
+        &:active { color: $linkactivecolour; }
+        &:first-child:active>span, &~label:active>img {
+            border-color: $linkactivecolour;
+        }
+
+        img {
+            margin-left: 0.25em;
+            width: 1.5em;
+            height: auto;
+        }
     }
 }
 
@@ -214,21 +299,7 @@ table.filelist {
         width: 0;
     }
 
-    th>label>* { padding: 1px; }
-    th>label, th>label>* {
-        white-space: nowrap;
-        vertical-align: middle;
-        display: -moz-inline-box;
-        display: inline-block;
-        cursor: pointer;
-    }
-    th img { margin-left: 0.5ex; }
-
-    tbody+tbody {
-        border-bottom: solid 1px $ruledefaultcolour;
-    }
-
-    img {
+    tbody img {
         display: block;
         height: 1.5em;
         width: auto;
diff --git a/layouts/clickytable.xsl b/layouts/clickytable.xsl
new file mode 100644 (file)
index 0000000..f442fa4
--- /dev/null
@@ -0,0 +1,125 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<!--
+  Nick's web site: XHTML+CSS sortable clicky table headers.
+
+  Copyright © 2021 Nick Bowler
+
+  This implements the gory markup for clicky table headers, creating
+  input and related label elements to implement the clicking part.
+
+  To use: add the "clicky" attribute to each sortable header th element.
+  Class names corresponding to that header will be generated based on
+  the value of the attribute.  For example, a header with clicky='date'
+  will add class="clicky-date" to the header and generate two input
+  elements: one with class="clicky-date" to indicate when that column
+  is selected for sorting, and another with class="clicky-date-rev" to
+  indicate when the reverse ordering is selected for that column.
+
+  The input table itself must have two tbody sections. The first is a
+  completely normal table body in the default sort order.  The second
+  encodes all other possible orderings.
+
+  This program is free software: you can redistribute it and/or modify
+  it under the terms of the GNU General Public License as published by
+  the Free Software Foundation, either version 3 of the License, or
+  (at your option) any later version.
+
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License
+  along with this program.  If not, see <https://www.gnu.org/licenses/>
+-->
+<xsl:stylesheet version='1.0'
+  xmlns='http://www.w3.org/1999/xhtml'
+  xmlns:xhtml='http://www.w3.org/1999/xhtml'
+  xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
+
+<xsl:template match='xhtml:table/@clicky|xhtml:th/@clicky' />
+<xsl:template match='xhtml:table[@clicky]'>
+  <xsl:variable name='group' select='generate-id(@clicky)' />
+  <xsl:variable name='clicky' select='@clicky' />
+
+  <div>
+    <script type='x'><![CDATA[]]x><!--]]></script>
+    <xsl:for-each select='xhtml:thead/*/xhtml:th/@clicky'>
+      <!-- hoist default element to be first in document order -->
+      <xsl:sort select='number(.!=$clicky)' data-type='number' />
+
+      <input style='display: none' id='sort-{generate-id(.)}'
+        type='radio' name='{$group}' class='clicky-{.}'>
+        <xsl:if test='.=$clicky'>
+          <xsl:attribute name='checked'>checked</xsl:attribute>
+        </xsl:if>
+      </input>
+    </xsl:for-each>
+    <xsl:for-each select='xhtml:thead/*/xhtml:th/@clicky'>
+      <input style='display: none' id='rev-{generate-id(.)}'
+        type='checkbox' class='clicky-{.}-rev' />
+    </xsl:for-each>
+    <script type='x'>--></script>
+
+    <xsl:copy>
+      <xsl:apply-templates select='node()|@*' />
+    </xsl:copy>
+  </div>
+</xsl:template>
+
+<xsl:template match='xhtml:table[@clicky]/xhtml:thead/*/xhtml:th[@clicky]'>
+  <xsl:copy>
+    <xsl:attribute name='class'>
+      <xsl:if test='@class'>
+        <xsl:value-of select='concat(@class, " ")' />
+      </xsl:if>
+      <xsl:value-of select='concat("clicky-", @clicky)' />
+    </xsl:attribute>
+    <xsl:apply-templates select='@*[local-name() != "class"]' />
+
+    <label for='sort-{generate-id(@clicky)}'>
+      <xsl:text>&#x2060;</xsl:text>
+      <span><xsl:apply-templates select='node()' /></span>
+    </label>
+
+    <script type='x'><![CDATA[]]x><!--]]></script>
+    <label for='rev-{generate-id(@clicky)}' style='display: none'>
+      <xsl:text>&#x2060;</xsl:text>
+      <span><xsl:apply-templates select='node()' /></span>
+      <img alt='FWD' width='16' height='16' src='/images/down.svg' />
+      <img alt='REV' width='16' height='16' src='/images/up.svg' style='display: none' />
+    </label>
+    <script type='x'>--></script>
+  </xsl:copy>
+</xsl:template>
+
+<xsl:template match='xhtml:table[@clicky]/xhtml:tbody[last()]'>
+  <xsl:copy>
+    <xsl:attribute name='style'>
+      <xsl:if test='@style'>
+        <xsl:value-of select='concat(@style, "; ")' />
+      </xsl:if>
+      <xsl:text>display: none</xsl:text>
+    </xsl:attribute>
+    <xsl:apply-templates select='node()|@*[local-name() != "style"]' />
+  </xsl:copy>
+</xsl:template>
+
+<!-- Insert script hack around the second <tbody> -->
+<xsl:template match='xhtml:table[@clicky]/xhtml:tbody[1]/*[last()]/*[last()]'>
+  <xsl:copy>
+    <xsl:apply-templates select='node()|@*' />
+    <script type='x'><![CDATA[]]x><!--]]></script>
+  </xsl:copy>
+</xsl:template>
+
+<!-- Note that it is not allowed for <tr> to have no child elements. -->
+<xsl:template
+  match='xhtml:table[@clicky]/xhtml:tbody[last()]/*[last()]/*[last()]'>
+  <xsl:copy>
+    <xsl:apply-templates select='node()|@*' />
+    <script type='x'>--></script>
+  </xsl:copy>
+</xsl:template>
+
+</xsl:stylesheet>
index 84dcf9df801d3a25c0f68a8d1d3b23964174b6a7..5a6c3eec06785191225ba8cfb0d93809e4ee42ad 100644 (file)
@@ -2,7 +2,7 @@
 <!--
   Nick's web site: XHTML output stage
 
-  Copyright © 2018-2020 Nick Bowler
+  Copyright © 2018-2021 Nick Bowler
 
   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   </html>
 </xsl:template>
 
+<xsl:include href='layouts/clickytable.xsl' />
+
 </xsl:stylesheet>
similarity index 59%
rename from layouts/listing.xhtml
rename to layouts/listing.erb
index 406e42ba79ac2eaac0fba108141b4fa2ec6f0833..02017a0304ed32bf31b535e1307d271636a76e98 100644 (file)
@@ -71,49 +71,13 @@ def render_entry(files, key)
   EOF
 end
 %>
-<div>
-<script type='x'><![CDATA[]]x><!--]]></script>
-<input style='display: none' type='radio' name='filelist-sort' id='filelist-name-sort' checked='checked' />
-<input style='display: none' type='radio' name='filelist-sort' id='filelist-date-sort' />
-<input style='display: none' type='radio' name='filelist-sort' id='filelist-size-sort' />
-<input style='display: none' type='checkbox' id='filelist-name-rev' />
-<input style='display: none' type='checkbox' id='filelist-date-rev' />
-<input style='display: none' type='checkbox' id='filelist-size-rev' />
-<script type='x'>--></script>
-<table class='filelist'>
+<table class='filelist' clicky='name'>
   <thead>
     <tr>
       <th />
-      <th class='name'>
-        <label for='filelist-name-sort'><span>Name</span></label>
-        <script type='x'><![CDATA[]]x><!--]]></script>
-        <label for='filelist-name-rev' style='display: none'>
-          <span>Name</span>
-          <img alt='FWD' width='16' height='16' src='/images/down.svg' />
-          <img alt='REV' width='16' height='16' src='/images/up.svg' style='display: none' />
-        </label>
-        <script type='x'>--></script>
-      </th>
-      <th class='date'>
-        <label for='filelist-date-sort'><span>Last Modified</span></label>
-        <script type='x'><![CDATA[]]x><!--]]></script>
-        <label for='filelist-date-rev' style='display: none'>
-          <span>Last Modified</span>
-          <img alt='FWD' width='16' height='16' src='/images/down.svg' />
-          <img alt='REV' width='16' height='16' src='/images/up.svg' style='display: none' />
-        </label>
-        <script type='x'>--></script>
-      </th>
-      <th class='size'>
-        <label for='filelist-size-sort'><span>Size</span></label>
-        <script type='x'><![CDATA[]]x><!--]]></script>
-        <label for='filelist-size-rev' style='display: none'>
-          <span>Size</span>
-          <img alt='FWD' width='16' height='16' src='/images/down.svg' />
-          <img alt='REV' width='16' height='16' src='/images/up.svg' style='display: none' />
-        </label>
-        <script type='x'>--></script>
-      </th>
+      <th clicky='name'>Name</th>
+      <th clicky='date'>Last Modified</th>
+      <th clicky='size'>Size</th>
     </tr>
   </thead>
   <tbody>
@@ -124,18 +88,14 @@ end
 %>
 <%
 by_name = files.keys.sort{ |a, b| strverscmp(a, b) }
-by_name.each_index do |i|
-  entry = render_entry(files, by_name[i])
-  if i+1 == by_name.length
-    entry.sub!(%r{(.*)</td>}m,
-               "\\1<script type='x'><![CDATA[]]x><!--]]></script></td>")
-  end
+by_name.each do |key|
+  entry = render_entry(files, key)
 %>
     <tr><%= entry %></tr>
 <% end %>
   </tbody>
 
-  <tbody style='display: none'>
+  <tbody>
 <%
 def meta_cmp(files, key, a, b)
   av, bv = files[a][key], files[b][key]
@@ -146,7 +106,7 @@ end
 by_date = files.keys.sort { |a, b| meta_cmp(files, :sorttime, a, b) }
 by_size = files.keys.sort { |a, b| meta_cmp(files, :size, a, b) }
 
-listnames = [ "namerev", "date", "daterev", "size", "sizerev" ]
+listnames = [ "namerev", "datefwd", "daterev", "sizefwd", "sizerev" ]
 lists = [ by_name.reverse, by_date, by_date.reverse, by_size, by_size.reverse ]
 if parentrow
 %>
@@ -186,7 +146,5 @@ while not (elems = lists.map(&:first)).compact.empty?
   even ^= true
 end
 %>
-    <tr><td><script type='x'>--></script></tr>
   </tbody>
 </table>
-</div>