Skip to content
Open
14 changes: 14 additions & 0 deletions config/navigation_preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ toc:
- section: APIs
external: https://www.elastic.co/docs/api/

###########
# PRODUCTS #
###########
- section: Products
dropdown:
- title: Elasticsearch
url: solutions/search/elasticsearch
- title: Observability
url: solutions/observability
- title: Security
url: solutions/security
- title: Elastic Cloud
url: deploy-manage/deploy/elastic-cloud

#############
# REFERENCE #
#############
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@ public interface ISiteNavigationEntry
IReadOnlyCollection<SiteTableOfContentsRef> Children { get; }
}

/// <summary>A link entry within a <c>dropdown:</c> section.</summary>
public record SiteDropdownLinkRef(string Title, string Url);

public record SiteSectionRef(
string Title,
string? ExternalUrl,
IReadOnlyCollection<SiteTableOfContentsRef> Children
IReadOnlyCollection<SiteTableOfContentsRef> Children,
IReadOnlyCollection<SiteDropdownLinkRef> DropdownLinks
) : ISiteNavigationEntry
{
public bool IsExternal => ExternalUrl is not null;
/// <summary>True when the section carries a dropdown list instead of tree children.</summary>
public bool IsDropdown => DropdownLinks.Count > 0;
}

[YamlSerializable]
Expand Down Expand Up @@ -207,6 +213,35 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria
}
value = childrenList;
}
else if (key.Value is "dropdown")
{
var dropdownList = new List<SiteDropdownLinkRef>();
_ = parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
if (!parser.TryConsume<MappingStart>(out _))
continue;
string? itemTitle = null;
string? itemUrl = null;
while (!parser.TryConsume<MappingEnd>(out _))
{
var itemKey = parser.Consume<Scalar>();
if (parser.Accept<Scalar>(out var itemValue))
{
_ = parser.MoveNext();
if (itemKey.Value is "title")
itemTitle = itemValue.Value;
else if (itemKey.Value is "url")
itemUrl = itemValue.Value;
}
else
parser.SkipThisAndNestedEvents();
}
if (itemTitle is not null && itemUrl is not null)
dropdownList.Add(new SiteDropdownLinkRef(itemTitle, itemUrl));
}
value = dropdownList;
}
else
parser.SkipThisAndNestedEvents();
}
Expand All @@ -222,7 +257,10 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria
IReadOnlyCollection<SiteTableOfContentsRef> children = dictionary.TryGetValue("children", out var childrenObj) && childrenObj is List<SiteTableOfContentsRef> refs
? refs
: [];
return new SiteSectionRef(sectionTitle, externalUrl, children);
IReadOnlyCollection<SiteDropdownLinkRef> dropdownLinks = dictionary.TryGetValue("dropdown", out var dropdownObj) && dropdownObj is List<SiteDropdownLinkRef> dLinks
? dLinks
: [];
return new SiteSectionRef(sectionTitle, externalUrl, children, dropdownLinks);
}

if (dictionary.TryGetValue("toc", out var tocPath) && tocPath is string sourceString)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ namespace Elastic.Documentation.Navigation.Assembler;
/// <summary>
/// Builds a <see cref="TopNavRenderModel"/> from the top-level navigation entries in
/// <c>navigation_preview.yml</c> when the <c>navigation-preview</c> feature flag is on.
/// Supports two entry shapes:
/// Supports three <c>section:</c> shapes:
/// <list type="bullet">
/// <item><c>toc:</c> — a single navigation root, becomes one tab.</item>
/// <item><c>section:</c> — a named group of toc: refs, becomes one tab whose active state
/// matches the section's navigation root. External sections become external-link tabs.</item>
/// <item><c>external:</c> — external-link tab, never active.</item>
/// <item><c>dropdown:</c> — a panel of links, never active (no tree membership).</item>
/// <item><c>children:</c> — maps to a <see cref="SectionNavigation"/> tree node; active when the
/// current page's NavigationRoot.Id equals the section's Id.</item>
/// </list>
/// Plain <c>toc:</c> entries also produce one tab, active when NavigationRoot.Id == item.Id.
/// Active state is determined by comparing the current page's NavigationRoot.Id to each
/// tab's stored <see cref="TopNavLinkItem.SectionId"/>.
/// </summary>
Expand Down Expand Up @@ -48,6 +50,15 @@ public static class SectionTopNavBuilder
{
items.Add(new TopNavLinkItem(section.Title, section.ExternalUrl!, IsExternal: true));
}
else if (section.IsDropdown)
{
// Resolve each link URL against the site prefix so hrefs in the template are site-absolute.
var sitePrefix = navigation.Url.TrimEnd('/');
var links = section.DropdownLinks
.Select(l => new TopNavLinkItem(l.Title, sitePrefix + "/" + l.Url.TrimStart('/'), IsExternal: false))
.ToArray();
items.Add(new TopNavDropdownItem(section.Title, [new TopNavGroup(null, links)]));
}
else if (sectionsByTitle.TryGetValue(section.Title, out var sectionNav))
{
// All pages within the section have NavigationRoot = sectionNav,
Expand Down
11 changes: 8 additions & 3 deletions src/Elastic.Documentation.Site/Assets/secondary-nav.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
@layer components {
.secondary-nav-mobile-menu summary {
.secondary-nav-mobile-menu summary,
.secondary-nav-mobile-submenu summary {
list-style: none;
}

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

Expand All @@ -13,7 +15,10 @@
transition: transform 0.15s ease;
}

.secondary-nav-mobile-menu[open] .secondary-nav-mobile-chevron {
.secondary-nav-mobile-menu[open] > summary .secondary-nav-mobile-chevron,
.secondary-nav-mobile-submenu[open]
> summary
.secondary-nav-mobile-chevron {
transform: rotate(180deg);
}
}
68 changes: 50 additions & 18 deletions src/Elastic.Documentation.Site/Layout/_PagesNav.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
@using Microsoft.AspNetCore.Html
@inherits RazorSlice<Elastic.Documentation.Site.GlobalLayoutViewModel>
@{
var topNavLinks = Model.TopNav?.Items.OfType<TopNavLinkItem>().ToArray() ?? [];
var topNavItems = Model.TopNav?.Items.ToArray() ?? [];
var topNavLinks = topNavItems.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;
var hasMobileDrawerControls = topNavItems.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
Expand All @@ -15,7 +16,7 @@
@if (hasMobileDrawerControls)
{
<div class="md:hidden border-b border-grey-20 px-4 py-4">
@if (topNavLinks.Length > 0)
@if (topNavItems.Length > 0)
{
<div class="font-sans">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-grey-70">Section</div>
Expand All @@ -29,25 +30,56 @@
</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)
@foreach (var item in topNavItems)
{
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)
if (item is TopNavDropdownItem dropdown)
{
<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>
<details class="secondary-nav-mobile-submenu">
<summary class="flex cursor-pointer select-none items-center justify-between px-3 py-2 text-ink-light hover:text-blue-elastic active:text-blue-elastic-100">
<span>@dropdown.Title</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="border-t border-grey-20 py-1">
@foreach (var group in dropdown.Groups)
{
if (group.Label is not null)
{
<div class="px-6 pt-2 pb-1 text-xs font-bold text-grey-80">@group.Label</div>
}
foreach (var link in group.Links)
{
<a class="block px-6 py-2 text-ink-light hover:text-blue-elastic active:text-blue-elastic-100"
href="@link.Url"
preload="mousedown">@link.Title</a>
}
}
</div>
</details>
}
else
else if (item is TopNavLinkItem link)
{
<a href="@link.Url"
preload="mousedown"
class="block px-3 py-2 @mobileStateClass">@link.Title</a>
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>
Expand Down
2 changes: 1 addition & 1 deletion src/Elastic.Markdown/Layout/_TableOfContents.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
@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">
<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 hidden md:block">
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
43 changes: 43 additions & 0 deletions tests/Navigation.Tests/Assembler/SectionNavigationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,49 @@ string[] GetLeafUrls(string siteNavYaml)
// SectionTopNavBuilder: tab built from section node children
// ──────────────────────────────────────────────────────────────

[Fact]
public void SectionTopNavBuilder_BuildsDropdownTab_WhenDropdownLinksPresent()
{
// YAML that has both a children section (to give SiteNavigation a valid index)
// and a dropdown section to exercise the new dropdown path.
// language=yaml
var yaml = """
toc:
- section: Guides
children:
- toc: observability://
path_prefix: /observability
- section: Products
dropdown:
- title: Elasticsearch
url: solutions/search/elasticsearch
- title: Observability
url: solutions/observability
""";

var (navigation, _, _) = BuildTwoChildSection(output, yaml);
var navFile = SiteNavigationFile.Deserialize(yaml);

var renderModel = SectionTopNavBuilder.Build(navigation, navFile);

renderModel.Should().NotBeNull();
renderModel.Items.Should().HaveCount(2, "one Guides tab + one Products dropdown");

var dropdown = renderModel.Items[1].Should().BeOfType<TopNavDropdownItem>().Subject;
dropdown.Title.Should().Be("Products");
dropdown.IsActive(currentSectionId: null).Should().BeFalse("dropdown tabs are never active");
dropdown.Groups.Should().HaveCount(1, "flat dropdown: items become one ungrouped group");

var group = dropdown.Groups[0];
group.Label.Should().BeNull("flat dropdown has no group label");
group.Links.Should().HaveCount(2);
group.Links[0].Title.Should().Be("Elasticsearch");
group.Links[0].Url.Should().Be("/docs/solutions/search/elasticsearch",
"site prefix is prepended to the configured url");
group.Links[1].Title.Should().Be("Observability");
group.Links[1].Url.Should().Be("/docs/solutions/observability");
}

[Fact]
public void SectionTopNavBuilder_BuildsTab_WithSectionId()
{
Expand Down
12 changes: 12 additions & 0 deletions tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ public async Task DropdownRendersItsGroupsAndLinks()
html.Should().NotContain("<summary><a");
}

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

html.Should().Contain("secondary-nav-mobile-submenu");
html.Should().Contain("<span>Products</span>");
html.Should().Contain("class=\"px-6 pt-2 pb-1 text-xs font-bold text-grey-80\">Stack products");
html.Should().Contain("href=\"/docs/products/elasticsearch/\"");
html.Should().Contain("href=\"/docs/products/\"");
}

[Fact]
public async Task TheItemCoveringTheCurrentPageIsMarkedActive()
{
Expand Down
Loading