github.com/troglobit / digit

Collapse repo page tools behind a hamburger on mobile

On a phone the repo tabs, branch and clone menus, and feed/website links
bunched up and scrolled away with the page, and the doc-tab row wrapped.

Collapse the secondary tools behind a hamburger on the sticky bar: an
accordion of the branch/clone dropdowns and the feed/website/upstream
links, sized to its contents and closable by tapping outside (no JS).
The content-frame doc tabs fold the same way, keeping the active doc as
the title.  Desktop layout is unchanged.

Also restore the clone button to a filled green button, expose the
current ref to assistive tech (aria-current, unmasked summary text), and
share one dropdown-chrome and hamburger-toggle ruleset across the three
menus.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Joachim Wiberg · 59 days ago commit 595dcb7 · parent 5eda372 · child d42bced · patch
diff --git a/internal/web/pages.go b/internal/web/pages.go
index 185107a..c2d2b39 100644
--- a/internal/web/pages.go
+++ b/internal/web/pages.go
@@ -54,12 +54,12 @@ type repoHead struct {
 // refsel is the branch selector; a sub-struct so embedding repoHead
 // cannot collide with page fields like refsData.Branches.
 type refsel struct {
+	Label    string // current ref, shown on the dropdown summary
 	Branches []refOption
 	Tags     []refOption
 }
 
-// refOption is one branch selector entry; an empty URL renders as a
-// disabled placeholder (e.g. when browsing at a bare commit hash).
+// refOption is one branch/tag entry in the ref dropdown.
 type refOption struct {
 	Name    string
 	URL     string
@@ -89,8 +89,10 @@ func (s *Server) head(repo *git.Repo, ref, view, path string) repoHead {
 	h.Refsel.Branches = refOptions(repo.Name, branches, ref, view, path)
 	h.Refsel.Tags = refOptions(repo.Name, tags, ref, view, path)
 	current := func(o refOption) bool { return o.Current }
-	if !slices.ContainsFunc(h.Refsel.Branches, current) && !slices.ContainsFunc(h.Refsel.Tags, current) {
-		h.Refsel.Branches = append([]refOption{{Name: shortsha(ref), Current: true}}, h.Refsel.Branches...)
+	if slices.ContainsFunc(h.Refsel.Branches, current) || slices.ContainsFunc(h.Refsel.Tags, current) {
+		h.Refsel.Label = ref
+	} else {
+		h.Refsel.Label = shortsha(ref) // browsing a bare commit
 	}
 	return h
 }
diff --git a/static/doctabs.js b/static/doctabs.js
index d7360b2..90d2d01 100644
--- a/static/doctabs.js
+++ b/static/doctabs.js
@@ -7,6 +7,8 @@
 	var tabs = document.querySelectorAll(".filehead a.doctab");
 	var bodies = document.querySelectorAll(".readme .filebody");
 	var src = document.getElementById("docsrc");
+	var title = document.getElementById("doctitle");
+	var toggle = document.getElementById("doctoggle");
 	if (tabs.length < 2)
 		return;
 
@@ -18,6 +20,10 @@
 		}
 		if (src)
 			src.href = tabs[n].getAttribute("data-blob");
+		if (title)
+			title.textContent = tabs[n].textContent;
+		if (toggle)
+			toggle.checked = false; /* close the mobile dropdown */
 	}
 
 	for (var i = 0; i < tabs.length; i++)
diff --git a/static/style.css b/static/style.css
index 8b099df..f02db7e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -175,39 +175,75 @@ nav.tabs a {
 nav.tabs a:hover { color: var(--fg); text-decoration: none; }
 nav.tabs a.active { color: var(--fg); border-bottom-color: var(--accent); }
 nav.tabs .right {
+  position: relative;
   margin-left: auto;
   display: flex;
   align-items: center;
   gap: 0.5rem;
 }
-details.clone-menu { position: relative; }
-details.clone-menu summary {
+/* Secondary tools show inline on wide screens; the mobile breakpoint
+   collapses them behind the ☰.  A checkbox toggle (not <details>) so
+   CSS fully controls visibility across browsers. */
+.more-toggle, .more-summary, .more-backdrop { display: none; }
+.more-panel { display: flex; align-items: center; gap: 0.5rem; }
+/* Branch and clone menus share one dropdown style. */
+details.refmenu, details.clone-menu { position: relative; }
+details.refmenu > summary, details.clone-menu > summary {
   list-style: none;
   cursor: pointer;
-  background: var(--accent);
-  color: var(--bg); /* contrast-safe on the accent in both themes */
-  padding: 0.15rem 0.75rem;
+  background: var(--code-bg);
+  color: var(--fg);
+  border: 1px solid var(--border);
   border-radius: 6px;
+  padding: 0.1rem 0.55rem;
   font-size: 0.875rem;
-  margin-bottom: 0.25rem;
 }
-details.clone-menu summary::-webkit-details-marker { display: none; }
-details.clone-menu summary::after { content: " ▾"; }
-.clone-panel {
+/* Clone is the primary action — a filled green button, GitHub-style. */
+details.clone-menu > summary {
+  background: var(--add);
+  border-color: var(--add);
+  color: var(--bg); /* contrast-safe on the green in both themes */
+}
+details.refmenu > summary::-webkit-details-marker,
+details.clone-menu > summary::-webkit-details-marker { display: none; }
+details.refmenu > summary::after,
+details.clone-menu > summary::after { content: " \25be"; color: var(--muted); }
+details.clone-menu > summary::after { color: var(--bg); }
+
+/* Floating-panel chrome, shared by the desktop ref/clone dropdowns and
+   the mobile ☰ panels.  The :checked selectors are inert on desktop —
+   the toggles are display:none there, so they never match. */
+.ref-panel, .clone-panel, .more-toggle:checked ~ .more-panel, .doc-toggle:checked ~ .doctabs {
   position: absolute;
   right: 0;
   top: calc(100% + 4px);
-  z-index: 10;
-  width: 24rem;
-  max-width: 90vw;
+  z-index: 15;
   background: var(--bg);
   border: 1px solid var(--border);
   border-radius: 6px;
-  padding: 0.25rem 0.75rem 0.75rem;
   box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
   font-size: 0.875rem;
   text-align: left;
 }
+.clone-panel { width: 24rem; max-width: 90vw; padding: 0.25rem 0.75rem 0.75rem; }
+.ref-panel {
+  display: flex;
+  flex-direction: column;
+  min-width: 9rem;
+  max-height: 20rem;
+  overflow-y: auto;
+  padding: 0.5rem 0.75rem;
+}
+.ref-panel strong {
+  color: var(--muted);
+  font-size: 0.7rem;
+  text-transform: uppercase;
+  margin-top: 0.5rem;
+}
+.ref-panel strong:first-child { margin-top: 0; }
+.ref-panel a { color: var(--fg); padding: 0.12rem 0; }
+.ref-panel a.current { color: var(--accent); font-weight: 600; }
+.ref-panel a:hover { color: var(--accent); text-decoration: none; }
 .clone-panel label {
   display: block;
   margin: 0.5rem 0 0.2rem;
@@ -236,17 +272,6 @@ details.clone-menu summary::after { content: " ▾"; }
 .copyrow button:hover { color: var(--fg); }
 .clone-panel a.dl { display: inline-block; margin-top: 0.6rem; }
 
-select.refsel {
-  background: var(--code-bg);
-  color: var(--fg);
-  border: 1px solid var(--border);
-  border-radius: 6px;
-  font: inherit;
-  font-size: 0.875rem;
-  padding: 0.1rem 0.3rem;
-  margin-bottom: 0.25rem;
-}
-
 table { border-collapse: collapse; width: 100%; }
 td {
   padding: 0.4rem 0.75rem 0.4rem 0;
@@ -317,11 +342,16 @@ table.log tr[hidden] { display: none; }
   gap: 1.25rem;
   align-items: baseline;
   flex-wrap: wrap;
+  position: relative; /* anchor for the mobile doc dropdown */
 }
 .readme .filehead a { color: var(--muted); }
 .readme .filehead a:hover { color: var(--fg); text-decoration: none; }
 .readme .filehead a.active { color: var(--fg); font-weight: 600; }
 .readme .filehead a.src { margin-left: auto; }
+/* Desktop: the wrapper is transparent so the tabs + source lay out
+   as direct flex children.  Mobile turns it into a dropdown. */
+.doctabs { display: contents; }
+.doc-title, .doc-toggle, .doc-summary, .doc-backdrop { display: none; }
 .readme .filebody { padding: 0.5rem 2rem 1rem; }
 .readme img { max-width: 100%; }
 .readme pre {
@@ -482,12 +512,94 @@ pre.diff {
     padding-right: 0.8rem;
   }
   .spacer { display: none; }
-  nav.tabs .right {
-    margin-left: 0;
-    flex-wrap: wrap;
-    width: 100%;
-    padding: 0.5rem 0 0.25rem;
+  /* Shared ☰ mechanics — the nav "more" menu and the content-frame doc
+     switcher are the same disclosure: a visually-hidden but focusable
+     checkbox, a label acting as the ☰ button, an opened column panel,
+     and a full-viewport backdrop to tap-close (no JS). */
+  .more-toggle, .doc-toggle {
+    position: absolute; /* off-screen but focusable for keyboard */
+    width: 1px;
+    height: 1px;
+    opacity: 0;
+  }
+  .more-summary, .doc-summary {
+    display: inline-flex;
+    align-items: center;
+    cursor: pointer;
+    font-size: 1.1rem;
+    color: var(--muted);
+  }
+  .more-summary { padding: 0.35rem 0.5rem; }
+  .doc-summary { margin-left: auto; padding: 0 0.25rem; }
+  .more-toggle:focus-visible + .more-summary,
+  .doc-toggle:focus-visible + .doc-summary {
+    outline: 2px solid var(--accent);
+    outline-offset: 2px;
+  }
+  .more-toggle:checked + .more-summary,
+  .doc-toggle:checked + .doc-summary { color: var(--fg); }
+  /* Opened panel: a uniform column that fits its contents (chrome comes
+     from the shared floating-panel rule). */
+  .more-toggle:checked ~ .more-panel,
+  .doc-toggle:checked ~ .doctabs {
+    display: flex;
+    flex-direction: column;
+    align-items: stretch;
+    width: max-content; /* fits what's shown; grows when a section opens */
+    max-width: calc(100vw - 1.5rem);
+    padding: 0.25rem 0;
+  }
+  .more-toggle:checked ~ .more-backdrop,
+  .doc-toggle:checked ~ .doc-backdrop {
+    display: block;
+    position: fixed;
+    inset: 0;
+    z-index: 14; /* tap-close layer: below the panel (z 15), above the page */
+  }
+
+  /* Nav "more" menu: the branch/clone <details> flatten into accordion
+     sections rather than the desktop's bordered dropdowns. */
+  .more-panel { display: none; }
+  .more-toggle:checked ~ .more-panel { max-height: 75vh; overflow-y: auto; font-size: 0.9rem; }
+  .more-panel > a,
+  .more-panel details > summary { padding: 0.5rem 0.9rem; color: var(--fg); }
+  .more-panel details { position: static; }
+  .more-panel details > summary {
+    display: block;
+    background: none;
+    border: none;
+    border-radius: 0;
+    font-size: inherit;
+  }
+  .more-panel details > summary::after { content: " \25b8"; color: var(--muted); }
+  .more-panel details[open] > summary::after { content: " \25be"; }
+  /* Section body: indented plain rows, no inner box or scrollbar —
+     the whole menu scrolls as one. */
+  .more-panel .ref-panel,
+  .more-panel .clone-panel {
+    position: static;
+    width: auto;
+    min-width: 0;
+    max-width: none;
+    max-height: none;
+    overflow: visible;
+    background: none;
+    border: none;
+    border-radius: 0;
+    box-shadow: none;
+    padding: 0 0.9rem 0.4rem 1.5rem; /* indent sub-items */
   }
+  .more-panel .ref-panel a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+  .more-panel .clone-panel a.dl { display: block; margin-top: 0.4rem; }
+  /* let the URL field shrink so the copy button stays on-screen */
+  .more-panel .copyrow input { min-width: 0; }
+  /* Content-frame doc switcher: keep the active doc name as the title
+     and fold the rest (+ source) behind a ☰, mirroring the nav. */
+  .filehead.foldable { align-items: center; }
+  .doc-title { display: block; color: var(--fg); font-weight: 600; }
+  .foldable .doctabs { display: none; } /* open rule out-specifies this */
+  .doc-toggle:checked ~ .doctabs a { padding: 0.5rem 0.9rem; }
+  .doc-toggle:checked ~ .doctabs a.src { margin-left: 0; }
   .logsearch { margin-left: 0; width: 100%; }
   .logsearch input { width: 100%; }
   .keyhelp > div { min-width: 0; width: calc(100% - 2rem); }
diff --git a/templates/layout.html b/templates/layout.html
index 2842fb2..94cde82 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -49,39 +49,47 @@
   <a{{if eq .View "log"}} class="active" aria-current="page"{{end}} data-nav="log" href="{{url .Name "log" .Ref}}">log</a>
   <a{{if eq .View "refs"}} class="active" aria-current="page"{{end}} href="{{url .Name "refs" .Ref}}">refs</a>
   <span class="right">
-    {{if feature "feeds"}}<a href="{{url .Name "atom"}}" title="Atom feed">feed</a>{{end}}
-    {{with .Website}}<a href="{{.}}">website &nearr;</a>{{end}}
-    {{with .Upstream}}<a href="{{.}}">upstream &nearr;</a>{{end}}
-    <select class="refsel" aria-label="Switch branch or tag" onchange="if (this.value) location.href = this.value">
-      <optgroup label="branches">{{range .Refsel.Branches}}{{template "refopt" .}}{{end}}</optgroup>
-      {{if .Refsel.Tags}}<optgroup label="tags">{{range .Refsel.Tags}}{{template "refopt" .}}{{end}}</optgroup>{{end}}
-    </select>
-    {{if or .CloneURL .SSHURL}}
-    <details class="clone-menu">
-      <summary>clone</summary>
-      <div class="clone-panel">
-        {{with .CloneURL}}
-        <label>HTTPS</label>
-        <div class="copyrow"><input readonly value="{{.}}"><button type="button" data-copy title="copy" aria-label="Copy to clipboard">&#x2398;</button></div>
-        {{end}}
-        {{with .SSHURL}}
-        <label>SSH</label>
-        <div class="copyrow"><input readonly value="{{.}}"><button type="button" data-copy title="copy" aria-label="Copy to clipboard">&#x2398;</button></div>
-        {{end}}
-        {{if and .Ref (feature "archive")}}
-        <a class="dl" href="{{url .Name "archive" (printf "%s.tar.gz" .Ref)}}">download {{basename .Ref}}.tar.gz</a>
-        <a class="dl" href="{{url .Name "archive" (printf "%s.zip" .Ref)}}">download {{basename .Ref}}.zip</a>
+    <input type="checkbox" id="moremenu" class="more-toggle">
+    <label for="moremenu" class="more-summary" aria-label="More">&#9776;</label>
+    <div class="more-panel">
+        {{if feature "feeds"}}<a href="{{url .Name "atom"}}" title="Atom feed">feed</a>{{end}}
+        {{with .Website}}<a href="{{.}}">website &nearr;</a>{{end}}
+        {{with .Upstream}}<a href="{{.}}">upstream &nearr;</a>{{end}}
+        <details class="refmenu">
+          <summary><span class="sr-only">Switch branch or tag: </span>{{.Refsel.Label}}</summary>
+          <div class="ref-panel">
+            <strong>branches</strong>
+            {{range .Refsel.Branches}}<a href="{{.URL}}"{{if .Current}} class="current" aria-current="true"{{end}}>{{.Name}}</a>{{end}}
+            {{if .Refsel.Tags}}<strong>tags</strong>
+            {{range .Refsel.Tags}}<a href="{{.URL}}"{{if .Current}} class="current" aria-current="true"{{end}}>{{.Name}}</a>{{end}}{{end}}
+          </div>
+        </details>
+        {{if or .CloneURL .SSHURL}}
+        <details class="clone-menu">
+          <summary>clone</summary>
+          <div class="clone-panel">
+            {{with .CloneURL}}
+            <label>HTTPS</label>
+            <div class="copyrow"><input readonly value="{{.}}"><button type="button" data-copy title="copy" aria-label="Copy to clipboard">&#x2398;</button></div>
+            {{end}}
+            {{with .SSHURL}}
+            <label>SSH</label>
+            <div class="copyrow"><input readonly value="{{.}}"><button type="button" data-copy title="copy" aria-label="Copy to clipboard">&#x2398;</button></div>
+            {{end}}
+            {{if and .Ref (feature "archive")}}
+            <a class="dl" href="{{url .Name "archive" (printf "%s.tar.gz" .Ref)}}">download {{basename .Ref}}.tar.gz</a>
+            <a class="dl" href="{{url .Name "archive" (printf "%s.zip" .Ref)}}">download {{basename .Ref}}.zip</a>
+            {{end}}
+          </div>
+        </details>
         {{end}}
       </div>
-    </details>
-    {{end}}
+    <label for="moremenu" class="more-backdrop" aria-hidden="true"></label>
   </span>
 </nav>
 {{end}}
 {{end}}
 
-{{define "refopt"}}{{if .URL}}<option value="{{.URL}}"{{if .Current}} selected{{end}}>{{.Name}}</option>{{else}}<option selected disabled>{{.Name}}</option>{{end}}{{end}}
-
 {{define "committable"}}
 <table class="log">
   <caption class="sr-only">Commit log</caption>
diff --git a/templates/repo.html b/templates/repo.html
index 47781e6..ff1d77c 100644
--- a/templates/repo.html
+++ b/templates/repo.html
@@ -9,9 +9,18 @@
 {{template "committable" .}}
 {{if .Docs}}
 <article class="readme">
-  <div class="filehead">
-    {{range $i, $t := .Docs}}<a class="doctab{{if not $i}} active{{end}}" href="{{$t.URL}}" data-blob="{{$t.BlobURL}}">{{$t.Label}}</a>{{end}}
-    <a class="src" id="docsrc" href="{{(index .Docs 0).BlobURL}}">source</a>
+  {{$multi := gt (len .Docs) 1}}
+  <div class="filehead{{if $multi}} foldable{{end}}">
+    {{if $multi}}
+    <span class="doc-title" id="doctitle">{{(index .Docs 0).Label}}</span>
+    <input type="checkbox" id="doctoggle" class="doc-toggle">
+    <label for="doctoggle" class="doc-summary" aria-label="Documents">&#9776;</label>
+    {{end}}
+    <div class="doctabs">
+      {{range $i, $t := .Docs}}<a class="doctab{{if not $i}} active{{end}}" href="{{$t.URL}}" data-blob="{{$t.BlobURL}}">{{$t.Label}}</a>{{end}}
+      <a class="src" id="docsrc" href="{{(index .Docs 0).BlobURL}}">source</a>
+    </div>
+    {{if $multi}}<label for="doctoggle" class="doc-backdrop" aria-hidden="true"></label>{{end}}
   </div>
   {{range $i, $t := .Docs}}
   <div class="filebody"{{if $i}} hidden{{end}}>{{$t.HTML}}</div>