From 2f34627da3ea85391aa62e74de0cfb7dfc70b54a Mon Sep 17 00:00:00 2001 From: Xin Date: Sun, 6 Aug 2023 15:23:37 +0100 Subject: [PATCH] feat: multi-level sidebar chore: support multiple search elements chore: sidebar display toc on mobile view chore: add hamburger menu to navbar on mobile chore: add markdown link hook for opening external link in new window chore: add sidebar footer - put search under params.type - make navbar link aware of external link --- assets/css/components/navbar.css | 47 ++++++ assets/css/components/sidebar.css | 2 +- assets/css/styles.css | 2 + assets/js/flexsearch.js | 83 +++++++--- assets/js/menu.js | 17 +++ data/icons.yaml | 1 + hugo.toml | 11 +- layouts/_default/_markup/render-link.html | 1 + layouts/partials/navbar.html | 32 ++-- layouts/partials/scripts.html | 4 + layouts/partials/search.html | 6 +- layouts/partials/sidebar.html | 175 ++++++++++++++++------ 12 files changed, 304 insertions(+), 77 deletions(-) create mode 100644 assets/css/components/navbar.css create mode 100644 assets/js/menu.js create mode 100644 layouts/_default/_markup/render-link.html diff --git a/assets/css/components/navbar.css b/assets/css/components/navbar.css new file mode 100644 index 0000000..53771a8 --- /dev/null +++ b/assets/css/components/navbar.css @@ -0,0 +1,47 @@ +nav { + .search-wrapper { + @apply hidden md:inline-block; + } +} + +.hamburger-menu svg { + g { + @apply origin-center; + transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1); + } + path { + opacity: 1; + transition: + transform 0.2s cubic-bezier(0.25, 1, 0.5, 1) 0.2s, + opacity 0.2s ease 0.2s; + } + + &.open { + path { + transition: + transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), + opacity 0s ease 0.2s; + } + g { + transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1) 0.2s; + } + } + + &.open > { + path { + @apply opacity-0; + } + g:nth-of-type(1) { + @apply rotate-45; + path { + transform: translate3d(0, 6px, 0); + } + } + g:nth-of-type(2) { + @apply -rotate-45; + path { + transform: translate3d(0, -6px, 0); + } + } + } +} diff --git a/assets/css/components/sidebar.css b/assets/css/components/sidebar.css index 3345bf0..5495018 100644 --- a/assets/css/components/sidebar.css +++ b/assets/css/components/sidebar.css @@ -1,7 +1,7 @@ @media (max-width: 767px) { .sidebar-container { @apply fixed pt-[calc(var(--navbar-height))] top-0 w-full bottom-0 z-[15] overscroll-contain bg-white dark:bg-dark; - /* transition: transform 0.8s cubic-bezier(0.52, 0.16, 0.04, 1); */ + transition: transform 0.8s cubic-bezier(0.52, 0.16, 0.04, 1); will-change: transform, opacity; contain: layout style; backface-visibility: hidden; diff --git a/assets/css/styles.css b/assets/css/styles.css index 275edb6..0085be4 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -8,6 +8,7 @@ @import "components/steps.css"; @import "components/search.css"; @import "components/sidebar.css"; +@import "components/navbar.css"; html { @apply text-base antialiased; @@ -22,6 +23,7 @@ body { :root { --primary-hue: 212deg; --navbar-height: 4rem; + --menu-height: 3.75rem; } .dark { diff --git a/assets/js/flexsearch.js b/assets/js/flexsearch.js index 23dbf6b..44f6cf4 100644 --- a/assets/js/flexsearch.js +++ b/assets/js/flexsearch.js @@ -10,17 +10,33 @@ (function () { const searchDataURL = '{{ $searchData.Permalink }}'; - const inputElement = document.getElementById('search-input'); - const resultsElement = document.getElementById('search-results'); + const inputElements = document.querySelectorAll('.search-input'); + for (const el of inputElements) { + el.addEventListener('focus', init); + el.addEventListener('keyup', search); + el.addEventListener('keydown', handleKeyDown); + } - inputElement.addEventListener('focus', init); - inputElement.addEventListener('keyup', search); - inputElement.addEventListener('keydown', handleKeyDown); + // Get the search wrapper, input, and results elements. + function getActiveSearchElement() { + const inputs = Array.from(document.querySelectorAll('.search-wrapper')).filter(el => el.clientHeight > 0); + if (inputs.length === 1) { + return { + wrapper: inputs[0], + inputElement: inputs[0].querySelector('.search-input'), + resultsElement: inputs[0].querySelector('.search-results') + }; + } + return undefined; + } const INPUTS = ['input', 'select', 'button', 'textarea'] // Focus the search input when pressing ctrl+k/cmd+k or /. document.addEventListener('keydown', function (e) { + const { inputElement } = getActiveSearchElement(); + if (!inputElement) return; + const activeElement = document.activeElement; const tagName = activeElement && activeElement.tagName; if ( @@ -42,18 +58,36 @@ } }); + // Dismiss the search results when clicking outside the search box. + document.addEventListener('mousedown', function (e) { + const { inputElement, resultsElement } = getActiveSearchElement(); + if (!inputElement || !resultsElement) return; + if ( + e.target !== inputElement && + e.target !== resultsElement && + !resultsElement.contains(e.target) + ) { + hideSearchResults(); + } + }); + // Get the currently active result and its index. function getActiveResult() { + const { resultsElement } = getActiveSearchElement(); + if (!resultsElement) return { result: undefined, index: -1 }; + const result = resultsElement.querySelector('.active'); - if (result) { - const index = parseInt(result.getAttribute('data-index')); - return { result, index }; - } - return { result: undefined, index: -1 }; + if (!result) return { result: undefined, index: -1 }; + + const index = parseInt(result.getAttribute('data-index')); + return { result, index }; } // Set the active result by index. function setActiveResult(index) { + const { resultsElement } = getActiveSearchElement(); + if (!resultsElement) return; + const { result: activeResult } = getActiveResult(); activeResult && activeResult.classList.remove('active'); const result = resultsElement.querySelector(`[data-index="${index}"]`); @@ -65,22 +99,31 @@ // Get the number of search results from the DOM. function getResultsLength() { + const { resultsElement } = getActiveSearchElement(); + if (!resultsElement) return 0; return resultsElement.querySelectorAll('li').length; } // Finish the search by hiding the results and clearing the input. function finishSearch() { + const { inputElement } = getActiveSearchElement(); + if (!inputElement) return; hideSearchResults(); inputElement.value = ''; inputElement.blur(); } function hideSearchResults() { + const { resultsElement } = getActiveSearchElement(); + if (!resultsElement) return; resultsElement.classList.add('hidden'); } // Handle keyboard events. function handleKeyDown(e) { + const { inputElement } = getActiveSearchElement(); + if (!inputElement) return; + const resultsLength = getResultsLength(); const { result: activeResult, index: activeIndex } = getActiveResult(); @@ -108,9 +151,11 @@ } // Initializes the search. - function init() { - inputElement.removeEventListener('focus', init); - preloadIndex().then(search); + function init(e) { + e.target.removeEventListener('focus', init); + if (!(window.pageIndex && window.sectionIndex)) { + preloadIndex(); + } } // Preload the search index. @@ -182,13 +227,14 @@ } } - function search() { - const query = inputElement.value; - if (!inputElement.value) { + function search(e) { + const query = e.target.value; + if (!e.target.value) { hideSearchResults(); return; } + const { resultsElement } = getActiveSearchElement(); while (resultsElement.firstChild) { resultsElement.removeChild(resultsElement.firstChild); } @@ -249,9 +295,10 @@ displayResults(sortedResults, query); } - - function displayResults(results, query) { + const { resultsElement } = getActiveSearchElement(); + if (!resultsElement) return; + if (!results.length) { resultsElement.innerHTML = `No results found.`; return; diff --git a/assets/js/menu.js b/assets/js/menu.js new file mode 100644 index 0000000..3822371 --- /dev/null +++ b/assets/js/menu.js @@ -0,0 +1,17 @@ +const menu = document.querySelector('.hamburger-menu'); + +menu.addEventListener('click', (e) => { + e.preventDefault(); + const sidebarContainer = document.querySelector('.sidebar-container'); + + // Toggle the hamburger menu + menu.querySelector('svg').classList.toggle('open'); + + // When the menu is open, we want to show the navigation sidebar + sidebarContainer.classList.toggle('max-md:[transform:translate3d(0,-100%,0)]'); + sidebarContainer.classList.toggle('max-md:[transform:translate3d(0,0,0)]'); + + // When the menu is open, we want to prevent the body from scrolling + document.body.classList.toggle('overflow-hidden'); + document.body.classList.toggle('md:overflow-auto'); +}); diff --git a/data/icons.yaml b/data/icons.yaml index b3c50d6..812f725 100644 --- a/data/icons.yaml +++ b/data/icons.yaml @@ -36,3 +36,4 @@ cards: check: +menu: diff --git a/hugo.toml b/hugo.toml index a12e125..0efdbb7 100644 --- a/hugo.toml +++ b/hugo.toml @@ -66,9 +66,18 @@ defaultContentLanguage = 'en' [[menu.main]] name = 'Search' weight = 35 - params = { placeholder = 'Search documentation...' } + params = { type = 'search', placeholder = 'Search documentation...' } [[menu.main]] name = 'GitHub' url = 'https://github.com' weight = 40 params = { icon = 'github' } + + [[menu.sidebar]] + name = 'More' + params = { type = 'separator' } + weight = 10 + [[menu.sidebar]] + name = 'Hugo Docs ↗' + url = 'https://gohugo.io/documentation/' + weight = 20 diff --git a/layouts/_default/_markup/render-link.html b/layouts/_default/_markup/render-link.html new file mode 100644 index 0000000..935bb3e --- /dev/null +++ b/layouts/_default/_markup/render-link.html @@ -0,0 +1 @@ +{{ .Text | safeHTML }} diff --git a/layouts/partials/navbar.html b/layouts/partials/navbar.html index 030b128..e408a4c 100644 --- a/layouts/partials/navbar.html +++ b/layouts/partials/navbar.html @@ -11,18 +11,30 @@ {{- $currentPage := . -}} {{- range .Site.Menus.main -}} - {{- if eq .Name "Search" -}} + {{- if eq .Params.type "search" -}} {{ partial "search.html" (dict "params" .Params) }} - {{- else if .Params.icon -}} - - {{ partial "utils/icon.html" (dict "name" .Params.icon "attributes" "height=24") }} - {{ .Name }} - - {{ else }} - + {{- else -}} + {{ $external := strings.HasPrefix .URL "http" }} + {{- if .Params.icon -}} + + {{ partial "utils/icon.html" (dict "name" .Params.icon "attributes" "height=24") }} + {{ .Name }} + + {{- else -}} + + {{- end -}} {{ end }} {{ end }} + + + diff --git a/layouts/partials/scripts.html b/layouts/partials/scripts.html index f451750..7cf7646 100644 --- a/layouts/partials/scripts.html +++ b/layouts/partials/scripts.html @@ -4,6 +4,9 @@ {{ $codeCopyJS := resources.Get "js/code-copy.js" }} +{{ $menuJS := resources.Get "js/menu.js" }} + + {{ if .Page.Store.Get "hasMermaid" }} {{ end }} + {{ $searchJSFile := printf "%s.search.js" .Language.Lang }} {{ $searchJS := resources.Get "js/flexsearch.js" | resources.ExecuteAsTemplate $searchJSFile . }} diff --git a/layouts/partials/search.html b/layouts/partials/search.html index b10142e..e4f52cf 100644 --- a/layouts/partials/search.html +++ b/layouts/partials/search.html @@ -1,14 +1,14 @@ {{- $placeholder := .params.placeholder | default "Search..." -}} -