From 4799b30e5007bf8df9dba081f1eea2dee1cb04ab Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Fri, 30 Oct 2026 15:53:49 +0100 Subject: [PATCH 01/12] Add quick links to the sections of an operation page A sticky bar above the sections links to Authorization, Path, Query, Header, Body and Response, and underlines the section in view. It follows the endpoint page proposal. Co-Authored-By: Claude Sonnet 5.5 --- docs/data/openapi/api-explorer.md | 2 + .../Operations/OperationView.cshtml | 5 ++ .../Operations/OperationViewModel.cs | 22 ++++++ .../Operations/_Partials/_SectionLinks.cshtml | 14 ++++ .../Assets/api-docs.css | 50 +++++++++++++ .../Assets/api-docs.ts | 47 ++++++++++++ .../Assets/api-section-links.test.ts | 73 +++++++++++++++++++ .../ApiSupplementalRenderTests.cs | 18 +++++ 8 files changed, 231 insertions(+) create mode 100644 src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml create mode 100644 src/Elastic.Documentation.Site/Assets/api-section-links.test.ts diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 0bd74a5624..c4081765c0 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -320,6 +320,8 @@ A request body leaves out `readOnly` fields, because only the server sets them, A request or response body that declares more than one content type shows a **Content type** select at the top of its body, with the content types that have a schema first. Choosing one swaps the body below it. A response row shows the content type in view as a tag, and the select updates it. A content type with no schema, such as one the spec lists only to attach examples, shows "No schema is declared for this content type." +A bar of quick links sits above the sections of an operation page: **Authorization**, **Path**, **Query**, **Header**, **Body** and **Response**, for the sections the operation has. A parameter or response link shows how many it lists. The bar stays under the page header while you scroll, a link jumps to its section, and the link of the section in view is underlined. + Each section is a collapsible heading, expanded when the page loads. A reader can fold it and open it again. Each status code under **Responses** works the same way, and opening one leaves the others as they are. The **hide properties** toggle on an object field is open on load too, so nested fields show without a click; select it to fold them. Its summary punctuation says what the names are: | Summary | Meaning | diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml index 4c334fd059..a5d0416f76 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -77,6 +77,11 @@ @pageModel.ExternalDocs.LinkText } + @* The design-system stylesheet has no styles for the bar yet, so the flagged page leaves it out. *@ + @if (!Model.BuildContext.Configuration.Features.NavigationPreviewEnabled && Model.SectionLinks is { Count: > 1 } sectionLinks) + { + @(await RenderPartialAsync<_SectionLinks, IReadOnlyList>(sectionLinks)) + } @(await RenderPartialAsync<_AuthorizationSection, AuthorizationSectionModel>(Model.Authorization)) @foreach (var section in pageModel.ParameterSections) { diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs index d01ecd397f..49761b5c8e 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs @@ -38,6 +38,9 @@ public record AuthorizationSectionModel( : Privileges.Any(static r => r.Join == "and") ? SummaryStyle.All : SummaryStyle.List; } +/// One quick link of the bar above an operation's sections; is shown when the section lists several things. +public record SectionLink(string Label, string Anchor, int? Count = null); + public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context) { public required ApiOperation Operation { get; init; } @@ -53,6 +56,25 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context Page.AuthBadge?.Kind == AuthBadgeKind.Optional ); + /// The sections the page renders, in page order, for the quick links bar. + public IReadOnlyList SectionLinks + { + get + { + var links = new List(); + if (Page.RequiredPrivileges.Count > 0) + links.Add(new SectionLink("Authorization", "required-authorization")); + links.AddRange( + Page.ParameterSections.Select(static s => new SectionLink(s.Location.ToString(), s.Header.Anchor, s.Parameters.Items.Count)) + ); + if (Operation.Operation.RequestBody is not null) + links.Add(new SectionLink("Body", "request-body")); + if (Page.Responses.Count > 0) + links.Add(new SectionLink("Response", "responses", Page.Responses.Count)); + return links; + } + } + /// Where the auth badge links: the Authentication page, or the endpoint's own section. public string? AuthBadgeHref => Page.AuthBadge?.Target switch { diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml new file mode 100644 index 0000000000..5928986d64 --- /dev/null +++ b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml @@ -0,0 +1,14 @@ +@inherits RazorSlice> +@* Quick links to the sections below. api-docs.ts marks the link of the section in view with aria-current. *@ + diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 10701284ef..71aa1692bb 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -4210,3 +4210,53 @@ html:has(.api-page-shell) { } } } + +/* Quick links to the sections of an endpoint: one bar that stays under the header while the sections scroll. */ +.api-section-links { + position: sticky; + top: var(--offset-top, 0px); + z-index: 5; + display: flex; + flex-wrap: nowrap; + gap: 24px; + margin: 24px 0 0; + overflow-x: auto; + background: #fff; + border-bottom: 1px solid #e3e8f2; +} + +.api-section-links a { + display: flex; + align-items: center; + gap: 8px; + height: 48px; + border-bottom: 2px solid transparent; + color: #516381; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} + +.api-section-links a:hover { + color: #1d2a3e; +} + +.api-section-links a[aria-current] { + border-bottom-color: #0b64dd; + color: #111c2c; +} + +.api-section-links-count { + padding: 0 8px; + border-radius: 999px; + background: #eef1f7; + color: #516381; + font-size: 12px; + line-height: 20px; +} + +/* A section reached from the bar stops below the bar instead of under it. */ +.api-section-links ~ .api-param-section, +.api-section-links ~ .responses-block { + scroll-margin-top: calc(var(--offset-top, 0px) + 64px); +} diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.ts b/src/Elastic.Documentation.Site/Assets/api-docs.ts index 17c5e6cda7..f986d2d91c 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.ts +++ b/src/Elastic.Documentation.Site/Assets/api-docs.ts @@ -311,7 +311,54 @@ function updateExamplesButtonVisibility(): void { examplesBtn.classList.toggle('visible', belowFold) } +/** The section link whose section last crossed the line below the sticky bar; the last link at the page bottom. */ +export function updateSectionLinks(): void { + const links = Array.from( + document.querySelectorAll( + '[data-api-section-links] a[data-section-link]' + ) + ) + if (links.length === 0) return + const line = + (links[0].closest('nav')?.getBoundingClientRect().bottom ?? 0) + 16 + let current = links[0] + for (const link of links) { + const section = document.getElementById(link.dataset.sectionLink ?? '') + if (section && section.getBoundingClientRect().top <= line) + current = link + } + const atBottom = + window.innerHeight + window.scrollY >= document.body.scrollHeight - 4 + if (atBottom) current = links[links.length - 1] + for (const link of links) { + if (link === current) link.setAttribute('aria-current', 'location') + else link.removeAttribute('aria-current') + } +} + +let sectionLinksBound = false + +function initSectionLinks(): void { + updateSectionLinks() + if (sectionLinksBound) return + sectionLinksBound = true + let ticking = false + window.addEventListener( + 'scroll', + () => { + if (ticking) return + ticking = true + window.requestAnimationFrame(() => { + updateSectionLinks() + ticking = false + }) + }, + { passive: true } + ) +} + function initOperationView(): void { + initSectionLinks() // Bound once: htmx swaps the section, so the handler looks the elements up each time if (!examplesScrollBound) { examplesScrollBound = true diff --git a/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts b/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts new file mode 100644 index 0000000000..7e3e9a3739 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts @@ -0,0 +1,73 @@ +import { updateSectionLinks } from './api-docs' +import { beforeEach, describe, expect, it } from 'vitest' + +const markup = ` + +
+
+
` + +function placeSections(tops: Record): void { + for (const [id, top] of Object.entries(tops)) + document.getElementById(id)!.getBoundingClientRect = () => + ({ top, bottom: top, height: 0 }) as DOMRect +} + +const current = () => + Array.from(document.querySelectorAll('[data-section-link]')) + .filter((a) => a.hasAttribute('aria-current')) + .map((a) => (a as HTMLElement).dataset.sectionLink) + +describe('section links', () => { + beforeEach(() => { + document.body.innerHTML = markup + // jsdom has no layout: a page taller than the window, scrolled to the top + Object.defineProperty(document.body, 'scrollHeight', { + configurable: true, + value: 5000, + }) + }) + + it('marks the first link while no section has reached the bar', () => { + placeSections({ + 'path-params': 400, + 'query-params': 900, + responses: 1500, + }) + updateSectionLinks() + expect(current()).toEqual(['path-params']) + }) + + it('marks the last section that has passed the bar', () => { + placeSections({ + 'path-params': -600, + 'query-params': 10, + responses: 700, + }) + updateSectionLinks() + expect(current()).toEqual(['query-params']) + }) + + it('marks the last link at the bottom of the page', () => { + placeSections({ + 'path-params': -600, + 'query-params': -300, + responses: 600, + }) + Object.defineProperty(document.body, 'scrollHeight', { + configurable: true, + value: window.innerHeight, + }) + updateSectionLinks() + expect(current()).toEqual(['responses']) + }) + + it('does nothing on a page without the bar', () => { + document.body.innerHTML = '

no bar

' + expect(() => updateSectionLinks()).not.toThrow() + }) +}) diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs index 51f1b44a30..a962ed1e60 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs @@ -185,6 +185,24 @@ public async Task Operation_NestedProperties_UseShowPropertiesDisclosure() html.Should().NotContain("toggle-label\">Hide "); } + [Test] + public async Task Operation_SectionLinks_ListEachSectionInPageOrderWithCounts() + { + var nav = SearchOperation(); + var html = await RenderAsync(nav.Model, nav); + + html.Should().Contain("data-api-section-links"); + html.Should().MatchRegex( + """href="#query-params" data-section-link="query-params">\s*Query\s*\d+""" + ); + html.Should().Contain("href=\"#request-body\" data-section-link=\"request-body\""); + html.Should().Contain("href=\"#responses\" data-section-link=\"responses\""); + html + .IndexOf("data-api-section-links", StringComparison.Ordinal) + .Should() + .BeLessThan(html.IndexOf("id=\"query-params\"", StringComparison.Ordinal), "the bar sits above the sections it links to"); + } + [Test] public async Task Operation_SectionHeadings_TitleIsHeaderLinkOutsideToggle() { From ee6bb59eb336f86a05de649ff16598ad361e59c8 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Fri, 30 Oct 2026 16:08:14 +0100 Subject: [PATCH 02/12] Reuse the outline scroll-spy and count pill for section links The bar now binds to the existing initToc scroll-spy and the example chip count pill instead of its own copies. Sections reached from the bar also stop below it, not under it. Co-Authored-By: Claude Sonnet 5.5 --- .../Operations/_Partials/_SectionLinks.cshtml | 8 +- .../Assets/api-docs.css | 13 +-- .../Assets/api-docs.ts | 49 +--------- .../Assets/api-section-links.test.ts | 93 +++++-------------- .../ApiSupplementalRenderTests.cs | 12 +-- 5 files changed, 37 insertions(+), 138 deletions(-) diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml index 5928986d64..38b3898f1c 100644 --- a/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml +++ b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml @@ -1,13 +1,13 @@ @inherits RazorSlice> -@* Quick links to the sections below. api-docs.ts marks the link of the section in view with aria-current. *@ -