diff --git a/config/navigation.yml b/config/navigation.yml index af1c4a9fd4..e36a46ff93 100644 --- a/config/navigation.yml +++ b/config/navigation.yml @@ -26,6 +26,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 3e593f2efd..2a457f38c0 100644 --- a/config/navigation_preview.yml +++ b/config/navigation_preview.yml @@ -29,6 +29,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..8ea47265de 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) @@ -111,6 +111,7 @@ public SiteNavigation( sectionNav.Url = firstChildUrl; ((IAssignableChildrenNavigation)sectionNav).SetNavigationItems(sectionChildren); + PromoteSectionListingIslands(sectionNav); items.Add(sectionNav); } else if (entry is SiteTableOfContentsRef tocRef) @@ -232,6 +233,38 @@ void IAssignableChildrenNavigation.SetNavigationItems(IReadOnlyCollection + /// Reference / Troubleshoot / Release notes: a section whose only child is a toc + /// listing. Mark that listing's group children as islands so the sidebar is + /// heading + Overview + stubs (arrow), not an ancestor folder tree. + /// + private static void PromoteSectionListingIslands(SectionNavigation section) + { + INodeNavigationItem? listing = null; + foreach (var item in section.NavigationItems) + { + if (item.Hidden) + continue; + if (listing is not null) + return; + if (item is not INodeNavigationItem { NavigationItems.Count: > 0 } node) + return; + listing = node; + } + + if (listing is null) + return; + + foreach (var item in listing.NavigationItems) + { + if (item.Hidden) + continue; + if (item is IAssignableIslandNavigation island + and INodeNavigationItem { NavigationItems.Count: > 0 }) + island.IsIsland = true; + } + } + private INavigationItem? CreateSiteTableOfContentsNavigation( SiteTableOfContentsRef tocRef, int index, @@ -281,6 +314,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.Navigation/Isolated/Node/VirtualFileNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs index 43c75dc0ac..1bb2ec3098 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/VirtualFileNavigation.cs @@ -12,7 +12,7 @@ namespace Elastic.Documentation.Navigation.Isolated.Node; /// Represents a file navigation item that defines children which are not part of the file tree. [DebuggerDisplay("{Url}")] public class VirtualFileNavigation(TModel model, IFileInfo fileInfo, VirtualFileNavigationArgs args) - : INodeNavigationItem, IAssignableChildrenNavigation + : INodeNavigationItem, IAssignableChildrenNavigation, IAssignableIslandNavigation where TModel : IDocumentationFile { /// @@ -30,6 +30,9 @@ public class VirtualFileNavigation(TModel model, IFileInfo fileInfo, Vir /// public bool Hidden { get; } = args.Hidden; + /// + public bool IsIsland { get; set; } + /// public int NavigationIndex { get; set; } diff --git a/src/Elastic.Documentation.Site/Assets/assembler.css b/src/Elastic.Documentation.Site/Assets/assembler.css index 87044180a6..82e4e81a7e 100644 --- a/src/Elastic.Documentation.Site/Assets/assembler.css +++ b/src/Elastic.Documentation.Site/Assets/assembler.css @@ -10,6 +10,9 @@ :root { --offset-top: calc(var(--spacing) * 18); } + body.navigation-preview { + --offset-top: 56px; + } } /* Reserve the space used by elastic-nav.js before its asynchronous render. 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/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 44076d1f75..4ede181532 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -300,9 +300,15 @@ document.addEventListener('htmx:beforeRequest', function (event: HtmxEvent) { } }) -// Boosted navigations swap the whole ; scroll to top like a normal page load +// Boosted navigations swap #main-container. show:none on stops HTMX +// from scrolling the container into view (that jumps the page up to the +// horizontal tabs). Instant window reset still matches a full page load. document.body.addEventListener('htmx:afterSwap', function (event: HtmxEvent) { - if (event.target === document.body) { + const target = event.target + if ( + target === document.body || + (target instanceof Element && target.id === 'main-container') + ) { window.scrollTo(0, 0) } }) 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..320a339b0c --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-figma.css @@ -0,0 +1,500 @@ +/* + * 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. + * + * All rules are scoped to body.navigation-preview so they only apply when the + * NAVIGATION_PREVIEW feature flag is enabled. + */ + +body.navigation-preview { + @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: 4px; + } + + #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 .pages-nav-v2__heading { + box-sizing: border-box; + display: flex; + align-items: center; + margin: 8px 8px 0; + padding: 8px 12px 6px; + cursor: text; + } + + #pages-nav .pages-nav-v2__heading-text { + display: block; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + line-height: 20px; + color: #1c1e23; + } + + #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, + #pages-nav .nav-island-arrow { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + margin-inline-start: auto; + flex-shrink: 0; + color: #98a2b3; + font-weight: 400; + pointer-events: none; + } + + #pages-nav .nav-island-arrow svg { + display: block; + width: 12px; + height: 12px; + fill: currentColor; + } + + #pages-nav .nav-chevron { + width: 12px; + height: 12px; + fill: currentColor; + stroke: none; + color: #98a2b3; + /* Tailwind v4 @apply -rotate-90 uses the rotate property; override that, not transform. */ + rotate: -90deg; + transform: none; + } + + #pages-nav .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.test.ts b/src/Elastic.Documentation.Site/Assets/pages-nav.test.ts new file mode 100644 index 0000000000..e97d0c4dce --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/pages-nav.test.ts @@ -0,0 +1,106 @@ +import { initNav, navSurfaceKey, syncPagesNavFromResponse } from './pages-nav' + +function pagesNav(treeId: string, heading: string, extra = ''): string { + const headingHtml = heading + ? `
${heading}
` + : '' + return ` + + ` +} + +describe('syncPagesNavFromResponse', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('replaces the sidebar when the incoming page has a different heading', () => { + document.body.innerHTML = pagesNav('nav-tree-ref', 'Reference') + + const replaced = syncPagesNavFromResponse( + `${pagesNav('nav-tree-es', 'Elasticsearch')}` + ) + + expect(replaced).toBe(true) + expect( + document.querySelector('.pages-nav-v2__heading-text')?.textContent + ).toBe('Elasticsearch') + expect(document.querySelector('[id^="nav-tree-"]')?.id).toBe( + 'nav-tree-es' + ) + }) + + it('keeps the live sidebar when the heading and tree stay the same', () => { + document.body.innerHTML = pagesNav( + 'nav-tree-ref', + 'Reference', + '' + ) + const live = document.querySelector('#pages-nav') + if (live) live.setAttribute('data-live', '1') + + const replaced = syncPagesNavFromResponse( + `${pagesNav('nav-tree-ref', 'Reference')}` + ) + + expect(replaced).toBe(false) + expect( + document.querySelector('#pages-nav')?.getAttribute('data-live') + ).toBe('1') + expect( + document.querySelector('#folder-a')?.checked + ).toBe(true) + }) + + it('adopts the incoming node into the live document', () => { + document.body.innerHTML = pagesNav('nav-tree-ref', 'Reference') + const replaced = syncPagesNavFromResponse( + `${pagesNav('nav-tree-es', 'Elasticsearch')}` + ) + expect(replaced).toBe(true) + expect(document.querySelector('#pages-nav')?.ownerDocument).toBe( + document + ) + }) + + it('restores expanded folders after an island swap back to the same tree', () => { + document.body.innerHTML = pagesNav( + 'nav-tree-ref', + 'Reference', + '' + ) + initNav() + + syncPagesNavFromResponse( + `${pagesNav('nav-tree-es', 'Elasticsearch')}` + ) + syncPagesNavFromResponse( + `${pagesNav( + 'nav-tree-ref', + 'Reference', + '' + )}` + ) + initNav() + + expect( + document.querySelector('#folder-a')?.checked + ).toBe(true) + }) +}) + +describe('navSurfaceKey', () => { + it('treats an outgoing tree id as the same island', () => { + document.body.innerHTML = pagesNav( + 'nav-tree-es-outgoing', + 'Elasticsearch' + ) + expect(navSurfaceKey(document)).toBe('nav-tree-es::Elasticsearch') + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav.ts b/src/Elastic.Documentation.Site/Assets/pages-nav.ts index 7c70c7bf1a..8002f3a969 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav.ts @@ -1,21 +1,34 @@ +import { initPagesNavScroll } from './pages-nav-scroll' import { throttle } from 'lodash' import { $optional, $$optional } from 'select-dom' const NAV_STATE_KEY = 'nav-expanded' -function isDevMode() { - return !!document.querySelector('diagnostics-panel') +function expandedStorageKey(nav: ParentNode) { + return `${NAV_STATE_KEY}:${navSurfaceKey(nav)}` } function saveNavState(nav: HTMLElement) { const expanded = $$optional('input[type="checkbox"]:checked', nav) .map((el) => el.id) .filter(Boolean) - sessionStorage.setItem(NAV_STATE_KEY, JSON.stringify(expanded)) + try { + sessionStorage.setItem( + expandedStorageKey(nav), + JSON.stringify(expanded) + ) + } catch { + /* private mode */ + } } function restoreNavState(nav: HTMLElement) { - const raw = sessionStorage.getItem(NAV_STATE_KEY) + let raw: string | null + try { + raw = sessionStorage.getItem(expandedStorageKey(nav)) + } catch { + return + } if (!raw) return try { const ids: string[] = JSON.parse(raw) @@ -41,6 +54,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 +67,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 +98,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,52 +129,273 @@ 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 +} - const dropdownActiveAnchor = $optional( - '#pages-dropdown a.pages-dropdown_active' +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 + } +} + +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') + } }) - scrollCurrentNaviItemIntoView(pagesNav) + applyAncestorHighlight(nav) +} + +let folderRowClickBound = false +let navStatePersistBound = false +let lastSwapHtml = '' + +export function navSurfaceKey(nav: ParentNode): string { + const heading = + nav + .querySelector('.pages-nav-v2-shell') + ?.getAttribute('data-nav-heading') ?? + nav.querySelector('.pages-nav-v2__heading-text')?.textContent?.trim() ?? + '' + const treeId = (nav.querySelector('[id^="nav-tree-"]')?.id ?? '').replace( + /-outgoing$/, + '' + ) + return `${treeId}::${heading}` +} + +/** + * After a boosted swap, replace `#pages-nav` only when the island/section + * surface changed. Same-tree navigations keep the live nav (hx-preserve) so + * expanded folders do not flash closed. + */ +export function syncPagesNavFromResponse( + responseHtml: string, + root: ParentNode = document +): boolean { + const current = root.querySelector('#pages-nav') + if (!current || !responseHtml) { + return false + } + const incoming = new DOMParser() + .parseFromString(responseHtml, 'text/html') + .querySelector('#pages-nav') + if (!incoming) { + return false + } + if (navSurfaceKey(current) === navSurfaceKey(incoming)) { + return false + } + if (current instanceof HTMLElement) { + saveNavState(current) + } + const liveDocument = current.ownerDocument ?? document + const next = liveDocument.importNode(incoming, true) + current.replaceWith(next) + if (next instanceof HTMLElement) { + restoreNavState(next) + } + return true +} - if (isDevMode()) { - saveNavState(pagesNav) - for (const cb of $$optional('input[type="checkbox"]', pagesNav)) { - cb.addEventListener('change', () => saveNavState(pagesNav)) +function clearHtmxHistoryCache() { + try { + sessionStorage.removeItem('htmx-history-cache') + } catch { + /* private mode */ + } +} + +function responseHtmlFromSwap(event: Event): string { + const detail = (event as CustomEvent).detail as + { serverResponse?: string; xhr?: { response?: string } } | undefined + if (typeof detail?.serverResponse === 'string' && detail.serverResponse) { + return detail.serverResponse + } + if (typeof detail?.xhr?.response === 'string' && detail.xhr.response) { + return detail.xhr.response + } + return lastSwapHtml +} + +function onBeforeSwap(event: Event) { + lastSwapHtml = responseHtmlFromSwap(event) +} + +function onAfterSwap(event: Event) { + const html = responseHtmlFromSwap(event) || lastSwapHtml + lastSwapHtml = '' + if (html) { + syncPagesNavFromResponse(html) + } +} + +if (typeof document !== 'undefined') { + clearHtmxHistoryCache() + document.addEventListener('htmx:beforeSwap', onBeforeSwap, true) + document.addEventListener('htmx:afterSwap', onAfterSwap, true) +} + +/** + * 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 + ) +} + +function ensureNavStatePersist() { + if (navStatePersistBound) { + return + } + navStatePersistBound = true + document.addEventListener('change', (e: Event) => { + const target = e.target + if ( + !(target instanceof HTMLInputElement) || + target.type !== 'checkbox' + ) { + return + } + const nav = target.closest('#pages-nav') + if (nav) { + saveNavState(nav) } + }) +} + +export function initNav() { + const pagesNav = $optional('#pages-nav') + if (!pagesNav) { + return } + + const dropdownActiveAnchor = $optional( + '#pages-dropdown a.pages-dropdown_active' + ) + if (dropdownActiveAnchor) { + preventFocusLossOnLinkClick(dropdownActiveAnchor) + } + + ensureFolderRowClick() + ensureNavStatePersist() + restoreNavState(pagesNav) + markCurrentPage(pagesNav) + scrollCurrentNaviItemIntoView(pagesNav) + initPagesNavScroll(pagesNav) } diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css index a782b9accb..953f853377 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav-dropdown.css @@ -5,22 +5,28 @@ * * Open/close is native
. Closing on outside click or Escape is not, * so that lives in secondary-nav.ts. + * + * Panel chrome and motion match EuiPopover (Borealis): no visible light-mode + * border, medium drop-shadow via filter, opacity + translateY with bounce. + * Visibility is driven by `.is-open` (added on rAF) so the panel can transition + * instead of popping in from the UA `display:none` on closed
. */ @layer components { .secondary-nav-dropdown { position: relative; - display: inline-flex; - align-items: center; + display: flex; + align-items: stretch; + height: 100%; } /* The nav bar uses overflow-x:auto for horizontal tab scrolling, which also clips vertically and would cut off an open dropdown. Let the menu - escape only while a dropdown is open. */ - .secondary-nav-scroll-container:has(.secondary-nav-dropdown[open]) { - /* !important to win over the Tailwind `overflow-x-auto` utility, - which sits in a higher cascade layer. */ - overflow: visible !important; + escape while open or still animating out. */ + .secondary-nav-scroll-container:has(.secondary-nav-dropdown[open]), + .secondary-nav-scroll-container:has(.secondary-nav-dropdown.is-open), + .secondary-nav-scroll-container:has(.secondary-nav-dropdown.is-closing) { + overflow: visible; } .secondary-nav-dropdown summary { @@ -30,58 +36,104 @@ display: none; } - .secondary-nav-dropdown-chevron { + .secondary-nav-icon.secondary-nav-dropdown-chevron { flex-shrink: 0; - color: var(--color-grey-60); + color: #516381; transition: transform 0.15s ease; } - .secondary-nav-dropdown[open] .secondary-nav-dropdown-chevron { + .secondary-nav-dropdown[open] .secondary-nav-dropdown-chevron, + .secondary-nav-dropdown.is-open .secondary-nav-dropdown-chevron { transform: rotate(180deg); } + /* Beat the UA `details:not([open]) > :not(summary) { display: none }` so + opacity/transform can actually transition. */ + details.secondary-nav-dropdown > .secondary-nav-dropdown-menu { + display: flex; + content-visibility: visible; + } + .secondary-nav-dropdown-menu { position: absolute; - top: calc(100% + 4px); + /* Same visual gap as EuiPopover on the 32px version trigger: that + button sits 12px above the bar bottom, with a 12px EUI offset. + This menu is positioned from the 56px tab, so extra offset is 0. */ + top: 100%; left: 0; - min-width: 220px; - max-width: 320px; + box-sizing: border-box; + width: 175px; z-index: 50; - display: none; flex-direction: column; - padding: 6px 0; - background: var(--color-white); - border: 1px solid var(--color-grey-20); - border-radius: 6px; - box-shadow: 0 8px 24px rgb(0 0 0 / 0.08); - font-weight: 500; + padding: 8px; + background: #fff; + border: 0; + border-radius: 4px; + box-shadow: none; + /* Borealis euiShadowMedium as filter (EuiPopover hasShadow=false). */ + filter: drop-shadow(0 0 2px rgb(43 57 79 / 0.16)) + drop-shadow(0 3px 10px rgb(43 57 79 / 0.1)) + drop-shadow(0 6px 14px rgb(43 57 79 / 0.06)); + font-family: var(--font-body); + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: #1d2a3e; + opacity: 0; + visibility: hidden; + pointer-events: none; + backface-visibility: hidden; + transform: translateY(0) translateZ(0); + transition: + opacity 350ms cubic-bezier(0.34, 1.61, 0.7, 1), + transform 450ms cubic-bezier(0.34, 1.61, 0.7, 1), + visibility 0s linear 350ms; } - .secondary-nav-dropdown[open] .secondary-nav-dropdown-menu { - display: flex; + .secondary-nav-dropdown.is-open .secondary-nav-dropdown-menu { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateY(-8px) translateZ(0); + transition: + opacity 350ms cubic-bezier(0.34, 1.61, 0.7, 1), + transform 450ms cubic-bezier(0.34, 1.61, 0.7, 1), + visibility 0s; + } + + @media (prefers-reduced-motion: reduce) { + .secondary-nav-dropdown-menu, + .secondary-nav-dropdown.is-open .secondary-nav-dropdown-menu { + transition: none; + transform: translateY(0) translateZ(0); + } } .secondary-nav-dropdown-group-label { - padding: 8px 14px 4px 14px; + padding: 6px 8px; font-size: 12px; - font-weight: 700; - color: var(--color-grey-80); + font-weight: 600; + line-height: 20px; + color: #516381; user-select: none; } - .secondary-nav-dropdown-group-label:not(:first-child) { - margin-top: 4px; - border-top: 1px solid var(--color-grey-15, var(--color-grey-20)); - padding-top: 10px; - } .secondary-nav-dropdown-link { - display: block; - padding: 6px 14px; + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 4px; font-size: 14px; - color: var(--color-ink-dark); + line-height: 20px; + font-weight: 400; + color: #1d2a3e; text-decoration: none; - transition: background-color 0.12s ease; } - .secondary-nav-dropdown-link:hover { - background: var(--color-grey-10); - color: var(--color-blue-elastic); + .secondary-nav-dropdown-link:hover, + .secondary-nav-dropdown-link:focus-visible { + background: #f6f9fc; + color: #1d2a3e; + } + .secondary-nav-dropdown-link:focus-visible { + outline: 2px solid #0b64dd; + outline-offset: -2px; } } diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav.css b/src/Elastic.Documentation.Site/Assets/secondary-nav.css index 2ee0c487d2..5091255922 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav.css +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.css @@ -1,24 +1,311 @@ @layer components { - .secondary-nav-mobile-menu summary, - .secondary-nav-mobile-submenu summary { - list-style: none; - } + body.navigation-preview { + .secondary-nav-mobile-menu summary, + .secondary-nav-mobile-submenu summary { + list-style: none; + } - .secondary-nav-mobile-menu summary::-webkit-details-marker, - .secondary-nav-mobile-submenu summary::-webkit-details-marker { - display: none; - } + .secondary-nav-mobile-menu summary::-webkit-details-marker, + .secondary-nav-mobile-submenu summary::-webkit-details-marker { + display: none; + } - .secondary-nav-mobile-chevron { - flex-shrink: 0; - color: var(--color-grey-60); - transition: transform 0.15s ease; - } - - .secondary-nav-mobile-menu[open] > summary .secondary-nav-mobile-chevron, - .secondary-nav-mobile-submenu[open] - > summary .secondary-nav-mobile-chevron { - transform: rotate(180deg); + flex-shrink: 0; + color: var(--color-grey-60); + transition: transform 0.15s ease; + } + + .secondary-nav-mobile-menu[open] + > summary + .secondary-nav-mobile-chevron, + .secondary-nav-mobile-submenu[open] + > summary + .secondary-nav-mobile-chevron { + transform: rotate(180deg); + } + + #secondary-nav { + background-color: #fff; + /* Inset so the active tab's 2px blue can paint over this 1px gray, + same overlap trick as the left sidebar current marker. */ + box-shadow: inset 0 -1px 0 #eeeff1; + } + + .secondary-nav-bar { + display: flex; + box-sizing: border-box; + width: 100%; + min-width: 0; + height: 56px; + max-width: var(--max-layout-width); + margin-inline: auto; + padding-inline: 16px; + align-items: stretch; + justify-content: flex-start; + gap: 12px; + } + + .secondary-nav-bar--desktop { + display: none; + } + + @media (width >= 768px) { + .secondary-nav-bar--desktop { + display: flex; + } + } + + .secondary-nav-scroll-container { + display: flex; + flex: 1; + min-width: 0; + align-items: stretch; + gap: 12px; + overflow-x: auto; + } + + .secondary-nav-actions { + display: none; + flex-shrink: 0; + align-items: center; + margin-inline-start: auto; + } + + @media (width >= 768px) { + .secondary-nav-actions { + display: flex; + } + } + + .secondary-nav-home { + box-sizing: border-box; + position: relative; + display: inline-flex; + flex-shrink: 0; + align-items: center; + height: 100%; + padding-inline-end: 24px; + color: #516381; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-decoration: none; + white-space: nowrap; + } + + /* 32px matches .secondary-nav-item__content (6+20+6), not the 56px bar. */ + .secondary-nav-home::after { + content: ''; + position: absolute; + inset-inline-end: 0; + top: 50%; + width: 1px; + height: 32px; + translate: 0 -50%; + background-color: #e3e8f2; + } + + .secondary-nav-home:hover, + .secondary-nav-home:focus-visible { + color: #1d2a3e; + } + + .secondary-nav-home:focus-visible { + outline: 2px solid #0b64dd; + outline-offset: 2px; + } + + .secondary-nav-list { + display: flex; + align-items: stretch; + gap: 4px; + height: 100%; + margin: 0; + padding: 0; + list-style: none; + font-family: var(--font-body); + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: #516381; + } + + .secondary-nav-item { + display: flex; + flex-shrink: 0; + align-items: stretch; + height: 100%; + color: #516381; + transition: color 0.12s ease; + } + + .secondary-nav-item__hit { + display: flex; + align-items: center; + height: 100%; + margin: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-decoration: none; + cursor: pointer; + white-space: nowrap; + } + + .secondary-nav-item__content { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-radius: 8px; + transition: + background-color 0.12s ease, + color 0.12s ease; + } + + .secondary-nav-item__content--dropdown { + gap: 6px; + } + + .secondary-nav-item:not(.secondary-nav-item--active):hover, + .secondary-nav-item:not(.secondary-nav-item--active):focus-within { + color: #1d2a3e; + } + + .secondary-nav-item:not(.secondary-nav-item--active):hover + .secondary-nav-item__content, + .secondary-nav-item:not(.secondary-nav-item--active):focus-within + .secondary-nav-item__content { + background-color: #f6f9fc; + } + + .secondary-nav-item--active { + position: relative; + z-index: 1; + color: #0b64dd; + font-weight: 600; + box-shadow: inset 0 -2px 0 #0b64dd; + } + + .secondary-nav-item__hit:focus-visible { + outline: 2px solid #0b64dd; + outline-offset: 2px; + border-radius: 8px; + } + + .secondary-nav-icon { + box-sizing: border-box; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: currentColor; + } + + .secondary-nav-icon svg { + display: block; + width: 100%; + height: 100%; + fill: currentColor; + } + + .secondary-nav-icon:has(use[href='#icon-list-bullet']) svg { + shape-rendering: crispEdges; + } + + .secondary-nav-icon--sm { + width: 12px; + height: 12px; + color: #516381; + } + + .nav-select { + box-sizing: border-box; + display: inline-flex; + align-items: center; + height: 32px; + margin: 0; + padding: 0; + border: 1px solid #cad3e2; + border-radius: 8px; + background: #fff; + box-shadow: 0 0 0 2px #f6f9fc; + color: inherit; + font-family: var(--font-body); + font-size: 14px; + line-height: 20px; + cursor: pointer; + appearance: none; + } + + .nav-select:focus-visible { + outline: 2px solid #0b64dd; + outline-offset: 2px; + } + + .nav-select__body { + display: flex; + align-items: center; + gap: 8px; + height: 20px; + padding: 0 8px; + } + + .nav-select__value { + color: #1d2a3e; + font-weight: 400; + white-space: nowrap; + } + + .nav-select__chevron { + box-sizing: border-box; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + overflow: clip; + color: #516381; + transition: transform 0.15s ease; + } + + .nav-select__chevron img, + .nav-select__chevron svg, + .nav-select__chevron .euiIcon { + display: block; + width: 12px; + height: 12px; + inline-size: 12px; + block-size: 12px; + } + + .nav-select__chevron img { + opacity: 0; + } + + .nav-select__chevron:has(img) { + background-color: currentColor; + -webkit-mask: var(--nav-select-chevron) center / contain no-repeat; + mask: var(--nav-select-chevron) center / contain no-repeat; + } + + .nav-select--open .nav-select__chevron { + transform: rotate(180deg); + } + + version-dropdown { + display: inline-flex; + } + + .secondary-nav-actions version-dropdown { + flex-shrink: 0; + } } } diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts b/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts index 8fe991c986..dcb411df4c 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.test.ts @@ -1,4 +1,4 @@ -import { initSecondaryNav } from './secondary-nav' +import { initSecondaryNav, syncSecondaryNavActive } from './secondary-nav' function renderNav() { document.body.innerHTML = ` @@ -70,4 +70,88 @@ describe('initSecondaryNav', () => { expect(products.open).toBe(false) expect(document.activeElement).toBe(summary) }) + + it('adds is-open on the next frame so the panel can transition in', () => { + const queued: FrameRequestCallback[] = [] + const raf = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + queued.push(cb) + return queued.length + }) + const { products } = renderNav() + products.open = true + products.dispatchEvent(new Event('toggle')) + + expect(products.classList.contains('is-open')).toBe(false) + queued.forEach((cb) => cb(0)) + expect(products.classList.contains('is-open')).toBe(true) + raf.mockRestore() + }) + + it('keeps is-closing on the panel until the EUI exit motion finishes', () => { + jest.useFakeTimers() + const { products, outside } = renderNav() + products.open = true + + outside.dispatchEvent(new MouseEvent('click', { bubbles: true })) + + expect(products.open).toBe(false) + expect(products.classList.contains('is-closing')).toBe(true) + + jest.advanceTimersByTime(349) + expect(products.classList.contains('is-closing')).toBe(true) + + jest.advanceTimersByTime(1) + expect(products.classList.contains('is-closing')).toBe(false) + jest.useRealTimers() + }) +}) + +describe('syncSecondaryNavActive', () => { + function renderTabs() { + document.body.innerHTML = ` + + ` + return { + guides: document.querySelectorAll('.secondary-nav-item')[0], + reference: document.querySelectorAll('.secondary-nav-item')[1], + products: document.querySelectorAll('.secondary-nav-item')[2], + } + } + + it('moves the active class to the item whose section ids include the current section', () => { + const { guides, reference, products } = renderTabs() + + syncSecondaryNavActive('ref-section-id') + + expect(guides.classList.contains('secondary-nav-item--active')).toBe( + false + ) + expect(reference.classList.contains('secondary-nav-item--active')).toBe( + true + ) + expect(products.classList.contains('secondary-nav-item--active')).toBe( + false + ) + }) + + it('clears every active tab when the page has no section', () => { + const { guides, reference } = renderTabs() + + syncSecondaryNavActive(null) + + expect(guides.classList.contains('secondary-nav-item--active')).toBe( + false + ) + expect(reference.classList.contains('secondary-nav-item--active')).toBe( + false + ) + }) }) diff --git a/src/Elastic.Documentation.Site/Assets/secondary-nav.ts b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts index a969fdf15b..e3df613099 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.ts @@ -1,13 +1,80 @@ /** - * Close behaviour for the top-bar dropdowns. + * Close behaviour for the top-bar dropdowns, and active-tab sync after htmx swaps. * * Native
opens and closes on summary clicks, but it does not close when * the user clicks elsewhere or presses Escape, which for a nav menu leaves a panel * stranded over the page. These are delegated document listeners, so they survive * the htmx body swaps that replace the nav on every navigation. + * + * The top bar itself is hx-preserve'd (same tabs on every page). After a swap we + * restyle --active from meta[name="docs:current-section"] so the highlight follows + * the new page without remounting icons. That meta lives in because boosted + * navigations swap body innerHTML and would leave a body data-* attribute stale. */ const DROPDOWN = 'details.secondary-nav-dropdown' +const ACTIVE = 'secondary-nav-item--active' +const OPEN = 'is-open' +const CLOSING = 'is-closing' +/** Matches EuiPopover opacity duration (`animation.slow`). */ +const CLOSE_MS = 350 + +const closingTimers = new WeakMap() +const openRafs = new WeakMap() + +function cancelOpenRaf(dropdown: HTMLDetailsElement) { + const raf = openRafs.get(dropdown) + if (raf === undefined) return + cancelAnimationFrame(raf) + openRafs.delete(dropdown) +} + +function clearClosing(dropdown: HTMLDetailsElement) { + dropdown.classList.remove(CLOSING) + const timer = closingTimers.get(dropdown) + if (timer === undefined) return + window.clearTimeout(timer) + closingTimers.delete(dropdown) +} + +function beginClosing(dropdown: HTMLDetailsElement) { + clearClosing(dropdown) + dropdown.classList.add(CLOSING) + const timer = window.setTimeout(() => { + dropdown.classList.remove(CLOSING) + closingTimers.delete(dropdown) + }, CLOSE_MS) + closingTimers.set(dropdown, timer) +} + +function prefersReducedMotion() { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + +function setMenuOpen(dropdown: HTMLDetailsElement) { + cancelOpenRaf(dropdown) + clearClosing(dropdown) + if (prefersReducedMotion()) { + dropdown.classList.add(OPEN) + return + } + // Paint once at opacity 0, then add is-open so the EUI transition can run. + dropdown.classList.remove(OPEN) + const raf = requestAnimationFrame(() => { + dropdown.classList.add(OPEN) + openRafs.delete(dropdown) + }) + openRafs.set(dropdown, raf) +} + +function setMenuClosed(dropdown: HTMLDetailsElement) { + cancelOpenRaf(dropdown) + dropdown.classList.remove(OPEN) + beginClosing(dropdown) +} function openDropdowns(): HTMLDetailsElement[] { return Array.from( @@ -17,11 +84,52 @@ function openDropdowns(): HTMLDetailsElement[] { function closeAllExcept(keep?: HTMLDetailsElement) { for (const dropdown of openDropdowns()) { - if (dropdown !== keep) dropdown.open = false + if (dropdown === keep) continue + dropdown.open = false + setMenuClosed(dropdown) + } +} + +function currentSectionId(): string | null { + const meta = document.querySelector( + 'meta[name="docs:current-section"]' + ) + const value = meta?.content + return value ? value : null +} + +function itemMatchesSection(item: Element, sectionId: string): boolean { + const ids = item.getAttribute('data-section-ids') + if (!ids) return false + return ids.split(/\s+/).includes(sectionId) +} + +export function syncSecondaryNavActive(sectionId: string | null | undefined) { + const items = document.querySelectorAll( + '#secondary-nav .secondary-nav-item' + ) + for (const item of items) { + const active = Boolean(sectionId && itemMatchesSection(item, sectionId)) + item.classList.toggle(ACTIVE, active) } } export function initSecondaryNav() { + document.addEventListener( + 'toggle', + (event: Event) => { + const dropdown = event.target + if ( + !(dropdown instanceof HTMLDetailsElement) || + !dropdown.matches(DROPDOWN) + ) + return + if (dropdown.open) setMenuOpen(dropdown) + else setMenuClosed(dropdown) + }, + true + ) + document.addEventListener('click', (event: MouseEvent) => { const target = event.target as HTMLElement | null // A click on a summary toggles its own dropdown; only the siblings close here, @@ -42,4 +150,10 @@ export function initSecondaryNav() { // Focus would otherwise be lost on the removed panel, stranding keyboard users. active?.querySelector('summary')?.focus() }) + + document.addEventListener('htmx:load', () => { + syncSecondaryNavActive(currentSectionId()) + }) + + syncSecondaryNavActive(currentSectionId()) } diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 0b760a4ca8..e5020e26ad 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,14 @@ body { max-height 0.3s ease; } + @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 +246,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/Assets/web-components/VersionDropdown.tsx b/src/Elastic.Documentation.Site/Assets/web-components/VersionDropdown.tsx index 7b7b2db69f..a910d6c4e7 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/VersionDropdown.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/VersionDropdown.tsx @@ -2,7 +2,6 @@ import '../eui-icons-cache' import { - EuiButton, EuiContextMenu, EuiFlexGroup, EuiFlexItem, @@ -14,7 +13,6 @@ import { useEuiOverflowScroll, useGeneratedHtmlId, useEuiTheme, - useEuiFontSize, } from '@elastic/eui' import { EuiContextMenuPanelDescriptor, @@ -184,24 +182,25 @@ const VersionDropdown = ({ ...(items != null ? subpanels() : []), ] + const currentLabel = currentVersion + ? `v${currentVersion} (Current)` + : 'Current' + const button = ( - - - Current version ({currentVersion}) - - + + {currentLabel} + + + ) return ( diff --git a/src/Elastic.Documentation.Site/Htmx.cs b/src/Elastic.Documentation.Site/Htmx.cs index 2d67b4fa54..e87e9813ef 100644 --- a/src/Elastic.Documentation.Site/Htmx.cs +++ b/src/Elastic.Documentation.Site/Htmx.cs @@ -5,9 +5,9 @@ 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. +/// Boosted links swap #main-container (article + sidebar) so island heading/Overview +/// replace the ancestor tree. preload stays per-link because the preload extension ignores +/// ancestor attributes. Preserve islands include the global elastic-nav wrapper. /// public static class Htmx { diff --git a/src/Elastic.Documentation.Site/Layout/_Head.cshtml b/src/Elastic.Documentation.Site/Layout/_Head.cshtml index dad49fa83f..16180b247f 100644 --- a/src/Elastic.Documentation.Site/Layout/_Head.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Head.cshtml @@ -26,6 +26,9 @@ break; } + @* hx-history=false stops new snapshots but still restores sessionStorage + leftovers from older pages, which keeps the previous island tree. *@ + @if (Model.BuildType == BuildType.Assembler && !Model.Features.AirGappedEnabled) { @@ -53,11 +56,12 @@ } @await RenderPartialAsync(_Favicon.Create(Model)) - + @if (!string.IsNullOrEmpty(Model.NavigationActiveUrl)) { } + diff --git a/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml b/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml index 7420b55dc3..014d5e5378 100644 --- a/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml @@ -6,12 +6,20 @@ var currentSectionId = Model.CurrentNavigationItem.NavigationRoot?.Id; var activeMobileLink = topNavLinks.FirstOrDefault(item => item.IsActive(currentSectionId)); var mobileSummary = activeMobileLink?.Title ?? "Docs Home"; - var hasMobileDrawerControls = topNavLinks.Length > 0 || Model.ShowVersionDropdown; + var navigationPreviewEnabled = Model.Features.NavigationPreviewEnabled; + var hasMobileDrawerControls = navigationPreviewEnabled && (topNavLinks.Length > 0 || Model.ShowVersionDropdown); + var asideClass = navigationPreviewEnabled + ? "sidebar font-sans bg-white md:bg-transparent fixed md:sticky shadow-2xl md:shadow-none left-full group-has-[#pages-nav-hamburger:checked]/body:left-0 bottom-0 md:left-auto top-[calc(var(--offset-top)+1px)] w-[80%] md:w-auto shrink-0 border-r border-r-grey-20 md:border-0 z-[10000] md:z-auto transition-[top,max-height] duration-300 md:col-start-1 md:row-start-1 flex flex-col min-h-0" + : "sidebar font-sans bg-white fixed md:sticky shadow-2xl md:shadow-none left-full group-has-[#pages-nav-hamburger:checked]/body:left-0 bottom-0 md:left-auto top-[calc(var(--offset-top)+1px)] w-[80%] md:w-auto shrink-0 border-r border-r-grey-20 z-[10000] md:z-auto transition-[top,max-height] duration-300 md:col-start-1 md:row-start-1"; + var navClass = navigationPreviewEnabled + ? "sidebar-nav min-h-0 flex-1 flex flex-col" + : "sidebar-nav h-full simple-scrollbar"; } -
+ Docs @foreach (var link in topNavLinks) { var isActive = link.IsActive(currentSectionId); @@ -76,5 +87,5 @@ @* ReSharper disable once Html.IdNotResolved *@ diff --git a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml index c65402c9d6..9f647012c1 100644 --- a/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml @@ -1,39 +1,63 @@ @using Elastic.Documentation.Configuration.Toc +@using Microsoft.AspNetCore.Html @inherits RazorSlice @{ var topNav = Model.TopNav; // Active tab: compare the current page's NavigationRoot.Id against each tab's SectionId. // The site root (SiteNavigation) has an Id that matches no tab, so homepage gets no active tab. var currentSectionId = Model.CurrentNavigationItem.NavigationRoot?.Id; + var docsHomeUrl = Model.Link("/"); + var navigationPreviewEnabled = Model.Features.NavigationPreviewEnabled; + + string? IconId(string title) => title switch + { + "Guides" => "documentation", + "Reference" => "list-bullet", + "Troubleshoot" => "wrench", + "Products" => "grid", + "APIs" => "code", + "Release notes" => "refresh-time", + _ => null + }; + + IHtmlContent NavIcon(string id, string extraClass = "") + { + var cls = string.IsNullOrEmpty(extraClass) ? "secondary-nav-icon" : $"secondary-nav-icon {extraClass}"; + return new HtmlString($""); + } } -
-
+} diff --git a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs index e1b755455a..c1819ad3fd 100644 --- a/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs +++ b/src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs @@ -23,9 +23,10 @@ public async Task RenderNavigation( if (renderRoot is not INodeNavigationItem group) return NavigationRenderResult.Empty; - return await _renderedNavigationCache.GetOrRenderAsync( + var rendered = await _renderedNavigationCache.GetOrRenderAsync( renderRoot, () => ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx)); + return NavigationCurrentMarker.Apply(rendered, currentNavigationItem); } /// @@ -54,6 +55,7 @@ private NavigationRenderModel CreateNavigationModel(INodeNavigationItem +/// Stamps current onto the cached sidebar HTML for the page being rendered. +/// The tree itself is shared across pages of the same island; only +/// this class changes per page, which is why it is applied after the render cache. +/// +public static class NavigationCurrentMarker +{ + public static NavigationRenderResult Apply(NavigationRenderResult result, INavigationItem current) + => Apply(result, ResolveActiveUrl(current)); + + public static NavigationRenderResult Apply(NavigationRenderResult result, string? currentUrl) + { + if (string.IsNullOrEmpty(result.Html) || string.IsNullOrEmpty(currentUrl)) + return result; + + var html = Apply(result.Html, currentUrl); + return ReferenceEquals(html, result.Html) ? result : result with { Html = html }; + } + + public static string Apply(string html, string currentUrl) + { + var target = NormalizePath(currentUrl); + var searchFrom = 0; + string? updated = null; + + while (true) + { + var tagStart = html.IndexOf("', tagStart); + if (tagEnd < 0) + break; + + searchFrom = tagEnd + 1; + var tag = html[tagStart..tagEnd]; + if (!tag.Contains("sidebar-link", StringComparison.Ordinal)) + continue; + + var href = GetQuotedAttribute(tag, "href"); + if (href is null || NormalizePath(href) != target) + continue; + + var marked = WithCurrentClass(tag); + if (marked.Equals(tag, StringComparison.Ordinal)) + continue; + + updated ??= html; + updated = string.Concat(updated.AsSpan(0, tagStart), marked, updated.AsSpan(tagEnd)); + searchFrom = tagStart + marked.Length + 1; + html = updated; + } + + return updated ?? html; + } + + /// + /// Hidden pages have no sidebar row; highlight the nearest visible ancestor, matching + /// docs:nav-active. Island pages keep their own URL because they have a sidebar. + /// + public static string ResolveActiveUrl(INavigationItem current) + { + if (!current.Hidden || current.FindIslandRoot() is not null) + return current.Url; + + for (var parent = current.Parent; parent is not null; parent = parent.Parent) + { + if (!parent.Hidden) + return parent.Url; + } + + return current.Url; + } + + internal static string NormalizePath(string url) + { + var path = url; + var cut = path.IndexOfAny(['?', '#']); + if (cut >= 0) + path = path[..cut]; + + path = path.TrimEnd('/'); + return path.Length == 0 ? "/" : path; + } + + private static string? GetQuotedAttribute(string tag, string name) + { + var needle = name + "=\""; + var start = tag.IndexOf(needle, StringComparison.Ordinal); + if (start < 0) + return null; + + start += needle.Length; + var end = tag.IndexOf('"', start); + return end < 0 ? null : tag[start..end]; + } + + private static string WithCurrentClass(string tag) + { + const string prefix = " class=\""; + var classStart = tag.IndexOf(prefix, StringComparison.Ordinal); + if (classStart < 0) + return tag; + + var valueStart = classStart + prefix.Length; + var valueEnd = tag.IndexOf('"', valueStart); + if (valueEnd < 0) + return tag; + + var classes = tag[valueStart..valueEnd]; + if (HasClass(classes, "current")) + return tag; + + return string.Concat(tag.AsSpan(0, valueEnd), " current", tag.AsSpan(valueEnd)); + } + + private static bool HasClass(string classes, string name) + { + var start = 0; + while (start < classes.Length) + { + while (start < classes.Length && classes[start] == ' ') + start++; + + var end = classes.IndexOf(' ', start); + if (end < 0) + end = classes.Length; + + if (end > start && classes.AsSpan(start, end - start).Equals(name, StringComparison.Ordinal)) + return true; + + start = end + 1; + } + + return false; + } +} diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs index 1dd9f795ca..37acf82ed7 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text; using Elastic.Documentation.Navigation; +using Elastic.Documentation.Navigation.Assembler; namespace Elastic.Documentation.Site.Navigation; @@ -38,9 +39,10 @@ public sealed record IslandBackLink(string Title, string Url); /// /// Everything _TocTree.cshtml renders, resolved from the domain navigation up front. -/// identifies the preserved tree content: pages whose trees are identical -/// share a nav-tree-* id so htmx keeps the sidebar DOM (and its expand/collapse state) alive, -/// while any visible change produces a new id and swaps in fresh HTML. +/// identifies the tree content so same-island pages share markup. +/// The tree itself lives in #pages-nav, which is hx-preserve'd so +/// expanding folders survives same-tree navigations. JS still replaces the nav +/// when the island/section surface changes (heading + Overview). /// public sealed record NavigationRenderModel { @@ -49,26 +51,37 @@ public sealed record NavigationRenderModel public required string CurrentTopLevelUrl { get; init; } public required IReadOnlyList DropdownItems { get; init; } /// - /// Root-first trail out of a nested island. - /// Empty when covers the outermost scope - /// (i.e. the render root is itself a top-level section with no island ancestors). + /// Root-first trail of island ancestors out of a nested island. + /// Empty when the dropdown or assembler Docs tab already covers the site root + /// and the render root has no other island ancestors. /// public required IReadOnlyList BackLinks { get; init; } /// /// Root index link as the first sidebar row when primary nav is off. - /// Null when primary nav / global assembly already covers that role. + /// Null when primary nav / global assembly already covers that role, + /// or when the index is flattened to an Overview row under . /// public NavigationRenderNode? RootIndex { get; init; } + /// + /// Non-clickable label above an island/section tree (e.g. "Elasticsearch", "Reference"). + /// The clickable index sits in as "Overview". + /// + public string? TreeHeading { get; init; } + /// Slug for the heading icon (reference, elasticsearch, …); null when none maps. + public string? TreeHeadingIcon { get; init; } public required IReadOnlyList Tree { get; init; } - /// Hash of the preserved tree content only; the dropdown, back-links and search live outside the preserved element. + /// Hash of the tree content; used as the nav-tree-* id so same-island pages share markup. public required string ContentHash { get; init; } + /// Whether the NAVIGATION_PREVIEW feature flag is enabled; drives nav-v2 vs legacy tree rendering. + public bool NavigationPreviewEnabled { get; init; } public static NavigationRenderModel Create( INodeNavigationItem tree, IEnumerable> topLevelItems, bool isUsingNavigationDropdown, bool isPrimaryNavEnabled, - bool isGlobalAssemblyBuild) + bool isGlobalAssemblyBuild, + bool navigationPreviewEnabled = false) { var topLevel = topLevelItems.ToArray(); // Resolve current top-level by walking self-then-ancestors so nested islands @@ -85,8 +98,28 @@ public static NavigationRenderModel Create( } } var rootIndex = CreateRootIndex(tree, isPrimaryNavEnabled, isGlobalAssemblyBuild); - var nodes = CreateNavigationItems(tree, isTopLevel: true).ToList(); - var backLinks = CreateBackLinks(tree, isUsingNavigationDropdown); + string? treeHeading = null; + List nodes; + if (TryUnwrapSingleChildSection(tree, out var onlyChild, out var sectionTitle)) + { + treeHeading = sectionTitle; + nodes = CreateNavigationItems(onlyChild, isTopLevel: true).ToList(); + if (!tree.Index.Hidden) + nodes = FlattenIslandOverview(SectionOverviewLeaf(tree.Url), nodes); + rootIndex = null; + } + else + { + nodes = CreateNavigationItems(tree, isTopLevel: true).ToList(); + if (rootIndex is not null && IsIslandSidebar(tree, isPrimaryNavEnabled, isGlobalAssemblyBuild)) + { + treeHeading = rootIndex.NavigationTitle; + nodes = FlattenIslandOverview(rootIndex, nodes); + rootIndex = null; + } + } + var backLinks = CreateBackLinks(tree, isUsingNavigationDropdown, omitSiteRoot: isGlobalAssemblyBuild); + var treeHeadingIcon = HeadingIconSlug(treeHeading); return new NavigationRenderModel { IsUsingNavigationDropdown = isUsingNavigationDropdown, @@ -97,22 +130,26 @@ public static NavigationRenderModel Create( : [], BackLinks = backLinks, RootIndex = rootIndex, + TreeHeading = treeHeading, + TreeHeadingIcon = treeHeadingIcon, Tree = nodes, - ContentHash = HashContent(rootIndex, nodes) + ContentHash = HashContent(rootIndex, treeHeading, treeHeadingIcon, nodes), + NavigationPreviewEnabled = navigationPreviewEnabled }; } /// /// Builds the root-first back-link trail out of a nested island. - /// When the dropdown is enabled, the navigation root is omitted (the dropdown replaces it), - /// but top-level ancestor entries are kept — clicking the active dropdown item is hard so - /// an explicit back-link is more usable. - /// Returns empty when the render root has no island ancestry (e.g. a top-level section whose - /// only ancestor is the nav root, which the dropdown already replaces). + /// Immediate parent is always included; further ancestors only if they render as + /// islands, so nested books stay visible after the sidebar collapses to the current one. + /// The site root is omitted when the dropdown or assembler Docs tab already links there. + /// Ancestors that share the render root URL are omitted so a tab landing + /// (Reference, Troubleshoot, Release notes) does not link back to itself. /// private static IReadOnlyList CreateBackLinks( INavigationItem renderRoot, - bool isUsingNavigationDropdown) + bool isUsingNavigationDropdown, + bool omitSiteRoot) { var immediateParent = renderRoot.Parent; if (immediateParent is null) @@ -122,22 +159,29 @@ private static IReadOnlyList CreateBackLinks( var seen = new HashSet(StringComparer.Ordinal); for (var ancestor = immediateParent; ancestor is not null; ancestor = ancestor.Parent) { - // Drop the nav root when the dropdown is enabled — the dropdown already represents it - if (isUsingNavigationDropdown && ancestor.Parent is null) + if ((isUsingNavigationDropdown || omitSiteRoot) && ancestor.Parent is null) + continue; + if (SameNavUrl(ancestor.Url, renderRoot.Url)) continue; var include = ReferenceEquals(ancestor, immediateParent) - || ancestor.Parent is null // top navigation root (when dropdown is off) + || ancestor.Parent is null || ancestor.RendersAsIsland(); if (!include || !seen.Add(ancestor.Url)) continue; var (_, title) = ParseNavTitle(ancestor.NavigationTitle); links.Add(new IslandBackLink(title, ancestor.Url)); } - links.Reverse(); // collected nearest-first, rendered root-first + links.Reverse(); return links; } + private static bool SameNavUrl(string left, string right) => + string.Equals(TrimNavUrl(left), TrimNavUrl(right), StringComparison.Ordinal); + + private static string TrimNavUrl(string url) => + url.Length > 1 ? url.TrimEnd('/') : url; + private static NavigationRenderNode? CreateRootIndex( INodeNavigationItem tree, bool isPrimaryNavEnabled, @@ -189,6 +233,81 @@ private static IReadOnlyList CreateBackLinks( }; } + /// + /// Nested island sidebars (assembler) and isolated island roots: heading + Overview leaf + /// in the same list as the children, not a wrapping folder. + /// + private static bool IsIslandSidebar( + INodeNavigationItem tree, + bool isPrimaryNavEnabled, + bool isGlobalAssemblyBuild) + { + if (isGlobalAssemblyBuild) + return tree.Parent?.Parent is not null; + return !isPrimaryNavEnabled && tree.RendersAsIsland(); + } + + /// + /// Reference / Troubleshoot: a section whose only child is a toc wrapper. Unwrap it so + /// the sidebar is "Reference" (heading) + Overview + the toc's children, not a folder. + /// + private static bool TryUnwrapSingleChildSection( + INodeNavigationItem tree, + out INodeNavigationItem child, + out string heading) + { + child = null!; + heading = ""; + if (tree is not SectionNavigation || tree.Parent?.Parent is not null) + return false; + + INodeNavigationItem? only = null; + foreach (var item in tree.NavigationItems) + { + if (item.Hidden) + continue; + if (only is not null) + return false; + if (item is not INodeNavigationItem { NavigationItems.Count: > 0 } node) + return false; + only = node; + } + + if (only is null) + return false; + + child = only; + (_, heading) = ParseNavTitle(tree.NavigationTitle); + return true; + } + + private static NavigationRenderNode SectionOverviewLeaf(string url) => + new() + { + Kind = NavigationRenderNodeKind.Leaf, + IsTopLevel = true, + NavigationTitle = "Overview", + Url = url + }; + + private static List FlattenIslandOverview( + NavigationRenderNode overview, + List children) + { + var overviewLeaf = overview with + { + Kind = NavigationRenderNodeKind.Leaf, + NavigationTitle = "Overview", + Id = null, + ShowToggle = false, + NavigationItems = [] + }; + if (children.Count == 0) + return [overviewLeaf]; + + return [overviewLeaf, .. children]; + } + private static IEnumerable CreateNavigationItems( INodeNavigationItem parent, bool isTopLevel) @@ -259,13 +378,32 @@ private static (string? Badge, string NavigationTitle) ParseNavTitle(string raw) return (null, raw); } - private static string HashContent(NavigationRenderNode? rootIndex, IReadOnlyList tree) + /// Top-nav / product glyph that matches a flattened heading, if we ship one. + internal static string? HeadingIconSlug(string? heading) => heading switch + { + "Guides" => "guides", + "Reference" => "reference", + "Troubleshoot" => "troubleshoot", + "Products" => "products", + "APIs" => "apis", + "Release notes" => "release-notes", + "Elasticsearch" => "elasticsearch", + _ => null + }; + + private static string HashContent( + NavigationRenderNode? rootIndex, + string? treeHeading, + string? treeHeadingIcon, + IReadOnlyList tree) { using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - Append(hash, "navigation-tree-v2"); + Append(hash, "navigation-tree-v4"); AppendInt(hash, rootIndex is null ? 0 : 1); if (rootIndex is not null) AppendNode(hash, rootIndex); + Append(hash, treeHeading ?? string.Empty); + Append(hash, treeHeadingIcon ?? string.Empty); AppendNodes(hash, tree); return Convert.ToHexStringLower(hash.GetHashAndReset().AsSpan(0, 8)); } diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml index 8d01b5eb64..16ef0f031d 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml @@ -1,6 +1,118 @@ @using Elastic.Documentation.Site.Navigation @inherits RazorSlice +@if (Model.NavigationPreviewEnabled) +{ +
+
+ + @if (Model.BackLinks.Count > 0) + { +
+ @foreach (var back in Model.BackLinks) + { + + + @back.Title + + } +
+ } + @if (Model.TreeHeading is { } treeHeading) + { +
+ @treeHeading +
+ } +
+ +
+
+
+ +
+
+ + +
+
+} +else +{
- @* Content-hash id: hx-preserve keeps the tree (and its expand/collapse state) across - navigations that render the exact same tree; any visible change produces a new id - so the new tree swaps in. *@
+} diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 29cad351d8..9c6ca38fa4 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -4,50 +4,50 @@ { if (item.Kind == NavigationRenderNodeKind.Leaf) { -
  • - - @item.NavigationTitle +
  • + + @item.NavigationTitle @if (item.Badge is not null) { @item.Badge }
  • } else if (item.Kind == NavigationRenderNodeKind.Island) { - } else { -