From 91189dfd2f4cb32e205117b327e0ae7a0c2dd716 Mon Sep 17 00:00:00 2001
From: Emile Bangma <github@emilebangma.com>
Date: Mon, 03 Feb 2025 14:25:42 +0000
Subject: [PATCH] feat(explorer): collapsible mobile explorer (#1471)

---
 quartz/components/Explorer.tsx               |   38 +++++
 quartz/components/styles/explorer.scss       |  150 +++++++++++++++++++++++-
 quartz.layout.ts                             |    4 
 quartz/components/scripts/explorer.inline.ts |  155 +++++++++++++++++++------
 4 files changed, 296 insertions(+), 51 deletions(-)

diff --git a/quartz.layout.ts b/quartz.layout.ts
index 4a78256..f45da0c 100644
--- a/quartz.layout.ts
+++ b/quartz.layout.ts
@@ -27,7 +27,7 @@
     Component.MobileOnly(Component.Spacer()),
     Component.Search(),
     Component.Darkmode(),
-    Component.DesktopOnly(Component.Explorer()),
+    Component.Explorer(),
   ],
   right: [
     Component.Graph(),
@@ -44,7 +44,7 @@
     Component.MobileOnly(Component.Spacer()),
     Component.Search(),
     Component.Darkmode(),
-    Component.DesktopOnly(Component.Explorer()),
+    Component.Explorer(),
   ],
   right: [],
 }
diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx
index ec7c48e..ac276a8 100644
--- a/quartz/components/Explorer.tsx
+++ b/quartz/components/Explorer.tsx
@@ -1,5 +1,5 @@
 import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
-import explorerStyle from "./styles/explorer.scss"
+import style from "./styles/explorer.scss"
 
 // @ts-ignore
 import script from "./scripts/explorer.inline"
@@ -83,18 +83,46 @@
       lastBuildId = ctx.buildId
       constructFileTree(allFiles)
     }
-
     return (
       <div class={classNames(displayClass, "explorer")}>
         <button
           type="button"
-          id="explorer"
+          id="mobile-explorer"
+          class="collapsed hide-until-loaded"
           data-behavior={opts.folderClickBehavior}
           data-collapsed={opts.folderDefaultState}
           data-savestate={opts.useSavedState}
           data-tree={jsonTree}
+          data-mobile={true}
           aria-controls="explorer-content"
-          aria-expanded={opts.folderDefaultState === "open"}
+          aria-expanded={false}
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            width="24"
+            height="24"
+            viewBox="0 0 24 24"
+            stroke-width="2"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            class="lucide lucide-menu"
+          >
+            <line x1="4" x2="20" y1="12" y2="12" />
+            <line x1="4" x2="20" y1="6" y2="6" />
+            <line x1="4" x2="20" y1="18" y2="18" />
+          </svg>
+        </button>
+        <button
+          type="button"
+          id="desktop-explorer"
+          class="title-button"
+          data-behavior={opts.folderClickBehavior}
+          data-collapsed={opts.folderDefaultState}
+          data-savestate={opts.useSavedState}
+          data-tree={jsonTree}
+          data-mobile={false}
+          aria-controls="explorer-content"
+          aria-expanded={true}
         >
           <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
           <svg
@@ -122,7 +150,7 @@
     )
   }
 
-  Explorer.css = explorerStyle
+  Explorer.css = style
   Explorer.afterDOMLoaded = script
   return Explorer
 }) satisfies QuartzComponentConstructor
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 33d328a..9c6c050 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -1,7 +1,9 @@
 import { FolderState } from "../ExplorerNode"
 
+// Current state of folders
 type MaybeHTMLElement = HTMLElement | undefined
 let currentExplorerState: FolderState[]
+
 const observer = new IntersectionObserver((entries) => {
   // If last element is observed, remove gradient of "overflow" class so element is visible
   const explorerUl = document.getElementById("explorer-ul")
@@ -16,23 +18,43 @@
 })
 
 function toggleExplorer(this: HTMLElement) {
+  // Toggle collapsed state of entire explorer
   this.classList.toggle("collapsed")
+
+  // Toggle collapsed aria state of entire explorer
   this.setAttribute(
     "aria-expanded",
     this.getAttribute("aria-expanded") === "true" ? "false" : "true",
   )
-  const content = this.nextElementSibling as MaybeHTMLElement
-  if (!content) return
 
+  const content = (
+    this.nextElementSibling?.nextElementSibling
+      ? this.nextElementSibling.nextElementSibling
+      : this.nextElementSibling
+  ) as MaybeHTMLElement
+  if (!content) return
   content.classList.toggle("collapsed")
+  content.classList.toggle("explorer-viewmode")
+
+  // Prevent scroll under
+  if (document.querySelector("#mobile-explorer")) {
+    // Disable scrolling on the page when the explorer is opened on mobile
+    const bodySelector = document.querySelector("#quartz-body")
+    if (bodySelector) bodySelector.classList.toggle("lock-scroll")
+  }
 }
 
 function toggleFolder(evt: MouseEvent) {
   evt.stopPropagation()
+
+  // Element that was clicked
   const target = evt.target as MaybeHTMLElement
   if (!target) return
 
+  // Check if target was svg icon or button
   const isSvg = target.nodeName === "svg"
+
+  // corresponding <ul> element relative to clicked button/folder
   const childFolderContainer = (
     isSvg
       ? target.parentElement?.nextSibling
@@ -42,10 +64,14 @@
     isSvg ? target.nextElementSibling : target.parentElement
   ) as MaybeHTMLElement
   if (!(childFolderContainer && currentFolderParent)) return
-
+  // <li> element of folder (stores folder-path dataset)
   childFolderContainer.classList.toggle("open")
+
+  // Collapse folder container
   const isCollapsed = childFolderContainer.classList.contains("open")
   setFolderState(childFolderContainer, !isCollapsed)
+
+  // Save folder state to localStorage
   const fullFolderPath = currentFolderParent.dataset.folderpath as string
   toggleCollapsedByPath(currentExplorerState, fullFolderPath)
   const stringifiedFileTree = JSON.stringify(currentExplorerState)
@@ -53,57 +79,106 @@
 }
 
 function setupExplorer() {
-  const explorer = document.getElementById("explorer")
-  if (!explorer) return
+  // Set click handler for collapsing entire explorer
+  const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
 
-  if (explorer.dataset.behavior === "collapse") {
+  for (const explorer of allExplorers) {
+    // Get folder state from local storage
+    const storageTree = localStorage.getItem("fileTree")
+
+    // Convert to bool
+    const useSavedFolderState = explorer?.dataset.savestate === "true"
+
+    if (explorer) {
+      // Get config
+      const collapseBehavior = explorer.dataset.behavior
+
+      // Add click handlers for all folders (click handler on folder "label")
+      if (collapseBehavior === "collapse") {
+        for (const item of document.getElementsByClassName(
+          "folder-button",
+        ) as HTMLCollectionOf<HTMLElement>) {
+          window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
+          item.addEventListener("click", toggleFolder)
+        }
+      }
+
+      // Add click handler to main explorer
+      window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
+      explorer.addEventListener("click", toggleExplorer)
+    }
+
+    // Set up click handlers for each folder (click handler on folder "icon")
     for (const item of document.getElementsByClassName(
-      "folder-button",
+      "folder-icon",
     ) as HTMLCollectionOf<HTMLElement>) {
       item.addEventListener("click", toggleFolder)
       window.addCleanup(() => item.removeEventListener("click", toggleFolder))
     }
+
+    // Get folder state from local storage
+    const oldExplorerState: FolderState[] =
+      storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
+    const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
+    const newExplorerState: FolderState[] = explorer.dataset.tree
+      ? JSON.parse(explorer.dataset.tree)
+      : []
+    currentExplorerState = []
+
+    for (const { path, collapsed } of newExplorerState) {
+      currentExplorerState.push({
+        path,
+        collapsed: oldIndex.get(path) ?? collapsed,
+      })
+    }
+
+    currentExplorerState.map((folderState) => {
+      const folderLi = document.querySelector(
+        `[data-folderpath='${folderState.path.replace("'", "-")}']`,
+      ) as MaybeHTMLElement
+      const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
+      if (folderUl) {
+        setFolderState(folderUl, folderState.collapsed)
+      }
+    })
   }
+}
 
-  explorer.addEventListener("click", toggleExplorer)
-  window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
+function toggleExplorerFolders() {
+  const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
+    /\/index$/g,
+    "",
+  )
+  const allFolders = document.querySelectorAll(".folder-outer")
 
-  // Set up click handlers for each folder (click handler on folder "icon")
-  for (const item of document.getElementsByClassName(
-    "folder-icon",
-  ) as HTMLCollectionOf<HTMLElement>) {
-    item.addEventListener("click", toggleFolder)
-    window.addCleanup(() => item.removeEventListener("click", toggleFolder))
-  }
-
-  // Get folder state from local storage
-  const storageTree = localStorage.getItem("fileTree")
-  const useSavedFolderState = explorer?.dataset.savestate === "true"
-  const oldExplorerState: FolderState[] =
-    storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
-  const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
-  const newExplorerState: FolderState[] = explorer.dataset.tree
-    ? JSON.parse(explorer.dataset.tree)
-    : []
-  currentExplorerState = []
-  for (const { path, collapsed } of newExplorerState) {
-    currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
-  }
-
-  currentExplorerState.map((folderState) => {
-    const folderLi = document.querySelector(
-      `[data-folderpath='${folderState.path}']`,
-    ) as MaybeHTMLElement
-    const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
+  allFolders.forEach((element) => {
+    const folderUl = Array.from(element.children).find((child) =>
+      child.matches("ul[data-folderul]"),
+    )
     if (folderUl) {
-      setFolderState(folderUl, folderState.collapsed)
+      if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
+        if (!element.classList.contains("open")) {
+          element.classList.add("open")
+        }
+      }
     }
   })
 }
 
 window.addEventListener("resize", setupExplorer)
+
 document.addEventListener("nav", () => {
+  const explorer = document.querySelector("#mobile-explorer")
+  if (explorer) {
+    explorer.classList.add("collapsed")
+    const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
+    if (content) {
+      content.classList.add("collapsed")
+      content.classList.toggle("explorer-viewmode")
+    }
+  }
   setupExplorer()
+
   observer.disconnect()
 
   // select pseudo element at end of list
@@ -111,6 +186,12 @@
   if (lastItem) {
     observer.observe(lastItem)
   }
+
+  // Hide explorer on mobile until it is requested
+  const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
+  hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
+
+  toggleExplorerFolders()
 })
 
 /**
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
index 397fd02..531e9ff 100644
--- a/quartz/components/styles/explorer.scss
+++ b/quartz/components/styles/explorer.scss
@@ -1,14 +1,70 @@
 @use "../../styles/variables.scss" as *;
 
+@media all and ($mobile) {
+  .page > #quartz-body {
+    // Shift page position when toggling Explorer on mobile.
+    & > :not(.sidebar.left:has(.explorer)) {
+      transform: translateX(0);
+      transition: transform 300ms ease-in-out;
+    }
+    &.lock-scroll > :not(.sidebar.left:has(.explorer)) {
+      transform: translateX(100dvw);
+      transition: transform 300ms ease-in-out;
+    }
+
+    // Sticky top bar (stays in place when scrolling down on mobile).
+    .sidebar.left:has(.explorer) {
+      box-sizing: border-box;
+      position: sticky;
+      background-color: var(--light);
+    }
+
+    // Hide Explorer on mobile until done loading.
+    // Prevents ugly animation on page load.
+    .hide-until-loaded ~ #explorer-content {
+      display: none;
+    }
+  }
+}
+
 .explorer {
   display: flex;
+  height: 100%;
   flex-direction: column;
   overflow-y: hidden;
+
+  @media all and ($mobile) {
+    order: -1;
+    height: initial;
+    overflow: hidden;
+    flex-shrink: 0;
+    align-self: flex-start;
+  }
+
+  button#mobile-explorer {
+    display: none;
+  }
+
+  button#desktop-explorer {
+    display: flex;
+  }
+
+  @media all and ($mobile) {
+    button#mobile-explorer {
+      display: flex;
+    }
+
+    button#desktop-explorer {
+      display: none;
+    }
+  }
+
   &.desktop-only {
     @media all and not ($mobile) {
       display: flex;
     }
   }
+
   /*&:after {
     pointer-events: none;
     content: "";
@@ -23,7 +79,8 @@
   }*/
 }
 
-button#explorer {
+button#mobile-explorer,
+button#desktop-explorer {
   background-color: transparent;
   border: none;
   text-align: left;
@@ -68,19 +125,19 @@
   list-style: none;
   overflow: hidden;
   overflow-y: auto;
-  max-height: 100%;
+  max-height: 0px;
   transition:
     max-height 0.35s ease,
-    visibility 0s linear 0s;
+    visibility 0s linear 0.35s;
   margin-top: 0.5rem;
-  visibility: visible;
+  visibility: hidden;
 
   &.collapsed {
-    max-height: 0;
+    max-height: 100%;
     transition:
       max-height 0.35s ease,
-      visibility 0s linear 0.35s;
-    visibility: hidden;
+      visibility 0s linear 0s;
+    visibility: visible;
   }
 
   & ul {
@@ -91,12 +148,14 @@
       max-height 0.35s ease,
       transform 0.35s ease,
       opacity 0.2s ease;
+
     & li > a {
       color: var(--dark);
       opacity: 0.75;
       pointer-events: all;
     }
   }
+
   > #explorer-ul {
     max-height: none;
   }
@@ -179,3 +238,80 @@
   // remove default margin from li
   margin: 0;
 }
+
+.explorer {
+  @media all and ($mobile) {
+    #explorer-content {
+      box-sizing: border-box;
+      overscroll-behavior: none;
+      z-index: 100;
+      position: absolute;
+      top: 0;
+      background-color: var(--light);
+      max-width: 100dvw;
+      left: -100dvw;
+      width: 100%;
+      transition: transform 300ms ease-in-out;
+      overflow: hidden;
+      padding: $topSpacing 2rem 2rem;
+      height: 100dvh;
+      max-height: 100dvh;
+      margin-top: 0;
+      visibility: hidden;
+
+      &:not(.collapsed) {
+        transform: translateX(100dvw);
+        visibility: visible;
+      }
+
+      ul.overflow {
+        max-height: 100%;
+        width: 100%;
+      }
+
+      &.collapsed {
+        transform: translateX(0);
+        visibility: visible;
+      }
+    }
+
+    #mobile-explorer {
+      margin: 5px;
+      z-index: 101;
+
+      &:not(.collapsed) .lucide-menu {
+        transform: rotate(-90deg);
+        transition: transform 200ms ease-in-out;
+      }
+
+      .lucide-menu {
+        stroke: var(--darkgray);
+        transition: transform 200ms ease;
+
+        &:hover {
+          stroke: var(--dark);
+        }
+      }
+    }
+  }
+}
+
+.no-scroll {
+  opacity: 0;
+  overflow: hidden;
+}
+
+html:has(.no-scroll) {
+  overflow: hidden;
+}
+
+@media all and not ($mobile) {
+  .no-scroll {
+    opacity: 1 !important;
+    overflow: auto !important;
+  }
+
+  html:has(.no-scroll) {
+    overflow: auto !important;
+  }
+}

--
Gitblit v1.10.0