diff --git a/docs/_docset.yml b/docs/_docset.yml index 6f66132a8a..b60ea00523 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -110,6 +110,7 @@ toc: - file: applies-switch.md - file: automated_settings.md - file: buttons.md + - file: card-group.md - file: changelog.md - file: code.md - file: comments.md @@ -120,10 +121,14 @@ toc: - file: file_inclusion.md - file: footnotes.md - file: frontmatter.md + - file: hero.md + - file: hub-pages.md - file: icons.md + - file: intro.md - file: images.md - file: videos.md - file: kbd.md + - file: link-card.md - file: math.md - file: diagrams.md - file: lists.md @@ -132,6 +137,7 @@ toc: - file: storybook.md - file: links.md - file: list-sub-pages.md + - file: on-this-page.md - file: page-card.md - file: stepper.md - file: substitutions.md @@ -140,6 +146,7 @@ toc: - file: tables.md - file: tabs.md - file: titles.md + - file: whats-new.md # Documentation builds - folder: documentation @@ -247,3 +254,15 @@ toc: # Development - toc: development + + # Hub / product page fixtures used by local tests and assembler preview + - folder: testing + children: + - folder: products + children: + - folder: elasticsearch + children: + - file: v9.md + - folder: kibana + children: + - file: v9.md diff --git a/docs/syntax/hub-pages.md b/docs/syntax/hub-pages.md index 71d3a9aaee..f277f8e32f 100644 --- a/docs/syntax/hub-pages.md +++ b/docs/syntax/hub-pages.md @@ -74,7 +74,7 @@ links: ## Product badges -Every regular (non-hub) page that declares one or more `products:` in its frontmatter automatically gets a clickable badge above its H1, linking to that product's hub page. The badge → hub URL mapping is set per product in [`config/products.yml`](../configure/site/products.md): +Every regular (non-hub) page that declares one or more `products:` in its frontmatter automatically gets a clickable badge above its H1, linking to that product's hub page. The badge → hub URL mapping is set per product in [`config/products.yml`](../documentation/catalog/products.md): ```yaml products: diff --git a/docs/syntax/whats-new.md b/docs/syntax/whats-new.md index e13a1c1b0b..bebb5687d3 100644 --- a/docs/syntax/whats-new.md +++ b/docs/syntax/whats-new.md @@ -4,7 +4,7 @@ A panel with a "New" badge, section title, optional release-notes link list on t ## Centralized lookup (recommended) -Edit content in one place — [`config/whats-new.yml`](../configure/site/products.md) — and any page can render a product's panel with a one-line directive: +Edit content in one place — [`config/whats-new.yml`](https://github.com/elastic/docs-builder/blob/main/config/whats-new.yml) — and any page can render a product's panel with a one-line directive: ```markdown :::{whats-new} diff --git a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs index 299c8b04b6..102ec92f82 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs @@ -102,9 +102,14 @@ INodeNavigationItem parent } public class ClassificationNavigationItem(ApiClassification classification, LandingNavigationItem rootNavigation, LandingNavigationItem parent) - : ApiGroupingNavigationItem(classification, rootNavigation, parent), IRootNavigationItem + : ApiGroupingNavigationItem(classification, rootNavigation, parent), + IRootNavigationItem, + ISidebarHeadingNavigationItem { - /// Section titles from x-tagGroups are not their own page; the sidebar link targets the main API overview for the product, not a tag (or the first child) page. + /// + /// Classifications have no dedicated page. Kept as the product overview URL for any code that still + /// reads ; Nav V2 renders these as non-clickable sidebar headings. + /// public override string Url => rootNavigation.Index.Url; /// @@ -144,7 +149,9 @@ INodeNavigationItem parent public interface IEndpointOrOperationNavigationItem : INavigationItem; public class EndpointNavigationItem(ApiEndpoint endpoint, IRootNavigationItem rootNavigation, INodeNavigationItem parent) - : IApiGroupingNavigationItem, IEndpointOrOperationNavigationItem + : IApiGroupingNavigationItem, + IEndpointOrOperationNavigationItem, + IMultiOperationNavigationItem { /// public string Url => NavigationItems.First().Url; diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 4c0a149c1c..f1e26ac7a8 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -13,8 +13,11 @@ namespace Elastic.ApiExplorer.Operations; -public record ApiOperation(HttpMethod OperationType, OpenApiOperation Operation, string Route, IOpenApiPathItem Path, string ApiName) : IApiModel +public record ApiOperation(HttpMethod OperationType, OpenApiOperation Operation, string Route, IOpenApiPathItem Path, string ApiName) + : IApiModel, IHttpMethodNavigationModel { + string IHttpMethodNavigationModel.HttpMethod => OperationType.Method.ToLowerInvariant(); + public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default) { var viewModel = new OperationViewModel(context) diff --git a/src/Elastic.ApiExplorer/_Layout.cshtml b/src/Elastic.ApiExplorer/_Layout.cshtml index 222a9f1d01..a9db94c876 100644 --- a/src/Elastic.ApiExplorer/_Layout.cshtml +++ b/src/Elastic.ApiExplorer/_Layout.cshtml @@ -11,23 +11,25 @@ else { @(await RenderPartialAsync(_IsolatedHeader.Create(Model))) } -
-
-
-
-
-
- - @await RenderBodyAsync() -
-
+@* Match Markdown Nav V2 shell so shared aside height / 279px chrome selectors apply. *@ +
+
+
+
+
+
+
+
+ + @await RenderBodyAsync() +
+
+
+ @await RenderPartialAsync(_ApiToc.Create(Model.TocItems.ToArray())) +
+ @await RenderPartialAsync(_PagesNav.Create(Model))
- @await RenderPartialAsync(_ApiToc.Create(Model.TocItems.ToArray()))
- @await RenderPartialAsync(_ApiPagesNav.Create(Model))
@if (Model.BuildType == BuildType.Assembler) diff --git a/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs b/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs new file mode 100644 index 0000000000..897982a3e8 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs @@ -0,0 +1,14 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Navigation; + +/// +/// Navigation model for an API operation leaf that exposes its HTTP method for sidebar chrome. +/// +public interface IHttpMethodNavigationModel : INavigationModel +{ + /// Lowercase HTTP method name (e.g. get, post). + string HttpMethod { get; } +} diff --git a/src/Elastic.Documentation.Navigation/IMultiOperationNavigationItem.cs b/src/Elastic.Documentation.Navigation/IMultiOperationNavigationItem.cs new file mode 100644 index 0000000000..0538c80859 --- /dev/null +++ b/src/Elastic.Documentation.Navigation/IMultiOperationNavigationItem.cs @@ -0,0 +1,11 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Navigation; + +/// +/// Marker for API navigation rows that group multiple HTTP operations under one logical endpoint. +/// Nav V2 shows a neutral multi-method badge (grid) instead of a single HTTP-method glyph. +/// +public interface IMultiOperationNavigationItem : INavigationItem; diff --git a/src/Elastic.Documentation.Navigation/ISidebarHeadingNavigationItem.cs b/src/Elastic.Documentation.Navigation/ISidebarHeadingNavigationItem.cs new file mode 100644 index 0000000000..0c6264d02e --- /dev/null +++ b/src/Elastic.Documentation.Navigation/ISidebarHeadingNavigationItem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Navigation; + +/// +/// Marker for sidebar headings that group children without a dedicated page of their own. +/// Nav V2 renders these as label spans (docs-sidebar-nav-v2__label--*), not accordion folder links. +/// +/// +/// Used by docs label: nodes and API OpenAPI x-tagGroups classifications. +/// Implement alongside . +/// +public interface ISidebarHeadingNavigationItem; diff --git a/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs b/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs index 55b17d3c39..cd8567f266 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Leaf/FileNavigationLeaf.cs @@ -48,6 +48,8 @@ string DetermineUrl() ? relativePath[..^3] // Remove last 3 characters (.md) : relativePath; + path = CollapseDotSegments(path); + // If a path ends with /index or is just index, omit it from the URL if (path.EndsWith("/index", StringComparison.OrdinalIgnoreCase)) path = path[..^6]; // Remove "/index" @@ -62,6 +64,24 @@ string DetermineUrl() } } + /// + /// Collapse . segments (./foo, a/./b) so site URLs never contain /./. + /// + private static string CollapseDotSegments(string path) + { + if (string.IsNullOrEmpty(path) || path.IndexOf('.', StringComparison.Ordinal) < 0) + return path; + + path = path.Replace('\\', '/'); + while (path.StartsWith("./", StringComparison.Ordinal)) + path = path[2..]; + while (path.Contains("/./", StringComparison.Ordinal)) + path = path.Replace("/./", "/", StringComparison.Ordinal); + if (path is "." or "./") + return string.Empty; + return path; + } + /// public bool Hidden { get; } = args.Hidden; diff --git a/src/Elastic.Documentation.Navigation/V2/LabelNavigationNode.cs b/src/Elastic.Documentation.Navigation/V2/LabelNavigationNode.cs index 5588fd3206..799bfd2eae 100644 --- a/src/Elastic.Documentation.Navigation/V2/LabelNavigationNode.cs +++ b/src/Elastic.Documentation.Navigation/V2/LabelNavigationNode.cs @@ -10,7 +10,7 @@ namespace Elastic.Documentation.Navigation.V2; /// A non-clickable section heading in the V2 navigation sidebar. /// Has children but no URL of its own. /// -public class LabelNavigationNode : INodeNavigationItem +public class LabelNavigationNode : INodeNavigationItem, ISidebarHeadingNavigationItem { private readonly LabelIndexLeaf _index; diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index ecc06b33c0..eeb0427974 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -155,6 +155,66 @@ .api-method-delete { @apply border-red-30 bg-red-10 text-red-90; } + +/* + * Nav V2 method badges — Figma APIs (node 10065:6317): + * GET green + pivot 180°; POST blue; PUT/PATCH orange; DELETE red + X; + * multi-operation (and HEAD/OPTIONS) gray + branch glyph. + */ +#pages-nav nav[data-nav-v2] .nav-v2-api-method { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 1px solid; + border-radius: 6px; + box-sizing: border-box; +} + +#pages-nav nav[data-nav-v2] .nav-v2-api-method svg { + display: block; +} + +/* Only GET flips the pivot arrow (incoming). */ +#pages-nav nav[data-nav-v2] .nav-v2-api-method-get svg { + transform: rotate(180deg); +} + +#pages-nav nav[data-nav-v2] .nav-v2-api-method-get { + background-color: #c9f3e3; + border-color: #aee8d2; + color: #008a5e; +} + +#pages-nav nav[data-nav-v2] .nav-v2-api-method-post { + background-color: #d9e8ff; + border-color: #bfdbff; + color: #0b64dd; +} + +#pages-nav nav[data-nav-v2] .nav-v2-api-method-put, +#pages-nav nav[data-nav-v2] .nav-v2-api-method-patch { + background-color: #ffdebf; + border-color: #ffcda1; + color: #ed6723; +} + +/* Neutral gray reserved for multi-operation endpoints (and rare HEAD/OPTIONS). */ +#pages-nav nav[data-nav-v2] .nav-v2-api-method-head, +#pages-nav nav[data-nav-v2] .nav-v2-api-method-options, +#pages-nav nav[data-nav-v2] .nav-v2-api-method-multi { + background-color: #fff; + border-color: #cad3e2; + color: #5a6d8c; +} + +#pages-nav nav[data-nav-v2] .nav-v2-api-method-delete { + background-color: #fdddd8; + border-color: #ffc9c2; + color: #c61e25; +} .api-url { margin-left: calc(var(--spacing) * 2); display: inline-block; diff --git a/src/Elastic.Documentation.Site/Assets/assembler.css b/src/Elastic.Documentation.Site/Assets/assembler.css index ada36d242f..b9e38797ba 100644 --- a/src/Elastic.Documentation.Site/Assets/assembler.css +++ b/src/Elastic.Documentation.Site/Assets/assembler.css @@ -1,7 +1,11 @@ /* Assembler build type specific styles */ -/* Elastic global nav is position:static (scrolls with page). - The secondary nav (Docs sub-header) is sticky on md+ viewports — match its height. */ +/* + * Elastic global nav is position:static (scrolls with page). + * Secondary nav (Guides / APIs / …) is sticky on md+ — --offset-top matches that + * sticky top only. Panel height also subtracts live elastic-nav while visible + * (see updatePagesNavAsideViewportHeight in pages-nav-v2.ts). + */ :root { --secondary-nav-height: 55px; --offset-top: 0px; diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts index b87ecfeec4..b0c714bd74 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts @@ -1,4 +1,4 @@ -import { $$ } from 'select-dom' +import { $$optional } from 'select-dom' import tippy from 'tippy.js' import type { Instance } from 'tippy.js' @@ -6,9 +6,14 @@ const navV2CollapsedStorageKey = 'docs-builder-nav-v2-collapsed-ids' let navV2FolderLinkToggleBound = false let navV2OptimisticNavigateBound = false +let navV2ScrollViewportBound = false let navV2TruncationTippyInstances: Instance[] = [] +/** Latest pages-nav aside / scrollport for viewport clamp + edge fades. */ +let navV2ScrollViewportAside: HTMLElement | null = null +let navV2ScrollViewportScrollEl: HTMLElement | null = null + function readCollapsedFolderIds(): Set { try { const raw = sessionStorage.getItem(navV2CollapsedStorageKey) @@ -46,11 +51,41 @@ function persistFolderCheckboxCollapsedState(cb: HTMLInputElement) { writeCollapsedFolderIds(ids) } +/** + * Normalize a docs pathname for nav matching: resolve {@code .}/{@code ..}, + * drop trailing slash, strip a trailing {@code .md}. Uses the URL parser so + * hrefs like {@code /docs/extend/kibana/./getting-started} match the live path. + */ function normalizeDocPathname(pathname: string) { - const p = pathname.replace(/\/$/, '') + let p: string + try { + p = new URL(pathname, 'https://docs.local').pathname + } catch { + p = pathname + } + p = p.replace(/\/$/, '') + if (p.endsWith('.md')) { + p = p.slice(0, -3) + } return p === '' ? '/' : p } +function anchorMatchesPath(anchor: HTMLAnchorElement, pathnameRaw: string) { + const href = anchor.getAttribute('href') + if (!href) { + return false + } + try { + return ( + normalizeDocPathname( + new URL(href, window.location.href).pathname + ) === normalizeDocPathname(pathnameRaw) + ) + } catch { + return false + } +} + /** * True when the section tab URL is also a normal sidebar link (e.g. Reference index). */ @@ -60,18 +95,9 @@ function sectionRootHasSidebarDestination(nav: HTMLElement): boolean { return false } - let pathname: string - try { - pathname = stripTrailingSlashForNavHref( - new URL(sectionUrl, window.location.href).pathname - ) - } catch { - return false - } - - return ( - nav.querySelector(`a[href="${pathname}"], a[href="${pathname}/"]`) != - null + return $$optional('a.sidebar-link[href]', nav).some( + (el) => + el instanceof HTMLAnchorElement && anchorMatchesPath(el, sectionUrl) ) } @@ -102,22 +128,8 @@ function isOnSectionRootPage(nav: HTMLElement): boolean { return !sectionRootHasSidebarDestination(nav) } -/** Matches {@link markCurrentPage} / {@link expandToCurrentPage} href selectors (not root-normalized). */ -function stripTrailingSlashForNavHref(pathname: string) { - return pathname.replace(/\/$/, '') -} - function linkPathMatchesCurrentPage(anchor: HTMLAnchorElement) { - const href = anchor.getAttribute('href') - if (!href) { - return false - } - - const linkPath = normalizeDocPathname( - new URL(href, window.location.href).pathname - ) - const currentPath = normalizeDocPathname(window.location.pathname) - return linkPath === currentPath + return anchorMatchesPath(anchor, window.location.pathname) } /** @@ -171,18 +183,14 @@ function ensureNavV2FolderLinkToggle() { } if (linkPathMatchesCurrentPage(a)) { - cb.checked = !cb.checked - cb.dispatchEvent(new Event('change', { bubbles: true })) - persistFolderCheckboxCollapsedState(cb) + setNavV2FolderOpen(cb, !cb.checked, { animate: true }) e.preventDefault() e.stopPropagation() return } if (!cb.checked) { - cb.checked = true - cb.dispatchEvent(new Event('change', { bubbles: true })) - persistFolderCheckboxCollapsedState(cb) + setNavV2FolderOpen(cb, true, { animate: true }) } }, true @@ -252,9 +260,10 @@ function ensureNavV2OptimisticCurrentOnNavigate() { return } - const here = window.location.pathname.replace(/\/$/, '') - const pathStripped = path.replace(/\/$/, '') - if (pathStripped === here) { + if ( + normalizeDocPathname(path) === + normalizeDocPathname(window.location.pathname) + ) { return } @@ -308,13 +317,165 @@ function initAccordion(nav: HTMLElement) { const target = e.target as HTMLInputElement if (target.checked) { getSiblingAccordionCheckboxes(target).forEach((sibling) => { - sibling.checked = false + if (sibling.checked) { + setNavV2FolderOpen(sibling, false, { animate: true }) + } }) } }) }) } +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +type NavV2FolderClipEls = { + clip: HTMLElement + inner: HTMLElement +} + +function getNavV2FolderClipEls( + cb: HTMLInputElement +): NavV2FolderClipEls | null { + const peer = cb.closest('.nav-folder-peer') + const li = peer?.parentElement + if (!li?.matches('li.group-navigation')) { + return null + } + + const clip = li.querySelector( + ':scope > .docs-sidebar-nav-v2__folder-clip' + ) + const inner = clip?.querySelector( + ':scope > .docs-sidebar-nav-v2__folder-clip-inner' + ) + if (!clip || !inner) { + return null + } + + return { clip, inner } +} + +const navV2FolderClipAnimToken = new WeakMap() +const navV2FolderClipOpenAnimation = new WeakMap() + +function clearNavV2FolderClipInlineAnim(clip: HTMLElement) { + navV2FolderClipOpenAnimation.get(clip)?.cancel() + navV2FolderClipOpenAnimation.delete(clip) + clip.style.height = '' + clip.style.minHeight = '' + clip.style.overflow = '' + clip.style.transition = '' + clip.style.gridTemplateRows = '' + clip.style.display = '' +} + +/** Children ul scrollHeight while the clip is at 0fr (inner is often 0). */ +function measureNavV2FolderContentHeight(clip: HTMLElement): number { + const ul = clip.querySelector( + ':scope > .docs-sidebar-nav-v2__folder-clip-inner > .docs-sidebar-nav-v2__folder-children' + ) + if (ul && ul.scrollHeight > 0) { + return ul.scrollHeight + } + const inner = clip.querySelector( + ':scope > .docs-sidebar-nav-v2__folder-clip-inner' + ) + return inner?.scrollHeight ?? 0 +} + +/** + * Open/close a folder checkbox. + * - Close: CSS {@code 1fr→0fr} (already reliable). + * - Open: Web Animations height 0→N (CSS {@code 0fr→1fr} / style transitions + * snap on the real click path because of style coalescing + grid). + * Pending opens set {@code data-nav-v2-pending-open} so optimistic + * {@link expandToCurrentPageForPath} does not force {@code checked} mid-tween. + */ +function setNavV2FolderOpen( + cb: HTMLInputElement, + open: boolean, + options: { animate?: boolean } = {} +) { + const animate = options.animate !== false + // Ignore duplicate opens while a tween is already in flight. + if (open && cb.dataset.navV2PendingOpen === 'true') { + return + } + if (cb.checked === open) { + return + } + + const els = getNavV2FolderClipEls(cb) + if (els) { + clearNavV2FolderClipInlineAnim(els.clip) + } + + const applyChecked = (next: boolean) => { + delete cb.dataset.navV2PendingOpen + cb.checked = next + cb.dispatchEvent(new Event('change', { bubbles: true })) + persistFolderCheckboxCollapsedState(cb) + } + + if (!animate || !els || prefersReducedMotion()) { + applyChecked(open) + return + } + + // Close: let CSS grid-template-rows animate 1fr → 0fr. + if (!open) { + applyChecked(false) + return + } + + const { clip } = els + const targetPx = measureNavV2FolderContentHeight(clip) + if (targetPx <= 0 || typeof clip.animate !== 'function') { + applyChecked(true) + return + } + + const token = (navV2FolderClipAnimToken.get(clip) ?? 0) + 1 + navV2FolderClipAnimToken.set(clip, token) + cb.dataset.navV2PendingOpen = 'true' + + clip.style.display = 'block' + clip.style.overflow = 'hidden' + clip.style.minHeight = '0' + clip.style.height = '0px' + + cb.checked = true + cb.dispatchEvent(new Event('change', { bubbles: true })) + + const animation = clip.animate( + [{ height: '0px' }, { height: `${targetPx}px` }], + { + duration: 220, + easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)', + fill: 'forwards', + } + ) + navV2FolderClipOpenAnimation.set(clip, animation) + + const finish = () => { + if (navV2FolderClipAnimToken.get(clip) !== token) { + return + } + navV2FolderClipOpenAnimation.delete(clip) + animation.cancel() + clip.style.height = '' + clip.style.minHeight = '' + clip.style.overflow = '' + clip.style.display = '' + delete cb.dataset.navV2PendingOpen + persistFolderCheckboxCollapsedState(cb) + } + + void animation.finished.then(finish).catch(finish) +} + function warmFolderSubtreeLayoutFromPeer(peer: HTMLElement) { const li = peer.parentElement if (!li?.matches('li.group-navigation')) { @@ -486,8 +647,9 @@ function deepestCurrentSidebarLink(nav: HTMLElement): HTMLAnchorElement | null { } /** - * Apply #F1F6FF background per design: folder index → whole folder + visible children; - * nested folder index → that folder + its children only; leaf → that row only. + * Apply #f6f9fc on the deepest {@code li.group-navigation} that contains the current page + * (not every expanded folder, not outer ancestors). Folder index → that group; leaf → + * immediate parent group. Ancestor rows still get weight via {@code nav-v2-active-ancestor}. */ function applyActiveSubtreeHighlight(nav: HTMLElement) { clearActiveSubtreeHighlight(nav) @@ -523,39 +685,52 @@ function applyActiveSubtreeHighlight(nav: HTMLElement) { } /* - * Keep ancestor-state styling across the full chain, but mark only the nearest parent - * with nav-v2-active-parent so background treatment can stay scoped to one level. + * Walk up: every ancestor group-navigation gets heading weight; background goes only + * on the deepest one (closest to current), never on outer wrappers. */ - let walk: Element | null = hostLi - let markedImmediateParent = false + const ancestorGroups: HTMLElement[] = [] + let walk: Element | null = hostLi.parentElement while (walk && walk !== nav) { if (walk.matches('li.group-navigation')) { const ancestorRow = walk.querySelector( ':scope > .nav-folder-peer > a.sidebar-link' ) - if (ancestorRow && ancestorRow !== current) { + if ( + ancestorRow && + ancestorRow !== current && + walk instanceof HTMLElement + ) { walk.classList.add('nav-v2-active-ancestor') - if (!markedImmediateParent) { - walk.classList.add('nav-v2-active-parent') - markedImmediateParent = true - } + ancestorGroups.push(walk) } } walk = walk.parentElement } + + if ( + ancestorGroups.length > 0 && + !hostLi.classList.contains('nav-v2-active-subtree') + ) { + ancestorGroups[0].classList.add('nav-v2-active-parent') + } } /** * Mark all nav links whose href matches {@code pathname} with the "current" CSS class. */ function markCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) { - $$('.current', nav).forEach((el) => el.classList.remove('current')) - - const pathname = stripTrailingSlashForNavHref(pathnameRaw) - $$(`a[href="${pathname}"], a[href="${pathname}/"]`, nav).forEach((el) => - el.classList.add('current') - ) + // $$ throws when empty; SSR has no .current yet, so use $$optional. + $$optional('.current', nav).forEach((el) => el.classList.remove('current')) + + $$optional('a.sidebar-link[href]', nav).forEach((el) => { + if ( + el instanceof HTMLAnchorElement && + anchorMatchesPath(el, pathnameRaw) + ) { + el.classList.add('current') + } + }) } /** @@ -564,7 +739,9 @@ function markCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) { */ function markCurrentPage(nav: HTMLElement) { if (isOnSectionRootPage(nav)) { - $$('.current', nav).forEach((el) => el.classList.remove('current')) + $$optional('.current', nav).forEach((el) => + el.classList.remove('current') + ) return } markCurrentPageForPath(nav, window.location.pathname) @@ -574,15 +751,16 @@ function pickDeepestAnchorMatchingPath( nav: HTMLElement, pathnameRaw: string ): HTMLElement | null { - const pathname = stripTrailingSlashForNavHref(pathnameRaw) - const matches = nav.querySelectorAll( - `a[href="${pathname}"], a[href="${pathname}/"]` + const matches = $$optional('a.sidebar-link[href]', nav).filter( + (el): el is HTMLAnchorElement => + el instanceof HTMLAnchorElement && + anchorMatchesPath(el, pathnameRaw) ) if (matches.length === 0) { return null } - let best = matches[0] + let best: HTMLElement = matches[0] let bestDepth = navListItemDepthFromAnchor(best, nav) for (let i = 1; i < matches.length; i++) { const m = matches[i] @@ -620,7 +798,9 @@ function expandToCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) { const currentIsThisFolderRow = rowLink !== null && rowLink === link - if (collapsedIds.has(cb.id)) { + if (cb.dataset.navV2PendingOpen === 'true') { + // Click handler owns an animated open on this checkbox — do not snap it. + } else if (collapsedIds.has(cb.id)) { if (currentIsThisFolderRow) { // User collapsed this folder while its index is current; HTML swap often // re-checks the input — force closed so a second click can stay collapsed. @@ -634,7 +814,9 @@ function expandToCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) { cb.checked = true } } else if (cb) { - cb.checked = true + if (cb.dataset.navV2PendingOpen !== 'true') { + cb.checked = true + } } } @@ -730,6 +912,206 @@ function initNavV2TruncationTooltips(nav: HTMLElement) { } } +function getNavV2ScrollOverflow(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, + } +} + +/** + * Soft top/bottom edge fades on the pages-nav scrollport: only when content + * overflows in that direction (so the first/last items stay sharp at rest). + * Also toggles Figma scroll buttons (hover reveal is CSS; direction via data-visible). + */ +function updateNavV2ScrollFades(scrollEl: HTMLElement) { + const { canScrollUp, canScrollDown } = getNavV2ScrollOverflow(scrollEl) + scrollEl.dataset.navFadeTop = canScrollUp ? 'true' : 'false' + scrollEl.dataset.navFadeBottom = canScrollDown ? 'true' : 'false' + + const { upBtn, downBtn } = findNavV2ScrollButtons(scrollEl) + if (upBtn) { + upBtn.dataset.visible = canScrollUp ? 'true' : 'false' + } + if (downBtn) { + downBtn.dataset.visible = canScrollDown ? 'true' : 'false' + } +} + +/** + * Buttons are siblings of the scrollport inside `.pages-nav-v2__menu` so they + * stay pinned while the tree scrolls. Fallbacks cover older HTML shapes. + */ +function findNavV2ScrollButtons(scrollEl: HTMLElement) { + const menu = + scrollEl.closest('.pages-nav-v2__menu') ?? + scrollEl.parentElement + const pagesNav = scrollEl.closest('#pages-nav') + const upBtn = + menu?.querySelector( + ':scope > .pages-nav-v2__scroll-btn--up' + ) ?? + scrollEl.querySelector( + ':scope > .pages-nav-v2__scroll-btn--up' + ) ?? + pagesNav?.querySelector( + ':scope > .pages-nav-v2__scroll-btn--up' + ) ?? + null + const downBtn = + menu?.querySelector( + ':scope > .pages-nav-v2__scroll-btn--down' + ) ?? + scrollEl.querySelector( + ':scope > .pages-nav-v2__scroll-btn--down' + ) ?? + pagesNav?.querySelector( + ':scope > .pages-nav-v2__scroll-btn--down' + ) ?? + null + return { upBtn, downBtn } +} + +function scrollNavV2ByPage(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 chrome → viewport bottom or + * footer. CSS padding (24px top / bottom, border-box) insets #pages-nav inside + * that strip; scroll buttons overlay .pages-nav-v2__menu (pinned over the scrollport). + * + * `--offset-top` is only the sticky secondary nav (assembler) / isolated header. + * While the Elastic global nav is still on screen (position:static, scrolls + * away), the aside sits lower — use its live getBoundingClientRect().top so the + * panel does not extend past the viewport bottom. + */ +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 refreshNavV2ScrollViewport() { + const aside = navV2ScrollViewportAside + const scrollEl = navV2ScrollViewportScrollEl + if (!aside || !scrollEl) { + return + } + + updatePagesNavAsideViewportHeight(aside) + updateNavV2ScrollFades(scrollEl) +} + +function initNavV2ScrollViewport(nav: HTMLElement) { + const 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 + } + + navV2ScrollViewportAside = aside + navV2ScrollViewportScrollEl = scrollEl + + if (!navV2ScrollViewportBound) { + navV2ScrollViewportBound = true + window.addEventListener('scroll', refreshNavV2ScrollViewport, { + passive: true, + }) + window.addEventListener('resize', refreshNavV2ScrollViewport, { + passive: true, + }) + } + + scrollEl.addEventListener( + 'scroll', + () => updateNavV2ScrollFades(scrollEl), + { passive: true } + ) + // Folder open/close changes scrollHeight without resizing the scrollport. + shell?.addEventListener('change', refreshNavV2ScrollViewport) + + const { upBtn, downBtn } = findNavV2ScrollButtons(scrollEl) + if (upBtn && upBtn.dataset.navScrollBound !== 'true') { + upBtn.dataset.navScrollBound = 'true' + upBtn.addEventListener('click', () => scrollNavV2ByPage(scrollEl, 'up')) + } + if (downBtn && downBtn.dataset.navScrollBound !== 'true') { + downBtn.dataset.navScrollBound = 'true' + downBtn.addEventListener('click', () => + scrollNavV2ByPage(scrollEl, 'down') + ) + } + + const content = scrollEl.querySelector('.pages-nav-v2__content') + if (content) { + const mo = new MutationObserver(refreshNavV2ScrollViewport) + mo.observe(content, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'open'], + }) + } + + const ro = new ResizeObserver(refreshNavV2ScrollViewport) + ro.observe(aside) + ro.observe(scrollEl) + // Assembler: elastic-nav.js injects the global header async and changes layout height. + const elasticNav = + document.querySelector('#elastic-nav') ?? + document.querySelector('#elastic-nav-wrapper') + if (elasticNav) { + ro.observe(elasticNav) + } + refreshNavV2ScrollViewport() + requestAnimationFrame(refreshNavV2ScrollViewport) +} + /** * Initialize all V2 nav behaviours on the given sidebar element. * Call this on every htmx:load when [data-nav-v2] is present. @@ -740,6 +1122,7 @@ export function initNavV2(nav: HTMLElement) { expandToCurrentPage(nav) applyActiveSubtreeHighlight(nav) initNavV2FolderLayoutWarmup(nav) + initNavV2ScrollViewport(nav) requestAnimationFrame(() => { requestAnimationFrame(() => initNavV2TruncationTooltips(nav)) }) diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 8c7dfb95a5..e3c54d4953 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -48,6 +48,8 @@ html { body { /* This is still needed because of some usages of ch units and to maintain the previous behavior */ font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } :root { @@ -116,8 +118,25 @@ body { flex-direction: column; align-self: start; min-height: 0; - max-height: calc(100vh - var(--offset-top)); + box-sizing: border-box; + /* + * Sticky host: full strip under the header → viewport/footer. + * Padding insets #pages-nav (24px top / bottom). + * --pages-nav-aside-height is set in pages-nav-v2.ts. + */ + 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-top: 24px; + padding-bottom: 24px; overflow: hidden; + background-color: transparent; } } @@ -263,6 +282,153 @@ body:has([data-nav-v2]) aside.sidebar:has(#pages-nav) { max-width: 279px; align-self: start; transition: none; + background-color: transparent; + border: 0; +} + +@media (width >= 768px) { + body:has([data-nav-v2]) aside.sidebar:has(#pages-nav) { + 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-top: 24px; + padding-bottom: 24px; + box-sizing: border-box; + overflow: hidden; + } + + /* + * Nav V2 panel: flex-fill the sticky host content box (inside the 24px gaps). + * Do not use height:100% — with border-box padding on the aside it can resolve + * against the border box and paint the #f6f9fc panel over the bottom gap. + * Scroll affordances overlay .pages-nav-v2__menu (not Search/Back above it). + */ + body:has([data-nav-v2]) #pages-nav.sidebar-nav { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-self: stretch; + min-height: 0; + height: auto; + max-height: 100%; + overflow: hidden; + background-color: #f6f9fc; + border-radius: 16px; + } +} + +/* + * Island Back: nav-item structure (padding / radius / height) + scroll-btn colors. + */ +/* Same chrome as Jump to page: 16px inset + bottom rule under Search / Back. */ +.pages-nav-v2__back-chrome { + box-sizing: border-box; + 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; +} + +/* Menu host: scrollport + pinned scroll buttons (below Search/Back in the shell). */ +.pages-nav-v2__menu { + position: relative; + min-height: 0; + overflow: hidden; +} + +/* Figma EuiButtonIcon — pinned over the scrollport, not scrolling with the tree. */ +.pages-nav-v2__scroll-btn { + position: absolute; + left: 50%; + /* Above scroll fades and nav tree chrome; host is .pages-nav-v2__menu. */ + 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; } body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { @@ -272,21 +438,81 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { /* Figma Nav shell: Search / Back / Content (Top · Subtitle · List). */ .pages-nav-v2-shell { + display: flex; min-width: 279px; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + max-height: 100%; + overflow: hidden; + background-color: transparent; } .pages-nav-v2__search-inner { - border-bottom: 1px solid transparent; -} - -.pages-nav-v2__search-inner[data-nav-scrolled] { - border-bottom-color: #e3e8f2; + border-bottom: 1px solid #e3e8f2; + background-color: transparent; + padding: 16px; } +/* Scrollbar fades in while hovering the pages aside (gutter stays stable). */ .pages-nav-v2__scroll { + --nav-scroll-fade-size: 28px; + padding-block: 8px; scrollbar-width: thin; - scrollbar-color: #e3e8f2 transparent; + scrollbar-color: transparent transparent; scrollbar-gutter: stable; + transition: scrollbar-color 0.28s ease; + /* Soft edge: items fade into the sidebar background as they leave the viewport. */ + -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 { @@ -294,19 +520,33 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { } .pages-nav-v2__scroll::-webkit-scrollbar-thumb { - background-color: #e3e8f2; + 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); } -.pages-nav-v2__scroll::-webkit-scrollbar-thumb:hover { +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: 24px; + /* Vertical inset lives on .pages-nav-v2__scroll (padding + fade mask). */ + padding-top: 0; + padding-left: 8px; } -/* First Top / Subtitle in scroll: no extra top inset (Figma); siblings get pt-16. */ +/* Label heading margins come from the label padding rules below (Figma). */ .pages-nav-v2__content .docs-sidebar-nav-v2__tree > li @@ -318,28 +558,23 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { margin: 0; } -.pages-nav-v2__content - .docs-sidebar-nav-v2__tree - > li - ~ li - > .docs-sidebar-nav-v2__block-top { - padding-top: 16px; -} - -.pages-nav-v2__content - .docs-sidebar-nav-v2__tree - > li - ~ li - > .docs-sidebar-nav-v2__block-subtitle { - padding-top: 16px; - padding-bottom: 8px; -} - .pages-nav-v2__content nav[data-nav-v2] ul.docs-sidebar-nav-v2__label-children, .pages-nav-v2__content nav[data-nav-v2] ul.docs-sidebar-nav-v2__subsection { gap: 1px; } +/* + * Space between root sections only when they are non-clickable top labels + * (docs label: / API classifications). Plain folder/leaf roots stay tight. + */ +#pages-nav + nav[data-nav-v2] + ul#nav-tree.docs-sidebar-nav-v2__tree + > li:has(> .docs-sidebar-nav-v2__label--top) + + li:has(> .docs-sidebar-nav-v2__label--top) { + margin-top: 8px; +} + /* Nav V2: no motion on sidebar column (sticky offset / max-height). */ #pages-nav:has(nav[data-nav-v2]).sidebar-nav { transition: none; @@ -347,7 +582,11 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { /* Sticky chrome wrapper is unstyled; search row chrome lives on .pages-nav-v2__search-inner. */ -.sidebar #pages-nav .pages-nav-menu.pages-nav-v2__scroll { +.sidebar #pages-nav .pages-nav-menu.pages-nav-v2__menu { + padding-right: 0; +} + +.sidebar #pages-nav .pages-nav-menu .pages-nav-v2__scroll { padding-right: 0; } @@ -372,7 +611,10 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { gap: 0 !important; } -/* Gap between sibling subsection blocks (each li: nested subtitle + its ul) under a top icon section only. */ +/* + * Nested-label blocks: spacing lives on the subtitle padding (8px first / 24px siblings), + * not on the parent ul gap. + */ #pages-nav nav[data-nav-v2] li @@ -388,7 +630,7 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { + ul.docs-sidebar-nav-v2__subsection:has( > li > .docs-sidebar-nav-v2__label--nested ) { - gap: 8px !important; + gap: 0 !important; } /* @@ -424,16 +666,23 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { min-height: 0; overflow: hidden; transform: translateZ(0); + /* Indent only here — rail lives on the children ul so active borders can overlap it + * (overflow:hidden on this box would clip a negative-margin border on the links). */ + margin-inline-start: 16px; } /* * Folder accordion body: flat/styled link rows (chevron folders, leaves). Distinct from * .docs-sidebar-nav-v2__subsection (label heading + list pattern). + * Rail on the ul (not clip-inner) so .current can pull 1px left and cover it. */ #pages-nav nav[data-nav-v2] ul.docs-sidebar-nav-v2__folder-children { display: flex; flex-direction: column; + gap: 1px; list-style: none; + margin-block: 8px; + border-inline-start: 1px solid #e3e8f2; } @media (prefers-reduced-motion: reduce) { @@ -446,22 +695,15 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { } /* - * Nav V2: block spacing only for root rows that start a top-level section (label--top). - * Leaf / folder rows without a top label do not get the 24px bottom padding. + * Root label sections: no extra li padding — vertical rhythm is on the heading itself + * (Figma: first top pb-8 / sibling top pt-16 + pb-8). */ #pages-nav nav[data-nav-v2] ul.docs-sidebar-nav-v2__tree > li:has(> .docs-sidebar-nav-v2__label--top) { margin-top: 0; - padding: 0 0 16px; -} - -#pages-nav - nav[data-nav-v2] - ul.docs-sidebar-nav-v2__tree - > li:has(> .docs-sidebar-nav-v2__label--top):last-child { - padding: 0 0 16px; + padding: 0; } /* @@ -493,49 +735,42 @@ body:has([data-nav-v2]) aside.sidebar:has(#toc-nav) { nav.docs-sidebar-nav-v2 { position: relative; overflow: hidden; + padding: 0; } -#pages-nav - nav.docs-sidebar-nav-v2:has(> ul > li:first-child):not( - :has(> ul > li:first-child > .docs-sidebar-nav-v2__label) - ) { - padding: 0 0 22px 0; -} - -/* Nav V2: top-level section titles (with icon) — inherit body font stack */ -nav.docs-sidebar-nav-v2 .docs-sidebar-nav-v2__label--top { +/* + * Non-clickable headings (docs label: + API x-tagGroups) — Figma Navigation: + * - Top: padding 16px 12px 8px, weight 700, 16/24 + * - Nested first: padding 8px 12px 8px + * - Nested siblings: padding-top 24px + */ +#pages-nav nav[data-nav-v2] .docs-sidebar-nav-v2__label--top { + box-sizing: border-box; + width: 100%; + margin: 0; + padding: 16px 12px 8px; font-weight: 700; font-size: 16px; line-height: 24px; - color: #1c1e23; - margin: 16px 0; + color: #111c2c; + border-radius: 8px; } -#pages-nav - nav[data-nav-v2] - .docs-sidebar-nav-v2__block-top.docs-sidebar-nav-v2__label--top, -#pages-nav - nav[data-nav-v2] - .docs-sidebar-nav-v2__block-subtitle.docs-sidebar-nav-v2__label--nested { +#pages-nav nav[data-nav-v2] .docs-sidebar-nav-v2__label--nested { + box-sizing: border-box; + width: 100%; margin: 0; + padding: 8px 12px 8px; + font-weight: 700; + font-size: 12px; + line-height: 16px; + letter-spacing: normal; + text-transform: uppercase; + color: #516381; } -/* - * Label--top spacing: - * - Default (12px 0 16px): top sections whose body is a folder list (.docs-sidebar-nav-v2__label-children). - * - Tighter bottom (12px 0 8px): top sections whose body is a true subsection list (nested labels only). - */ -#pages-nav - nav[data-nav-v2] - .docs-sidebar-nav-v2__label--top:has(+ ul.docs-sidebar-nav-v2__subsection) { - margin: 12px 0 8px; -} - -/* Nav V2: nested subgroup section titles (non-clickable; color never overridden by active path) */ -nav.docs-sidebar-nav-v2 .docs-sidebar-nav-v2__label--nested { - font-weight: 500; - margin: 16px 0; - color: #a2b1c9; +#pages-nav nav[data-nav-v2] li ~ li > .docs-sidebar-nav-v2__label--nested { + padding-top: 24px; } /* Nav V2: top-level section title text only (icons removed). */ @@ -555,19 +790,6 @@ nav.docs-sidebar-nav-v2 width: 100%; } -/* - * Subsection heading copy (nested label + nav-text). Scoped to nested only so link/folder - * rows keep normal body styling. - */ -#pages-nav - nav[data-nav-v2] - .docs-sidebar-nav-v2__label--nested.docs-sidebar-nav-v2__nav-text { - font-size: 12px; - color: #8a919e; - margin: 16px 0 8px; - font-weight: 700; -} - #pages-nav nav[data-nav-v2] .docs-sidebar-nav-v2__nav-text { min-width: 0; max-width: 100%; @@ -599,11 +821,48 @@ nav.docs-sidebar-nav-v2 font-weight: 400; border-radius: 8px; background-color: transparent; + transition: + background-color 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; +} + +/* + * Nested rows / open groups sit against the folder rail. Square off the left corners + * so borders meet the rail cleanly. Top-level tree rows keep full 8px radius. + * + * Always reserve a 1px transparent inline-start border (pulled over the rail) so + * toggling .current only changes color — avoids chevron/layout jump. + */ +#pages-nav + nav[data-nav-v2] + .docs-sidebar-nav-v2__folder-children + a.sidebar-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; + /* Keep hover fill inside the padding box so it does not paint through the + * transparent/blue left border and cover the folder rail. */ + background-clip: padding-box; +} + +#pages-nav + nav[data-nav-v2] + .docs-sidebar-nav-v2__folder-children + li.nav-v2-active-parent, +#pages-nav + nav[data-nav-v2] + .docs-sidebar-nav-v2__folder-children + li.nav-v2-active-subtree { + border-start-start-radius: 0; + border-end-start-radius: 0; } #pages-nav nav[data-nav-v2] a.sidebar-link.nav-v2-link { - padding-inline-end: 10px; - padding-inline-start: max(10px, calc(10px + var(--nav-level, 0) * 12px)); + min-height: 32px; + padding-inline: 12px; } #pages-nav nav[data-nav-v2] a.sidebar-link:not(.text-grey-40) { @@ -613,7 +872,8 @@ nav.docs-sidebar-nav-v2 #pages-nav nav[data-nav-v2] a.sidebar-link:not(.text-grey-40):not(.current):hover { - background-color: #f6f9fc; + background-color: #ecf1f9; + color: #1d2a3e; } #pages-nav nav[data-nav-v2] a.sidebar-link.text-grey-40 { @@ -621,32 +881,44 @@ nav.docs-sidebar-nav-v2 } #pages-nav nav[data-nav-v2] a.sidebar-link.text-grey-40:not(.current):hover { - background-color: #f6f9fc; + background-color: #ecf1f9; } +/* + * Active row: #0B64DD text; idle fill transparent; hover uses the same fills as siblings. + * Do not set border-inline-start here — nested rows keep a 1px transparent border + * always (color flips / thickens on .current). A shorthand `none` would zero the width. + * Top-level rows have no reserved border, so they stay borderless. + */ #pages-nav nav[data-nav-v2] a.sidebar-link.current { position: relative; color: #0b64dd !important; + background-color: transparent !important; +} + +/* Weight 600 on label text only — never on the chevron. */ +#pages-nav + nav[data-nav-v2] + a.sidebar-link.current + .docs-sidebar-nav-v2__nav-text { font-weight: 600 !important; - background-color: #ecf1f9 !important; } #pages-nav nav[data-nav-v2] a.sidebar-link.current:hover { - color: #0b64dd; + color: #0b64dd !important; background-color: #ecf1f9 !important; } -/* Vertical accent on the active page row (blue bar + blue title; chevron uses parent-path ink). */ #pages-nav nav[data-nav-v2] a.sidebar-link.current::before { - content: ''; - position: absolute; - inset-inline-start: 3px; - top: 6px; - bottom: 6px; - width: 3px; - border-radius: 9999px; - background-color: var(--color-blue-elastic); - pointer-events: none; + content: none; +} + +/* Nested current: active rail marker is 2px (idle rows keep 1px transparent). */ +#pages-nav + nav[data-nav-v2] + .docs-sidebar-nav-v2__folder-children + a.sidebar-link.current { + border-inline-start: 2px solid #0b64dd; z-index: 1; } @@ -673,11 +945,25 @@ nav.docs-sidebar-nav-v2 background-color: #ecf1f9 !important; } -#pages-nav nav[data-nav-v2] li.nav-v2-active-subtree a.sidebar-link.current, #pages-nav nav[data-nav-v2] li.nav-v2-active-subtree - a.sidebar-link.current:hover { + a.sidebar-link.current:not(:hover) { + background-color: transparent !important; +} + +#pages-nav + nav[data-nav-v2] + li.nav-v2-active-subtree + a.sidebar-link.current:hover, +#pages-nav + nav[data-nav-v2] + li.nav-v2-active-parent + a.sidebar-link.current:hover, +#pages-nav + nav[data-nav-v2] + li.nav-v2-active-leaf + > a.sidebar-link.current:hover { background-color: #ecf1f9 !important; } @@ -688,24 +974,24 @@ nav.docs-sidebar-nav-v2 background-color: #ecf1f9 !important; } -#pages-nav nav[data-nav-v2] li.nav-v2-active-leaf > a.sidebar-link.current, #pages-nav nav[data-nav-v2] li.nav-v2-active-leaf - > a.sidebar-link.current:hover { - background-color: #ecf1f9 !important; + > a.sidebar-link.current:not(:hover) { + background-color: transparent !important; } /* - * Ancestor folder rows (current is deeper): semibold + dark ink on the open path. - * .current on the same row (folder index) uses blue text; chevron stays dark via scoped rule below. + * Ancestor folder rows on the path to current: weight 600 on the label only. + * Open ancestor ink: #516381 on the row link (chevron stays muted). + * Background (#f6f9fc) is only on the deepest group-navigation (nav-v2-active-parent / subtree). */ #pages-nav nav[data-nav-v2] li.nav-v2-active-ancestor > .nav-folder-peer - > a.sidebar-link { - color: #1d2a3e !important; + > a.sidebar-link + .docs-sidebar-nav-v2__nav-text { font-weight: 600 !important; } @@ -720,14 +1006,14 @@ nav.docs-sidebar-nav-v2 > .nav-folder-peer > a.sidebar-link.current { color: #0b64dd !important; - font-weight: 600 !important; } #pages-nav nav[data-nav-v2] li.nav-v2-active-ancestor > .nav-folder-peer - > a.sidebar-link.text-grey-40 { + > a.sidebar-link.text-grey-40 + .docs-sidebar-nav-v2__nav-text { color: var(--color-grey-40) !important; font-weight: 600 !important; } @@ -769,57 +1055,35 @@ nav.docs-sidebar-nav-v2 min-width: 0; } -/* Chevrons: muted by default; active folder row icon follows active blue. */ +/* Chevrons: muted by default (no special color on open ancestors). */ #pages-nav nav[data-nav-v2] .nav-folder-chevron { - color: #a2b1c9; -} - -#pages-nav - nav[data-nav-v2] - .nav-folder-peer - > a.sidebar-link.current - .nav-folder-chevron { - color: #0b64dd; -} - -#pages-nav - nav[data-nav-v2] - li.nav-v2-active-ancestor - > .nav-folder-peer - > a.sidebar-link:not(.current) - .nav-folder-chevron { - color: currentColor; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-weight: 400; + color: #98a2b3; + transition: color 0.12s ease; } -#pages-nav - nav[data-nav-v2] - .nav-folder-peer - > a.sidebar-link.current - .nav-folder-chevron - svg, +/* Open ancestor of current: muted ink on the row label only (not the chevron). */ #pages-nav nav[data-nav-v2] li.nav-v2-active-ancestor - > .nav-folder-peer - > a.sidebar-link - .nav-folder-chevron - svg { - stroke-width: 3.5; + > .nav-folder-peer:has(input[type='checkbox']:checked) + > a.sidebar-link:not(.current):not(.text-grey-40) { + color: #516381; } .docs-sidebar-nav-v2 .nav-folder-peer .nav-folder-chevron svg { display: block; - width: 12px; - height: 12px; + width: 16px; + height: 16px; + /* Collapsed = chevron right; open = chevron down. */ transform: rotate(-90deg); } -.docs-sidebar-nav-v2 .nav-item-slot { - display: block; - width: 12px; - height: 12px; -} - .docs-sidebar-nav-v2 .nav-folder-peer:has(input[type='checkbox']:checked) .nav-folder-chevron diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx index 6bcefe6667..9a6fc40c4d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/NavigationSearchComponent.tsx @@ -3,7 +3,6 @@ import '../../eui-icons-cache' import { sharedQueryClient } from '../shared/queryClient' import { NavigationSearch } from './NavigationSearch' import { EuiProvider } from '@elastic/eui' -import { css } from '@emotion/react' import r2wc from '@r2wc/react-to-web-component' import { QueryClientProvider, useQuery } from '@tanstack/react-query' import { StrictMode } from 'react' @@ -31,12 +30,7 @@ const NavigationSearchInner = ({ placeholder }: NavigationSearchProps) => { } return ( -
+
) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchInput.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchInput.tsx index ef62dc9caf..ecfd22e555 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchInput.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchInput.tsx @@ -50,7 +50,6 @@ export interface SearchInputProps { export const SearchInput = ({ placeholder, - size, inputRef, value, onChange, @@ -106,15 +105,8 @@ export const SearchInput = ({ disabled={disabled} css={css` width: 100%; - padding: calc( - ${ - size === 's' - ? euiTheme.size.xs - : euiTheme.size.s - } + - 2px - ) - ${size === 's' ? euiTheme.size.s : euiTheme.size.m}; + /* 5px block → 32px total with 20px line-height + 1px borders (matches Back). */ + padding: 5px 12px; padding-left: 34px; padding-right: calc( ${euiTheme.size.m} + ${isMac ? '2ch' : '4ch'} + diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts index bfc5867357..f262c174ab 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts @@ -29,4 +29,17 @@ export const assemblerStrategy: HtmxUrlStrategy = { return null } }, + + getFirstSegment: (path) => { + const relative = path.startsWith(`${root}/`) + ? path.slice(root.length + 1) + : path.replace(/^\/docs\//, '') + return relative.split('/').filter(Boolean)[0] ?? '' + }, + + isSimpleSwapPath: (path) => { + const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path + if (normalizedPath === root || normalizedPath === '/docs') return true + return path === apiRoot || path.startsWith(`${apiRoot}/`) + }, } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts index 25932cd9db..0baf0b6164 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts @@ -14,4 +14,18 @@ export const codexStrategy: HtmxUrlStrategy = { return null } }, + + getFirstSegment: (path) => { + const rMatch = path.match(/^\/r\/([^/]+)/) + if (rMatch) return rMatch[1] + const gMatch = path.match(/^\/g\/([^/]+)/) + if (gMatch) return gMatch[1] + return path.split('/').filter(Boolean)[0] ?? '' + }, + + isSimpleSwapPath: (path) => { + const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path + if (normalizedPath === '' || normalizedPath === '/') return true + return path.startsWith('/g/') + }, } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts index 7a68e668a0..71b00426ef 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts @@ -14,4 +14,8 @@ export const isolatedStrategy: HtmxUrlStrategy = { return null } }, + + getFirstSegment: (path) => path.replace('/docs/', '/').split('/')[1] ?? '', + + isSimpleSwapPath: () => false, } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts index fe65168a67..b45f0dece5 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts @@ -5,4 +5,6 @@ export interface HtmxUrlStrategy { isExternalDocsUrl(url: string): boolean getPathFromUrl(url: string): string | null + getFirstSegment(path: string): string + isSimpleSwapPath(path: string): boolean } diff --git a/src/Elastic.Documentation.Site/Htmx.cs b/src/Elastic.Documentation.Site/Htmx.cs index f460f899e0..d9b2b2716b 100644 --- a/src/Elastic.Documentation.Site/Htmx.cs +++ b/src/Elastic.Documentation.Site/Htmx.cs @@ -25,7 +25,11 @@ public string GetHxAttributes( ) { var attributes = new StringBuilder(); + // Unquoted attribute values: Razor HTML-encodes @Model.Htmx.* output, so quotes + // become " and break htmx. hx-swap=none is required with hx-select-oob — + // otherwise body hx-boost also swaps the whole body and leaves stale content. _ = attributes.Append($" hx-select-oob={hxSwapOob ?? GetHxSelectOob(hasSameTopLevelGroup)}"); + _ = attributes.Append(" hx-swap=none"); if (!string.IsNullOrEmpty(preload)) _ = attributes.Append($" preload={preload}"); return attributes.ToString(); @@ -35,6 +39,7 @@ public string GetNavHxAttributes(bool hasSameTopLevelGroup = false, string? prel { var attributes = new StringBuilder(); _ = attributes.Append($" hx-select-oob={GetHxSelectOob(hasSameTopLevelGroup)}"); + _ = attributes.Append(" hx-swap=none"); if (!string.IsNullOrEmpty(preload)) _ = attributes.Append($" preload={preload}"); return attributes.ToString(); diff --git a/src/Elastic.Documentation.Site/Navigation/NavV2LabelListKind.cs b/src/Elastic.Documentation.Site/Navigation/NavV2LabelListKind.cs index 4665099182..670b2f892d 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavV2LabelListKind.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavV2LabelListKind.cs @@ -13,14 +13,14 @@ internal static class NavV2LabelListKind /// True when every direct child under this label renders as a non-folder row (nested labels, plain links). /// False when any child renders as li.group-navigation (accordion folder rows from _TocTreeNavV2). /// - public static bool IsSubsectionList(LabelNavigationNode label) => + public static bool IsSubsectionList(INodeNavigationItem label) => label.NavigationItems.Count > 0 && label.NavigationItems.All(i => !RendersAsGroupNavigationRow(i)); private static bool RendersAsGroupNavigationRow(INavigationItem item) { if (item is PlaceholderNavigationNode) return true; - if (item is LabelNavigationNode) + if (item is ISidebarHeadingNavigationItem) return false; if (item is INodeNavigationItem node && node.NavigationItems.Count > 0) return true; diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml index 7fd2eeadbd..8e6d581d68 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml @@ -29,97 +29,126 @@ @if (showBack) { - - - Back - + } -
-
- @if (!Model.IsNavV2) - { - - - } - else - { -
+ - - } + } + else + { + + } +
+ @if (Model.IsNavV2) + { + @* Absolute over the scrollport only (siblings of .pages-nav-v2__scroll), pinned while content scrolls. *@ + + + }
diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNavV2.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNavV2.cshtml index 14a332bede..f2d6203fbe 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNavV2.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNavV2.cshtml @@ -5,7 +5,7 @@ @using Elastic.Documentation.Site.Navigation @inherits RazorSlice @{ - var isTopLevel = Model.Level == 0 && Model.SubTree is not LabelNavigationNode; + var isTopLevel = Model.Level == 0 && Model.SubTree is not ISidebarHeadingNavigationItem; // Within an island sidebar, only content + in-page TOC change — not #main-container (avoids flash). // Entering an island from the parent section uses islandHxAttrs below (#pages-nav included once). var navHxAttrs = Model.Htmx.GetNavHxAttributes(hasSameTopLevelGroup: true, preload: null); @@ -17,9 +17,9 @@ continue; } - if (item is LabelNavigationNode label) + if (item is ISidebarHeadingNavigationItem and INodeNavigationItem heading) { - var labelBodyIsSubsection = NavV2LabelListKind.IsSubsectionList(label); + var labelBodyIsSubsection = NavV2LabelListKind.IsSubsectionList(heading); var labelBodyClass = labelBodyIsSubsection ? "docs-sidebar-nav-v2__subsection" : "docs-sidebar-nav-v2__label-children"; @@ -27,17 +27,17 @@
  • @if (isTopLevel) { - - @label.NavigationTitle + + @heading.NavigationTitle } else { - - @label.NavigationTitle + + @heading.NavigationTitle } - @if (label.NavigationItems.Count > 0) + @if (heading.NavigationItems.Count > 0) {
  • @if (placeholderGroup.NavigationItems.Count > 0) @@ -148,8 +142,9 @@ + @island.NavigationTitle @if (!islandAllHidden) { } - else - { - - } - @island.NavigationTitle
    @if (island.NavigationItems.Count > 0) @@ -198,10 +187,9 @@ - @group.NavigationTitle @@ -209,6 +197,7 @@ else if (item is INodeNavigationItem folder) { var allHidden = folder.NavigationItems.All(n => n.Hidden); + var isMultiOp = item is IMultiOperationNavigationItem;
  • @if (folder.NavigationItems.Count > 0) @@ -269,14 +269,43 @@ } else if (item is ILeafNavigationItem leaf) { + var httpMethod = leaf.Model is IHttpMethodNavigationModel methodModel + ? methodModel.HttpMethod + : null;
  • - + @if (httpMethod is not null) + { + + @* Figma APIs icons: pivot arrow (GET rotated via CSS); DELETE uses cross. *@ + @if (httpMethod == "delete") + { + + } + else + { + + } + + } @leaf.NavigationTitle
  • diff --git a/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts b/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts index e26d365ffc..71e0da9074 100644 --- a/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts +++ b/src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts @@ -77,10 +77,13 @@ journey('navigation test', ({ page, params }) => { await expect( page.getByRole('heading', { name: 'Elastic fundamentals' }) ).toBeVisible() - // Tag the nav tree so later steps can tell preserved DOM from a swap + // Tag sidebar chrome so later steps can tell preserved DOM from a swap + // (Nav V2 keeps #pages-nav / #nav-tree; prefer the stable pages-nav host). await page.evaluate(() => { - const navTree = document.querySelector('[id^="nav-tree"]') - if (navTree) navTree['__synthOriginal'] = true + const nav = + document.querySelector('#pages-nav') ?? + document.querySelector('[id^="nav-tree"]') + if (nav) nav['__synthOriginal'] = true }) }) @@ -120,48 +123,63 @@ journey('navigation test', ({ page, params }) => { } ) - step('Click on "deployment options" in nav', async () => { - // Expand a collapsed nav section so we can assert its state survives + step('Click on "Evaluate Elastic during a trial" in nav', async () => { + // Expand a collapsed nav folder so we can assert its state survives const expandedId = await page.evaluate(() => { const checkbox = document.querySelector( - '[id^="nav-tree"] input[type="checkbox"]:not(:checked)' + '#pages-nav input[type="checkbox"]:not(:checked), [id^="nav-tree"] input[type="checkbox"]:not(:checked)' ) if (checkbox) checkbox.checked = true return checkbox?.id ?? null }) - await page - .getByRole('link', { name: 'Deployment options' }) + // Nav V2 IA: same Guides/get-started group (deployment-options is no longer a sibling). + const evaluateLink = page + .locator('#pages-nav') + .getByRole('link', { name: 'Evaluate Elastic during a trial' }) .first() - .click() - await expect(page).toHaveURL( - `${host}/docs/get-started/deployment-options` - ) - await expect(page).toHaveTitle(/Deployment options/) + await expect(evaluateLink).toBeVisible() + await Promise.all([ + page.waitForURL(`${host}/docs/get-started/evaluate-elastic`, { + timeout: 15000, + }), + evaluateLink.click(), + ]) + await expect(page).toHaveTitle(/Evaluate Elastic during a trial/) await expect( - page.getByRole('heading', { name: 'Deployment options' }) + page.getByRole('heading', { + name: 'Evaluate Elastic during a trial', + }) ).toBeVisible() - // Same-group navigation: no reload, nav tree DOM (and state) preserved + // Same-group navigation: no reload, sidebar host DOM (and state) preserved const state = await page.evaluate((id) => { - const navTree = document.querySelector('[id^="nav-tree"]') + const nav = + document.querySelector('#pages-nav') ?? + document.querySelector('[id^="nav-tree"]') return { noReload: window['__synthNoReload'] === true, - navTreePreserved: navTree?.['__synthOriginal'] === true, + navPreserved: nav?.['__synthOriginal'] === true, checkboxStillChecked: id ? (document.getElementById(id) as HTMLInputElement)?.checked : null, } }, expandedId) expect(state.noReload).toBe(true) - expect(state.navTreePreserved).toBe(true) + expect(state.navPreserved).toBe(true) if (expandedId) expect(state.checkboxStillChecked).toBe(true) }) step('Click on "Elastic Cloud" in markdown content', async () => { - const treeIdBefore = await page.evaluate( - () => document.querySelector('[id^="nav-tree"]')?.id - ) + const navMarkerBefore = await page.evaluate(() => { + const nav = + document.querySelector('#pages-nav') ?? + document.querySelector('[id^="nav-tree"]') + return { + id: nav?.id ?? null, + hadMarker: nav?.['__synthOriginal'] === true, + } + }) await page .locator('#markdown-content') .getByRole('link', { name: 'Elastic Cloud' }) @@ -172,22 +190,28 @@ journey('navigation test', ({ page, params }) => { ) await expect(page).toHaveTitle(/Elastic Cloud/) - // Cross-group navigation: still no reload, but the nav tree is replaced + // Cross-group navigation: still no reload, but sidebar chrome is replaced const state = await page.evaluate(() => { - const navTree = document.querySelector('[id^="nav-tree"]') + const nav = + document.querySelector('#pages-nav') ?? + document.querySelector('[id^="nav-tree"]') return { noReload: window['__synthNoReload'] === true, - treeId: navTree?.id, - treeIsNewNode: navTree?.['__synthOriginal'] === undefined, - treeShowsNewGroup: - navTree?.querySelector('a[href*="/deploy-manage/"]') !== - null, + navId: nav?.id ?? null, + navIsNewNode: nav?.['__synthOriginal'] === undefined, + navShowsNewGroup: + nav?.querySelector('a[href*="/deploy-manage/"]') !== null, } }) expect(state.noReload).toBe(true) - expect(state.treeId).not.toBe(treeIdBefore) - expect(state.treeIsNewNode).toBe(true) - expect(state.treeShowsNewGroup).toBe(true) + // Nav V2 may keep a stable #pages-nav id while replacing inner tree HTML. + if (navMarkerBefore.id && state.navId === navMarkerBefore.id) { + expect(state.navIsNewNode).toBe(true) + } else { + expect(state.navId).not.toBe(navMarkerBefore.id) + expect(state.navIsNewNode).toBe(true) + } + expect(state.navShowsNewGroup).toBe(true) }) step('Use dropdown to navigate to reference', async () => { diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs index 33bd09e7e2..4e33e65404 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs @@ -18,7 +18,7 @@ public class GlobalNavigationHtmlWriter(ILoggerFactory logFactory, SiteNavigatio { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly SemaphoreSlim _semaphore = new(1, 1); - + private readonly NavigationRenderCache _v1NavigationCache = new(); private readonly ConcurrentDictionary _renderedNavigationCache = []; public async Task RenderNavigation( @@ -37,34 +37,21 @@ public async Task RenderNavigation( if (currentRootNavigation.Parent is null or not SiteNavigation) collector.EmitGlobalError($"Passed root is not actually a top level navigation item {currentRootNavigation.NavigationTitle} ({currentRootNavigation.Id}) in {currentRootNavigation.Url}, trying to render: {currentNavigationItem.Url}"); - if (_renderedNavigationCache.TryGetValue(currentRootNavigation.Id, out var html)) - return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; - if (currentRootNavigation is not INodeNavigationItem group) return NavigationRenderResult.Empty; - await _semaphore.WaitAsync(ctx); - - try + // Share one render result per root (reference equality) so same-section pages + // reuse the identical NavigationRenderResult instance across the build. + return await _v1NavigationCache.GetOrRenderAsync(currentRootNavigation, async () => { - if (_renderedNavigationCache.TryGetValue(currentRootNavigation.Id, out html)) - return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; - _logger.LogInformation("Rendering navigation for {NavigationTitle} ({Id})", currentRootNavigation.NavigationTitle, currentRootNavigation.Id); - - var model = CreateNavigationModel(group); - html = await ((INavigationHtmlWriter)this).Render(model, ctx); - _renderedNavigationCache[currentRootNavigation.Id] = html; + var html = await ((INavigationHtmlWriter)this).Render(CreateNavigationModel(group), ctx); return new NavigationRenderResult { Html = html, Id = currentRootNavigation.Id }; - } - finally - { - _ = _semaphore.Release(); - } + }); } private async Task RenderSectionNavigation( diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 5f34d33929..9e3985c46d 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -77,12 +77,18 @@ public async Task AssertRealNavigation() root.Parent.Should().BeOfType(); }*/ - var slice = _TocTree.Create(NavigationRenderModel.Create( - tree: navigation, - topLevelItems: navigation.TopLevelItems, - isUsingNavigationDropdown: true, - isPrimaryNavEnabled: true, - isGlobalAssemblyBuild: true)); + var slice = _TocTree.Create(new NavigationViewModel + { + Title = navigation.NavigationTitle, + TitleUrl = navigation.Url, + Tree = navigation, + TopLevelItems = navigation.TopLevelItems, + IsUsingNavigationDropdown = true, + IsPrimaryNavEnabled = true, + IsGlobalAssemblyBuild = true, + Htmx = new DefaultHtmxAttributeProvider("/"), + BuildType = BuildType.Assembler + }); var html = await slice.RenderAsync(cancellationToken: ctx); var context = BrowsingContext.New(); var document = await context.OpenAsync(req => req.Content(html), ctx); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index b7d7c416b1..11ad6dbf9d 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -41,6 +41,7 @@ public async Task Render_MarksOnlyCurrentVersionSelected() Next = null, NavigationHtml = string.Empty, UrlPathPrefix = string.Empty, + Htmx = new DefaultHtmxAttributeProvider("/"), AllowIndexing = false, CanonicalBaseUrl = null, GoogleTagManager = new GoogleTagManagerConfiguration(), diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index d031a35420..bd9cb950e3 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -14,7 +14,10 @@ namespace Elastic.Markdown.Tests.Assembler; -/// Tests that assembler builds produce correct HTMX attributes on markdown cross-links (same-site, not target=_blank). +/// +/// Navigation relies on body-level hx-boost with hx-preserve islands, so markdown links must +/// not carry per-link htmx attributes. Cross-links stay same-site (no target=_blank). +/// public class AssemblerHtmxMarkdownLinkTests(ITestOutputHelper output) : LinkTestBase(output, "Go to [test](kibana://index.md)") { protected override BuildContext CreateBuildContext( @@ -28,12 +31,11 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void CrossLink_UsesGranularSwap_ForAssembler() => - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#pages-nav\""); - - [Fact] - public void CrossLink_HasPreload() => + public void CrossLink_HasNoSelectOobButKeepsPreload() + { + Html.Should().NotContain("hx-select-oob"); Html.Should().Contain("preload=\"mousedown\""); + } [Fact] public void CrossLink_NoTargetBlank() => @@ -50,7 +52,7 @@ public void EmitsCrossLink() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Internal links in assembler use #content-container,#toc-nav (same as isolated). +/// Internal links in assembler carry no per-link htmx attributes. public class AssemblerHtmxInternalLinkTests(ITestOutputHelper output) : LinkTestBase(output, "[Requirements](testing/req.md)") { protected override BuildContext CreateBuildContext( @@ -64,11 +66,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void InternalLink_UsesContentContainerAndTocNav_ForAssembler() - { - // Assembler: same-docset links use #content-container,#toc-nav (same as isolated) - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav\""); - } + public void InternalLink_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -77,7 +76,7 @@ public void InternalLink_UsesContentContainerAndTocNav_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Absolute path links in assembler get HTMX attributes (granular swap when nav roots not same). +/// Absolute path links in assembler carry no per-link htmx attributes. public class AssemblerHtmxAbsolutePathLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [Elasticsearch](/_static/img/observability.png) @@ -95,10 +94,9 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void AbsolutePathLink_GetsHtmxAttributes_ForAssembler() + public void AbsolutePathLink_HasNoSelectOobButKeepsPreload() { - // Assembler: absolute path links get HTMX (granular swap when hasSameTopLevelGroup is false) - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#pages-nav\""); + Html.Should().NotContain("hx-select-oob"); Html.Should().Contain("preload=\"mousedown\""); } @@ -106,7 +104,7 @@ public void AbsolutePathLink_GetsHtmxAttributes_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Reference-style internal links in assembler use #content-container,#toc-nav. +/// Reference-style internal links in assembler carry no per-link htmx attributes. public class AssemblerHtmxReferenceLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [test][test] @@ -126,10 +124,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void ReferenceLink_UsesContentContainerAndTocNav_ForAssembler() - { - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav\""); - } + public void ReferenceLink_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -138,7 +134,7 @@ public void ReferenceLink_UsesContentContainerAndTocNav_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// Empty-text cross-links in assembler still get granular swap (and emit error). +/// Empty-text cross-links in assembler carry no per-link htmx attributes (and emit error). public class AssemblerHtmxEmptyTextCrossLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ @@ -157,8 +153,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void EmptyTextCrossLink_UsesGranularSwap_ForAssembler() => - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav,#pages-nav\""); + public void EmptyTextCrossLink_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmptyTextCrossLink_NoTargetBlank() => @@ -178,7 +174,7 @@ public void EmitsCrossLink() } } -/// Insert-page-title links (empty text, internal target) use #content-container,#toc-nav. +/// Insert-page-title links (empty text, internal target) carry no per-link htmx attributes. public class AssemblerHtmxInsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, """ [](testing/req.md) @@ -196,10 +192,8 @@ protected override BuildContext CreateBuildContext( }; [Fact] - public void InsertPageTitle_UsesContentContainerAndTocNav_ForAssembler() - { - Html.Should().Contain("hx-select-oob=\"#content-container,#toc-nav\""); - } + public void InsertPageTitle_HasNoPerLinkHtmxAttributes() => + Html.Should().NotContain("hx-select-oob"); [Fact] public void EmitsNoCrossLink() => Collector.CrossLinks.Should().HaveCount(0); @@ -208,7 +202,7 @@ public void InsertPageTitle_UsesContentContainerAndTocNav_ForAssembler() public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); } -/// HTTP links in assembler do NOT get HTMX attributes (target="_blank" instead). +/// HTTP links in assembler get target="_blank" and no htmx attributes. public class AssemblerHtmxExternalLinkTests(ITestOutputHelper output) : LinkTestBase(output, """ [link to app]({{some-url-with-a-version}}) diff --git a/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs b/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs index 065689032c..da96b38fbe 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationRenderingTests.cs @@ -4,8 +4,10 @@ using AwesomeAssertions; using Elastic.Codex.Navigation; +using Elastic.Documentation; using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Navigation.Isolated.Node; +using Elastic.Documentation.Site; using Elastic.Documentation.Site.Navigation; using RazorSlices; @@ -237,17 +239,31 @@ public void GroupLandingPage_HasAllMembersAsNavigationItems() private static async Task RenderNavigation( IRootNavigationItem navigation) { - var renderModel = NavigationRenderModel.Create( + var topLevelItems = navigation.NavigationItems.OfType>(); + // ContentHash distinguishes trees that share a navigation.Id (projectless repos). + var contentHash = NavigationRenderModel.Create( tree: navigation, - topLevelItems: navigation.NavigationItems.OfType>(), + topLevelItems: topLevelItems, isUsingNavigationDropdown: false, isPrimaryNavEnabled: false, - isGlobalAssemblyBuild: false); - var html = await _TocTree.Create(renderModel).RenderAsync(cancellationToken: TestContext.Current.CancellationToken); + isGlobalAssemblyBuild: false).ContentHash; + var model = new NavigationViewModel + { + Title = navigation.NavigationTitle, + TitleUrl = navigation.Url, + Tree = navigation, + IsUsingNavigationDropdown = false, + IsPrimaryNavEnabled = false, + IsGlobalAssemblyBuild = false, + TopLevelItems = topLevelItems, + Htmx = new DefaultHtmxAttributeProvider("/"), + BuildType = BuildType.Codex + }; + var html = await _TocTree.Create(model).RenderAsync(cancellationToken: TestContext.Current.CancellationToken); return new NavigationRenderResult { Html = html, - Id = renderModel.ContentHash + Id = contentHash }; } }