diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md
index 0bd74a5624..3063adff42 100644
--- a/docs/data/openapi/api-explorer.md
+++ b/docs/data/openapi/api-explorer.md
@@ -320,13 +320,15 @@ 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 parameter, body and response sections of an operation page: **Path**, **Query**, **Headers**, **Body** and **Responses**, for the sections the operation has. **Required privileges** comes before the bar and is not one of its links. 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 |
|---|---|
| `{ a, b }` | The fields of a JSON request body |
| `A \| B` | Alternatives, such as the variants of a `oneOf` or the credentials of **Authentication** |
-| `A + B` | Needed together, such as the privileges in **Required authorization** |
+| `A + B` | Needed together, such as the privileges in **Required privileges** |
| `a, b` | Anything else, such as parameters or the fields of a form |
The names in a summary aren't searchable text, so find-in-page (Ctrl+F) skips them and finds the same field inside the section, which opens if a reader has folded it. Sections start expanded, so find-in-page reaches them on first load. Reaching a section a reader has folded relies on `hidden="until-found"`: in a browser without it, a folded section can't be searched.
@@ -420,9 +422,9 @@ The paths come from either of two sources, and both produce the same rows:
- The `**All methods and paths for this operation:**` (or `**Spaces method and path for this operation:**`) HTML block that bump.sh-flavoured specs put in the operation description. The block is removed from the rendered description. When it lists a `/s/{space_id}/…` route, the page also lists an optional `space_id` path parameter, described as the space identifier, defaulting to the default space, followed by the `Refer to [Spaces](…) for more information.` sentence that follows the block. That sentence is removed from the operation description.
- Separate spec operations that share `x-namespace` and `x-api-name` (see below).
-### Required authorization [x-req-auth]
+### Required privileges [x-req-auth]
-The **Required authorization** section lists the privileges an operation needs. It follows the description and also appears in the on-page table of contents. Like the other sections it is expanded by default. Its header names the privileges: `a + b` when they are needed together and `a | b` when they are alternatives.
+The **Required privileges** section lists the privileges an operation needs. It follows the description and also appears in the on-page table of contents. Like the other sections it is expanded by default. Its header names the privileges: `a + b` when they are needed together and `a | b` when they are alternatives.
Add the operation-level `x-req-auth` extension to list those privileges. It is a JSON array of strings. Each non-empty string becomes one row, with leading and trailing whitespace trimmed.
@@ -447,13 +449,13 @@ Specs that do not use `x-req-auth` still work. The API Explorer reads two patter
A line that mixes `OR` and `AND`, or a bullet with extra words around the values, stays in the description as written. When `x-req-auth` is present, it wins over the description. When neither source has privileges, the section is omitted. Malformed `x-req-auth` values are skipped and the build may log a warning.
-`x-req-auth` is independent of OpenAPI `security` and `securitySchemes`. Privileges go in **Required authorization**. The credentials to send go in **Authentication**.
+`x-req-auth` is independent of OpenAPI `security` and `securitySchemes`. Privileges go in **Required privileges**. The credentials to send go in **Authentication**.
### Authentication
The **Authentication** section shows how to send a credential. It is expanded by default, and a reader who already has auth set up can fold it. The header shows the scheme names, for example `Api key | Basic | Bearer`.
-The API Explorer reads the document-level `security`, or one alternative per scheme in `components.securitySchemes` when the document has none. The full section lives on the product's `/authentication` page. An endpoint page shows its own section, below **Required authorization** and in the on-page table of contents, only when the operation sets a `security` that names other alternatives than the document's. An empty operation `security: []` is an explicit override to none and shows no section.
+The API Explorer reads the document-level `security`, or one alternative per scheme in `components.securitySchemes` when the document has none. The full section lives on the product's `/authentication` page. An endpoint page shows its own section, below **Required privileges** and in the on-page table of contents, only when the operation sets a `security` that names other alternatives than the document's. An empty operation `security: []` is an explicit override to none and shows no section.
Every endpoint with authentication has an `Auth` badge in the row under its path, before the availability badge. The badges in that row, `Auth` and `Deprecated`, share the outlined look of the availability badge. `Deprecated` keeps a red outline and text, and a badge that links is underlined:
diff --git a/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs b/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs
index 2870c55f45..64fb1be2ef 100644
--- a/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs
+++ b/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs
@@ -48,7 +48,7 @@ public record ApiPostSection(string Heading, string Anchor, HtmlString BodyHtml,
internal static readonly FrozenSet OperationReservedAnchors = FrozenSet.ToFrozenSet(
[
"paths",
- "required-authorization",
+ "required-privileges",
"authentication",
"description",
"path-params",
diff --git a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs
index 55350e46e1..8e8a68e18b 100644
--- a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs
+++ b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs
@@ -119,7 +119,7 @@ private static void WriteAuthorization(StringBuilder markdown, OperationPageMode
{
if (page.RequiredPrivileges.Count > 0)
{
- ApiCommonMark.Heading(markdown, 2, "Required authorization");
+ ApiCommonMark.Heading(markdown, 2, "Required privileges");
foreach (var row in page.RequiredPrivileges)
{
var values = string.Join(row.Join is null ? ", " : $" {row.Join} ", row.Values.Select(ApiCommonMark.CodeSpan));
diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml
index 4c334fd059..c5867484f0 100644
--- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml
+++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml
@@ -78,6 +78,11 @@
}
@(await RenderPartialAsync<_AuthorizationSection, AuthorizationSectionModel>(Model.Authorization))
+ @* 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))
+ }
@foreach (var section in pageModel.ParameterSections)
{
@(await RenderPartialAsync<_ParameterListSection, ParameterListSection>(section))
diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
index d01ecd397f..44e82340aa 100644
--- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
+++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
@@ -20,7 +20,7 @@ public record AuthenticationSectionModel(
bool AlwaysTabs = false
);
-/// Required privileges and credentials for the Required authorization and Authentication sections; renders descriptions.
+/// Required privileges and credentials for the Required privileges and Authentication sections; renders descriptions.
public record AuthorizationSectionModel(
IReadOnlyList Requirements,
IReadOnlyList Privileges,
@@ -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.
+public record SectionLink(string Label, string Anchor);
+
public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context)
{
public required ApiOperation Operation { get; init; }
@@ -53,6 +56,27 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context
Page.AuthBadge?.Kind == AuthBadgeKind.Optional
);
+ ///
+ /// The sections under the quick links bar, in page order. Required privileges is not one of them: it comes before the bar.
+ ///
+ public IReadOnlyList SectionLinks
+ {
+ get
+ {
+ var links = new List(
+ Page.ParameterSections.Select(static s => new SectionLink(ParameterLabel(s.Location), s.Header.Anchor))
+ );
+ if (Operation.Operation.RequestBody is not null)
+ links.Add(new SectionLink("Body", "request-body"));
+ if (Page.Responses.Count > 0)
+ links.Add(new SectionLink("Responses", "responses"));
+ return links;
+ }
+ }
+
+ private static string ParameterLabel(ParameterLocation location) =>
+ location == ParameterLocation.Header ? "Headers" : location.ToString();
+
/// Where the auth badge links: the Authentication page, or the endpoint's own section.
public string? AuthBadgeHref => Page.AuthBadge?.Target switch
{
@@ -77,7 +101,7 @@ protected override IReadOnlyList GetTocItems()
var tocItems = new List { new("Paths", "paths", 2) };
if (Page.RequiredPrivileges.Count > 0)
- tocItems.Add(new TocItem("Required authorization", "required-authorization", 2));
+ tocItems.Add(new TocItem("Required privileges", "required-privileges", 2));
if (Page.AuthRequirements.Count > 0)
tocItems.Add(new TocItem("Authentication", "authentication", 2));
diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml
index 50071ebe1c..4f8b8a0685 100644
--- a/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml
+++ b/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml
@@ -8,8 +8,8 @@
@if (Model.Privileges.Count > 0)
{
@foreach (var row in Model.Privileges)
{
diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml
new file mode 100644
index 0000000000..8b07094975
--- /dev/null
+++ b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml
@@ -0,0 +1,8 @@
+@inherits RazorSlice>
+@* Quick links to the sections below. The scroll-spy of the "On this page" outline (ds/toc.ts) marks the link of the section in view. *@
+
diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css
index 10701284ef..a63cf6b500 100644
--- a/src/Elastic.Documentation.Site/Assets/api-docs.css
+++ b/src/Elastic.Documentation.Site/Assets/api-docs.css
@@ -2944,6 +2944,19 @@ button.example-response-tab:focus-visible {
min-width: 0;
padding-top: 16px;
padding-bottom: 48px;
+ /* Stays in view while the sections scroll, however long the page is. The rail itself does not clip, so the
+ Copy page menu can open past its edge; the examples below it shrink to the window and scroll. */
+ position: sticky;
+ top: var(--offset-top, 0px);
+ align-self: start;
+ max-height: calc(100vh - var(--offset-top, 0px));
+ box-sizing: border-box;
+ }
+
+ .api-page-rail > :not(.api-page-actions) {
+ flex: 0 1 auto;
+ min-height: 0;
+ overflow: hidden auto;
}
.api-page-rail > .api-page-actions {
@@ -2956,7 +2969,7 @@ button.example-response-tab:focus-visible {
margin-bottom: 0;
}
- /* The examples are normal page flow: no sticky, no scrolling box. Long examples make the page long. */
+ /* The code blocks are normal page flow and cap at a fixed number of lines; the rail around them is sticky (above). */
#api-examples-panel .api-examples-panel-nav {
display: flex;
flex-direction: column;
@@ -4210,3 +4223,44 @@ 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;
+}
+
+/* A section reached from the bar stops below the bar instead of under it. */
+#elastic-api-v3 .api-param-section-header,
+#elastic-api-v3 .responses-header {
+ 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..bfcf3dc564 100644
--- a/src/Elastic.Documentation.Site/Assets/api-docs.ts
+++ b/src/Elastic.Documentation.Site/Assets/api-docs.ts
@@ -8,6 +8,7 @@ import { initApiExamples, syncDescriptions } from './api-examples-carousel'
import { fitParamSummaries } from './api-param-summary'
import { initUnionChips, revealVariantsAround } from './api-union-chips'
import { iconCheckEui, iconCopyEui } from './copybutton'
+import { initToc } from './ds/toc'
import { prefersReducedMotion } from './motion'
import { flashTooltip, initTooltips } from './tooltip'
@@ -312,6 +313,7 @@ function updateExamplesButtonVisibility(): void {
}
function initOperationView(): void {
+ initToc()
// 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..ebb0ffae74
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts
@@ -0,0 +1,33 @@
+import { initApiDocs } from './api-docs'
+import { beforeAll, describe, expect, it } from 'vitest'
+
+describe('section links', () => {
+ beforeAll(() => initApiDocs())
+
+ it('marks the first link of the bar on an operation page, through the outline scroll-spy', () => {
+ document.body.innerHTML = `
+
+
+
+
+ `
+ // jsdom has no layout: a page taller than the window, scrolled to the top
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ value: 5000,
+ configurable: true,
+ })
+ // jsdom has no layout: the first section is in view, the second is below the fold
+ const top = { 'query-params': -100, responses: 900 }
+ for (const [id, y] of Object.entries(top))
+ document.getElementById(id)!.getBoundingClientRect = () =>
+ ({ top: y }) as DOMRect
+ initApiDocs()
+
+ const current = document.querySelectorAll('[aria-current]')
+ expect(current).toHaveLength(1)
+ expect(current[0].getAttribute('href')).toBe('#query-params')
+ })
+})
diff --git a/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts b/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts
index 340dba4417..19afedeaff 100644
--- a/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts
+++ b/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts
@@ -14,6 +14,10 @@ function setup() {
describe('initToc', () => {
beforeEach(() => {
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ value: 5000,
+ configurable: true,
+ })
Object.defineProperty(window, 'innerHeight', {
value: 800,
configurable: true,
@@ -71,4 +75,164 @@ describe('initToc', () => {
.getAttribute('aria-current')
).toBe('location')
})
+
+ it('marks the last heading at the bottom of the page, though it never reaches the line', () => {
+ setup()
+ document.getElementById('two')!.getBoundingClientRect = () =>
+ ({ top: 700 }) as DOMRect
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ value: 800,
+ configurable: true,
+ })
+ initToc()
+
+ expect(
+ document
+ .querySelector('[href="#two"]')!
+ .getAttribute('aria-current')
+ ).toBe('location')
+ expect(
+ document
+ .querySelector('[href="#one"]')!
+ .hasAttribute('aria-current')
+ ).toBe(false)
+ })
+
+ it('stops tracking an outline that left the page', () => {
+ setup()
+ const toc = document.querySelector('[data-toc]')!
+ const oldHeading = document.getElementById('two')!
+ initToc()
+ // a new page arrives and its outline is bound too
+ setup()
+ document.getElementById('two')!.getBoundingClientRect = () =>
+ ({ top: 100 }) as DOMRect
+ initToc()
+ // the old heading now crosses the line, so a spy still tracking the old page would mark its link
+ oldHeading.getBoundingClientRect = () => ({ top: 100 }) as DOMRect
+ window.dispatchEvent(new Event('scroll'))
+
+ expect(
+ toc.querySelector('[href="#two"]')!.hasAttribute('aria-current')
+ ).toBe(false)
+ expect(
+ document
+ .querySelector('[href="#two"]')!
+ .getAttribute('aria-current')
+ ).toBe('location')
+ })
+
+ it('marks the clicked section of a sticky outline, though the next heading is above the 25% line', () => {
+ document.body.innerHTML = `
+
+
One
Two
`
+ const nav = document.querySelector('[data-toc]')!
+ nav.getBoundingClientRect = () => ({ bottom: 97 }) as DOMRect
+ // the section a click just landed under the bar, and a short one that follows it inside the 25% line (200px)
+ document.getElementById('one')!.getBoundingClientRect = () =>
+ ({ top: 112 }) as DOMRect
+ document.getElementById('two')!.getBoundingClientRect = () =>
+ ({ top: 190 }) as DOMRect
+ initToc()
+
+ expect(
+ document
+ .querySelector('[href="#one"]')!
+ .getAttribute('aria-current')
+ ).toBe('location')
+ expect(
+ document
+ .querySelector('[href="#two"]')!
+ .hasAttribute('aria-current')
+ ).toBe(false)
+ })
+
+ it('marks the section the address names at the bottom of a page too short to scroll', () => {
+ setup()
+ document.getElementById('one')!.getBoundingClientRect = () =>
+ ({ top: 100 }) as DOMRect
+ document.getElementById('two')!.getBoundingClientRect = () =>
+ ({ top: 500 }) as DOMRect
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ value: 800,
+ configurable: true,
+ })
+ window.location.hash = '#one'
+ initToc()
+
+ expect(
+ document
+ .querySelector('[href="#one"]')!
+ .getAttribute('aria-current')
+ ).toBe('location')
+ expect(
+ document
+ .querySelector('[href="#two"]')!
+ .hasAttribute('aria-current')
+ ).toBe(false)
+
+ window.location.hash = ''
+ })
+
+ it('follows the address when it changes without a scroll', () => {
+ setup()
+ document.getElementById('one')!.getBoundingClientRect = () =>
+ ({ top: 100 }) as DOMRect
+ document.getElementById('two')!.getBoundingClientRect = () =>
+ ({ top: 500 }) as DOMRect
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ value: 800,
+ configurable: true,
+ })
+ initToc()
+ window.location.hash = '#one'
+ window.dispatchEvent(new HashChangeEvent('hashchange'))
+
+ expect(
+ document
+ .querySelector('[href="#one"]')!
+ .getAttribute('aria-current')
+ ).toBe('location')
+
+ window.location.hash = ''
+ })
+
+ it('follows a link click that pushes the address without a hashchange or a scroll', () => {
+ setup()
+ document.getElementById('one')!.getBoundingClientRect = () =>
+ ({ top: 100 }) as DOMRect
+ document.getElementById('two')!.getBoundingClientRect = () =>
+ ({ top: 500 }) as DOMRect
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ value: 800,
+ configurable: true,
+ })
+ initToc()
+ // at the bottom of a page that cannot scroll, the last section is current
+ expect(
+ document
+ .querySelector('[href="#two"]')!
+ .getAttribute('aria-current')
+ ).toBe('location')
+
+ // what the in-page link handler does: pushState, no hashchange, no scroll
+ const one = document.querySelector('[href="#one"]')!
+ one.addEventListener('click', (e) => {
+ e.preventDefault()
+ history.pushState(null, '', '#one')
+ })
+ one.click()
+
+ expect(one.getAttribute('aria-current')).toBe('location')
+ expect(
+ document
+ .querySelector('[href="#two"]')!
+ .hasAttribute('aria-current')
+ ).toBe(false)
+
+ history.pushState(null, '', window.location.pathname)
+ })
})
diff --git a/src/Elastic.Documentation.Site/Assets/ds/toc.ts b/src/Elastic.Documentation.Site/Assets/ds/toc.ts
index 910188894c..bfa983068e 100644
--- a/src/Elastic.Documentation.Site/Assets/ds/toc.ts
+++ b/src/Elastic.Documentation.Site/Assets/ds/toc.ts
@@ -1,6 +1,15 @@
// Scroll-spy for the "On this page" outline: marks the link of the heading in view with aria-current="location".
// The outline works as plain anchor links without this.
const activationOffset = 0.25
+// Within this many pixels of the page bottom the last heading is current: a short last section can never reach the line.
+const bottomSlack = 4
+// A sticky outline marks a heading once it is this far below the outline's own bottom edge, which is where a click lands it.
+const stickySlack = 32
+
+function isInView(element: Element): boolean {
+ const top = element.getBoundingClientRect().top
+ return top >= 0 && top < window.innerHeight
+}
export function initToc(scope: ParentNode = document) {
scope
@@ -17,10 +26,18 @@ export function initToc(scope: ParentNode = document) {
),
}))
+ const listeners = new AbortController()
let queued = false
const update = () => {
queued = false
- const line = window.innerHeight * activationOffset
+ // An htmx swap replaced the page: stop tracking its outline
+ if (!toc.isConnected) {
+ listeners.abort()
+ return
+ }
+ const line = toc.hasAttribute('data-toc-sticky')
+ ? toc.getBoundingClientRect().bottom + stickySlack
+ : window.innerHeight * activationOffset
let active: HTMLAnchorElement | null = null
for (const { link, heading } of targets) {
if (
@@ -30,6 +47,22 @@ export function initToc(scope: ParentNode = document) {
active = link
}
}
+ const atBottom =
+ window.innerHeight + window.scrollY >=
+ document.documentElement.scrollHeight - bottomSlack
+ if (atBottom) {
+ // A page too short to scroll cannot bring a clicked section to the line: the section the address
+ // names, if it is in view, is the one the reader chose; otherwise the last one is.
+ const chosen = targets.find(
+ ({ link, heading }) =>
+ heading &&
+ link.hash === window.location.hash &&
+ isInView(heading)
+ )
+ for (const { link, heading } of targets) {
+ if (heading) active = chosen?.link ?? link
+ }
+ }
active ??= targets[0]?.link ?? null
for (const { link } of targets) {
if (link === active) {
@@ -45,8 +78,24 @@ export function initToc(scope: ParentNode = document) {
window.requestAnimationFrame(update)
}
- window.addEventListener('scroll', schedule, { passive: true })
- window.addEventListener('resize', schedule)
+ window.addEventListener('scroll', schedule, {
+ passive: true,
+ signal: listeners.signal,
+ })
+ window.addEventListener('resize', schedule, {
+ signal: listeners.signal,
+ })
+ window.addEventListener('hashchange', schedule, {
+ signal: listeners.signal,
+ })
+ // A link click that pushes the address emits no hashchange, and a page too short to scroll emits no scroll
+ // either. The update waits a frame, so the click handlers have set the address by then.
+ toc.addEventListener('click', schedule, {
+ signal: listeners.signal,
+ })
+ window.addEventListener('popstate', schedule, {
+ signal: listeners.signal,
+ })
update()
})
}
diff --git a/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs
index 3d058a29d9..c9947ecfc9 100644
--- a/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs
@@ -81,14 +81,14 @@ public async Task Render_PrivilegesNeededTogether_AreSummarisedAsSeparateNamesJo
var html = await RenderPrivileges(new PrivilegeRow("Privileges", ["integrations-all", "fleet-agent-policies-all"], "and"));
html.Should().Contain("data-param-section");
- html.Should().Contain("id=\"required-authorization\"");
+ html.Should().Contain("id=\"required-privileges\"");
html.Should().Contain("");
html.Should().Contain("");
html.Should().Contain("api-param-summary is-all");
- html.Should().Contain("id=\"required-authorization-list\"");
- html.Should().Contain("id=\"required-authorization-list\">", "the body has no hidden attribute, so it shows on load");
- html.Should().NotContain("id=\"required-authorization-list\" hidden");
- html.Should().MatchRegex("aria-expanded=\"true\"\\s+aria-controls=\"required-authorization-list\"");
+ html.Should().Contain("id=\"required-privileges-list\"");
+ html.Should().Contain("id=\"required-privileges-list\">", "the body has no hidden attribute, so it shows on load");
+ html.Should().NotContain("id=\"required-privileges-list\" hidden");
+ html.Should().MatchRegex("aria-expanded=\"true\"\\s+aria-controls=\"required-privileges-list\"");
html.Should().Contain("integrations-all", "the rows are still listed in the body");
}
diff --git a/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs
index 88f4a93ca5..e32b122028 100644
--- a/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs
@@ -28,7 +28,7 @@ public async Task Render_SearchOperation_UsesOneH1ThenH2SectionsAndH3Nested()
.Equal("h1", "h2", "h2", "h2", "h2", "h2", "h2", "h3");
html.Should().Contain("
");
html.Should().Contain("href=\"#header-params\"");
+ html.Should().Contain("href=\"#header-params\" data-toc-link>Headers", "the quick link names the section in the plural");
+ html.Should().Contain("data-toc-link>Path").And.Contain("data-toc-link>Query");
}
[Test]
diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs
index 51f1b44a30..f9277fb0df 100644
--- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs
@@ -185,6 +185,29 @@ public async Task Operation_NestedProperties_UseShowPropertiesDisclosure()
html.Should().NotContain("toggle-label\">Hide ");
}
+ [Test]
+ public async Task Operation_SectionLinks_ListEachSectionInPageOrder()
+ {
+ var nav = SearchOperation();
+ var html = await RenderAsync(nav.Model, nav);
+
+ html.Should().Contain("class=\"api-section-links\" aria-label=\"Sections of this endpoint\" data-toc data-toc-sticky>");
+ html.Should().Contain("href=\"#query-params\" data-toc-link>Query", "a link is its label alone, with no count");
+ html.Should().Contain("href=\"#request-body\" data-toc-link>Body", "a link is its label alone, with no count");
+ html.Should().Contain("href=\"#responses\" data-toc-link>Responses", "a link is its label alone, with no count");
+ var bar = html[html.IndexOf("