Emile Bangma
2025-02-03 91189dfd2f4cb32e205117b327e0ae7a0c2dd716
feat(explorer): collapsible mobile explorer (#1471)

Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
4 files modified
301 ■■■■■ changed files
quartz.layout.ts 4 ●●●● patch | view | raw | blame | history
quartz/components/Explorer.tsx 38 ●●●● patch | view | raw | blame | history
quartz/components/scripts/explorer.inline.ts 105 ●●●● patch | view | raw | blame | history
quartz/components/styles/explorer.scss 154 ●●●●● patch | view | raw | blame | history
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: [],
}
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
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,20 +79,34 @@
}
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)
      window.addCleanup(() => item.removeEventListener("click", toggleFolder))
    }
  }
  explorer.addEventListener("click", toggleExplorer)
      // 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(
@@ -77,8 +117,6 @@
  }
  // 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]))
@@ -86,13 +124,17 @@
    ? JSON.parse(explorer.dataset.tree)
    : []
  currentExplorerState = []
  for (const { path, collapsed } of newExplorerState) {
    currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
      currentExplorerState.push({
        path,
        collapsed: oldIndex.get(path) ?? collapsed,
      })
  }
  currentExplorerState.map((folderState) => {
    const folderLi = document.querySelector(
      `[data-folderpath='${folderState.path}']`,
        `[data-folderpath='${folderState.path.replace("'", "-")}']`,
    ) as MaybeHTMLElement
    const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
    if (folderUl) {
@@ -100,10 +142,43 @@
    }
  })
}
}
function toggleExplorerFolders() {
  const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
    /\/index$/g,
    "",
  )
  const allFolders = document.querySelectorAll(".folder-outer")
  allFolders.forEach((element) => {
    const folderUl = Array.from(element.children).find((child) =>
      child.matches("ul[data-folderul]"),
    )
    if (folderUl) {
      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()
})
/**
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: 0px;
  transition:
    max-height 0.35s ease,
    visibility 0s linear 0.35s;
  margin-top: 0.5rem;
  visibility: hidden;
  &.collapsed {
  max-height: 100%;
  transition:
    max-height 0.35s ease,
    visibility 0s linear 0s;
  margin-top: 0.5rem;
  visibility: visible;
  &.collapsed {
    max-height: 0;
    transition:
      max-height 0.35s ease,
      visibility 0s linear 0.35s;
    visibility: hidden;
  }
  & 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;
  }
}