Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions dev-docs/feature-format-matrix/qmd-files/callout/collapse.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
20 changes: 19 additions & 1 deletion llm-docs/callout-styling-html.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ The `hasBootstrap()` function (in `filters/common/pandoc.lua`) checks the `has-b
</div>
```

A collapsible callout (`collapse="true"` or `"false"`) wraps the header content in a native `<button>`, so its children are `<span>`s (a button accepts only phrasing content). Bootstrap updates `aria-expanded` and the `collapsed` class on the button, not on the header:

```html
<div class="callout-header callout-collapsible d-flex align-content-center">
<button type="button" class="callout-toggle-btn d-flex flex-fill collapsed"
data-bs-toggle="collapse" data-bs-target=".callout-1-contents"
aria-controls="callout-1" aria-expanded="false">
<span class="callout-icon-container"><i class='callout-icon'></i></span>
<span class="callout-title-container flex-fill">Title</span>
<span class="callout-btn-toggle ..."><i class="callout-toggle"></i></span>
</button>
</div>
<div id="callout-1" class="callout-1-contents callout-collapse collapse">...</div>
```

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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>`, so keyboard users can reach it with Tab and toggle it with Enter or Space. Screen readers announce the callout type and title as the button name, instead of "Toggle callout". The appearance does not change, except for a focus ring on keyboard focus.

## Formats

Expand Down Expand Up @@ -76,6 +77,7 @@ All changes included in 1.11:
- ([#15125](https://github.com/quarto-dev/quarto-cli/pull/15125)): Fix Google Scholar metadata failing the render with a CSL JSON (`.json`) bibliography.
- ([#15128](https://github.com/quarto-dev/quarto-cli/pull/15128)): Fix Google Scholar metadata missing references with a YAML bibliography not wrapped in `---` delimiters.
- ([#15131](https://github.com/quarto-dev/quarto-cli/issues/15131)): Fix the light/dark toggle appearing, with no dark styles to switch to, when the `dark` brand file holds only comments and is ignored.
- ([#15177](https://github.com/quarto-dev/quarto-cli/issues/15177)): Fix the hidden callout type label running into the title in the page text and the website search index, for example `NoteRemember this` instead of `Note Remember this`.

### `pdf`

Expand Down
45 changes: 28 additions & 17 deletions src/resources/filters/modules/callouts.lua
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ local function calloutDiv(node)

if needs_screen_reader_callout_type then
-- add a screen reader callout type
local srCalloutType = pandoc.Span(pandoc.Str(displayName(callout_type)))
-- the trailing space keeps the type and title apart in the
-- accessible name of the collapse button ("Note Title", not "NoteTitle")
local srCalloutType = pandoc.Span({pandoc.Str(displayName(callout_type)), pandoc.Space()})
srCalloutType.attr.classes:insert("screen-reader-only")
title:insert(1, srCalloutType)
end
Expand All @@ -281,35 +283,40 @@ local function calloutDiv(node)
local calloutid = "callout-" .. calloutidx
calloutidx = calloutidx + 1

-- create the header to contain the title
-- title should expand to fill its space
local titleDiv = pandoc.Div(pandoc.Plain(title), pandoc.Attr("", {"callout-title-container", "flex-fill"}))
local headerDiv = pandoc.Div({imgDiv, titleDiv}, pandoc.Attr("", {"callout-header", "d-flex", "align-content-center"}))
-- create the header to contain the icon and title; its content is
-- filled below, once per branch (collapsible or not)
local headerDiv = pandoc.Div({}, pandoc.Attr("", {"callout-header", "d-flex", "align-content-center"}))
local bodyDiv = div
bodyDiv.attr.classes:insert("callout-body")

if collapse ~= nil then

-- collapse default value
local expandedAttrVal = "true"
local collapsedClz = ""
if collapse == "true" or collapse == true then
expandedAttrVal = "false"
headerDiv.attr.classes:insert("collapsed")
collapsedClz = " collapsed"
end
headerDiv.attr.classes:insert("callout-collapsible")

-- create the collapse button
-- the whole header is a native <button> (APG disclosure pattern), so
-- its children must be phrasing content: use spans, not divs
local iconSpan = pandoc.Span(imgPlaceholder.content, pandoc.Attr("", {"callout-icon-container"}))
local titleSpan = pandoc.Span(title, pandoc.Attr("", {"callout-title-container", "flex-fill"}))
local btnClasses = "callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end"
local btnIcon = "<i class='callout-toggle'></i>"
local toggleButton = pandoc.RawInline("html", "<div class='" .. btnClasses .. "'>" .. btnIcon .. "</div>")
headerDiv.content:insert(pandoc.Plain(toggleButton));

-- configure the header div for collapse
local toggleIcon = pandoc.RawInline("html", "<span class='" .. btnClasses .. "'><i class='callout-toggle'></i></span>")
-- target the contents class (not the id): margin content inside the
-- callout is moved out of it and carries this class too
local bsTargetClz = calloutid .. "-contents"
headerDiv.attr.attributes["bs-toggle"] = "collapse"
headerDiv.attr.attributes["bs-target"] = "." .. bsTargetClz
headerDiv.attr.attributes["aria-controls"] = calloutid
headerDiv.attr.attributes["aria-expanded"] = expandedAttrVal
headerDiv.attr.attributes["aria-label"] = 'Toggle callout'
local btnOpen = pandoc.RawInline("html",
"<button type='button' class='callout-toggle-btn d-flex flex-fill" .. collapsedClz .. "'" ..
" data-bs-toggle='collapse' data-bs-target='." .. bsTargetClz .. "'" ..
" aria-controls='" .. calloutid .. "' aria-expanded='" .. expandedAttrVal .. "'>")
-- pandoc has no button element, so raw open/close tags wrap the AST
-- spans (as in foldcode.lua's <summary>); keep them in the same Plain
local btnClose = pandoc.RawInline("html", "</button>")
headerDiv.content = pandoc.Blocks({pandoc.Plain({btnOpen, iconSpan, titleSpan, toggleIcon, btnClose})})

-- configure the body div for collapse
local collapseDiv = pandoc.Div({})
Expand All @@ -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
Expand Down
45 changes: 33 additions & 12 deletions src/resources/formats/html/bootstrap/_bootstrap-rules.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
16 changes: 16 additions & 0 deletions tests/docs/playwright/html/callouts/callout-collapse-keyboard.qmd
Original file line number Diff line number Diff line change
@@ -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.
:::
35 changes: 35 additions & 0 deletions tests/docs/smoke-all/callouts/a11y/issue-4934.qmd
Original file line number Diff line number Diff line change
@@ -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}
:::
50 changes: 50 additions & 0 deletions tests/integration/playwright/tests/html-callouts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading