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/scripts/explorer.inline.ts | 212 +++++++++++++++++++++++++++++++++-------------------
1 files changed, 133 insertions(+), 79 deletions(-)
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 8e79d20..9c6c050 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -1,16 +1,18 @@
import { FolderState } from "../ExplorerNode"
// Current state of folders
-let explorerState: FolderState[]
+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 explorer = document.getElementById("explorer-ul")
+ const explorerUl = document.getElementById("explorer-ul")
+ if (!explorerUl) return
for (const entry of entries) {
if (entry.isIntersecting) {
- explorer?.classList.add("no-background")
+ explorerUl.classList.add("no-background")
} else {
- explorer?.classList.remove("no-background")
+ explorerUl.classList.remove("no-background")
}
}
})
@@ -18,113 +20,163 @@
function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed")
- const content = this.nextElementSibling as HTMLElement
+
+ // Toggle collapsed aria state of entire explorer
+ this.setAttribute(
+ "aria-expanded",
+ this.getAttribute("aria-expanded") === "true" ? "false" : "true",
+ )
+
+ const content = (
+ this.nextElementSibling?.nextElementSibling
+ ? this.nextElementSibling.nextElementSibling
+ : this.nextElementSibling
+ ) as MaybeHTMLElement
+ if (!content) return
content.classList.toggle("collapsed")
- content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
+ 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 HTMLElement
+ 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
- let childFolderContainer: HTMLElement
-
+ const childFolderContainer = (
+ isSvg
+ ? target.parentElement?.nextSibling
+ : target.parentElement?.parentElement?.nextElementSibling
+ ) as MaybeHTMLElement
+ const currentFolderParent = (
+ isSvg ? target.nextElementSibling : target.parentElement
+ ) as MaybeHTMLElement
+ if (!(childFolderContainer && currentFolderParent)) return
// <li> element of folder (stores folder-path dataset)
- let currentFolderParent: HTMLElement
-
- // Get correct relative container and toggle collapsed class
- if (isSvg) {
- childFolderContainer = target.parentElement?.nextSibling as HTMLElement
- currentFolderParent = target.nextElementSibling as HTMLElement
-
- childFolderContainer.classList.toggle("open")
- } else {
- childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
- currentFolderParent = target.parentElement as HTMLElement
-
- childFolderContainer.classList.toggle("open")
- }
- if (!childFolderContainer) return
+ childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
// Save folder state to localStorage
- const clickFolderPath = currentFolderParent.dataset.folderpath as string
-
- const fullFolderPath = clickFolderPath
- toggleCollapsedByPath(explorerState, fullFolderPath)
-
- const stringifiedFileTree = JSON.stringify(explorerState)
+ const fullFolderPath = currentFolderParent.dataset.folderpath as string
+ toggleCollapsedByPath(currentExplorerState, fullFolderPath)
+ const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
function setupExplorer() {
// Set click handler for collapsing entire explorer
- const explorer = document.getElementById("explorer")
+ const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
- // Get folder state from local storage
- const storageTree = localStorage.getItem("fileTree")
+ 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"
+ // Convert to bool
+ const useSavedFolderState = explorer?.dataset.savestate === "true"
- if (explorer) {
- // Get config
- const collapseBehavior = explorer.dataset.behavior
+ if (explorer) {
+ // Get config
+ const collapseBehavior = explorer.dataset.behavior
- // Add click handlers for all folders (click handler on folder "label")
- if (collapseBehavior === "collapse") {
- Array.prototype.forEach.call(
- document.getElementsByClassName("folder-button"),
- function (item) {
- item.removeEventListener("click", toggleFolder)
+ // 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
- explorer.removeEventListener("click", toggleExplorer)
- explorer.addEventListener("click", toggleExplorer)
- }
-
- // Set up click handlers for each folder (click handler on folder "icon")
- Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
- item.removeEventListener("click", toggleFolder)
- item.addEventListener("click", toggleFolder)
- })
-
- if (storageTree && useSavedFolderState) {
- // Get state from localStorage and set folder state
- explorerState = JSON.parse(storageTree)
- explorerState.map((folderUl) => {
- // grab <li> element for matching folder path
- const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
-
- // Get corresponding content <ul> tag and set state
- if (folderLi) {
- const folderUL = folderLi.parentElement?.nextElementSibling
- if (folderUL) {
- setFolderState(folderUL as HTMLElement, folderUl.collapsed)
}
}
+
+ // 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-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)
+ }
})
- } else if (explorer?.dataset.tree) {
- // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
- explorerState = JSON.parse(explorer.dataset.tree)
}
}
+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()
@@ -134,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()
})
/**
@@ -142,11 +200,7 @@
* @param collapsed if folder should be set to collapsed or not
*/
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
- if (collapsed) {
- folderElement?.classList.remove("open")
- } else {
- folderElement?.classList.add("open")
- }
+ return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
/**
--
Gitblit v1.10.0