diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 4944dc36ba..49b5fb3886 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -22,6 +22,7 @@ All changes included in 1.11: - ([#14378](https://github.com/quarto-dev/quarto-cli/issues/14378)): Make scrollable code blocks and cell output keyboard-focusable in HTML output, so keyboard users can Tab to them and scroll with the arrow keys (axe rule `scrollable-region-focusable`, WCAG 2.1.1). While a region overflows it gets `tabindex="0"`, `role="group"`, and a localized `aria-label` (new `scrollable-code-label` and `scrollable-output-label` language keys, translated in all 34 built-in language files); when it fits again the attributes are removed. Chrome and Firefox already focus such regions natively; this adds Safari coverage and an accessible name. - ([#14795](https://github.com/quarto-dev/quarto-cli/issues/14795)): Fix keyboard focus in `revealjs` presentations reaching links and controls on nearby slides that are not shown. Tab now stays on the current slide, except in overview, scroll and print views where all slides are shown. - ([#14952](https://github.com/quarto-dev/quarto-cli/issues/14952)): Fix `revealjs` presentations disabling pinch-zoom on touch devices. The viewport `` tag no longer sets `maximum-scale=1.0` and `user-scalable=no`, which axe-core flagged (`meta-viewport`). +- ([#14968](https://github.com/quarto-dev/quarto-cli/issues/14968)): The header of notebook preview pages (back link, notebook title, download link) is now a navigation landmark named after the notebook, so it is reachable through landmark navigation and no longer flagged by axe-core (`region`). - ([#15011](https://github.com/quarto-dev/quarto-cli/issues/15011)): Fix the slide menu of `revealjs` presentations being read by screen readers while it is closed and off screen, and Tab moving keyboard focus to it once it lists more slides than fit. (author: @jooyoungseo) - ([#15015](https://github.com/quarto-dev/quarto-cli/issues/15015)): Fix the "Back to Article" link of notebook previews in website projects not being reachable or usable with the keyboard. @@ -71,6 +72,7 @@ All changes included in 1.11: ### Websites - ([#10756](https://github.com/quarto-dev/quarto-cli/issues/10756)): Fix the notebook preview of an embedded `.qmd` file going missing from the output directory when a project is rendered again, which broke its source notebook link. +- ([#14972](https://github.com/quarto-dev/quarto-cli/issues/14972)): Fix the website navbar, sidebar toggle bar, or announcement covering the header of notebook preview pages, which hid the back link, notebook title, and download link. The notebook preview header now sits below the site header. - ([#14974](https://github.com/quarto-dev/quarto-cli/issues/14974)): Fix math in `.llms.md` files from `llms-txt` coming out garbled with the default `mathjax` method, and with `katex` and `webtex`. Math is now written as `$...$` and `$$...$$` with the `mathjax`, `katex`, `webtex` and `mathml` methods. - ([#15004](https://github.com/quarto-dev/quarto-cli/issues/15004)): Fix notebook preview pages and notebook sources missing from the output directory when a website with embedded notebooks is rendered from a directory other than the project directory. diff --git a/src/format/html/format-html-notebook.ts b/src/format/html/format-html-notebook.ts index ff378ace34..d424d06346 100644 --- a/src/format/html/format-html-notebook.ts +++ b/src/format/html/format-html-notebook.ts @@ -36,6 +36,18 @@ const kQuartoCellDecoratorClass = "cell-decorator"; export function notebookViewPostProcessor() { return (doc: Document): Promise => { doc.body.classList.add(kQuartoNbClass); + + // When the page has a site header, the notebook preview header joins it + // so the two fixed bars stack instead of overlapping + const embedHeaderEl = doc.getElementById("quarto-embed-header"); + const siteHeaderEl = doc.getElementById("quarto-header"); + if (embedHeaderEl && siteHeaderEl) { + embedHeaderEl.classList.remove("fixed-top"); + embedHeaderEl.classList.remove("headroom"); + siteHeaderEl.appendChild(embedHeaderEl); + } + + const cells = doc.querySelectorAll("div.cell"); let cellCount = 0; for (const cell of cells) { diff --git a/src/resources/formats/html/embed/template.html b/src/resources/formats/html/embed/template.html index 79079db893..faf33fb7f0 100644 --- a/src/resources/formats/html/embed/template.html +++ b/src/resources/formats/html/embed/template.html @@ -68,14 +68,19 @@ window.document.addEventListener("DOMContentLoaded", function () { var header = window.document.querySelector("#quarto-embed-header"); - const titleBannerEl = window.document.querySelector("body > #title-block-header"); - if (titleBannerEl) { - titleBannerEl.style.paddingTop = header.clientHeight + "px"; - } - const contentEl = window.document.getElementById('quarto-content'); - for (const child of contentEl.children) { - child.style.paddingTop = header.clientHeight + "px"; - child.style.marginTop = "1em"; + // Inside the site header, the site navigation offsets the content + // and manages headroom + const inSiteHeader = header.parentElement.id === "quarto-header"; + if (!inSiteHeader) { + const titleBannerEl = window.document.querySelector("body > #title-block-header"); + if (titleBannerEl) { + titleBannerEl.style.paddingTop = header.clientHeight + "px"; + } + const contentEl = window.document.getElementById('quarto-content'); + for (const child of contentEl.children) { + child.style.paddingTop = header.clientHeight + "px"; + child.style.marginTop = "1em"; + } } // Use the article root if the `back` call doesn't work. This isn't perfect @@ -97,14 +102,16 @@ }, 100); } - const headroom = new window.Headroom(header, { - tolerance: 5, - onPin: function () { - }, - onUnpin: function () { - }, - }); - headroom.init(); + if (!inSiteHeader) { + const headroom = new window.Headroom(header, { + tolerance: 5, + onPin: function () { + }, + onUnpin: function () { + }, + }); + headroom.init(); + } }); @@ -116,10 +123,10 @@ -
- +
+ $for(include-before)$ $include-before$ $endfor$ $if(title)$ $title-block.html()$ $endif$ $if(toc)$ $toc.html()$ $endif$ $body$ diff --git a/tests/docs/playwright/html/notebook-preview-standalone/.gitignore b/tests/docs/playwright/html/notebook-preview-standalone/.gitignore new file mode 100644 index 0000000000..66901a1bd5 --- /dev/null +++ b/tests/docs/playwright/html/notebook-preview-standalone/.gitignore @@ -0,0 +1,2 @@ +/notebook.out.ipynb +**/*.quarto_ipynb diff --git a/tests/docs/playwright/html/notebook-preview-standalone/index.qmd b/tests/docs/playwright/html/notebook-preview-standalone/index.qmd new file mode 100644 index 0000000000..ff868c9c4c --- /dev/null +++ b/tests/docs/playwright/html/notebook-preview-standalone/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb b/tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb new file mode 100644 index 0000000000..4a8eaaca77 --- /dev/null +++ b/tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/docs/playwright/website/notebook-preview-announcement/.gitignore b/tests/docs/playwright/website/notebook-preview-announcement/.gitignore new file mode 100644 index 0000000000..91e4dc52c8 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml b/tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml new file mode 100644 index 0000000000..6e9f2c4a61 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml @@ -0,0 +1,9 @@ +project: + type: website + +website: + title: "notebook-preview-announcement" + announcement: + content: "Site announcement" + +format: html diff --git a/tests/docs/playwright/website/notebook-preview-announcement/index.qmd b/tests/docs/playwright/website/notebook-preview-announcement/index.qmd new file mode 100644 index 0000000000..ff868c9c4c --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb b/tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb new file mode 100644 index 0000000000..4a8eaaca77 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/docs/playwright/website/notebook-preview-navbar/.gitignore b/tests/docs/playwright/website/notebook-preview-navbar/.gitignore new file mode 100644 index 0000000000..91e4dc52c8 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml b/tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml new file mode 100644 index 0000000000..06a1460526 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml @@ -0,0 +1,10 @@ +project: + type: website + +website: + title: "notebook-preview-navbar" + navbar: + left: + - index.qmd + +format: html diff --git a/tests/docs/playwright/website/notebook-preview-navbar/index.qmd b/tests/docs/playwright/website/notebook-preview-navbar/index.qmd new file mode 100644 index 0000000000..ff868c9c4c --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb b/tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb new file mode 100644 index 0000000000..4a8eaaca77 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/.gitignore b/tests/docs/playwright/website/notebook-preview-sidebar/.gitignore new file mode 100644 index 0000000000..91e4dc52c8 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml b/tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml new file mode 100644 index 0000000000..762a0f7920 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml @@ -0,0 +1,10 @@ +project: + type: website + +website: + title: "notebook-preview-sidebar" + sidebar: + contents: + - index.qmd + +format: html diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/index.qmd b/tests/docs/playwright/website/notebook-preview-sidebar/index.qmd new file mode 100644 index 0000000000..ff868c9c4c --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb b/tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb new file mode 100644 index 0000000000..4a8eaaca77 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/integration/playwright/tests/notebook-preview-header.spec.ts b/tests/integration/playwright/tests/notebook-preview-header.spec.ts new file mode 100644 index 0000000000..6fa99c1238 --- /dev/null +++ b/tests/integration/playwright/tests/notebook-preview-header.spec.ts @@ -0,0 +1,93 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +// True when the element at the center of `locator` belongs to it, i.e. no +// other element (such as a fixed navbar) is painted on top of it. +async function isTopmostAtCenter(locator: Locator): Promise { + return await locator.evaluate((el) => { + const box = el.getBoundingClientRect(); + const hit = document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2, + ); + return hit !== null && el.contains(hit); + }); +} + +function embedHeaderControls(page: Page) { + const embedHeader = page.locator("#quarto-embed-header"); + return { + "back link": embedHeader.locator(".quarto-back-link"), + title: embedHeader.locator("h6"), + "download link": embedHeader.getByRole("link", { name: "Download Notebook" }), + }; +} + +// The notebook preview page has its own fixed header bar. On a website with +// a site header (navbar, sidebar toggle, announcement), both bars sit at the +// top of the page, and the site header must not cover the preview controls +// (#14972). +const testCases = [ + { + name: "website navbar", + url: "./website/notebook-preview-navbar/_site/notebook-preview.html", + siteHeader: true, + }, + { + name: "website announcement only", + url: "./website/notebook-preview-announcement/_site/notebook-preview.html", + siteHeader: true, + }, + { + name: "website sidebar only, narrow viewport", + url: "./website/notebook-preview-sidebar/_site/notebook-preview.html", + siteHeader: true, + viewport: { width: 400, height: 800 }, + }, + { + name: "standalone document, no site header", + url: "./html/notebook-preview-standalone/notebook-preview.html", + siteHeader: false, + }, +]; + +test.describe("Notebook preview header", () => { + for (const { name, url, siteHeader, viewport } of testCases) { + test.describe(name, () => { + if (viewport) { + test.use({ viewport }); + } + + test.beforeEach(async ({ page }) => { + await page.goto(url); + }); + + test("controls are not covered by another element", async ({ page }) => { + await expect(page.locator("#quarto-header")).toHaveCount(siteHeader ? 1 : 0); + for (const [label, control] of Object.entries(embedHeaderControls(page))) { + await expect(control).toBeVisible(); + expect(await isTopmostAtCenter(control), `${label} is covered by another element`).toBe(true); + } + }); + + test("first notebook cell starts below the header", async ({ page }) => { + const header = page.locator("#quarto-embed-header"); + // div.cell matches every notebook cell; the first is the top of the content + const cell = page.locator("div.cell").first(); + await expect(cell).toBeVisible(); + await expect.poll(async () => { + const headerBox = await header.boundingBox(); + const cellBox = await cell.boundingBox(); + return cellBox!.y - (headerBox!.y + headerBox!.height); + }).toBeGreaterThanOrEqual(0); + }); + + // #14968 + test("controls are in a navigation landmark named after the notebook", async ({ page }) => { + const landmark = page.getByRole("navigation", { name: "notebook.ipynb" }); + await expect(landmark).toHaveCount(1); + await expect(landmark.locator(".quarto-back-link")).toBeVisible(); + await expect(landmark.getByRole("link", { name: "Download Notebook" })).toBeVisible(); + }); + }); + } +});