From e301edd16f0261303b9a4e53b999d1430c918997 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 16 Oct 2026 13:00:56 +0200 Subject: [PATCH 1/3] Group the API product switcher like the catalog page The product menu listed every API in one flat run. It now shows the same Elastic Stack and Orchestration bands as the catalog landing page, built from the same grouping so the two cannot drift. Co-Authored-By: Claude Sonnet 5.5 --- .../Infrastructure/ApiHubSwitcher.cs | 21 +++++++--- .../Infrastructure/ApiViewModel.cs | 8 ++-- .../Assets/api-context-bar.css | 11 +++++ .../Layout/_ApiProductSwitcher.cshtml | 39 ++++++++++++------ src/Elastic.Documentation.Site/_ViewModels.cs | 5 ++- .../ApiHubSwitcherTests.cs | 41 ++++++++++--------- .../ApiLayoutVersionDropdownTests.cs | 8 ++-- .../ApiPagesNavRenderingTests.cs | 9 +++- .../Rendering/SecondaryNavRenderingTests.cs | 17 +++++++- 9 files changed, 109 insertions(+), 50 deletions(-) diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs index 4d5176e195..d7d66f8c74 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs @@ -9,17 +9,26 @@ namespace Elastic.ApiExplorer.Infrastructure; public static class ApiHubSwitcher { - public static IReadOnlyList Build(IReadOnlyList entries, string? currentApiKey) + /// The catalog's groups, so the menu and the landing page sort the APIs into the same bands. + public static IReadOnlyList Build(IReadOnlyList entries, string? currentApiKey) { if (entries.Count == 0 || currentApiKey is null) return []; return [ - .. entries - .OrderBy(e => ApiCatalogOrder.Rank(e.Key)) - .ThenBy(e => e.Title, StringComparer.OrdinalIgnoreCase) - .ThenBy(e => e.Key, StringComparer.Ordinal) - .Select(e => new ApiContextProduct(e.Title, e.Url, e.Key == currentApiKey, e.IconSvg)) + .. ApiCatalogViewModel.Group(entries).Select( + g => new ApiContextProductGroup( + g.Title, + [ + .. g + .Entries + .OrderBy(e => ApiCatalogOrder.Rank(e.Key)) + .ThenBy(e => e.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Key, StringComparer.Ordinal) + .Select(e => new ApiContextProduct(e.Title, e.Url, e.Key == currentApiKey, e.IconSvg)) + ] + ) + ) ]; } } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index b47d80f541..80a97b2cf0 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -142,13 +142,15 @@ private ApiContextBar BuildApiContext(string docTitle, string catalogUrl, string { var productId = RenderContext.Product?.Id ?? RenderContext.CurrentApiKey; var versions = RenderContext.VersionSwitcherItems; - var products = ApiHubSwitcher.Build(RenderContext.CatalogEntries, RenderContext.CurrentApiKey); + var productGroups = ApiHubSwitcher.Build(RenderContext.CatalogEntries, RenderContext.CurrentApiKey); // Same text as the menu row, so the bar and the menu name the API alike. - var title = products.FirstOrDefault(static p => p.Selected)?.Label ?? RenderContext.Product?.DisplayName ?? docTitle; + var title = productGroups.SelectMany(static g => g.Products).FirstOrDefault(static p => p.Selected)?.Label + ?? RenderContext.Product?.DisplayName + ?? docTitle; return new ApiContextBar( title, ProductIcons.Get(productId), - products, + productGroups, catalogUrl, [.. versions.Select(static v => new NavigationSelectOption(v.Label, v.Url, v.Selected))], new ApiSpecDownload(ApiOutputPaths.JsonUrl(specRootUrl), ApiOutputPaths.YamlUrl(specRootUrl)) diff --git a/src/Elastic.Documentation.Site/Assets/api-context-bar.css b/src/Elastic.Documentation.Site/Assets/api-context-bar.css index 86c5f7612c..e22c0eff06 100644 --- a/src/Elastic.Documentation.Site/Assets/api-context-bar.css +++ b/src/Elastic.Documentation.Site/Assets/api-context-bar.css @@ -127,6 +127,17 @@ gap: 8px; } + .api-context-menu-group-label { + padding: 4px 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 16px; + text-transform: uppercase; + color: #516381; + user-select: none; + } + .api-context-menu-sep { height: 1px; margin: 6px 0; diff --git a/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml b/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml index db30eea6fc..7451d41433 100644 --- a/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml @@ -1,7 +1,7 @@ @using Microsoft.AspNetCore.Html @inherits RazorSlice @* The menu is a popover (top layer), positioned by api-menu.ts. *@ -@if (Model.Products.Count > 0) +@if (Model.ProductGroups.Count > 0) {
- @foreach (var product in Model.Products) + @for (var g = 0; g < Model.ProductGroups.Count; g++) { - var selectedClass = product.Selected ? " secondary-nav-dropdown-link--selected" : ""; - - @if (product.IconSvg is not null) + var group = Model.ProductGroups[g]; + var labelId = "api-hub-group-" + g; + if (g > 0) + { + + } +
+ @if (group.Title is not null) { - +
@group.Title
} - @product.Label -
+ @foreach (var product in group.Products) + { + var selectedClass = product.Selected ? " secondary-nav-dropdown-link--selected" : ""; + + @if (product.IconSvg is not null) + { + + } + @product.Label + + } +
} @if (Model.HubUrl is not null) { diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index f9a20d23c8..9b112ddaba 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -42,11 +42,14 @@ public sealed record ApiSpecDownload(string JsonUrl, string YamlUrl); /// One API product in the context bar's product menu. public sealed record ApiContextProduct(string Label, string Url, bool Selected, string? IconSvg); +/// A labelled band of the product menu. is null for a group that needs no label. +public sealed record ApiContextProductGroup(string? Title, IReadOnlyList Products); + /// The identity of the current API: product, its logo, the other products, the versions and the spec downloads. public sealed record ApiContextBar( string Title, string? IconSvg, - IReadOnlyList Products, + IReadOnlyList ProductGroups, string? HubUrl, IReadOnlyList Versions, ApiSpecDownload Spec diff --git a/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs index cd7eead28d..3fd32d8738 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs @@ -31,13 +31,21 @@ public void Build_OrdersEntriesAlphabeticallyByTitle() { var items = ApiHubSwitcher.Build([Entry("kibana", "Kibana"), Entry("elasticsearch", "Elasticsearch")], currentApiKey: "kibana"); - items.Select(i => i.Label).Should().Equal("Elasticsearch", "Kibana"); + items.SelectMany(g => g.Products).Select(i => i.Label).Should().Equal("Elasticsearch", "Kibana"); } [Test] - public void Build_ListsStackWithVariantsThenCloudThenTheRestByTitle() + public void Build_SingleGroupHasNoTitle() { - var items = ApiHubSwitcher.Build( + var groups = ApiHubSwitcher.Build([Entry("kibana", "Kibana"), Entry("elasticsearch", "Elasticsearch")], currentApiKey: "kibana"); + + groups.Should().ContainSingle().Which.Title.Should().BeNull(); + } + + [Test] + public void Build_GroupsMatchTheCatalogLanding() + { + var groups = ApiHubSwitcher.Build( [ Entry("zeta", "Zeta API"), Entry("kibana-serverless", "Kibana Serverless APIs"), @@ -53,21 +61,14 @@ public void Build_ListsStackWithVariantsThenCloudThenTheRestByTitle() currentApiKey: "kibana" ); - items - .Select(i => i.Label) + groups.Select(g => g.Title).Should().Equal("Elastic Stack", "Orchestration", null); + groups[0] + .Products + .Select(p => p.Label) .Should() - .Equal( - "Elasticsearch API", - "Elasticsearch Serverless API", - "Kibana APIs", - "Kibana Serverless APIs", - "Logstash APIs", - "Elastic Cloud API", - "Cloud Billing API", - "Elastic Cloud Serverless API", - "Alpha API", - "Zeta API" - ); + .Equal("Elasticsearch API", "Elasticsearch Serverless API", "Kibana APIs", "Kibana Serverless APIs", "Logstash APIs"); + groups[1].Products.Select(p => p.Label).Should().Equal("Elastic Cloud API", "Cloud Billing API", "Elastic Cloud Serverless API"); + groups[2].Products.Select(p => p.Label).Should().Equal("Alpha API", "Zeta API"); } [Test] @@ -78,8 +79,8 @@ public void Build_SelectedMatchesCurrentKey() currentApiKey: "elasticsearch" ); - items.Count(i => i.Selected).Should().Be(1); - items.Single(i => i.Selected).Label.Should().Be("Elasticsearch"); + items.SelectMany(g => g.Products).Count(i => i.Selected).Should().Be(1); + items.SelectMany(g => g.Products).Single(i => i.Selected).Label.Should().Be("Elasticsearch"); } [Test] @@ -87,7 +88,7 @@ public void Build_ResolvesTheProductIcon() { var items = ApiHubSwitcher.Build([Entry("elasticsearch", "Elasticsearch")], currentApiKey: "elasticsearch"); - items.Should().ContainSingle().Which.IconSvg.Should().Contain(" g.Products).Should().ContainSingle().Which.IconSvg.Should().Contain(" new(key, title, $"/api/doc/{key}/"); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs index aa230cb28c..0d40744a30 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs @@ -49,9 +49,9 @@ public void CreateGlobalLayoutModel_DescribesTheCurrentApiInTheContextBar() layout.ApiContext.Should().NotBeNull(); layout.ApiContext!.Title.Should().Be("Elasticsearch"); layout.ApiContext.HubUrl.Should().Be("/docs/api"); - layout.ApiContext.Products.Select(p => p.Label).Should().Equal("Elasticsearch", "Kibana"); - layout.ApiContext.Products.Should().ContainSingle(p => p.Selected && p.Label == "Elasticsearch"); - layout.ApiContext.Products.Should().OnlyContain(p => p.IconSvg != null); + layout.ApiContext.ProductGroups.SelectMany(g => g.Products).Select(p => p.Label).Should().Equal("Elasticsearch", "Kibana"); + layout.ApiContext.ProductGroups.SelectMany(g => g.Products).Should().ContainSingle(p => p.Selected && p.Label == "Elasticsearch"); + layout.ApiContext.ProductGroups.SelectMany(g => g.Products).Should().OnlyContain(p => p.IconSvg != null); layout.ApiContext.Versions.Should().HaveCount(3); layout.Breadcrumbs.Select(b => b.NavigationTitle).Should().Equal("Docs", "APIs"); layout.Breadcrumbs.Select(b => b.Url).Should().Equal("/docs", "/docs/api"); @@ -63,7 +63,7 @@ public void CreateGlobalLayoutModel_BuildsTheContextBarInIsolatedToo() var layout = CreateLayout(catalogEntries: Catalog); layout.ApiContext.Should().NotBeNull(); - layout.ApiContext!.Products.Should().HaveCount(2); + layout.ApiContext!.ProductGroups.SelectMany(g => g.Products).Should().HaveCount(2); } private static readonly ApiCatalogEntry[] Catalog = diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index 2dbd039a43..8efde958e3 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -291,8 +291,13 @@ private static ApiContextBar CreateApiContext(IReadOnlyList", [ - new ApiContextProduct("Elasticsearch API", "/docs/api/doc/elasticsearch/", true, ""), - new ApiContextProduct("Kibana APIs", "/docs/api/doc/kibana/", false, null) + new ApiContextProductGroup( + "Elastic Stack", + [ + new ApiContextProduct("Elasticsearch API", "/docs/api/doc/elasticsearch/", true, ""), + new ApiContextProduct("Kibana APIs", "/docs/api/doc/kibana/", false, null) + ] + ), + new ApiContextProductGroup(null, [new ApiContextProduct("Alpha API", "/docs/api/doc/alpha/", false, null)]) ], "/docs/api/", [ @@ -194,6 +200,13 @@ public async Task ApiContextBarShowsTheApiAndItsSpecDownload() // Product and version together on the left, the spec download alone on the right. html.Should().Contain("id=\"api-hub-switcher\"").And.Contain("popovertarget=\"api-hub-menu\""); + html.Should().Contain("api-context-menu-group-label\">Elastic Stack
").And.Contain("role=\"group\""); + // A group without a title gets no label, and the separator sits between the groups. + html.Should().NotContain("id=\"api-hub-group-1\""); + var groupOrder = new[] { "Kibana APIs", "api-context-menu-sep", "Alpha API" }.Select( + t => html.IndexOf(t, StringComparison.Ordinal) + ).ToArray(); + groupOrder.Should().OnlyContain(p => p >= 0).And.BeInAscendingOrder(); html.Should().Contain("id=\"api-version-switcher\"").And.Contain("popovertarget=\"api-version-menu\""); html.Should().Contain("Download OpenAPI spec").And.Contain("popovertarget=\"api-spec-menu\""); html.Should().Contain("href=\"/docs/api/doc/elasticsearch.json\"").And.Contain("download"); From 28ed29482723bfec65f48ac147565eaa5b0d61b6 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 16 Oct 2026 13:04:04 +0200 Subject: [PATCH 2/3] Make the API product menu group labels more subtle Co-Authored-By: Claude Sonnet 5.5 --- src/Elastic.Documentation.Site/Assets/api-context-bar.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-context-bar.css b/src/Elastic.Documentation.Site/Assets/api-context-bar.css index e22c0eff06..1ec6588891 100644 --- a/src/Elastic.Documentation.Site/Assets/api-context-bar.css +++ b/src/Elastic.Documentation.Site/Assets/api-context-bar.css @@ -129,12 +129,12 @@ .api-context-menu-group-label { padding: 4px 8px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.06em; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; line-height: 16px; text-transform: uppercase; - color: #516381; + color: #6a7a96; user-select: none; } From e1b9cebb53946d88e44da28e5a75fb74510f0f45 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 16 Oct 2026 13:06:02 +0200 Subject: [PATCH 3/3] fix: darken the group label color to meet WCAG AA contrast (per review by @infra-vault-gh-plugin-prod) Co-Authored-By: Claude Sonnet 5.5 --- src/Elastic.Documentation.Site/Assets/api-context-bar.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-context-bar.css b/src/Elastic.Documentation.Site/Assets/api-context-bar.css index 1ec6588891..c297b31e0e 100644 --- a/src/Elastic.Documentation.Site/Assets/api-context-bar.css +++ b/src/Elastic.Documentation.Site/Assets/api-context-bar.css @@ -134,7 +134,7 @@ letter-spacing: 0.04em; line-height: 16px; text-transform: uppercase; - color: #6a7a96; + color: #637290; user-select: none; }