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 diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 4c4907daaf0..11e50428cc8 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -44,6 +44,7 @@ All changes included in 1.11: - ([#15080](https://github.com/quarto-dev/quarto-cli/issues/15080)): Fix footnote, citation, cross-reference and code annotation popups not closing with Esc, and staying open over the next focused element after keyboard focus leaves them. - ([#15129](https://github.com/quarto-dev/quarto-cli/issues/15129)): Fix default listings rendering an empty, focusable metadata link for posts with no date, author or other listed field to show, flagged by axe-core (`link-name`). - ([#15159](https://github.com/quarto-dev/quarto-cli/issues/15159), [#15160](https://github.com/quarto-dev/quarto-cli/issues/15160)): Fix the `revealjs` speaker view's Layout selector having no accessible name or visible keyboard focus, and its current slide, upcoming slide, time, and notes areas not being named regions. Changing layouts no longer forwards control keys to the slides, and the slide navigation buttons no longer introduce an unrelated complementary landmark. (author: @jooyoungseo) +- ([#4934](https://github.com/quarto-dev/quarto-cli/issues/4934)): The header of a collapsible callout is now a `") + headerDiv.content = pandoc.Blocks({pandoc.Plain({btnOpen, iconSpan, titleSpan, toggleIcon, btnClose})}) -- configure the body div for collapse local collapseDiv = pandoc.Div({}) @@ -324,6 +331,10 @@ local function calloutDiv(node) -- add the current body to the collapse div and use the collapse div instead collapseDiv.content:insert(bodyDiv) bodyDiv = collapseDiv + else + -- title should expand to fill its space + local titleDiv = pandoc.Div(pandoc.Plain(title), pandoc.Attr("", {"callout-title-container", "flex-fill"})) + headerDiv.content = pandoc.Blocks({imgDiv, titleDiv}) end -- add the header and body to the div diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 512c1bb7aee..ac1c0860614 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -1479,7 +1479,10 @@ kbd, border-bottom-right-radius: add($border-radius, -1px); } -.callout > .callout-header.collapsed { +// Bootstrap toggles `collapsed` on the trigger (the button), not the header. +// Without :has() support (e.g. Firefox < 121) the corner stays square; +// that is cosmetic only, so no JS fallback. +.callout > .callout-header:has(> .callout-toggle-btn.collapsed) { border-bottom-right-radius: add($border-radius, -1px); } @@ -1510,17 +1513,35 @@ kbd, margin-top: 0; } -.callout > div.callout-header[data-bs-toggle="collapse"] { - cursor: pointer; +// the header of a collapsible callout is a native button (#4934); +// remove the UA button styles so it looks like the header it replaced +// (Reboot already sets margin, font size, line height and cursor) +.callout .callout-toggle-btn { + appearance: none; + background: transparent; + border: 0; + padding: 0; + font: inherit; + color: inherit; + text-align: inherit; + letter-spacing: inherit; } -.callout.callout-style-default .callout-header[aria-expanded="false"], -.callout.callout-style-default .callout-header[aria-expanded="true"] { +.callout .callout-toggle-btn:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.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, .code-copy-button) { padding-bottom: 0.5rem; @@ -1613,36 +1634,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..3c2d53ae2b9 --- /dev/null +++ b/tests/docs/smoke-all/callouts/a11y/issue-4934.qmd @@ -0,0 +1,35 @@ +--- +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', + '.callout-margin-content.callout-3-contents.collapse'] + - ['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. +::: + +::: {.callout-note collapse="true"} +## With margin content + +Body. [Margin note]{.column-margin} +::: 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(); + }); +});