Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Elastic.Documentation.Site/Assets/secondary-nav.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@layer components {
.secondary-nav-mobile-menu summary {
list-style: none;
}

.secondary-nav-mobile-menu summary::-webkit-details-marker {
display: none;
}

.secondary-nav-mobile-chevron {
flex-shrink: 0;
color: var(--color-grey-60);
transition: transform 0.15s ease;
}

.secondary-nav-mobile-menu[open] .secondary-nav-mobile-chevron {
transform: rotate(180deg);
}
}
1 change: 1 addition & 0 deletions src/Elastic.Documentation.Site/Assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
@import './markdown/image-carousel.css';
@import './markdown/hr.css';
@import './modal.css';
@import './secondary-nav.css';
@import './secondary-nav-dropdown.css';
@import './archive.css';
@import './markdown/stepper.css';
Expand Down
62 changes: 62 additions & 0 deletions src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml
Original file line number Diff line number Diff line change
@@ -1,8 +1,70 @@
@using Elastic.Documentation.Configuration.Toc
@using Microsoft.AspNetCore.Html
@inherits RazorSlice<Elastic.Documentation.Site.GlobalLayoutViewModel>
@{
var topNavLinks = Model.TopNav?.Items.OfType<TopNavLinkItem>().ToArray() ?? [];
var currentSectionId = Model.CurrentNavigationItem.NavigationRoot?.Id;
var activeMobileLink = topNavLinks.FirstOrDefault(item => item.IsActive(currentSectionId));
var mobileSummary = activeMobileLink?.Title ?? "Docs Home";
var hasMobileDrawerControls = topNavLinks.Length > 0 || Model.ShowVersionDropdown;
}
<aside class="sidebar font-sans bg-white fixed md:sticky shadow-2xl md:shadow-none left-full group-has-[#pages-nav-hamburger:checked]/body:left-0 bottom-0 md:left-auto top-[calc(var(--offset-top)+1px)] w-[80%] md:w-auto shrink-0 border-r border-r-grey-20 z-[10000] md:z-auto transition-[top,max-height] duration-300 md:col-start-1 md:row-start-1">
<nav
id="pages-nav"
class="sidebar-nav h-full simple-scrollbar">
@if (hasMobileDrawerControls)
{
<div class="md:hidden border-b border-grey-20 px-4 py-4">
@if (topNavLinks.Length > 0)
{
<div class="font-sans">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-grey-70">Section</div>
<details class="secondary-nav-mobile-menu">
<summary class="flex w-full cursor-pointer select-none items-center justify-between rounded-md border border-grey-20 bg-white px-3 py-2 text-sm font-semibold text-ink">
<span>@mobileSummary</span>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"
class="secondary-nav-mobile-chevron">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"/>
</svg>
</summary>
<div class="mt-2 rounded-md border border-grey-20 bg-white py-1 text-sm font-semibold shadow-sm">
@foreach (var link in topNavLinks)
{
var isActive = link.IsActive(currentSectionId);
var mobileStateClass = isActive ? "text-blue-elastic" : "text-ink-light hover:text-blue-elastic active:text-blue-elastic-100";
@if (link.IsExternal)
{
<a href="@link.Url"
target="_blank"
rel="noopener noreferrer"
class="block px-3 py-2 @mobileStateClass">
@link.Title
<span class="sr-only">(opens in a new tab)</span>
</a>
}
else
{
<a href="@link.Url"
preload="mousedown"
class="block px-3 py-2 @mobileStateClass">@link.Title</a>
}
}
</div>
</details>
</div>
}
@if (Model.ShowVersionDropdown)
{
<div class="mt-4 font-sans">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-grey-70">Version</div>
<version-dropdown all-versions-url="@Model.AllVersionsUrl" current-version='@(Model.CurrentVersion)' items='@(new HtmlString(Model.VersionDropdownSerializedModel))'>
<div class="h-8">@*height of dropdown button*@</div>
</version-dropdown>
</div>
}
</div>
}
@(new HtmlString(Model.NavigationHtml))
</nav>
@* ReSharper disable once Html.IdNotResolved *@
Expand Down
6 changes: 3 additions & 3 deletions src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
class="bg-grey-10 border-b border-grey-20">
@if (topNav is not null)
{
@* navigation-preview: scrollable section-driven top nav.
@* navigation-preview: desktop section-driven top nav.
secondary-nav-dropdown.css lets an open dropdown escape the overflow clipping. *@
<div class="
secondary-nav-scroll-container
w-full min-w-0 overflow-x-auto
max-w-(--max-layout-width) flex mx-auto justify-start items-center
hidden w-full min-w-0 overflow-x-auto md:flex
max-w-(--max-layout-width) mx-auto justify-start items-center
px-4 py-6">
<ul class="flex gap-6 font-sans font-semibold text-sm text-ink-light md:text-base">
@foreach (var item in topNav.Items)
Expand Down
8 changes: 8 additions & 0 deletions src/Elastic.Documentation.Site/_ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ public TopNavRenderModel? TopNav
}
}

public string? VersionDropdownSerializedModel { get; init; }

public string? CurrentVersion { get; init; }

public string? AllVersionsUrl { get; init; }

public bool ShowVersionDropdown { get; init; }

/// <summary>
/// When the current page is a hidden nav item (e.g. an individual detection rule page),
/// the URL of its nearest visible ancestor. The client uses this to highlight the correct
Expand Down
6 changes: 3 additions & 3 deletions src/Elastic.Markdown/Layout/_TableOfContents.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
@using Microsoft.AspNetCore.Html
@inherits RazorSlice<Elastic.Markdown.MarkdownLayoutViewModel>
<aside class="sidebar md:block w-full lg:max-w-65 order-1 lg:order-2">
<nav id="toc-nav" class="sidebar-nav lg:h-full flex flex-row-reverse lg:block items-center justify-between md:justify-start gap-4 simple-scrollbar">
@if (Model.Features.PrimaryNavEnabled && !Model.VersioningSystem.IsVersionless && Model.BuildType != BuildType.Codex && Model.Branding is null)
<nav id="toc-nav" class="sidebar-nav lg:h-full flex lg:block items-center justify-start md:justify-start gap-4 simple-scrollbar">
@if (Model.ShowVersionDropdown)
{
<div class="mt-4">
<div class="mt-4 hidden md:block">
<version-dropdown all-versions-url="@Model.AllVersionsUrl" current-version='@(Model.CurrentVersion)' items='@(new HtmlString(Model.VersionDropdownSerializedModel))'>
<div class="h-8">@*height of dropdown button*@</div>
</version-dropdown>
Expand Down
6 changes: 0 additions & 6 deletions src/Elastic.Markdown/MarkdownLayoutViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,6 @@ public record MarkdownLayoutViewModel : GlobalLayoutViewModel

public required VersioningSystem VersioningSystem { get; init; }

public required string? VersionDropdownSerializedModel { get; init; }

public required string? CurrentVersion { get; init; }

public required string? AllVersionsUrl { get; init; }

public string? RedirectUrl { get; init; }

/// <summary>The resolved right-gutter CTA for this page (docset.yml template, or the built-in default).</summary>
Expand Down
1 change: 1 addition & 0 deletions src/Elastic.Markdown/Page/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
VersioningSystem = Model.VersioningSystem,
VersionDropdownSerializedModel = JsonSerializer.Serialize(Model.VersionDropdownItems,
ViewModelSerializerContext.Default.VersionDropDownItemViewModelArray),
ShowVersionDropdown = Model.Features.PrimaryNavEnabled && !Model.VersioningSystem.IsVersionless && Model.BuildType != BuildType.Codex && Model.Branding is null,
// Header properties for isolated mode
HeaderTitle = Model.DocSetName,
HeaderVersion = null,
Expand Down
24 changes: 23 additions & 1 deletion src/Elastic.Markdown/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@
</div>
</div>
}

private async Task RenderLanding()
{
<div class="relative">
<input type="checkbox" class="hidden" id="pages-nav-hamburger">
@if (!string.IsNullOrWhiteSpace(Model.NavigationHtml) || Model.TopNav is not null || Model.ShowVersionDropdown)
{
@* ReSharper disable once Html.IdNotResolved *@
<label role="button" class="absolute left-4 top-3 z-10 md:hidden cursor-pointer" for="pages-nav-hamburger">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12"/>
</svg>
</label>
}
@await RenderPartialAsync(_LandingPage.Create(Model))
<div class="md:hidden">
@await RenderPartialAsync(_PagesNav.Create(Model))
</div>
</div>
}
}

@if (RenderHeaderAndFooter)
Expand All @@ -108,7 +130,7 @@
await RenderPartialAsync(_NotFound.Create(Model));
break;
case MarkdownPageLayout.LandingPage:
await RenderPartialAsync(_LandingPage.Create(Model));
await RenderLanding();
break;
case MarkdownPageLayout.Archive:
await RenderPartialAsync(_Archive.Create(Model));
Expand Down
80 changes: 76 additions & 4 deletions tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ public class SecondaryNavRenderingTests(ITestOutputHelper output) : Documentatio
])
]);

private static readonly TopNavRenderModel LinkOnlyTopNav = new([
new TopNavLinkItem("Guides", "/docs/guides/", false),
new TopNavLinkItem("Reference", "/docs/reference/", false, SectionId: ReferenceSectionId),
new TopNavLinkItem("APIs", "https://www.elastic.co/docs/api/", true)
]);

[Fact]
public async Task WithoutConfigurationTheBuiltInLinksAreRendered()
{
Expand All @@ -59,13 +65,49 @@ public async Task ConfiguredLinksReplaceTheBuiltInOnes()
html.Should().Contain("id=\"htmx-indicator\"");
}

[Fact]
public async Task TopNavLinksRenderInMobileDrawer()
{
var html = await RenderPagesNav(LinkOnlyTopNav, currentUrl: "/docs/reference/some-page", root: new MockSectionRoot(ReferenceSectionId));

html.Should().Contain("secondary-nav-mobile-menu");
html.Should().Contain(">Section<");
html.Should().Contain("<span>Reference</span>");
html.Should().Contain("href=\"/docs/guides/\"");
html.Should().Contain("href=\"/docs/reference/\"");
html.Should().Contain("href=\"https://www.elastic.co/docs/api/\"");
html.Should().Contain("target=\"_blank\"");
html.Should().Contain("(opens in a new tab)");
}

[Fact]
public async Task TopNavMobileDrawerUsesDocsHomeFallback()
{
var html = await RenderPagesNav(LinkOnlyTopNav, currentUrl: "/docs/");

html.Should().Contain("<span>Docs Home</span>");
}

[Fact]
public async Task MobileDrawerRendersVersionDropdown()
{
var html = await RenderPagesNav(topNav: null, currentUrl: "/docs/", showVersionDropdown: true);

html.Should().Contain(">Version<");
html.Should().Contain("<version-dropdown");
html.Should().Contain("all-versions-url=\"/docs/versions/\"");
html.Should().Contain("8.19");
html.Should().Contain("items='[]'");
}

[Fact]
public async Task WithTopNavTheBarIsLeftAlignedAndCarriesNoBrandLink()
{
var html = await Render(TopNav, "/docs/");

html.Should().NotContain(">Docs<");
html.Should().Contain("justify-start").And.NotContain("justify-between");
html.Should().Contain("secondary-nav-scroll-container");
html.Should().Contain("justify-start");
}

[Fact]
Expand Down Expand Up @@ -107,12 +149,14 @@ public async Task TheItemCoveringTheCurrentPageIsMarkedActive()
// Active state is determined by NavigationRoot.Id matching the tab's SectionId.
var refRoot = new MockSectionRoot(ReferenceSectionId);
var reference = await Render(TopNav, currentUrl: "/docs/reference/some-page", root: refRoot);
var referenceListItem = reference.Split("<li").First(li => li.Contains("Reference"));
var desktopTabs = reference.Split("<ul").Last();
var referenceListItem = desktopTabs.Split("<li").First(li => li.Contains("Reference"));
referenceListItem.Should().Contain("text-blue-elastic").And.NotContain("hover:text-blue-elastic");

// Dropdown tabs have no tree backing — they are never marked active via section ID.
var product = await Render(TopNav, currentUrl: "/docs/products/elasticsearch/index");
var productListItem = product.Split("<li").First(li => li.Contains("Products"));
desktopTabs = product.Split("<ul").Last();
var productListItem = desktopTabs.Split("<li").First(li => li.Contains("Products"));
// "hover:text-blue-elastic" present means the inactive CSS variant is applied, not the active one.
productListItem.Should().Contain("hover:text-blue-elastic")
.And.NotContain("relative text-blue-elastic\"");
Expand All @@ -131,6 +175,34 @@ private async Task<string> Render(
TopNavRenderModel? topNav,
string currentUrl,
IRootNavigationItem<INavigationModel, INavigationItem>? root = null)
{
var model = CreateModel(topNav, currentUrl, root);

return await _SecondaryNav.Create(model).RenderAsync(cancellationToken: TestContext.Current.CancellationToken);
}

private async Task<string> RenderPagesNav(
TopNavRenderModel? topNav,
string currentUrl,
IRootNavigationItem<INavigationModel, INavigationItem>? root = null,
bool showVersionDropdown = false)
{
var model = CreateModel(topNav, currentUrl, root) with
{
NavigationHtml = "<ul id=\"nav-tree-test\"></ul>",
ShowVersionDropdown = showVersionDropdown,
AllVersionsUrl = "/docs/versions/",
CurrentVersion = "8.19",
VersionDropdownSerializedModel = "[]"
};

return await _PagesNav.Create(model).RenderAsync(cancellationToken: TestContext.Current.CancellationToken);
}

private GlobalLayoutViewModel CreateModel(
TopNavRenderModel? topNav,
string currentUrl,
IRootNavigationItem<INavigationModel, INavigationItem>? root = null)
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("/docs");
Expand Down Expand Up @@ -159,7 +231,7 @@ private async Task<string> Render(
StaticFileContentHashProvider = new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context)),
};

return await _SecondaryNav.Create(model).RenderAsync(cancellationToken: TestContext.Current.CancellationToken);
return model;
}

/// <summary>The secondary nav only reads <see cref="INavigationItem.Url"/> off the current page.</summary>
Expand Down
Loading