diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css
index db18f32d7f..f4c748b0db 100644
--- a/src/Elastic.Documentation.Site/Assets/api-docs.css
+++ b/src/Elastic.Documentation.Site/Assets/api-docs.css
@@ -2637,7 +2637,6 @@ a.docs-reference-btn::after {
--api-code-muted: var(--code-muted);
--api-code-accent: var(--color-blue-elastic-50);
--api-code-scroll: var(--code-border);
- --api-code-scroll-hover: var(--color-grey-100);
overflow: visible;
position: relative;
margin-bottom: 24px;
@@ -2648,35 +2647,6 @@ a.docs-reference-btn::after {
box-shadow: none;
}
-/* Thin scrollbar thumb on the dark surface. */
-.api-code-card .api-code-sample-body,
-.api-code-card .example-block-body {
- scrollbar-width: thin;
- scrollbar-color: var(--api-code-scroll) transparent;
-}
-
-.api-code-card .api-code-sample-body::-webkit-scrollbar,
-.api-code-card .example-block-body::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-.api-code-card .api-code-sample-body::-webkit-scrollbar-track,
-.api-code-card .example-block-body::-webkit-scrollbar-track {
- background: transparent;
-}
-
-.api-code-card .api-code-sample-body::-webkit-scrollbar-thumb,
-.api-code-card .example-block-body::-webkit-scrollbar-thumb {
- background-color: var(--api-code-scroll);
- border-radius: 9999px;
-}
-
-.api-code-card .api-code-sample-body::-webkit-scrollbar-thumb:hover,
-.api-code-card .example-block-body::-webkit-scrollbar-thumb:hover {
- background-color: var(--api-code-scroll-hover);
-}
-
.example-block {
position: relative;
}
@@ -3978,6 +3948,27 @@ html:has(.api-page-shell) {
-webkit-user-select: none;
}
+/* In a response the note sits in .example-media-body, and a sticky box cannot leave its parent: the media body
+ spans the full code width so the note can stay put, and everything in it that is not code (the note, a caption,
+ a media header with its copy button) keeps to the visible width and to the left. The panel is the container
+ because, as a block inside the scroll body, it is exactly the visible width; 100cqi of the scroll body itself
+ would include its vertical scrollbar. */
+#api-examples-panel .example-response-panel {
+ container-type: inline-size;
+}
+
+#api-examples-panel .example-media-body {
+ width: max-content;
+ min-width: 100%;
+
+ > :not(.notranslate) {
+ position: sticky;
+ left: 0;
+ box-sizing: border-box;
+ width: 100cqi;
+ }
+}
+
/* The short line carries the full explanation in a tooltip; the dotted underline says there is one. */
.api-code-carousel-card-note > span {
cursor: help;
@@ -4179,10 +4170,12 @@ html:has(.api-page-shell) {
overflow: auto;
}
-/* 100 characters of code; the dialog fits around it (gutter and padding add themselves) and longer lines scroll sideways. */
+/* At least 100 characters of code; the dialog fits around it (gutter and padding add themselves), grows with a longer
+ line up to the window and scrolls past that. A minimum, not a width: a fixed width would end .code-lines at 100ch,
+ and the sticky line numbers could not follow a longer line. */
.api-code-preview-body .highlight pre code {
box-sizing: content-box;
- width: 100ch;
+ min-width: 100ch;
}
@media (max-width: 1023px) {
diff --git a/src/Elastic.Documentation.Site/Assets/code-block.css b/src/Elastic.Documentation.Site/Assets/code-block.css
index c6ec418a7c..fade9d3873 100644
--- a/src/Elastic.Documentation.Site/Assets/code-block.css
+++ b/src/Elastic.Documentation.Site/Assets/code-block.css
@@ -205,32 +205,29 @@
}
}
- /* Line-number gutter (rendered by _ApiCodeBlock.cshtml): a sibling element that is excluded from
- selection and copy. The wrapper owns the surface so the gutter sits on the same fill. */
+ /* Line-number gutter (rendered by _ApiCodeBlock.cshtml): a sibling excluded from selection and copy.
+ As wide as its longest line, so long lines scroll in the card while the sticky gutter stays put. */
.highlight .code-lines {
display: flex;
align-items: flex-start;
- min-width: 0;
+ width: max-content;
+ min-width: 100%;
background-color: var(--code-bg);
border-radius: var(--code-radius);
- /* width:max-content keeps long lines from wrapping; the card scrolls instead. */
> pre {
display: block;
margin: 0;
flex: 1 0 auto;
- width: max-content;
- min-width: 0;
code {
display: block;
- width: max-content;
- min-width: 0;
max-height: none;
overflow: visible;
background-color: transparent;
border-radius: 0;
- padding-left: 0.5rem !important;
+ /* The gap after the numbers is the gutter's, so it holds while the code scrolls. */
+ padding-left: 0 !important;
white-space: pre;
word-break: normal;
overflow-wrap: normal;
@@ -238,18 +235,21 @@
}
}
+ /* Sticky, and opaque so code scrolls under it. */
.highlight .code-line-gutter {
+ position: sticky;
+ left: 0;
flex: 0 0 auto;
margin: 0;
border: 0;
- background: transparent;
+ background-color: var(--code-bg);
user-select: none;
-webkit-user-select: none;
pointer-events: none;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: pre;
- padding: var(--code-pad) 0.5rem var(--code-pad) 1rem;
+ padding: var(--code-pad) 1rem;
color: var(--code-gutter);
font-family: var(--code-font);
font-size: var(--code-size);
diff --git a/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs b/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs
new file mode 100644
index 0000000000..b3d5853785
--- /dev/null
+++ b/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs
@@ -0,0 +1,140 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.Globalization;
+using System.Text.Json;
+using AwesomeAssertions;
+using Elastic.Documentation.Journeys.Tests.Targets;
+using Nullean.Cdp;
+using Nullean.Snapshots;
+
+namespace Elastic.Documentation.Journeys.Tests.Areas;
+
+///
+/// Long lines in the API examples rail scroll sideways while the line numbers and the "Generated from the API spec" note
+/// stay in place. Only real layout shows this, so it runs in Chrome: a journey file has no way to compare two positions.
+/// The Cloud Connect spec renders in under a second, and this operation has a wide generated sample in both its request
+/// and its response.
+///
+[ClassDataSource(Shared = SharedType.PerTestSession)]
+[NotInParallel]
+public class ApiExampleScrollingTests(JourneyTarget target)
+{
+ private const string Operation = "api/doc/cloud-connect/operation/operation-patchcluster";
+ private const string Scenario = ".api-examples-scenario-panel:not([hidden])";
+
+ [Test]
+ public async Task Request_ScrollingSideways_KeepsLineNumbersAndNoteInPlace()
+ {
+ var report = await ScrollSidewaysAsync($"{Scenario} .api-code-carousel-card.is-active .api-code-carousel-card-body").ConfigureAwait(
+ false
+ );
+
+ _ = report.Should().MatchInlineSnapshot(
+ """
+ overflows sideways: True
+ scrolled sideways: True
+ language before and after: curl, curl
+ line numbers from the left edge: 0px
+ note from the right edge: 0px
+ note from the bottom edge: 0px
+ """
+ );
+ }
+
+ [Test]
+ public async Task Response_ScrollingSideways_KeepsLineNumbersAndNoteInPlace()
+ {
+ var report = await ScrollSidewaysAsync($"{Scenario} .example-block--response .example-block-body").ConfigureAwait(false);
+
+ _ = report.Should().MatchInlineSnapshot(
+ """
+ overflows sideways: True
+ scrolled sideways: True
+ language before and after: curl, curl
+ line numbers from the left edge: 0px
+ note from the right edge: 0px
+ note from the bottom edge: 0px
+ """
+ );
+ }
+
+ private async Task ScrollSidewaysAsync(string body)
+ {
+ if (target.Name != "isolated")
+ Skip.Test($"The API pages these tests open are part of the isolated site, not '{target.Name}'.");
+
+ var url = new Uri(target.BaseUrl, Operation);
+ await WaitForPageAsync(url).ConfigureAwait(false);
+
+ await using var browser = await Browser.LaunchAsync(new BrowserLaunchOptions()).ConfigureAwait(false);
+ await using var context = await browser.NewContextAsync().ConfigureAwait(false);
+ var page = await context.NewPageAsync().ConfigureAwait(false);
+ await page.SetViewportAsync(1280, 900, 1).ConfigureAwait(false);
+ await page.GotoAsync(url.ToString()).ConfigureAwait(false);
+ await page.WaitForLoadStateAsync("networkidle").ConfigureAwait(false);
+
+ var before = await MeasureAsync(page, body).ConfigureAwait(false);
+ // A sideways wheel over the code, as a trackpad swipe or shift+wheel sends it.
+ await page.SendAsync("Input.dispatchMouseEvent", writer =>
+ {
+ writer.WriteString("type", "mouseWheel");
+ writer.WriteNumber("x", before.GetProperty("centerX").GetDouble());
+ writer.WriteNumber("y", before.GetProperty("centerY").GetDouble());
+ writer.WriteNumber("deltaX", 200);
+ writer.WriteNumber("deltaY", 0);
+ }).ConfigureAwait(false);
+ await Task.Delay(300).ConfigureAwait(false);
+ var after = await MeasureAsync(page, body).ConfigureAwait(false);
+
+ return string.Join(
+ '\n',
+ $"overflows sideways: {before.GetProperty("overflows").GetBoolean()}",
+ $"scrolled sideways: {after.GetProperty("scrollLeft").GetInt32() > 0}",
+ $"language before and after: {before.GetProperty("language").GetString()}, {after.GetProperty("language").GetString()}",
+ $"line numbers from the left edge: {after.GetProperty("gutter").GetInt32()}px",
+ $"note from the right edge: {after.GetProperty("noteRight").GetInt32()}px",
+ $"note from the bottom edge: {after.GetProperty("noteBottom").GetInt32()}px"
+ );
+ }
+
+ // Edges are the scroll body's visible area: inside its border, short of its scrollbars.
+ private static async Task MeasureAsync(Page page, string body) =>
+ await page.EvaluateAsync(
+ $$"""
+ (() => {
+ const body = document.querySelector({{JsonSerializer.Serialize(body)}});
+ const box = body.getBoundingClientRect();
+ const left = box.left + body.clientLeft, top = box.top + body.clientTop;
+ const gutter = body.querySelector('.code-line-gutter').getBoundingClientRect();
+ const note = body.querySelector('.api-code-carousel-card-note').getBoundingClientRect();
+ return {
+ language: document.querySelector('{{Scenario}} .api-code-carousel-card.is-active').dataset.lang,
+ overflows: body.scrollWidth > body.clientWidth,
+ scrollLeft: body.scrollLeft,
+ centerX: left + body.clientWidth / 2,
+ centerY: top + body.clientHeight / 2,
+ gutter: Math.round(gutter.left - left),
+ noteRight: Math.round(left + body.clientWidth - note.right),
+ noteBottom: Math.round(top + body.clientHeight - note.bottom),
+ };
+ })()
+ """
+ ).ConfigureAwait(false);
+
+ // The site serves an API's pages once its spec is rendered; on the isolated site that waits for every spec, Elasticsearch's too.
+ private static async Task WaitForPageAsync(Uri url)
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
+ var deadline = DateTime.UtcNow.AddMinutes(5);
+ while (DateTime.UtcNow < deadline)
+ {
+ using var response = await http.GetAsync(url).ConfigureAwait(false);
+ if (response.IsSuccessStatusCode)
+ return;
+ await Task.Delay(500).ConfigureAwait(false);
+ }
+ throw new TimeoutException(string.Create(CultureInfo.InvariantCulture, $"{url} did not render within 5 minutes."));
+ }
+}
diff --git a/tests/Elastic.Documentation.Journeys.Tests/shards.json b/tests/Elastic.Documentation.Journeys.Tests/shards.json
index 722645af73..cc18b5e982 100644
--- a/tests/Elastic.Documentation.Journeys.Tests/shards.json
+++ b/tests/Elastic.Documentation.Journeys.Tests/shards.json
@@ -1,6 +1,7 @@
{
"shards": [
{ "name": "site", "classes": ["A11yJourneys", "ChromeJourneys", "ContentJourneys", "FeedbackJourneys", "LandingJourneys", "SynthJourneys", "NavigationJourneys", "PageActionsJourneys"] },
- { "name": "http-and-tool", "classes": ["DocsApiHttp", "DocsJourneysCliTests", "DocsJourneysContextTests", "DocsJourneysEmbeddedTests", "DocsJourneysExportConformanceTests", "DocsJourneysExportTests", "MarkdownAlternatesHttp", "McpHttp"] }
+ { "name": "http-and-tool", "classes": ["DocsApiHttp", "DocsJourneysCliTests", "DocsJourneysContextTests", "DocsJourneysEmbeddedTests", "DocsJourneysExportConformanceTests", "DocsJourneysExportTests", "MarkdownAlternatesHttp", "McpHttp"] },
+ { "name": "api-examples", "classes": ["ApiExampleScrollingTests"] }
]
}