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"] } ] }