feat(explorer): collapsible mobile explorer (#1471)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
This commit is contained in:
		
							parent
							
								
									fbc45548f7
								
							
						
					
					
						commit
						91189dfd2f
					
				| @ -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(), | ||||
| @ -44,7 +44,7 @@ export const defaultListPageLayout: PageLayout = { | ||||
|     Component.MobileOnly(Component.Spacer()), | ||||
|     Component.Search(), | ||||
|     Component.Darkmode(), | ||||
|     Component.DesktopOnly(Component.Explorer()), | ||||
|     Component.Explorer(), | ||||
|   ], | ||||
|   right: [], | ||||
| } | ||||
|  | ||||
| @ -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 @@ 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={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 @@ export default ((userOpts?: Partial<Options>) => { | ||||
|     ) | ||||
|   } | ||||
| 
 | ||||
|   Explorer.css = explorerStyle | ||||
|   Explorer.css = style | ||||
|   Explorer.afterDOMLoaded = script | ||||
|   return Explorer | ||||
| }) satisfies QuartzComponentConstructor | ||||
|  | ||||
| @ -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 @@ 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 | ||||
| @ -42,10 +64,14 @@ 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) | ||||
| @ -53,57 +79,106 @@ function toggleFolder(evt: MouseEvent) { | ||||
| } | ||||
| 
 | ||||
| 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 @@ document.addEventListener("nav", () => { | ||||
|   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() | ||||
| }) | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -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 @@ button#explorer { | ||||
|   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 @@ button#explorer { | ||||
|       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 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg { | ||||
|   // 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; | ||||
|   } | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user