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