From 50217ee4696fabcf8c40671fab3f3c0e8d6fad83 Mon Sep 17 00:00:00 2001 From: Alex Cerezo Date: Thu, 20 Aug 2026 14:21:06 +0200 Subject: [PATCH 1/4] Restyle the left TOC to match the Figma sidebar Island overviews nest as folders, assembler titles can override nav labels, and the tree uses a single chevron without the htmx top-bar indicator. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- config/navigation.yml | 1 + config/navigation_preview.yml | 1 + .../Toc/SiteNavigationFile.cs | 25 +- .../Assembler/SectionTopNavBuilder.cs | 26 +- .../Assembler/SiteNavigation.cs | 4 +- .../INavigationItem.cs | 9 + .../Node/DocumentationSetNavigation.cs | 7 +- .../Isolated/Node/FolderNavigation.cs | 7 +- .../Node/TableOfContentsNavigation.cs | 6 +- .../Assets/assembler.css | 2 +- .../Assets/codex.css | 10 - .../Assets/pages-nav-figma.css | 465 ++++++++++++++++++ .../Assets/pages-nav-scroll.ts | 185 +++++++ .../Assets/pages-nav.ts | 192 ++++++-- .../Assets/styles.css | 41 +- src/Elastic.Documentation.Site/Htmx.cs | 1 + .../Layout/_PagesNav.cshtml | 7 +- .../Navigation/NavigationRenderModel.cs | 57 ++- .../Navigation/_TocTree.cshtml | 50 +- .../Navigation/_TocTreeNav.cshtml | 56 +-- .../_GlobalLayout.cshtml | 18 +- .../journeys/api-explorer.journey.ts | 2 +- .../ElasticsearchMarkdownExporter.Export.cs | 2 +- src/Elastic.Markdown/_Layout.cshtml | 4 +- .../Assembler/SectionNavigationTests.cs | 88 +++- .../Assembler/SiteNavigationTests.cs | 2 +- .../Rendering/NavigationRenderModelTests.cs | 18 +- .../Rendering/TocTreeRenderingTests.cs | 167 +++++++ 28 files changed, 1264 insertions(+), 189 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/pages-nav-figma.css create mode 100644 src/Elastic.Documentation.Site/Assets/pages-nav-scroll.ts create mode 100644 tests/Navigation.Tests/Rendering/TocTreeRenderingTests.cs diff --git a/config/navigation.yml b/config/navigation.yml index 14b502d12d..d3422bb5bb 100644 --- a/config/navigation.yml +++ b/config/navigation.yml @@ -23,6 +23,7 @@ toc: - toc: explore-analyze - toc: deploy-manage - toc: cloud-account + navigation_title: Manage your Cloud account - toc: troubleshoot diff --git a/config/navigation_preview.yml b/config/navigation_preview.yml index a1d7d891d6..64ce722a34 100644 --- a/config/navigation_preview.yml +++ b/config/navigation_preview.yml @@ -26,6 +26,7 @@ toc: - toc: explore-analyze - toc: deploy-manage - toc: cloud-account + navigation_title: Manage your Cloud account - toc: extend children: - toc: kibana://extend diff --git a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs index d942e2f25c..4aa3c03333 100644 --- a/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/SiteNavigationFile.cs @@ -146,7 +146,16 @@ public class SiteTableOfContents : List; /// When true, the resolved navigation node is marked as an island from the assembler side. /// OR-ed with any island: true the content set already declares — can only enable, never disable. /// -public record SiteTableOfContentsRef(Uri Source, string PathPrefix, IReadOnlyCollection Children, bool Island = false) +/// +/// Optional assembler-side label for this TOC root. When set, replaces the index page title +/// in the assembled navigation (dropdowns, back-links, sidebar root row). Does not change the page H1. +/// +public record SiteTableOfContentsRef( + Uri Source, + string PathPrefix, + IReadOnlyCollection Children, + bool Island = false, + string? NavigationTitle = null) : ISiteNavigationEntry, ITableOfContentsItem { // For site-level TOC refs, the Path is the path prefix (where it will be mounted in the site) @@ -281,7 +290,12 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr && bool.TryParse(islandStr, out var islandBool) && islandBool; - return new SiteTableOfContentsRef(source, pathPrefix, children, island); + var navigationTitle = dictionary.TryGetValue("navigation_title", out var titleObj) && titleObj is string title + && !string.IsNullOrWhiteSpace(title) + ? title + : null; + + return new SiteTableOfContentsRef(source, pathPrefix, children, island, navigationTitle); } var keys = string.Join(", ", dictionary.Keys.Select(k => $"'{k}'")); @@ -356,7 +370,12 @@ public class SiteTableOfContentsRefYamlConverter : IYamlTypeConverter var island = dictionary.TryGetValue("island", out var islandObj) && islandObj is string islandStr && bool.TryParse(islandStr, out var islandBool) && islandBool; - return new SiteTableOfContentsRef(source, pathPrefix, children, island); + var navigationTitle = dictionary.TryGetValue("navigation_title", out var titleObj) && titleObj is string title + && !string.IsNullOrWhiteSpace(title) + ? title + : null; + + return new SiteTableOfContentsRef(source, pathPrefix, children, island, navigationTitle); } var keys = string.Join(", ", dictionary.Keys.Select(k => $"'{k}'")); diff --git a/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs b/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs index c48a3b08d8..33e8e0f45e 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SectionTopNavBuilder.cs @@ -16,7 +16,7 @@ namespace Elastic.Documentation.Navigation.Assembler; /// children: — maps to a tree node; active when the /// current page's NavigationRoot.Id equals the section's Id. /// -/// Plain toc: entries also produce one tab, active when NavigationRoot.Id == item.Id. +/// Leftover top-level toc: entries are not tabs (they stay in the tree). /// Active state is determined by comparing the current page's NavigationRoot.Id to each /// tab's stored . /// @@ -28,14 +28,8 @@ public static class SectionTopNavBuilder if (navFile.TableOfContents.Count == 0) return null; - // Index plain toc: items by Identifier for fast lookup. - // Sections with children now live in the tree as SectionNavigation nodes and - // are looked up by title instead. - var byIdentifier = topLevel - .OfType>() - .Where(item => item is not SectionNavigation) - .ToDictionary(item => item.Identifier); - + // Sections with children live in the tree as SectionNavigation nodes and + // are looked up by title. var sectionsByTitle = topLevel .OfType() .ToDictionary(s => s.Title, StringComparer.OrdinalIgnoreCase); @@ -74,17 +68,11 @@ public static class SectionTopNavBuilder } } } - else if (entry is SiteTableOfContentsRef tocRef) + else if (entry is SiteTableOfContentsRef) { - // Plain toc: entry — one tab, active when NavigationRoot.Id == item.Id - if (byIdentifier.TryGetValue(tocRef.Source, out var navItem)) - { - items.Add(new TopNavLinkItem( - navItem.NavigationTitle, - navItem.Index.Url, - IsExternal: false, - SectionId: navItem.Id)); - } + // Preview tabs come from section: entries only. A leftover top-level + // toc: (the local docs-builder inject) stays in the tree, not the top bar. + continue; } } diff --git a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs index a8ad0d7eaf..c85a677f2a 100644 --- a/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Assembler/SiteNavigation.cs @@ -52,7 +52,7 @@ public SiteNavigation( Phantoms = siteNavigationFile.Phantoms; DeclaredPhantoms = [.. siteNavigationFile.Phantoms.Select(p => new Uri(p.Source))]; DeclaredTableOfContents = SiteNavigationFile.GetAllDeclaredSources(siteNavigationFile); - NavigationTitle = "Elastic Docs"; + NavigationTitle = "Docs"; _nodes = []; foreach (var setNavigation in documentationSetNavigations) @@ -281,6 +281,8 @@ void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection +/// Optional assembler-side label. When set, replaces the index page title in navigation +/// (sidebar, dropdowns, back-links) without changing the page H1. +/// +public interface IAssignableNavigationTitle +{ + string? NavigationTitleOverride { get; set; } +} + public interface IRootNavigationItem : INodeNavigationItem, IAssignableChildrenNavigation where TIndex : INavigationModel where TChildNavigation : INavigationItem diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs index d91b904239..57c71beae7 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs @@ -16,14 +16,9 @@ namespace Elastic.Documentation.Navigation.Isolated.Node; -public interface IDocumentationSetNavigation +public interface IDocumentationSetNavigation : IAssignableNavigationTitle { IReadOnlyDictionary> TableOfContentNodes { get; } - - /// - /// Optional override for the navigation title. When set, this is used instead of the index page's title. - /// - string? NavigationTitleOverride { get; set; } } [DebuggerDisplay("{Url}")] diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs index 05ce21068d..39f3020842 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/FolderNavigation.cs @@ -12,7 +12,7 @@ public class FolderNavigation( string parentPath, INodeNavigationItem? parent, INavigationHomeAccessor homeAccessor) - : INodeNavigationItem, IAssignableChildrenNavigation, IAssignableIslandNavigation + : INodeNavigationItem, IAssignableChildrenNavigation, IAssignableIslandNavigation, IAssignableNavigationTitle where TModel : class, IDocumentationFile { // Will be set by SetNavigationItems @@ -23,7 +23,10 @@ public class FolderNavigation( public string Url => Index.Url; /// - public string NavigationTitle => Index.NavigationTitle; + public string? NavigationTitleOverride { get; set; } + + /// + public string NavigationTitle => NavigationTitleOverride ?? Index.NavigationTitle; /// public IRootNavigationItem NavigationRoot => homeAccessor.HomeProvider.NavigationRoot; diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs index 51dbf5ca8d..483bbf0778 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/TableOfContentsNavigation.cs @@ -13,6 +13,7 @@ public class TableOfContentsNavigation : IRootNavigationItem Index.Url; /// - public string NavigationTitle => Index.NavigationTitle; + public string? NavigationTitleOverride { get; set; } + + /// + public string NavigationTitle => NavigationTitleOverride ?? Index.NavigationTitle; /// /// TableOfContentsNavigation's NavigationRoot comes from its HomeProvider. diff --git a/src/Elastic.Documentation.Site/Assets/assembler.css b/src/Elastic.Documentation.Site/Assets/assembler.css index 87044180a6..a272a0fec9 100644 --- a/src/Elastic.Documentation.Site/Assets/assembler.css +++ b/src/Elastic.Documentation.Site/Assets/assembler.css @@ -8,7 +8,7 @@ @media screen and (min-width: 768px) { :root { - --offset-top: calc(var(--spacing) * 18); + --offset-top: 56px; } } diff --git a/src/Elastic.Documentation.Site/Assets/codex.css b/src/Elastic.Documentation.Site/Assets/codex.css index 6c70124498..18b8a4640a 100644 --- a/src/Elastic.Documentation.Site/Assets/codex.css +++ b/src/Elastic.Documentation.Site/Assets/codex.css @@ -7,16 +7,6 @@ --offset-top: calc(var(--header-height) + var(--sub-header-height)); } -#htmx-indicator { - top: var(--header-height); -} - -body:has(.codex-root-landing) { - #htmx-indicator { - top: 0; - } -} - /* Codex header specific styles - only apply on lg screens (matches --breakpoint-lg) */ @media screen and (min-width: 1280px) { .has-isolated-header { diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-figma.css b/src/Elastic.Documentation.Site/Assets/pages-nav-figma.css new file mode 100644 index 0000000000..56de3d837d --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-figma.css @@ -0,0 +1,465 @@ +/* + * Figma Nav look on the V1 pages sidebar (#pages-nav). + * Re-scoped from Nav V2 (body:has([data-nav-v2]) / nav[data-nav-v2]) so Fabrizio's + * TOC markup (.nav-link / .nav-folder / .nav-subtree) picks up the same chrome + * without enabling NAV_V2. + */ + +@media (width >= 768px) { + div.min-h-screen.grid:has(> .sidebar > #pages-nav), + div.min-h-screen.grid:has(> aside.sidebar > #pages-nav) { + grid-template-columns: minmax(0, 279px) 1fr; + } +} + +aside.sidebar:has(#pages-nav) { + --pages-nav-inset: 24px; + max-width: 279px; +} + +@media (width < 768px) { + aside.sidebar:has(#pages-nav) { + max-width: none; + } +} + +@media (width >= 768px) { + aside.sidebar:has(#pages-nav) { + display: flex; + flex-direction: column; + align-self: start; + min-height: 0; + box-sizing: border-box; + top: var(--offset-top); + height: var(--pages-nav-aside-height, calc(100vh - var(--offset-top))); + max-height: var( + --pages-nav-aside-height, + calc(100vh - var(--offset-top)) + ); + padding: var(--pages-nav-inset) 0; + overflow: hidden; + background-color: transparent; + border: 0; + } + + .sidebar #pages-nav.sidebar-nav { + position: relative; + top: auto; + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-self: stretch; + min-height: 0; + height: auto; + max-height: 100%; + overflow: hidden; + scrollbar-gutter: auto; + background-color: #f6f9fc; + border-radius: 16px; + } +} + +@media (width < 768px) { + .sidebar #pages-nav.sidebar-nav { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + max-height: 100%; + overflow: hidden; + scrollbar-gutter: auto; + background-color: #f6f9fc; + } +} + +.pages-nav-v2-shell { + display: flex; + min-width: 0; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + max-height: 100%; + overflow: hidden; + background-color: transparent; +} + +@media (width >= 768px) { + .pages-nav-v2-shell { + min-width: 0; + } +} + +.pages-nav-v2__chrome { + box-sizing: border-box; + flex-shrink: 0; +} + +.pages-nav-v2__dropdown { + padding: 16px 16px 12px; +} + +.pages-nav-v2__back-chrome { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border-bottom: 1px solid #e3e8f2; + background-color: transparent; +} + +.pages-nav-v2__back { + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + margin: 0; + min-height: 32px; + padding-inline: 12px; + border: 1px solid #d3dae6; + border-radius: 8px; + background-color: #fff; + color: #343741; + font-size: 14px; + font-weight: 400; + line-height: 20px; + text-decoration: none; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.12s ease; +} + +.pages-nav-v2__back:hover { + background-color: #f5f7fa; + border-color: #98a2b3; + color: #343741; +} + +.pages-nav-v2__back:focus-visible { + outline: 2px solid #0b64dd; + outline-offset: 2px; +} + +.pages-nav-v2__back-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.7; +} + +.pages-nav-v2__menu { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.pages-nav-v2__scroll-btn { + position: absolute; + left: 50%; + z-index: 50; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + margin: 0; + border: 1px solid #d3dae6; + border-radius: 8px; + background-color: #fff; + color: #343741; + cursor: pointer; + opacity: 0; + pointer-events: none; + transform: translateX(-50%); + transition: + opacity 0.28s ease, + background-color 0.15s ease, + border-color 0.15s ease; +} + +.pages-nav-v2__scroll-btn--up { + top: 8px; +} + +.pages-nav-v2__scroll-btn--down { + bottom: 8px; +} + +.pages-nav-v2__scroll-btn:hover { + background-color: #f5f7fa; + border-color: #98a2b3; +} + +.pages-nav-v2__scroll-btn:focus-visible { + outline: 2px solid #0b64dd; + outline-offset: 2px; +} + +aside.sidebar:has(#pages-nav):hover + .pages-nav-v2__scroll-btn[data-visible='true'] { + opacity: 1; + pointer-events: auto; +} + +.pages-nav-v2__scroll { + --nav-scroll-fade-size: 28px; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-y: auto; + padding-block: 8px; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + scrollbar-gutter: stable; + transition: scrollbar-color 0.28s ease; + -webkit-mask-image: none; + mask-image: none; +} + +.pages-nav-v2__scroll[data-nav-fade-top='true'] { + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--nav-scroll-fade-size), + #000 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--nav-scroll-fade-size), + #000 100% + ); +} + +.pages-nav-v2__scroll[data-nav-fade-bottom='true'] { + -webkit-mask-image: linear-gradient( + to bottom, + #000 0, + #000 calc(100% - var(--nav-scroll-fade-size)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + #000 0, + #000 calc(100% - var(--nav-scroll-fade-size)), + transparent 100% + ); +} + +.pages-nav-v2__scroll[data-nav-fade-top='true'][data-nav-fade-bottom='true'] { + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--nav-scroll-fade-size), + #000 calc(100% - var(--nav-scroll-fade-size)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--nav-scroll-fade-size), + #000 calc(100% - var(--nav-scroll-fade-size)), + transparent 100% + ); +} + +.pages-nav-v2__scroll::-webkit-scrollbar { + width: 8px; +} + +.pages-nav-v2__scroll::-webkit-scrollbar-thumb { + background-color: rgb(227 232 242 / 0); + border-radius: 9999px; + transition: background-color 0.28s ease; +} + +aside.sidebar:has(#pages-nav):hover .pages-nav-v2__scroll { + scrollbar-color: #e3e8f2 transparent; +} + +aside.sidebar:has(#pages-nav):hover + .pages-nav-v2__scroll::-webkit-scrollbar-thumb { + background-color: rgb(227 232 242 / 1); +} + +aside.sidebar:has(#pages-nav):hover + .pages-nav-v2__scroll::-webkit-scrollbar-thumb:hover { + background-color: #c5cedb; + transition: background-color 0.15s ease; +} + +.pages-nav-v2__content { + padding-top: 0; + padding-left: 8px; + padding-right: 8px; +} + +#pages-nav .pages-nav-v2__content ul[id^='nav-tree'] { + display: flex; + flex-direction: column; + gap: 1px; + padding: 0; + margin: 0; +} + +#pages-nav .pages-nav-v2__content li { + min-width: 0; + width: 100%; + margin: 0; + padding: 0; +} + +#pages-nav li.current::before { + content: none; +} + +#pages-nav .nav-v2-separator { + padding-block: 8px 4px; +} + +#pages-nav .nav-v2-separator hr { + border: 0; + border-top: 1px solid #e3e8f2; + margin: 0; +} + +#pages-nav a.sidebar-link.nav-v2-link { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 4px; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 32px; + padding: 6px 12px; + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: #516381; + text-wrap: wrap; + word-break: break-word; + overflow-wrap: break-word; + border-radius: 8px; + background-color: transparent; + transition: + background-color 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; +} + +#pages-nav .nav-v2-nav-text { + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + font-weight: 400; +} + +#pages-nav a.sidebar-link.nav-v2-link:not(.current):hover { + background-color: #ecf1f9; + color: #1d2a3e; +} + +#pages-nav a.sidebar-link.nav-v2-link.current { + position: relative; + color: #0b64dd; + background-color: transparent; + font-weight: 400; +} + +#pages-nav a.sidebar-link.nav-v2-link.current .nav-v2-nav-text, +#pages-nav + li.nav-v2-active-ancestor + > .nav-folder-peer + > a.sidebar-link:not(.current) + .nav-v2-nav-text { + font-weight: 600; +} + +#pages-nav a.sidebar-link.nav-v2-link.current:hover { + color: #0b64dd; + background-color: #ecf1f9; +} + +#pages-nav a.sidebar-link.nav-v2-link.current::before { + content: none; +} + +#pages-nav .nav-folder-peer { + min-width: 0; +} + +#pages-nav .nav-folder-chevron { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-inline-start: auto; + flex-shrink: 0; + color: #98a2b3; + font-weight: 400; + pointer-events: none; +} + +#pages-nav .nav-chevron { + width: 16px; + height: 16px; + stroke: #98a2b3; + color: #98a2b3; + /* Tailwind v4 @apply -rotate-90 uses the rotate property; override that, not transform. */ + rotate: -90deg; + transform: none; +} + +#pages-nav .nav-folder-peer:has(input[type='checkbox']:checked) .nav-chevron { + rotate: 0deg; +} + +#pages-nav .nav-subtree { + display: none; + position: relative; + flex-direction: column; + gap: 1px; + width: 100%; + margin: 8px 0 8px; + margin-inline-start: 16px; + padding: 0; + list-style: none; + border-inline-start: 1px solid #e3e8f2; +} + +#pages-nav .nav-subtree::before { + content: none; +} + +#pages-nav .peer:has(:checked) ~ .nav-subtree { + display: flex; +} + +#pages-nav .nav-subtree a.sidebar-link.nav-v2-link { + border-start-start-radius: 0; + border-end-start-radius: 0; + border-inline-start: 1px solid transparent; + margin-inline-start: -1px; + padding-inline-start: 12px; + background-clip: padding-box; +} + +#pages-nav .nav-subtree a.sidebar-link.nav-v2-link.current { + border-inline-start: 2px solid #0b64dd; + z-index: 1; +} diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-scroll.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-scroll.ts new file mode 100644 index 0000000000..44cc0a2c9a --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-scroll.ts @@ -0,0 +1,185 @@ +/** Latest pages-nav aside / scrollport for viewport clamp + edge fades. */ +let scrollViewportAside: HTMLElement | null = null +let scrollViewportScrollEl: HTMLElement | null = null +let scrollViewportWindowBound = false + +function getNavScrollOverflow(scrollEl: HTMLElement) { + const { scrollTop, scrollHeight, clientHeight } = scrollEl + const maxScroll = scrollHeight - clientHeight + const eps = 1 + const canScroll = maxScroll > eps + return { + canScrollUp: canScroll && scrollTop > eps, + canScrollDown: canScroll && scrollTop < maxScroll - eps, + } +} + +function findNavScrollButtons(scrollEl: HTMLElement) { + const menu = + scrollEl.closest('.pages-nav-v2__menu') ?? + scrollEl.parentElement + const upBtn = + menu?.querySelector( + ':scope > .pages-nav-v2__scroll-btn--up' + ) ?? null + const downBtn = + menu?.querySelector( + ':scope > .pages-nav-v2__scroll-btn--down' + ) ?? null + return { upBtn, downBtn } +} + +function updateNavScrollFades(scrollEl: HTMLElement) { + const { canScrollUp, canScrollDown } = getNavScrollOverflow(scrollEl) + scrollEl.dataset.navFadeTop = canScrollUp ? 'true' : 'false' + scrollEl.dataset.navFadeBottom = canScrollDown ? 'true' : 'false' + + const { upBtn, downBtn } = findNavScrollButtons(scrollEl) + if (upBtn) { + upBtn.dataset.visible = canScrollUp ? 'true' : 'false' + } + if (downBtn) { + downBtn.dataset.visible = canScrollDown ? 'true' : 'false' + } +} + +function scrollNavByPage(scrollEl: HTMLElement, direction: 'up' | 'down') { + const delta = Math.max(120, Math.round(scrollEl.clientHeight * 0.75)) + scrollEl.scrollBy({ + top: direction === 'up' ? -delta : delta, + behavior: 'smooth', + }) +} + +function findSiteFooter(): HTMLElement | null { + return ( + document.querySelector('footer.bg-ink-dark') ?? + document.querySelector('body > footer:last-of-type') + ) +} + +function getOffsetTopPx() { + const raw = getComputedStyle(document.documentElement) + .getPropertyValue('--offset-top') + .trim() + const parsed = Number.parseFloat(raw) + return Number.isFinite(parsed) ? parsed : 48 +} + +/** + * Clamp the sticky host to the visible strip under the topbar → viewport + * bottom or footer. Sticky top is --offset-top only; the 24px inset is padding + * inside the host so it does not get added to the offset. + */ +function updatePagesNavAsideViewportHeight(aside: HTMLElement) { + if (!window.matchMedia('(width >= 768px)').matches) { + aside.style.removeProperty('--pages-nav-aside-height') + return + } + + const stickyTop = getOffsetTopPx() + const layoutTop = aside.getBoundingClientRect().top + const top = Number.isFinite(layoutTop) + ? Math.max(stickyTop, Math.round(layoutTop)) + : stickyTop + let bottom = window.innerHeight + const footer = findSiteFooter() + if (footer) { + const footerTop = footer.getBoundingClientRect().top + if (footerTop < bottom) { + bottom = footerTop + } + } + + const height = Math.max(0, Math.round(bottom - top)) + aside.style.setProperty('--pages-nav-aside-height', `${height}px`) +} + +function refreshNavScrollViewport() { + const aside = scrollViewportAside + const scrollEl = scrollViewportScrollEl + if (!aside || !scrollEl) { + return + } + + updatePagesNavAsideViewportHeight(aside) + updateNavScrollFades(scrollEl) +} + +/** + * Fades and optional scroll buttons on `.pages-nav-v2-shell`. + * Does not require `data-nav-v2`. + */ +export function initPagesNavScroll(nav: HTMLElement) { + const shell = + nav.querySelector('.pages-nav-v2-shell') ?? + nav.closest('.pages-nav-v2-shell') + const scrollEl = shell?.querySelector('.pages-nav-v2__scroll') + const aside = + nav.closest('aside.sidebar') ?? + document.querySelector('aside.sidebar:has(#pages-nav)') + if (!scrollEl || !aside) { + return + } + + scrollViewportAside = aside + scrollViewportScrollEl = scrollEl + + if (!scrollViewportWindowBound) { + scrollViewportWindowBound = true + window.addEventListener('scroll', refreshNavScrollViewport, { + passive: true, + }) + window.addEventListener('resize', refreshNavScrollViewport, { + passive: true, + }) + } + + if (scrollEl.dataset.navScrollInit !== 'true') { + scrollEl.dataset.navScrollInit = 'true' + scrollEl.addEventListener( + 'scroll', + () => updateNavScrollFades(scrollEl), + { passive: true } + ) + shell?.addEventListener('change', refreshNavScrollViewport) + + const { upBtn, downBtn } = findNavScrollButtons(scrollEl) + if (upBtn && upBtn.dataset.navScrollBound !== 'true') { + upBtn.dataset.navScrollBound = 'true' + upBtn.addEventListener('click', () => + scrollNavByPage(scrollEl, 'up') + ) + } + if (downBtn && downBtn.dataset.navScrollBound !== 'true') { + downBtn.dataset.navScrollBound = 'true' + downBtn.addEventListener('click', () => + scrollNavByPage(scrollEl, 'down') + ) + } + + const content = scrollEl.querySelector('.pages-nav-v2__content') + if (content) { + const mo = new MutationObserver(refreshNavScrollViewport) + mo.observe(content, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'open'], + }) + } + + const ro = new ResizeObserver(refreshNavScrollViewport) + ro.observe(aside) + ro.observe(scrollEl) + const elasticNav = + document.querySelector('#elastic-nav') ?? + document.querySelector('#elastic-nav-wrapper') + if (elasticNav) { + ro.observe(elasticNav) + } + } + + refreshNavScrollViewport() + requestAnimationFrame(refreshNavScrollViewport) +} diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav.ts b/src/Elastic.Documentation.Site/Assets/pages-nav.ts index 7c70c7bf1a..700edcb944 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav.ts @@ -1,3 +1,4 @@ +import { initPagesNavScroll } from './pages-nav-scroll' import { throttle } from 'lodash' import { $optional, $$optional } from 'select-dom' @@ -41,6 +42,10 @@ function expandAllParents(navItem: HTMLElement) { } } +function getNavScrollContainer(nav: HTMLElement) { + return nav.querySelector('.pages-nav-v2__scroll') ?? nav +} + function scrollCurrentNaviItemIntoViewImpl(nav: HTMLElement) { const currentNavItem = $optional('.current', nav) @@ -50,15 +55,17 @@ function scrollCurrentNaviItemIntoViewImpl(nav: HTMLElement) { expandAllParents(currentNavItem) - const navRect = nav.getBoundingClientRect() + const scrollContainer = getNavScrollContainer(nav) + const navRect = scrollContainer.getBoundingClientRect() const currentNavItemRect = currentNavItem.getBoundingClientRect() - // Get the sticky element's height to account for content hidden under it - // The sticky element contains the search and dropdown, staying fixed at top when scrolling - const stickyElement = $optional('.sticky', nav) - const stickyHeight = stickyElement?.getBoundingClientRect().height ?? 0 + // Sticky chrome (dropdown / back) sits above the scrollport in the Figma shell. + const stickyElement = $optional('.pages-nav-v2__chrome, .sticky', nav) + const stickyHeight = + scrollContainer === nav + ? (stickyElement?.getBoundingClientRect().height ?? 0) + : 0 - // The effective visible top of the nav is below the sticky element const effectiveNavTop = navRect.top + stickyHeight // Check if the item is already fully visible in the nav container's viewport @@ -79,10 +86,9 @@ function scrollCurrentNaviItemIntoViewImpl(nav: HTMLElement) { const currentPositionInNav = currentNavItemRect.top - navRect.top const scrollOffset = currentPositionInNav - targetPosition - // Apply the scroll, clamping to valid scroll range - const newScrollTop = Math.max(0, nav.scrollTop + scrollOffset) + const newScrollTop = Math.max(0, scrollContainer.scrollTop + scrollOffset) - nav.scrollTop = newScrollTop + scrollContainer.scrollTop = newScrollTop } // Throttle with leading: false, trailing: true - only executes the last call within the window @@ -111,47 +117,169 @@ function preventFocusLossOnLinkClick(anchor: HTMLAnchorElement) { }) } -export function initNav() { - const pagesNav = $optional('#pages-nav') - if (!pagesNav) { - return +function normalizeNavPathname(pathname: string) { + let p: string + try { + p = new URL(pathname, window.location.href).pathname + } catch { + p = pathname + } + p = p.replace(/\/$/, '') + return p === '' ? '/' : p +} + +function anchorMatchesPath(anchor: HTMLAnchorElement, pathnameRaw: string) { + const href = anchor.getAttribute('href') + if (!href) { + return false + } + try { + return ( + normalizeNavPathname( + new URL(href, window.location.href).pathname + ) === normalizeNavPathname(pathnameRaw) + ) + } catch { + return false } +} - const dropdownActiveAnchor = $optional( - '#pages-dropdown a.pages-dropdown_active' +function folderCheckboxForRow(anchor: HTMLAnchorElement) { + return anchor.parentElement?.querySelector( + ':scope > input[type="checkbox"]' ) - if (dropdownActiveAnchor) { - preventFocusLossOnLinkClick(dropdownActiveAnchor) +} + +function clearAncestorHighlight(nav: HTMLElement) { + $$optional('.nav-v2-active-ancestor', nav).forEach((el) => { + el.classList.remove('nav-v2-active-ancestor') + }) +} + +function applyAncestorHighlight(nav: HTMLElement) { + clearAncestorHighlight(nav) + const current = $optional('a.sidebar-link.current', nav) + if (!current) { + return } - if (isDevMode()) { - restoreNavState(pagesNav) + const hostLi = current.closest('li') + let walk: Element | null = hostLi?.parentElement ?? null + while (walk && walk !== nav) { + if (walk.matches('li.nav-folder')) { + const row = walk.querySelector( + ':scope > .nav-folder-peer > a.sidebar-link' + ) + if (row && row !== current) { + walk.classList.add('nav-v2-active-ancestor') + } + } + walk = walk.parentElement } +} - // Remove current class from all nav items before marking new ones - const currentNavItems = $$optional('.current', pagesNav) - currentNavItems.forEach((el) => { +function markCurrentPage(nav: HTMLElement) { + $$optional('.current', nav).forEach((el) => { el.classList.remove('current') }) - // Normalize pathname by removing trailing slash to handle both URL variants const pathname = window.location.pathname.replace(/\/$/, '') - - // When the page is a hidden nav item (e.g. an individual detection rule), the server - // emits docs:nav-active pointing to the nearest visible ancestor so we can highlight it. const navActiveMeta = document.querySelector( 'meta[name="docs:nav-active"]' ) const activePathname = navActiveMeta?.content ?? pathname - const navItems = $$optional( - 'a[href="' + activePathname + '"], a[href="' + activePathname + '/"]', - pagesNav - ) - navItems.forEach((el) => { - el.classList.add('current') + $$optional('a.sidebar-link[href]', nav).forEach((el) => { + if ( + el instanceof HTMLAnchorElement && + anchorMatchesPath(el, activePathname) + ) { + el.classList.add('current') + } }) + applyAncestorHighlight(nav) +} + +let folderRowClickBound = false + +/** + * Folder row = label + chevron as one hit target (chevron lives inside the ). + * First click on a collapsed folder expands it and navigates to its overview. + * Clicking the same row while it is current toggles the group closed/open. + */ +function ensureFolderRowClick() { + if (folderRowClickBound) { + return + } + folderRowClickBound = true + + document.addEventListener( + 'click', + (e: MouseEvent) => { + if (!(e.target instanceof Element)) { + return + } + if ( + e.defaultPrevented || + e.button !== 0 || + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey + ) { + return + } + + const a = e.target.closest( + '#pages-nav li.nav-folder > .nav-folder-peer > a.sidebar-link' + ) as HTMLAnchorElement | null + if (!a) { + return + } + + const cb = folderCheckboxForRow(a) + if (!cb) { + return + } + + if (anchorMatchesPath(a, window.location.pathname)) { + cb.checked = !cb.checked + cb.dispatchEvent(new Event('change', { bubbles: true })) + e.preventDefault() + e.stopPropagation() + return + } + + if (!cb.checked) { + cb.checked = true + cb.dispatchEvent(new Event('change', { bubbles: true })) + } + }, + true + ) +} + +export function initNav() { + const pagesNav = $optional('#pages-nav') + if (!pagesNav) { + return + } + + const dropdownActiveAnchor = $optional( + '#pages-dropdown a.pages-dropdown_active' + ) + if (dropdownActiveAnchor) { + preventFocusLossOnLinkClick(dropdownActiveAnchor) + } + + if (isDevMode()) { + restoreNavState(pagesNav) + } + + ensureFolderRowClick() + markCurrentPage(pagesNav) scrollCurrentNaviItemIntoView(pagesNav) + initPagesNavScroll(pagesNav) if (isDevMode()) { saveNavState(pagesNav) diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 0b760a4ca8..6f12f18fc4 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -33,6 +33,7 @@ @import './markdown/storybook.css'; @import './markdown/hub.css'; @import './api-docs.css'; +@import './pages-nav-figma.css'; @import 'tippy.js/dist/tippy.css'; html { @@ -124,6 +125,16 @@ body { max-height 0.3s ease; } + /* Right rail: 24px is padding, not added to --offset-top, so the gap + stays at rest and while stuck under the topbar. */ + @media (width >= 1280px) { + #toc-nav.sidebar-nav { + box-sizing: border-box; + padding-top: 24px; + padding-bottom: 24px; + } + } + .sidebar-link { @apply text-ink-light inline-block leading-[1.2em] text-pretty hover:text-black md:text-sm; word-break: break-word; @@ -237,36 +248,6 @@ body { outline: none; } -.htmx-indicator { - display: none; -} -.htmx-request .htmx-indicator, -.htmx-request.htmx-indicator { - display: block; - /* Elastic's global nav (elastic-nav.js) also sits at z-index 9999 and comes - later in the DOM, so a tie here would let it paint over the indicator. */ - z-index: 10000; -} - -.progress { - animation: progress 1s infinite linear; -} - -.left-right { - transform-origin: 0% 50%; -} -@keyframes progress { - 0% { - transform: translateX(0) scaleX(0); - } - 40% { - transform: translateX(0) scaleX(0.4); - } - 100% { - transform: translateX(100%) scaleX(0.5); - } -} - /* Wobble animation for diagnostics button */ @keyframes wobble { 0%, diff --git a/src/Elastic.Documentation.Site/Htmx.cs b/src/Elastic.Documentation.Site/Htmx.cs index 2d67b4fa54..fd9cdc6b92 100644 --- a/src/Elastic.Documentation.Site/Htmx.cs +++ b/src/Elastic.Documentation.Site/Htmx.cs @@ -8,6 +8,7 @@ namespace Elastic.Documentation.Site; /// Boosted links use htmx's default whole-body swap with hx-preserve islands, so links no /// longer need hx-select-oob. preload stays per-link because the preload extension ignores /// ancestor attributes. +/// Preserve islands include the nav tree (content-hash id) and the global elastic-nav wrapper. /// public static class Htmx { diff --git a/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml b/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml index 5d3a94a2fa..8a46d275f3 100644 --- a/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml @@ -9,10 +9,10 @@ var mobileSummary = activeMobileLink?.Title ?? "Docs Home"; var hasMobileDrawerControls = topNavItems.Length > 0 || Model.ShowVersionDropdown; } -