Skip to content

Commit

Permalink
feat(explorer): collapsible mobile explorer (#1471)
Browse files Browse the repository at this point in the history
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
  • Loading branch information
saberzero1 and aarnphm authored Feb 3, 2025
1 parent fbc4554 commit 91189df
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 49 deletions.
4 changes: 2 additions & 2 deletions quartz.layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const defaultContentPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
Component.Explorer(),
],
right: [
Component.Graph(),
Expand All @@ -44,7 +44,7 @@ export const defaultListPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
Component.Explorer(),
],
right: [],
}
38 changes: 33 additions & 5 deletions quartz/components/Explorer.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -83,18 +83,46 @@ export default ((userOpts?: Partial<Options>) => {
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={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={opts.folderDefaultState === "open"}
aria-expanded={true}
>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg
Expand Down Expand Up @@ -122,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
)
}

Explorer.css = explorerStyle
Explorer.css = style
Explorer.afterDOMLoaded = script
return Explorer
}) satisfies QuartzComponentConstructor
151 changes: 116 additions & 35 deletions quartz/components/scripts/explorer.inline.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -16,23 +18,43 @@ const observer = new IntersectionObserver((entries) => {
})

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
Expand All @@ -42,75 +64,134 @@ function toggleFolder(evt: MouseEvent) {
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)
localStorage.setItem("fileTree", stringifiedFileTree)
}

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>

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)
}

if (explorer.dataset.behavior === "collapse") {
// 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))
}
}

explorer.addEventListener("click", toggleExplorer)
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
// 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,
})
}

// 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))
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)
}
})
}
}

// 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 })
}
function toggleExplorerFolders() {
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
/\/index$/g,
"",
)
const allFolders = document.querySelectorAll(".folder-outer")

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
const lastItem = document.getElementById("explorer-end")
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()
})

/**
Expand Down
Loading

0 comments on commit 91189df

Please sign in to comment.