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/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..e89130a254 100644 --- a/src/Elastic.Documentation.Site/Assets/secondary-nav.css +++ b/src/Elastic.Documentation.Site/Assets/secondary-nav.css @@ -21,4 +21,294 @@ .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:hover .secondary-nav-item__content, + .secondary-nav-item: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: block; + flex-shrink: 0; + width: 16px; + height: 16px; + overflow: clip; + background-color: currentColor; + -webkit-mask: var(--secondary-nav-icon) center / contain no-repeat; + mask: var(--secondary-nav-icon) center / contain no-repeat; + } + + .secondary-nav-icon img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0; + } + + .secondary-nav-icon--sm { + color: #516381; + } + + /* Tight-cropped Figma glyphs: size to match EuiIcon size s (12px box, + 16 viewBox), not a 12×12 square which would upscale them. */ + .secondary-nav-icon.secondary-nav-dropdown-chevron { + width: 9.53px; + height: 5.3px; + } + + .secondary-nav-icon--external, + .secondary-nav-icon--sm:not(.secondary-nav-dropdown-chevron) { + width: 7.77px; + height: 7.77px; + } + + .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..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/_Head.cshtml b/src/Elastic.Documentation.Site/Layout/_Head.cshtml index dad49fa83f..091f5c564c 100644 --- a/src/Elastic.Documentation.Site/Layout/_Head.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Head.cshtml @@ -53,11 +53,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 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; } -