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..425654f42bf 100644 --- a/src/resources/formats/html/quarto.js +++ b/src/resources/formats/html/quarto.js @@ -66,17 +66,42 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { } }; - // dispatch for htmlwidgets - // they use slideenter event to trigger resize + // 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); window.document.dispatchEvent(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 @@ -96,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/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..00b91638918 100644 --- a/tests/integration/playwright/tests/html-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-tabsets.spec.ts @@ -17,4 +17,28 @@ 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 as soon as it is shown', async ({ page }) => { + await page.goto('/html/tabsets/plotly-tabsets.html'); + const tab2 = page.getByRole('tab', { name: 'Tab 2' }); + const tab2Content = page.locator('div.tab-content div.tab-pane').nth(1); + // Plotly adds a second .main-svg for hover labels; the first one holds the figure + 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); +});