From 6fffe1830e2bc6b814a36f91d99610f6e15d8ab4 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Fri, 2 Oct 2026 11:57:58 -0700 Subject: [PATCH 1/9] callouts: make the collapsible callout header a native button The collapse toggle was a div with aria-expanded, no role, no tab stop and an aria-label that hid the title. The header now wraps its icon, title and chevron in a ") + headerDiv.content = pandoc.Blocks({pandoc.Plain({btnOpen, iconSpan, titleSpan, toggleIcon, btnClose})}) -- configure the body div for collapse local collapseDiv = pandoc.Div({}) diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 06db8a53d89..77fc4d4851f 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -1479,7 +1479,8 @@ kbd, border-bottom-right-radius: add($border-radius, -1px); } -.callout > .callout-header.collapsed { +// Bootstrap toggles `collapsed` on the trigger (the button), not the header +.callout > .callout-header:has(> .callout-toggle-btn.collapsed) { border-bottom-right-radius: add($border-radius, -1px); } @@ -1510,17 +1511,31 @@ kbd, margin-top: 0; } -.callout > div.callout-header[data-bs-toggle="collapse"] { +// the header of a collapsible callout is a native button (#4934); +// reset its UA styles so it looks like the header it replaced +.callout .callout-toggle-btn { + all: unset; + box-sizing: border-box; cursor: pointer; + font: inherit; + color: inherit; +} + +.callout .callout-toggle-btn:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; } -.callout.callout-style-default .callout-header[aria-expanded="false"], -.callout.callout-style-default .callout-header[aria-expanded="true"] { +.callout.callout-style-default .callout-header.callout-collapsible { padding-top: 0px; margin-bottom: 0px; align-items: center; } +.callout.callout-style-default .callout-toggle-btn { + align-items: center; +} + .callout.callout-titled .callout-body > :last-child:not(.sourceCode), .callout.callout-titled .callout-body > div > :last-child:not(.sourceCode) { padding-bottom: 0.5rem; @@ -1572,36 +1587,36 @@ $code-block-border-left-color: $table-border-color !default; transition: transform 0.2s linear; } -.callout .callout-header[aria-expanded="false"] .callout-toggle::before { +.callout .callout-toggle-btn[aria-expanded="false"] .callout-toggle::before { transform: rotate(-90deg); } -.callout .callout-header[aria-expanded="true"] .callout-toggle::before { +.callout .callout-toggle-btn[aria-expanded="true"] .callout-toggle::before { transform: none; } -.callout.callout-style-simple:not(.no-icon) div.callout-icon-container { +.callout.callout-style-simple:not(.no-icon) .callout-icon-container { padding-top: 0.2em; padding-right: 0.55em; } -.callout.callout-style-default:not(.no-icon) div.callout-icon-container { +.callout.callout-style-default:not(.no-icon) .callout-icon-container { padding-top: 0.1em; padding-right: 0.35em; } -.callout.callout-style-default:not(.no-icon) div.callout-title-container { +.callout.callout-style-default:not(.no-icon) .callout-title-container { margin-top: -1px; } .callout.callout-style-default.callout-caution:not(.no-icon) - div.callout-icon-container { + .callout-icon-container { padding-top: 0.3em; padding-right: 0.35em; } .callout > .callout-body > .callout-icon-container > .no-icon, -.callout > .callout-header > .callout-icon-container > .no-icon { +.callout > .callout-header .callout-icon-container > .no-icon { display: none; } diff --git a/tests/docs/playwright/html/callouts/callout-collapse-keyboard.qmd b/tests/docs/playwright/html/callouts/callout-collapse-keyboard.qmd new file mode 100644 index 00000000000..d0d34bbb733 --- /dev/null +++ b/tests/docs/playwright/html/callouts/callout-collapse-keyboard.qmd @@ -0,0 +1,16 @@ +--- +title: Collapsible callout keyboard toggle +format: html +--- + +::: {#collapsed .callout-note collapse="true"} +## Starts collapsed + +Collapsed body. +::: + +::: {#expanded .callout-tip collapse="false"} +## Starts expanded + +Expanded body. +::: diff --git a/tests/docs/smoke-all/callouts/a11y/issue-4934.qmd b/tests/docs/smoke-all/callouts/a11y/issue-4934.qmd new file mode 100644 index 00000000000..538759a0a5f --- /dev/null +++ b/tests/docs/smoke-all/callouts/a11y/issue-4934.qmd @@ -0,0 +1,28 @@ +--- +format: html +title: Collapsible callout toggle is an accessible control +_quarto: + tests: + html: + ensureHtmlElements: + - ['div.callout-header > button.callout-toggle-btn[type="button"][aria-expanded="false"][aria-controls="callout-1"][data-bs-target=".callout-1-contents"]', + 'div.callout-header > button.callout-toggle-btn[aria-expanded="true"][aria-controls="callout-2"]', + 'button.callout-toggle-btn > span.callout-title-container > span.screen-reader-only', + '#callout-1.callout-1-contents.collapse:not(.show)', + '#callout-2.callout-2-contents.collapse.show'] + - ['div.callout-header[aria-expanded]', 'div.callout-header[aria-label]', 'div.callout-header[data-bs-toggle]', + 'button.callout-toggle-btn[aria-label]', 'button.callout-toggle-btn div', + 'div.callout-header.collapsed'] +--- + +::: {.callout-note collapse="true"} +## Expand to learn more + +Hidden content. +::: + +::: {.callout-tip collapse="false"} +## Already open + +Visible content. +::: diff --git a/tests/integration/playwright/tests/html-callouts.spec.ts b/tests/integration/playwright/tests/html-callouts.spec.ts index 595f29afc88..4ef46f19344 100644 --- a/tests/integration/playwright/tests/html-callouts.spec.ts +++ b/tests/integration/playwright/tests/html-callouts.spec.ts @@ -37,3 +37,53 @@ test('Default callout spacing is handled by padding', async ({ page }) => { const paddingBottom = await getCSSProperty(lastChild, 'padding-bottom', true) as number; expect(paddingBottom).toBeGreaterThan(0); }); + +test.describe('Collapsible callout toggle is keyboard operable (#4934)', () => { + test('Tab reaches the toggle; Enter and Space open and close it', async ({ page, browserName }) => { + await page.goto('./html/callouts/callout-collapse-keyboard.html'); + // WebKit only tabs to buttons with Alt held + const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + + const button = page.locator('#collapsed .callout-toggle-btn'); + const body = page.locator('#collapsed .callout-collapse'); + // Bootstrap ignores a toggle while a collapse transition runs + const settled = () => expect(body).not.toHaveClass(/collapsing/); + await expect(button).toHaveAccessibleName('Note Starts collapsed'); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + await expect(body).toBeHidden(); + + for (let i = 0; i < 20; i++) { + await page.keyboard.press(tab); + if (await button.evaluate((el) => el === document.activeElement)) break; + } + await expect(button).toBeFocused(); + await expect(button).toHaveCSS('outline-style', 'solid'); + + await page.keyboard.press('Enter'); + await expect(button).toHaveAttribute('aria-expanded', 'true'); + await expect(body).toBeVisible(); + await settled(); + + await page.keyboard.press('Space'); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + await expect(body).toBeHidden(); + await settled(); + + await page.keyboard.press('Space'); + await expect(button).toHaveAttribute('aria-expanded', 'true'); + await expect(body).toBeVisible(); + }); + + test('An expanded callout starts open and collapses with Enter', async ({ page }) => { + await page.goto('./html/callouts/callout-collapse-keyboard.html'); + const button = page.locator('#expanded .callout-toggle-btn'); + const body = page.locator('#expanded .callout-collapse'); + await expect(button).toHaveAttribute('aria-expanded', 'true'); + await expect(body).toBeVisible(); + + await button.focus(); + await page.keyboard.press('Enter'); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + await expect(body).toBeHidden(); + }); +}); From 4c1f3a6ccb66e63541ccc301952aed13ccd21d3d Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Fri, 2 Oct 2026 12:37:58 -0700 Subject: [PATCH 2/9] callouts: update collapse selectors in the feature-format matrix and llm-docs The collapsed state now lives on the toggle button, not the header (#4934). --- .../qmd-files/callout/collapse.qmd | 6 ++++-- llm-docs/callout-styling-html.md | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/dev-docs/feature-format-matrix/qmd-files/callout/collapse.qmd b/dev-docs/feature-format-matrix/qmd-files/callout/collapse.qmd index 6ec4e207b56..96994bc023b 100644 --- a/dev-docs/feature-format-matrix/qmd-files/callout/collapse.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/callout/collapse.qmd @@ -8,8 +8,10 @@ _quarto: html: ensureHtmlElements: - - - "div.callout-header.collapsed ~ div#callout-1.collapse" - - "div.callout-header:not(.collapsed) ~ div#callout-2.collapse.show" + - "div.callout-header > button.callout-toggle-btn.collapsed[aria-expanded='false'][aria-controls='callout-1']" + - "div.callout-header ~ div#callout-1.collapse:not(.show)" + - "div.callout-header > button.callout-toggle-btn:not(.collapsed)[aria-expanded='true'][aria-controls='callout-2']" + - "div.callout-header ~ div#callout-2.collapse.show" --- ::: {.callout-note collapse="true"} diff --git a/llm-docs/callout-styling-html.md b/llm-docs/callout-styling-html.md index 84962371896..37e35022121 100644 --- a/llm-docs/callout-styling-html.md +++ b/llm-docs/callout-styling-html.md @@ -57,6 +57,23 @@ The `hasBootstrap()` function (in `filters/common/pandoc.lua`) checks the `has-b ``` +A collapsible callout (`collapse="true"` or `"false"`) wraps the header content in a native ` + +
...
+``` + +The target is the `callout-N-contents` class, not the id: margin content moved out of the callout carries the class too, so it collapses with the callout. + ### EPUB/RevealJS HTML ```html @@ -93,7 +110,8 @@ File: `src/resources/formats/html/bootstrap/_bootstrap-rules.scss` |-------|-----------|-------------| | Titled | `.callout-titled` | Has a title/header | | Untitled | `:not(.callout-titled)` | Content only, no header | -| Collapsed | `.callout-header.collapsed` | Collapsible, currently closed | +| Collapsible | `.callout-header.callout-collapsible` | Header holds a `.callout-toggle-btn` button | +| Collapsed | `.callout-toggle-btn.collapsed` | Collapsible, currently closed (on the button; style the header with `:has()`) | | Empty content | `.callout-empty-content` | No body content | ### Styling Patterns From f0441cef98bf0808ae31b7ba7815cb59a2ffaea0 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Fri, 2 Oct 2026 12:50:07 -0700 Subject: [PATCH 3/9] changelog: note the collapsible callout selector change (#4934) --- news/changelog-1.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 5e56e3faf23..ba2999b18c6 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -17,7 +17,7 @@ All changes included in 1.11: - ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Translate the new `navigation-*-label` keys in all 33 built-in language files. The values are drawn from existing human-translated interface strings (LibreOffice, GNOME, Wikidata) and each one carries a comment naming its source; values that were adapted rather than used verbatim are marked `needs review`. - ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Label the table of contents `