From 82275c49f81b0d8bf35e353f97a31bd24bb01c60 Mon Sep 17 00:00:00 2001 From: Mark Pipe Date: Wed, 23 Sep 2026 17:14:16 +0100 Subject: [PATCH 1/2] Fix Plotly figures in initially hidden tabs not filling the tab width Plotly resizes responsive figures from a window resize listener, but when a tab was shown quarto.js only dispatched slideenter, so figures drawn while their tab was hidden kept Plotly's default 700px width. Also dispatch a window resize event from fireSlideEnter(). Closes #14939 Co-Authored-By: Claude Opus 5.5 --- news/changelog-1.11.md | 1 + src/resources/formats/html/quarto.js | 6 +++-- .../html/tabsets/plotly-tabsets.qmd | 25 +++++++++++++++++++ .../playwright/tests/html-tabsets.spec.ts | 19 +++++++++++++- 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 tests/docs/playwright/html/tabsets/plotly-tabsets.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 6e99e9d014b..f7bf96623c6 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -31,6 +31,7 @@ All changes included in 1.11: ### `html` - ([#14684](https://github.com/quarto-dev/quarto-cli/issues/14684)): Add a "Skip to main content" link to Bootstrap-themed HTML output (documents, websites, books, dashboards) so keyboard users can bypass the navbar and sidebars. +- ([#14939](https://github.com/quarto-dev/quarto-cli/issues/14939)): Fix Plotly figures in a tab that is hidden on page load rendering at Plotly's default width instead of filling the tab when it is shown. ### `typst` diff --git a/src/resources/formats/html/quarto.js b/src/resources/formats/html/quarto.js index ee807684be1..d8cb720fc84 100644 --- a/src/resources/formats/html/quarto.js +++ b/src/resources/formats/html/quarto.js @@ -66,12 +66,14 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { } }; - // dispatch for htmlwidgets - // they use slideenter event to trigger resize + // dispatch for htmlwidgets and other resizable content (e.g. plotly) + // htmlwidgets use slideenter event to trigger resize, while plotly listens + // for window resize to fit figures drawn while their tab was hidden function fireSlideEnter() { const event = window.document.createEvent("Event"); event.initEvent("slideenter", true, true); window.document.dispatchEvent(event); + window.dispatchEvent(new Event("resize")); } const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]'); diff --git a/tests/docs/playwright/html/tabsets/plotly-tabsets.qmd b/tests/docs/playwright/html/tabsets/plotly-tabsets.qmd new file mode 100644 index 00000000000..403c2d9cb00 --- /dev/null +++ b/tests/docs/playwright/html/tabsets/plotly-tabsets.qmd @@ -0,0 +1,25 @@ +--- +title: "Plotly figures in tabsets" +format: + html: + page-layout: full +engine: jupyter +--- + +::: {.panel-tabset} + +## Tab 1 + +From https://github.com/quarto-dev/quarto-cli/issues/14939 + +The Plotly figure in Tab 2 is drawn while its tab is hidden. + +## Tab 2 + +```{python} +import plotly.graph_objects as go + +go.Figure(go.Scatter(x=[1, 2, 3], y=[1, 3, 2])) +``` + +::: diff --git a/tests/integration/playwright/tests/html-tabsets.spec.ts b/tests/integration/playwright/tests/html-tabsets.spec.ts index 38f5e78a7c2..415f9cb8eea 100644 --- a/tests/integration/playwright/tests/html-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-tabsets.spec.ts @@ -17,4 +17,21 @@ test('Jupyter - Creates working tabsets from for loops', async ({ page }) => { const tab2Content = tabContent.locator('div.tab-pane').nth(1); await expect(tab2Content).toHaveClass(/active/); await expect(tab2Content.locator('img')).toBeVisible(); -}); \ No newline at end of file +}); + +test('Plotly figures in initially hidden tabs fill the tab when shown', async ({ page }) => { + await page.goto('/html/tabsets/plotly-tabsets.html'); + await page.getByRole('tab', { name: 'Tab 2' }).click(); + const tab2Content = page.locator('div.tab-content div.tab-pane').nth(1); + await expect(tab2Content).toHaveClass(/active/); + const plot = tab2Content.locator('.js-plotly-plot'); + // Plotly adds a second .main-svg for hover labels; the first one holds the figure + const figure = plot.locator('.main-svg').first(); + await expect(figure).toBeVisible(); + // Figures drawn while their tab is hidden fall back to Plotly's default 700px width + await expect.poll(async () => { + const plotBox = await plot.boundingBox(); + const figureBox = await figure.boundingBox(); + return Math.abs(figureBox!.width - plotBox!.width); + }).toBeLessThanOrEqual(1); +}); From c317ae138687b4ac3e9c9d7db3db89a961da9386 Mon Sep 17 00:00:00 2001 From: Mark Pipe Date: Wed, 23 Sep 2026 18:03:09 +0100 Subject: [PATCH 2/2] Redraw Plotly figures in a newly shown tab before it is painted Plotly waits 100ms before handling window resize, so figures drawn while their tab was hidden were briefly shown at Plotly's default size before resizing. Redraw them with Plotly.relayout as soon as the tab is shown, and make the test check the first painted frame. Refs #14939 Co-Authored-By: Claude Opus 5.5 --- src/resources/formats/html/quarto.js | 32 ++++++++++++++++--- .../playwright/tests/html-tabsets.spec.ts | 31 +++++++++++------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/resources/formats/html/quarto.js b/src/resources/formats/html/quarto.js index d8cb720fc84..425654f42bf 100644 --- a/src/resources/formats/html/quarto.js +++ b/src/resources/formats/html/quarto.js @@ -66,9 +66,9 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { } }; - // dispatch for htmlwidgets and other resizable content (e.g. plotly) - // htmlwidgets use slideenter event to trigger resize, while plotly listens - // for window resize to fit figures drawn while their tab was hidden + // dispatch for htmlwidgets and other resizable content + // htmlwidgets use slideenter event to trigger resize, while other libraries + // (e.g. plotly) listen for window resize function fireSlideEnter() { const event = window.document.createEvent("Event"); event.initEvent("slideenter", true, true); @@ -76,9 +76,32 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { window.dispatchEvent(new Event("resize")); } + // plotly figures drawn while their tab was hidden get plotly's default width, + // and plotly waits before handling window resize, so redraw them straight + // away to avoid showing them at the wrong size first + function resizePlotlyFigures(pane) { + if (!pane || !window.Plotly) { + return; + } + pane.querySelectorAll(".js-plotly-plot").forEach((plot) => { + // figures with a fixed size don't need resizing + const layout = plot.layout; + if (!layout || (layout.width && layout.height)) { + return; + } + window.Plotly.relayout(plot, { autosize: true }); + }); + } + const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]'); tabs.forEach((tab) => { - tab.addEventListener("shown.bs.tab", fireSlideEnter); + tab.addEventListener("shown.bs.tab", (event) => { + const target = event.target.getAttribute("data-bs-target"); + if (target && target.startsWith("#")) { + resizePlotlyFigures(window.document.getElementById(target.slice(1))); + } + fireSlideEnter(); + }); }); // dispatch for shiny @@ -98,6 +121,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { document.addEventListener( "tabby", function (event) { + resizePlotlyFigures(event.detail.content); fireSlideEnter(); distpatchShinyEvents(event.detail.previousTab, event.detail.tab); }, diff --git a/tests/integration/playwright/tests/html-tabsets.spec.ts b/tests/integration/playwright/tests/html-tabsets.spec.ts index 415f9cb8eea..00b91638918 100644 --- a/tests/integration/playwright/tests/html-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-tabsets.spec.ts @@ -19,19 +19,26 @@ test('Jupyter - Creates working tabsets from for loops', async ({ page }) => { await expect(tab2Content.locator('img')).toBeVisible(); }); -test('Plotly figures in initially hidden tabs fill the tab when shown', async ({ page }) => { +test('Plotly figures in initially hidden tabs fill the tab as soon as it is shown', async ({ page }) => { await page.goto('/html/tabsets/plotly-tabsets.html'); - await page.getByRole('tab', { name: 'Tab 2' }).click(); + const tab2 = page.getByRole('tab', { name: 'Tab 2' }); const tab2Content = page.locator('div.tab-content div.tab-pane').nth(1); - await expect(tab2Content).toHaveClass(/active/); - const plot = tab2Content.locator('.js-plotly-plot'); // Plotly adds a second .main-svg for hover labels; the first one holds the figure - const figure = plot.locator('.main-svg').first(); - await expect(figure).toBeVisible(); - // Figures drawn while their tab is hidden fall back to Plotly's default 700px width - await expect.poll(async () => { - const plotBox = await plot.boundingBox(); - const figureBox = await figure.boundingBox(); - return Math.abs(figureBox!.width - plotBox!.width); - }).toBeLessThanOrEqual(1); + await expect(tab2Content.locator('.js-plotly-plot .main-svg').first()).toBeAttached(); + // Figures drawn while their tab is hidden fall back to Plotly's default 700px width. + // Measure in the first animation frame after the click, which runs before the + // browser paints the tab, so a figure that is only resized later fails the test. + const widths = await tab2.evaluate((tab) => new Promise<{ figure: number; container: number }>((resolve) => { + tab.click(); + requestAnimationFrame(() => { + const pane = document.getElementById(tab.getAttribute('data-bs-target')!.slice(1))!; + const plot = pane.querySelector('.js-plotly-plot')!; + resolve({ + figure: plot.querySelector('.main-svg')!.getBoundingClientRect().width, + container: plot.getBoundingClientRect().width, + }); + }); + })); + await expect(tab2Content).toHaveClass(/active/); + expect(Math.abs(widths.figure - widths.container)).toBeLessThanOrEqual(1); });